958 lines
45 KiB
C#
958 lines
45 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Globalization;
|
||
using System.Text.RegularExpressions;
|
||
using UnityEngine;
|
||
using UnityEngine.UI;
|
||
|
||
namespace TKS
|
||
{
|
||
// ---------- 데이터(Resources/ChapterMap/Data/world.json · 원화 세션 chap_install.py 생성 · 좌표 = 맵 px · 왼쪽 위 원점 · y 아래로) ----------
|
||
// 맵 그림·길 = world.json(맵 id 별) · 노드 자리 = Data/<맵>.json 의 stages[].at(기획 확정판 · 없으면 slots_N) · 게임 챕터·스테이지·노드 수 = BalanceConfig.chapters·stages(스테이지 표 CSV)
|
||
|
||
[Serializable]
|
||
public class WorldKitItem
|
||
{
|
||
public string name;
|
||
public float[] pivot; // 이미지 좌표(위 → 아래) 비율
|
||
public float[] refSize; // UI 기준 px
|
||
public float[] numberCenter; // 번호 자리(이미지 좌표 비율)
|
||
}
|
||
|
||
[Serializable]
|
||
public class WorldLandmark
|
||
{
|
||
public string ko;
|
||
public float x;
|
||
public float y;
|
||
public string role;
|
||
}
|
||
|
||
[Serializable]
|
||
public class WorldChapter
|
||
{
|
||
public string id;
|
||
public int no;
|
||
public string ko;
|
||
public string years;
|
||
public string map;
|
||
public float[] roadX;
|
||
public float[] roadY;
|
||
public WorldLandmark[] landmarks;
|
||
// world.json 의 nodes·stageLink(구 임시 연결 spec/stage_plan.json) 는 읽지 않는다 — 노드 = 스테이지 표(2026-11-05 145 스테이지)
|
||
}
|
||
|
||
[Serializable]
|
||
public class WorldData
|
||
{
|
||
public float[] mapSize;
|
||
public float[] viewSize;
|
||
public float dotSpacing;
|
||
public float[] flagStars; // x0,y0,x1,y1,… (노드 기준 · y 아래로)
|
||
public float[] flagBossStars;
|
||
public float[] flagCursor;
|
||
public float[] flagBossCursor;
|
||
public float[] flagFrame;
|
||
public bool showLabels; // 장소 이름판 표시(그림 속 장소 위치와 맞춘 뒤 켬)
|
||
public WorldKitItem[] kit;
|
||
public WorldChapter[] chapters;
|
||
|
||
public const string Path = "ChapterMap/Data/world";
|
||
public const string DataRoot = "ChapterMap/Data/";
|
||
|
||
public WorldChapter FindMap(string map)
|
||
{
|
||
if (chapters != null)
|
||
foreach (var c in chapters)
|
||
if (c.map == map) return c;
|
||
return null;
|
||
}
|
||
|
||
/// <summary>Data/<map>.json 의 stages(기획 확정판 맵 · 스테이지 번호·보스·지명·노드 좌표 at). 없으면 null.</summary>
|
||
public static AnchorStage[] AnchorStages(string map)
|
||
{
|
||
var t = Resources.Load<TextAsset>(DataRoot + map);
|
||
if (t == null) return null;
|
||
try
|
||
{
|
||
var f = JsonUtility.FromJson<AnchorFile>(t.text);
|
||
return f != null && f.stages != null && f.stages.Length > 0 ? f.stages : null;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogWarning("[TKS] " + DataRoot + map + " stages 읽기 실패: " + e.Message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>Data/<map>.json(원화 세션 anchors) 의 slots_n(맵 px · 첫 칸 = 출발). 없으면 null.</summary>
|
||
public static Vector2[] AnchorSlots(string map, int n)
|
||
{
|
||
var t = Resources.Load<TextAsset>(DataRoot + map);
|
||
if (t == null || n <= 0) return null;
|
||
var m = Regex.Match(t.text, "\"slots_" + n + "\"\\s*:\\s*\\[((?:\\s*\\[[^\\]]*\\]\\s*,?)*)\\s*\\]");
|
||
if (!m.Success) return null;
|
||
var nums = Regex.Matches(m.Groups[1].Value, @"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?");
|
||
if (nums.Count != n * 2) return null;
|
||
var res = new Vector2[n];
|
||
for (int i = 0; i < n; i++)
|
||
res[i] = new Vector2(float.Parse(nums[2 * i].Value, CultureInfo.InvariantCulture), float.Parse(nums[2 * i + 1].Value, CultureInfo.InvariantCulture));
|
||
return res;
|
||
}
|
||
|
||
/// <summary>점 p 에 가장 가까운 길 위 지점의 호 길이.</summary>
|
||
public static float ArcOf(WorldChapter c, float[] acc, Vector2 p)
|
||
{
|
||
int m = acc.Length;
|
||
if (m == 1) return 0f;
|
||
float best = float.MaxValue, res = 0f;
|
||
for (int i = 1; i < m; i++)
|
||
{
|
||
var a = new Vector2(c.roadX[i - 1], c.roadY[i - 1]);
|
||
var b = new Vector2(c.roadX[i], c.roadY[i]);
|
||
var ab = b - a;
|
||
float t = Mathf.Clamp01(Vector2.Dot(p - a, ab) / Mathf.Max(ab.sqrMagnitude, 1e-6f));
|
||
float d = (a + ab * t - p).sqrMagnitude;
|
||
if (d < best)
|
||
{
|
||
best = d;
|
||
res = acc[i - 1] + (acc[i] - acc[i - 1]) * t;
|
||
}
|
||
}
|
||
return res;
|
||
}
|
||
|
||
public static WorldData Load()
|
||
{
|
||
var t = Resources.Load<TextAsset>(Path);
|
||
if (t == null) return null;
|
||
try
|
||
{
|
||
return JsonUtility.FromJson<WorldData>(t.text);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogError("[TKS] world.json 읽기 실패: " + e.Message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
public WorldKitItem Kit(string name)
|
||
{
|
||
if (kit != null)
|
||
foreach (var k in kit)
|
||
if (k.name == name) return k;
|
||
return null;
|
||
}
|
||
|
||
/// <summary>길(road) 을 호 길이로 n 등분한 노드 자리(맵 px). 첫 칸 = 출발 · 마지막 = 보스.</summary>
|
||
public static Vector2[] Slots(WorldChapter c, int n)
|
||
{
|
||
var acc = Arc(c);
|
||
var res = new Vector2[Mathf.Max(0, n)];
|
||
float total = acc[acc.Length - 1];
|
||
for (int i = 0; i < res.Length; i++) res[i] = At(c, acc, n <= 1 ? 0f : total * i / (n - 1));
|
||
return res;
|
||
}
|
||
|
||
public static float[] Arc(WorldChapter c)
|
||
{
|
||
int m = Mathf.Min(c.roadX.Length, c.roadY.Length);
|
||
var acc = new float[Mathf.Max(1, m)];
|
||
for (int i = 1; i < m; i++)
|
||
acc[i] = acc[i - 1] + Vector2.Distance(new Vector2(c.roadX[i - 1], c.roadY[i - 1]), new Vector2(c.roadX[i], c.roadY[i]));
|
||
return acc;
|
||
}
|
||
|
||
public static Vector2 At(WorldChapter c, float[] acc, float len)
|
||
{
|
||
int m = acc.Length;
|
||
if (m == 1) return new Vector2(c.roadX[0], c.roadY[0]);
|
||
for (int i = 1; i < m; i++)
|
||
{
|
||
if (acc[i] >= len)
|
||
{
|
||
float t = (len - acc[i - 1]) / Mathf.Max(acc[i] - acc[i - 1], 1e-4f);
|
||
return Vector2.Lerp(new Vector2(c.roadX[i - 1], c.roadY[i - 1]), new Vector2(c.roadX[i], c.roadY[i]), t);
|
||
}
|
||
}
|
||
return new Vector2(c.roadX[m - 1], c.roadY[m - 1]);
|
||
}
|
||
}
|
||
|
||
/// <summary>Data/<맵>.json 의 stages 한 칸(챕터맵 보강 세션 기획 확정판 · 기획서 §4 번호·유형·지명 + 노드 좌표).</summary>
|
||
[Serializable]
|
||
public class AnchorStage
|
||
{
|
||
public int stage;
|
||
public bool boss;
|
||
public string place;
|
||
public string title;
|
||
public float[] at; // 맵 px(왼쪽 위 원점 · y 아래로)
|
||
}
|
||
|
||
[Serializable]
|
||
public class AnchorFile
|
||
{
|
||
public AnchorStage[] stages;
|
||
}
|
||
|
||
public enum NodeState { Lock, Open, Clear }
|
||
|
||
/// <summary>노드 자리 출처: 기획 확정판 stages[].at · 가안 slots_N · 길 위 균등(대체).</summary>
|
||
public enum NodeSource { Stages, Slots, Road }
|
||
|
||
/// <summary>진행도(실행 중 메모리) · 챕터별 깬 노드 수. 시작 때 아웃게임 세이브(stages)에서 채운다(OutGameSession.SyncWorldProgress · #1093).</summary>
|
||
public static class WorldProgress
|
||
{
|
||
static readonly Dictionary<int, int> cleared = new Dictionary<int, int>();
|
||
|
||
public static int Cleared(int chapter)
|
||
{
|
||
int v;
|
||
return cleared.TryGetValue(chapter, out v) ? v : 0;
|
||
}
|
||
|
||
public static void MarkCleared(int chapter, int node)
|
||
{
|
||
if (node + 1 > Cleared(chapter)) cleared[chapter] = node + 1;
|
||
}
|
||
|
||
public static void Reset()
|
||
{
|
||
cleared.Clear();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 게임 시작 화면 = 챕터 월드맵(스크롤 · 상하좌우로 끌기). 길 위 깃발 노드를 누르면 스테이지 팝업 → 출전 = 전투 시작 ·
|
||
/// 전투 결과를 탭하면 월드맵으로 돌아와 다음 노드가 열린다. 그림·길·부품 배치 = world.json · 노드 자리 = Data/<맵>.json stages[].at(없으면 slots_N → 길 위 균등) ·
|
||
/// 챕터·노드·전투 = BalanceConfig.chapters·stages(스테이지 표 · C45) · 보스 노드 = type boss.
|
||
/// </summary>
|
||
public class WorldMapScreen : MonoBehaviour
|
||
{
|
||
public static WorldMapScreen Instance { get; private set; }
|
||
|
||
const string UiRoot = "ChapterMap/UI/";
|
||
const string MapRoot = "ChapterMap/Maps/";
|
||
static readonly Color Ink = new Color(0.14f, 0.09f, 0.07f);
|
||
static readonly Color Paper = new Color(1f, 0.98f, 0.93f);
|
||
|
||
BattleManager battle;
|
||
BattleHUD hud;
|
||
SlingshotInput sling;
|
||
WorldData data;
|
||
BalanceConfig cfg;
|
||
readonly List<int> nodeStages = new List<int>(); // 현재 챕터 노드 → stages 인덱스
|
||
|
||
GameObject root;
|
||
CanvasGroup group;
|
||
ScrollRect scroll;
|
||
RectTransform viewport;
|
||
RectTransform content;
|
||
Image mapImage;
|
||
Text bannerText;
|
||
Button prevButton;
|
||
Button nextButton;
|
||
RectTransform cursor;
|
||
Vector2 cursorBase;
|
||
readonly List<GameObject> spawned = new List<GameObject>();
|
||
readonly List<Button> nodeButtons = new List<Button>();
|
||
|
||
GameObject popup;
|
||
Text popupTitle;
|
||
Text popupBody;
|
||
Button popupGo;
|
||
Text popupGoLabel;
|
||
Button popupSweep;
|
||
Text popupSweepLabel;
|
||
RectTransform popupField;
|
||
Text popupCost;
|
||
Text popupCode;
|
||
int popupNode = -1;
|
||
RectTransform bannerRt;
|
||
RectTransform prevRt;
|
||
RectTransform nextRt;
|
||
Button backButton;
|
||
|
||
// ---------- 아웃게임 연결(2026-11-05 #1093 · OutGame/OutGameRoot 가 채움 · 비면 기존 동작) ----------
|
||
/// <summary>스테이지 인덱스 → 팝업 추가 줄(군량 · 보상 · 별).</summary>
|
||
public Func<int, string> PopupInfo;
|
||
/// <summary>스테이지 팝업 본문 그리기(스테이지 인덱스 · 잠김 · 필드) — 있으면 글자 본문 대신(Fable 제안서 §4-3 · #1104).</summary>
|
||
public Action<int, bool, RectTransform> PopupFill;
|
||
/// <summary>출전 군량(스테이지 인덱스) — 출정 버튼 비용 칩.</summary>
|
||
public Func<int, int> CostOf;
|
||
/// <summary>출전 직전(군량 지불) · false = 출전 안 함(부족 안내는 연결한 쪽이 띄움).</summary>
|
||
public Func<int, bool> TryEnter;
|
||
/// <summary>깬 노드 별 수(0~3) · null 이면 깬 노드 = 별 전부.</summary>
|
||
public Func<int, int> StarsOf;
|
||
/// <summary>소탕(별 3 · 전투 없음) 가능 여부 · 실행.</summary>
|
||
public Func<int, bool> CanSweep;
|
||
public Action<int> Sweep;
|
||
/// <summary>「◀ 본영」(로비로) · null 이면 버튼 숨김.</summary>
|
||
public Action BackRequested;
|
||
/// <summary>위 공통 재화 바 높이(UI px) — 챕터 띠·넘김 버튼을 그만큼 내린다.</summary>
|
||
public float TopInset;
|
||
|
||
float inputBlockUntil;
|
||
int chapter;
|
||
int entryChapter = -1;
|
||
int entryNode = -1;
|
||
|
||
public bool HasData
|
||
{
|
||
get { return data != null && data.chapters != null && data.chapters.Length > 0 && cfg != null && cfg.chapters != null && cfg.chapters.Length > 0 && cfg.stages.Length > 0; }
|
||
}
|
||
public bool IsVisible { get { return root != null && root.activeSelf; } }
|
||
public bool PopupVisible { get { return popup != null && popup.activeSelf; } }
|
||
public int Chapter { get { return chapter; } }
|
||
public int ChapterCount { get { return HasData ? cfg.chapters.Length : 0; } }
|
||
public WorldData Data { get { return data; } }
|
||
public Sprite MapSprite { get { return mapImage != null ? mapImage.sprite : null; } }
|
||
public int NodeCount { get { return nodeButtons.Count; } }
|
||
public int BossNodeCount { get; private set; }
|
||
|
||
/// <summary>챕터(0부터)의 노드 수 = 스테이지 표의 그 챕터 스테이지 수.</summary>
|
||
public int NodesIn(int ch)
|
||
{
|
||
return HasData && ch >= 0 && ch < cfg.chapters.Length ? cfg.StagesOfChapter(cfg.chapters[ch].no).Count : 0;
|
||
}
|
||
|
||
/// <summary>챕터(0부터) · 노드(0부터) → BalanceConfig.stages 인덱스(없으면 −1).</summary>
|
||
public int StageIndexOf(int ch, int node)
|
||
{
|
||
if (!HasData || ch < 0 || ch >= cfg.chapters.Length) return -1;
|
||
var list = cfg.StagesOfChapter(cfg.chapters[ch].no);
|
||
return node >= 0 && node < list.Count ? list[node] : -1;
|
||
}
|
||
|
||
/// <summary>챕터(0부터)의 맵 길(world.json · chapters.csv map 열). 없으면 첫 맵.</summary>
|
||
public WorldChapter MapOf(int ch)
|
||
{
|
||
if (!HasData) return null;
|
||
var wc = ch >= 0 && ch < cfg.chapters.Length ? data.FindMap(cfg.chapters[ch].map) : null;
|
||
return wc ?? data.chapters[0];
|
||
}
|
||
|
||
/// <summary>
|
||
/// 노드 자리(맵 px) = Data/<맵>.json stages[].at(스테이지 번호로 맞춤 · 기획 확정판 맵) → 없거나 번호가 빠지면 slots_N → 그것도 없으면 길 위 균등.
|
||
/// </summary>
|
||
public Vector2[] NodeSlots(int ch, out NodeSource source)
|
||
{
|
||
var list = HasData && ch >= 0 && ch < cfg.chapters.Length ? cfg.StagesOfChapter(cfg.chapters[ch].no) : new List<int>();
|
||
int n = list.Count;
|
||
var wc = MapOf(ch);
|
||
var plan = wc != null ? WorldData.AnchorStages(wc.map) : null;
|
||
if (plan != null)
|
||
{
|
||
var res = new Vector2[n];
|
||
int found = 0;
|
||
for (int i = 0; i < n; i++)
|
||
foreach (var p in plan)
|
||
if (p.stage == cfg.stages[list[i]].stage && p.at != null && p.at.Length >= 2)
|
||
{
|
||
res[i] = new Vector2(p.at[0], p.at[1]);
|
||
found++;
|
||
break;
|
||
}
|
||
if (found == n)
|
||
{
|
||
source = NodeSource.Stages;
|
||
return res;
|
||
}
|
||
Debug.LogWarning("[TKS] " + wc.map + " stages[].at 이 스테이지 표와 안 맞음(" + found + "/" + n + ") → slots_N");
|
||
}
|
||
var s = wc != null ? WorldData.AnchorSlots(wc.map, n) : null;
|
||
source = s != null ? NodeSource.Slots : NodeSource.Road;
|
||
return s ?? (wc != null ? WorldData.Slots(wc, n) : new Vector2[0]);
|
||
}
|
||
|
||
void Awake()
|
||
{
|
||
Instance = this;
|
||
}
|
||
|
||
void OnDestroy()
|
||
{
|
||
if (Instance == this) Instance = null;
|
||
}
|
||
|
||
public void Build(BattleManager b, BattleHUD h, SlingshotInput s, Camera cam)
|
||
{
|
||
battle = b;
|
||
hud = h;
|
||
sling = s;
|
||
data = WorldData.Load();
|
||
cfg = b != null ? b.Config : null;
|
||
if (!HasData)
|
||
{
|
||
Debug.LogWarning("[TKS] 월드맵 데이터 없음(" + WorldData.Path + " 또는 BalanceConfig.chapters — TKS/Import Stages) → 바로 전투");
|
||
return;
|
||
}
|
||
battle.ResultHandler = OnBattleResult;
|
||
|
||
// 캔버스 층(PD 2026-11-10): 루트 = 바탕 · 챕터 띠 · 넘김 · 본영 버튼(챕터를 바꿀 때만 · Static)
|
||
// → Content(맵 · 노드 · 스크롤로 움직임 · Frame) → 커서(매 프레임 흔들림 · Frame) · Popup(Event)
|
||
root = new GameObject("WorldMapCanvas");
|
||
root.transform.SetParent(transform, false);
|
||
UiLayer.Root(root, cam, 300, 0.5f, new Vector2(data.viewSize[0], data.viewSize[1]), UiRate.Static); // 카메라 캡처(자동 플레이)에도 찍힘
|
||
group = root.AddComponent<CanvasGroup>();
|
||
|
||
// 스크롤(맵 = 콘텐츠 · 왼쪽 위 기준)
|
||
viewport = Rect("Viewport", root.transform, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero);
|
||
var vpImg = viewport.gameObject.AddComponent<Image>();
|
||
vpImg.color = new Color(0.1f, 0.12f, 0.1f);
|
||
viewport.gameObject.AddComponent<RectMask2D>();
|
||
content = Rect("Content", viewport, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(0f, 1f), Vector2.zero, new Vector2(data.mapSize[0], data.mapSize[1]));
|
||
UiLayer.Nest(content.gameObject, UiRate.Frame, true); // 스크롤 내용 = 자기 캔버스(노드 = 누름)
|
||
mapImage = content.gameObject.AddComponent<Image>();
|
||
mapImage.raycastTarget = true; // 맵 아무 데나 끌기
|
||
scroll = viewport.gameObject.AddComponent<ScrollRect>();
|
||
scroll.viewport = viewport;
|
||
scroll.content = content;
|
||
scroll.horizontal = true;
|
||
scroll.vertical = true;
|
||
scroll.movementType = ScrollRect.MovementType.Clamped;
|
||
scroll.inertia = true;
|
||
scroll.decelerationRate = 0.12f;
|
||
scroll.scrollSensitivity = 60f;
|
||
|
||
// 위 챕터 띠 + 챕터 넘김
|
||
var banner = Img("Banner", root.transform, Sprite("banner"), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -40f), new Vector2(640f, 112f));
|
||
banner.type = Image.Type.Sliced;
|
||
var bk = data.Kit("banner");
|
||
float tcy = bk != null && bk.numberCenter != null ? bk.numberCenter[1] : 0.4f;
|
||
bannerText = Label(banner.transform, "", 40, Paper, new Vector2(0.5f, 1f - tcy));
|
||
bannerRt = banner.rectTransform;
|
||
prevButton = SmallButton("Prev", root.transform, "◀", new Vector2(0f, 1f), new Vector2(70f, -96f), () => ShowChapter(chapter - 1));
|
||
nextButton = SmallButton("Next", root.transform, "▶", new Vector2(1f, 1f), new Vector2(-70f, -96f), () => ShowChapter(chapter + 1));
|
||
prevRt = (RectTransform)prevButton.transform;
|
||
nextRt = (RectTransform)nextButton.transform;
|
||
|
||
var hint = Label(root.transform, "맵을 끌어 둘러보고 깃발을 눌러 출전", 30, Paper, new Vector2(0.5f, 0f));
|
||
var hrt = hint.rectTransform;
|
||
hrt.anchorMin = hrt.anchorMax = new Vector2(0.5f, 0f);
|
||
hrt.anchoredPosition = new Vector2(0f, 90f);
|
||
|
||
// 본영(로비)으로 — 아웃게임이 BackRequested 를 채우면 보인다
|
||
var back = Img("Back", root.transform, Sprite("plate"), new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(0.5f, 0.5f), new Vector2(130f, 90f), new Vector2(220f, 96f));
|
||
back.type = Image.Type.Sliced;
|
||
back.raycastTarget = true;
|
||
backButton = back.gameObject.AddComponent<Button>();
|
||
backButton.onClick.AddListener(() =>
|
||
{
|
||
if (BackRequested != null && Time.unscaledTime >= inputBlockUntil) BackRequested();
|
||
});
|
||
var bl = Label(back.transform, "◀ 본영", 36, new Color(0.45f, 0.22f, 0.12f), new Vector2(0.5f, 0.55f));
|
||
bl.GetComponent<Outline>().enabled = false;
|
||
|
||
BuildPopup();
|
||
root.SetActive(false);
|
||
}
|
||
|
||
// ---------- 화면 전환 ----------
|
||
|
||
/// <summary>월드맵을 연다(전투 정리 · 전투 HUD·조작 끔).</summary>
|
||
public void Show(int ch)
|
||
{
|
||
if (!HasData) return;
|
||
if (battle != null && battle.Phase != BattlePhase.Setup) battle.GoIdle();
|
||
if (hud != null) hud.gameObject.SetActive(false);
|
||
if (sling != null) sling.enabled = false;
|
||
root.SetActive(true);
|
||
inputBlockUntil = Time.unscaledTime + 0.25f; // 결과 화면 탭이 노드 클릭으로 이어지지 않게
|
||
bannerRt.anchoredPosition = new Vector2(0f, -40f - TopInset);
|
||
prevRt.anchoredPosition = new Vector2(70f, -96f - TopInset);
|
||
nextRt.anchoredPosition = new Vector2(-70f, -96f - TopInset);
|
||
backButton.gameObject.SetActive(BackRequested != null);
|
||
ShowChapter(ch);
|
||
}
|
||
|
||
/// <summary>월드맵을 닫는다(로비로 돌아갈 때 · 전투는 건드리지 않음).</summary>
|
||
public void Hide()
|
||
{
|
||
ClosePopup();
|
||
if (root != null) root.SetActive(false);
|
||
}
|
||
|
||
/// <summary>팝업이 열려 있으면 내용을 다시 채운다(군량 구매·소탕 뒤).</summary>
|
||
public void RefreshPopup()
|
||
{
|
||
if (PopupVisible && popupNode >= 0) OpenPopup(popupNode);
|
||
}
|
||
|
||
public void ShowChapter(int ch)
|
||
{
|
||
if (!HasData) return;
|
||
chapter = Mathf.Clamp(ch, 0, cfg.chapters.Length - 1);
|
||
ClosePopup();
|
||
var def = cfg.chapters[chapter];
|
||
var c = MapOf(chapter);
|
||
foreach (var go in spawned)
|
||
if (go != null) Destroy(go);
|
||
spawned.Clear();
|
||
nodeButtons.Clear();
|
||
cursor = null;
|
||
nodeStages.Clear();
|
||
nodeStages.AddRange(cfg.StagesOfChapter(def.no));
|
||
BossNodeCount = 0;
|
||
|
||
if (c.map != def.map) Debug.LogWarning("[TKS] 챕터 " + def.no + " 맵 「" + def.map + "」 이 world.json 에 없음 → " + c.map);
|
||
var sp = Resources.Load<Sprite>(MapRoot + c.map);
|
||
if (sp == null) Debug.LogWarning("[TKS] 맵 그림 없음: " + MapRoot + c.map);
|
||
mapImage.sprite = sp;
|
||
mapImage.color = sp != null ? Color.white : new Color(0.55f, 0.7f, 0.45f);
|
||
bannerText.text = "제" + def.no + "장 " + def.title;
|
||
prevButton.interactable = chapter > 0;
|
||
nextButton.interactable = chapter < cfg.chapters.Length - 1;
|
||
|
||
NodeSource source;
|
||
var slots = NodeSlots(chapter, out source);
|
||
var acc = WorldData.Arc(c);
|
||
var len = new float[slots.Length];
|
||
for (int i = 0; i < slots.Length; i++) len[i] = WorldData.ArcOf(c, acc, slots[i]);
|
||
int cleared = ChapterUnlocked(chapter) ? WorldProgress.Cleared(chapter) : 0;
|
||
|
||
// 길 점(노드 사이 · 노드 자리를 길에 투영한 호 길이 구간)
|
||
float spacing = Mathf.Max(8f, data.dotSpacing);
|
||
for (int i = 0; i + 1 < slots.Length; i++)
|
||
{
|
||
float l0 = Mathf.Min(len[i], len[i + 1]) + 44f;
|
||
float l1 = Mathf.Max(len[i], len[i + 1]) - 44f;
|
||
if (l1 <= l0) continue;
|
||
int m = Mathf.Max(0, Mathf.FloorToInt((l1 - l0) / spacing));
|
||
for (int j = 0; j <= m; j++)
|
||
{
|
||
var p = WorldData.At(c, acc, Mathf.Lerp(l0, l1, m == 0 ? 0.5f : (float)j / m));
|
||
Piece(i < cleared ? "dot_on" : "dot_off", p, false);
|
||
}
|
||
}
|
||
// 장소 이름판
|
||
if (data.showLabels && c.landmarks != null)
|
||
foreach (var L in c.landmarks)
|
||
{
|
||
if (string.IsNullOrEmpty(L.ko)) continue;
|
||
var plate = Piece("plate", new Vector2(L.x, L.y), false);
|
||
if (plate == null) continue;
|
||
plate.type = Image.Type.Sliced;
|
||
var t = Label(plate.transform, L.ko, 26, new Color(0.28f, 0.17f, 0.11f), new Vector2(0.5f, 0.5f));
|
||
t.GetComponent<Outline>().enabled = false;
|
||
plate.rectTransform.sizeDelta = new Vector2(t.preferredWidth + 60f, plate.rectTransform.sizeDelta.y);
|
||
}
|
||
// 노드
|
||
for (int i = 0; i < slots.Length; i++)
|
||
{
|
||
var stage = cfg.stages[nodeStages[i]];
|
||
bool boss = stage.kind == StageKind.Boss;
|
||
if (boss) BossNodeCount++;
|
||
var st = StateOf(chapter, i);
|
||
string key = (boss ? "flag_boss_" : "flag_") + (st == NodeState.Clear ? "clear" : st == NodeState.Open ? "open" : "lock");
|
||
if (boss) Piece("frame", slots[i] + V(data.flagFrame), false);
|
||
var img = Piece(key, slots[i], true);
|
||
if (img == null) continue;
|
||
int node = i;
|
||
var btn = img.gameObject.AddComponent<Button>();
|
||
btn.transition = Selectable.Transition.ColorTint;
|
||
btn.onClick.AddListener(() => OnNodeClicked(node));
|
||
nodeButtons.Add(btn);
|
||
var kit = data.Kit(key);
|
||
if (st != NodeState.Lock && kit != null)
|
||
{
|
||
var t = Label(img.transform, def.no + "-" + stage.stage, boss ? 38 : (def.no >= 10 || stage.stage >= 10 ? 26 : 32), Paper, new Vector2(kit.numberCenter[0], 1f - kit.numberCenter[1]));
|
||
t.raycastTarget = false;
|
||
}
|
||
if (st == NodeState.Clear)
|
||
{
|
||
var so = boss ? data.flagBossStars : data.flagStars;
|
||
int stars = StarsOf != null ? StarsOf(nodeStages[i]) : int.MaxValue; // 아웃게임 세이브 별(#1093 §6)
|
||
for (int k = 0; k + 1 < so.Length; k += 2) Piece(k / 2 < stars ? "star_on" : "star_off", slots[i] + new Vector2(so[k], so[k + 1]), false);
|
||
}
|
||
if (st == NodeState.Open)
|
||
{
|
||
var co = boss ? data.flagBossCursor : data.flagCursor;
|
||
var cimg = Piece("cursor", slots[i] + V(co), false);
|
||
if (cimg != null)
|
||
{
|
||
cursor = cimg.rectTransform;
|
||
cursorBase = cursor.anchoredPosition;
|
||
// 커서 = 매 프레임 위아래 흔들림 → 자기 캔버스(맵·노드 전체를 매 프레임 다시 묶지 않게 · PD 2026-11-10)
|
||
UiLayer.Nest(cimg.gameObject, UiRate.Frame, false);
|
||
}
|
||
}
|
||
}
|
||
// 처음 위치 = 도전할 노드(없으면 출발)
|
||
int focus = Mathf.Clamp(Mathf.Min(cleared, slots.Length - 1), 0, Mathf.Max(0, slots.Length - 1));
|
||
if (slots.Length > 0) ScrollTo(slots[focus]);
|
||
}
|
||
|
||
/// <summary>맵 px 지점을 화면 가운데로(맵 끝에서 멈춤).</summary>
|
||
public void ScrollTo(Vector2 p)
|
||
{
|
||
Canvas.ForceUpdateCanvases();
|
||
var vr = viewport.rect;
|
||
float w = data.mapSize[0], h = data.mapSize[1];
|
||
float nx = w > vr.width ? Mathf.Clamp01((p.x - vr.width / 2f) / (w - vr.width)) : 0f;
|
||
float ny = h > vr.height ? Mathf.Clamp01((p.y - vr.height / 2f) / (h - vr.height)) : 0f;
|
||
scroll.StopMovement();
|
||
scroll.normalizedPosition = new Vector2(nx, 1f - ny);
|
||
}
|
||
|
||
public NodeState StateOf(int ch, int node)
|
||
{
|
||
if (!HasData || !ChapterUnlocked(ch)) return NodeState.Lock;
|
||
int cleared = WorldProgress.Cleared(ch);
|
||
if (node < cleared) return NodeState.Clear;
|
||
return node == cleared ? NodeState.Open : NodeState.Lock;
|
||
}
|
||
|
||
public bool ChapterUnlocked(int ch)
|
||
{
|
||
if (!HasData || ch < 0 || ch >= cfg.chapters.Length) return false;
|
||
return ch == 0 || WorldProgress.Cleared(ch - 1) >= NodesIn(ch - 1);
|
||
}
|
||
|
||
// ---------- 팝업 ----------
|
||
|
||
void OnNodeClicked(int node)
|
||
{
|
||
if (Time.unscaledTime < inputBlockUntil) return;
|
||
OpenPopup(node);
|
||
}
|
||
|
||
public void OpenPopup(int node)
|
||
{
|
||
if (!HasData) return;
|
||
int si = StageIndexOf(chapter, node);
|
||
if (si < 0) return;
|
||
popupNode = node;
|
||
var st = StateOf(chapter, node);
|
||
var def = cfg.chapters[chapter];
|
||
var stage = cfg.stages[si];
|
||
string tag = stage.kind == StageKind.Boss ? " 보스" : stage.kind == StageKind.Elite ? " 엘리트" : "";
|
||
popupTitle.text = def.no + "-" + stage.stage + tag + "\n제" + def.no + "장 " + def.title;
|
||
var body = new System.Text.StringBuilder();
|
||
body.Append(stage.title);
|
||
if (!string.IsNullOrEmpty(stage.place)) body.Append("\n<size=26>").Append(stage.place).Append(string.IsNullOrEmpty(stage.bgTheme) ? "" : " · " + stage.bgTheme).Append("</size>");
|
||
body.Append("\n\n").Append(EnemySummary(stage));
|
||
if (PopupInfo != null)
|
||
{
|
||
var extra = PopupInfo(si);
|
||
if (!string.IsNullOrEmpty(extra)) body.Append("\n\n").Append(extra);
|
||
}
|
||
if (st == NodeState.Lock) body.Append("\n\n잠긴 스테이지 — 앞 스테이지를 먼저 깨세요");
|
||
popupBody.text = body.ToString();
|
||
popupGo.interactable = st != NodeState.Lock;
|
||
bool sweep = st == NodeState.Clear && CanSweep != null && Sweep != null && CanSweep(si);
|
||
popupSweep.gameObject.SetActive(sweep);
|
||
if (popupField != null && PopupFill != null)
|
||
{
|
||
// 팩 팝업(#1104): 본문 = 필드 안 그림 · 출정(붉은 Primary + 군량 비용) · 소탕(녹 Secondary) 왼쪽
|
||
popupBody.gameObject.SetActive(false);
|
||
popupField.gameObject.SetActive(true);
|
||
foreach (Transform c in popupField) c.gameObject.SetActive(false);
|
||
for (int i = popupField.childCount - 1; i >= 0; i--) Destroy(popupField.GetChild(i).gameObject);
|
||
PopupFill(si, st == NodeState.Lock, popupField);
|
||
popupGoLabel.text = st == NodeState.Lock ? "잠김" : st == NodeState.Clear ? "다시 출정" : "출정";
|
||
if (popupCode != null)
|
||
{
|
||
popupCode.text = def.no + "-" + stage.stage + (stage.kind == StageKind.Boss ? " 보스" : stage.kind == StageKind.Elite ? " 정예" : "");
|
||
popupTitle.text = "제" + def.no + "장 " + def.title;
|
||
}
|
||
if (popupCost != null) popupCost.text = CostOf != null ? CostOf(si).ToString() : "";
|
||
((RectTransform)popupGo.transform).anchoredPosition = new Vector2(sweep ? 390f : 240f, -PopupBtnY);
|
||
}
|
||
else
|
||
{
|
||
popupBody.gameObject.SetActive(true);
|
||
if (popupField != null) popupField.gameObject.SetActive(false);
|
||
popupGoLabel.text = st == NodeState.Clear ? "다시 출전" : "출전";
|
||
}
|
||
popup.SetActive(true);
|
||
}
|
||
|
||
void OnSweepClicked()
|
||
{
|
||
if (popupNode < 0 || Sweep == null) return;
|
||
int si = StageIndexOf(chapter, popupNode);
|
||
if (si >= 0) Sweep(si);
|
||
}
|
||
|
||
public void ClosePopup()
|
||
{
|
||
popupNode = -1;
|
||
if (popup != null) popup.SetActive(false);
|
||
}
|
||
|
||
public void ConfirmPopup()
|
||
{
|
||
if (popupNode < 0) return;
|
||
if (StateOf(chapter, popupNode) == NodeState.Lock) return;
|
||
if (TryEnter != null && !TryEnter(StageIndexOf(chapter, popupNode))) return; // 군량 부족(#1093 §2)
|
||
EnterStage(chapter, popupNode);
|
||
}
|
||
|
||
/// <summary>노드의 전투로 들어간다(월드맵 닫음 · HUD·조작 켬).</summary>
|
||
public void EnterStage(int ch, int node)
|
||
{
|
||
if (!HasData)
|
||
{
|
||
battle.StartStage(0);
|
||
return;
|
||
}
|
||
chapter = Mathf.Clamp(ch, 0, cfg.chapters.Length - 1);
|
||
int si = StageIndexOf(chapter, node);
|
||
if (si < 0) return;
|
||
entryChapter = chapter;
|
||
entryNode = node;
|
||
ClosePopup();
|
||
root.SetActive(false);
|
||
if (hud != null) hud.gameObject.SetActive(true);
|
||
if (sling != null) sling.enabled = true;
|
||
battle.StartStage(si);
|
||
}
|
||
|
||
/// <summary>결과 화면 「다시」(#1104) — 결과 처리(진행 기록 · 월드맵 복귀)를 한 뒤 같은 노드로 다시 출전 · 군량이 모자라면 월드맵에 남음(충전 팝업).</summary>
|
||
public void RetryLast()
|
||
{
|
||
int ch = entryChapter >= 0 ? entryChapter : chapter;
|
||
int node = entryNode;
|
||
battle.ContinueAfterResult();
|
||
if (node < 0 || !HasData) return;
|
||
if (TryEnter != null && !TryEnter(StageIndexOf(ch, node))) return;
|
||
EnterStage(ch, node);
|
||
}
|
||
|
||
void OnBattleResult(bool win)
|
||
{
|
||
int ch = entryChapter >= 0 ? entryChapter : chapter;
|
||
if (win && entryNode >= 0)
|
||
{
|
||
WorldProgress.MarkCleared(ch, entryNode);
|
||
// 챕터 마지막 스테이지를 깨면 다음 챕터 맵으로
|
||
if (entryNode == NodesIn(ch) - 1 && ch + 1 < cfg.chapters.Length) ch++;
|
||
}
|
||
entryChapter = entryNode = -1;
|
||
Show(ch);
|
||
}
|
||
|
||
/// <summary>적 수·병종 + 장수(보스 · 엘리트) 이름.</summary>
|
||
static string EnemySummary(BalanceConfig.StageDef stage)
|
||
{
|
||
var counts = new int[UnitTypeInfo.Count];
|
||
var bosses = new List<string>();
|
||
var elites = new List<string>();
|
||
foreach (var e in stage.enemies)
|
||
{
|
||
counts[(int)e.type]++;
|
||
if (e.isBoss) bosses.Add(e.name);
|
||
else if (e.isGeneral) elites.Add(e.name);
|
||
}
|
||
var sb = new System.Text.StringBuilder("적 " + stage.enemies.Count + "기 · ");
|
||
bool first = true;
|
||
for (int t = 0; t < counts.Length; t++)
|
||
{
|
||
if (counts[t] == 0) continue;
|
||
if (!first) sb.Append(" ");
|
||
sb.Append(UnitTypeInfo.Korean((UnitType)t)).Append(" ").Append(counts[t]);
|
||
first = false;
|
||
}
|
||
if (bosses.Count > 0) sb.Append("\n보스 ").Append(string.Join(" · ", bosses.ToArray()));
|
||
if (elites.Count > 0) sb.Append("\n장수 ").Append(string.Join(" · ", elites.ToArray()));
|
||
return sb.ToString();
|
||
}
|
||
|
||
// 팩 스테이지 팝업 배치(UI px · 패널 왼쪽 위 기준 · #1104)
|
||
const float PopupW = 900f;
|
||
const float PopupH = 1160f;
|
||
const float PopupBtnY = PopupH - 52f - 104f;
|
||
|
||
void BuildPopup()
|
||
{
|
||
var dim = Img("Popup", root.transform, null, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero);
|
||
dim.color = new Color(0f, 0f, 0f, 0.6f);
|
||
dim.raycastTarget = true;
|
||
popup = dim.gameObject;
|
||
UiLayer.Nest(popup, UiRate.Event, true); // 스테이지 팝업 = 자기 캔버스(열 때·소탕 때만)
|
||
// 아웃게임 줄(군량 · 보상 · 별 · #1093)이 들어가도록 2026-11-05 패널을 키움(880×760 → 900×1080)
|
||
var panel = Img("Panel", popup.transform, Sprite("plate"), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, 40f), new Vector2(900f, 1080f));
|
||
panel.type = Image.Type.Sliced;
|
||
panel.raycastTarget = true;
|
||
// 스테이지 팝업만 팩 Popup(PD #1100 · 거점 kit 유지) · 테마가 없으면 kit plate·banner
|
||
bool pack = UISkin.Apply(panel, "popup");
|
||
if (pack)
|
||
{
|
||
panel.color = UISkin.Col("paper", Color.white);
|
||
var band = Img("Head", panel.transform, null, new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -6f), new Vector2(-14f, 150f));
|
||
UISkin.Apply(band, "popup_head");
|
||
band.color = UISkin.Col("header", Ink);
|
||
popupTitle = Label(band.transform, "", 38, Color.white, new Vector2(0.5f, 0.5f));
|
||
popupTitle.font = UISkin.TitleFont;
|
||
popupTitle.GetComponent<Outline>().effectColor = UISkin.Col("stroke", Ink);
|
||
var x = Img("Close", panel.transform, null, new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(0.5f, 0.5f), new Vector2(-58f, -60f), new Vector2(86f, 88f));
|
||
UISkin.Apply(x, "close");
|
||
x.raycastTarget = true;
|
||
x.gameObject.AddComponent<Button>().onClick.AddListener(ClosePopup);
|
||
}
|
||
else
|
||
{
|
||
var head = Img("Head", panel.transform, Sprite("banner"), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), new Vector2(0f, 0f), new Vector2(760f, 112f));
|
||
head.type = Image.Type.Sliced;
|
||
var bk = data.Kit("banner");
|
||
popupTitle = Label(head.transform, "", 34, Paper, new Vector2(0.5f, 1f - (bk != null ? bk.numberCenter[1] : 0.4f)));
|
||
}
|
||
popupTitle.lineSpacing = 0.9f;
|
||
popupBody = Label(panel.transform, "", 30, pack ? UISkin.Col("ink", new Color(0.25f, 0.15f, 0.1f)) : new Color(0.25f, 0.15f, 0.1f), new Vector2(0.5f, pack ? 0.545f : 0.56f));
|
||
popupBody.GetComponent<Outline>().enabled = false;
|
||
popupBody.rectTransform.sizeDelta = new Vector2(800f, pack ? 640f : 720f);
|
||
popupBody.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||
popupBody.supportRichText = true;
|
||
if (pack && UISkin.Has("panel_field") && UISkin.Has("btn_l_red"))
|
||
{
|
||
// Fable 제안서 §4-3(#1104): 팝업 900×1160 · 본문 필드(panel_field 인셋 20) · 출정 = 붉은 Primary 420×104 + 군량 비용 · 소탕 = 녹 Secondary · 닫기 = X 만
|
||
panel.rectTransform.sizeDelta = new Vector2(PopupW, PopupH);
|
||
var field = Img("Field", panel.transform, null, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero);
|
||
UISkin.Apply(field, "panel_field");
|
||
field.color = UISkin.Col("panel_field", Ink);
|
||
field.rectTransform.offsetMin = new Vector2(20f, PopupH - PopupBtnY + 16f);
|
||
field.rectTransform.offsetMax = new Vector2(-20f, -126f);
|
||
// 제목 띠 = 공용 팝업과 같은 h110 · #1F2138 · 왼쪽 스테이지 칩(검수 결함 6)
|
||
var head = panel.transform.Find("Head") as RectTransform;
|
||
if (head != null)
|
||
{
|
||
head.sizeDelta = new Vector2(head.sizeDelta.x, 110f);
|
||
head.GetComponent<Image>().color = UISkin.Col("popup_band", Ink);
|
||
popupCode = TKS.OutGame.UiKit.InfoChip(head, "Code", 30f, 27f, 190f, 56f, null, "", 30);
|
||
popupTitle.fontSize = 40;
|
||
}
|
||
popupField = field.rectTransform;
|
||
popupGo = TKS.OutGame.UiKit.SkinBtn(panel.transform, "Go", "btn_l_red", UISkin.Col("red", new Color(0.89f, 0.33f, 0.23f)), "출정", 46, Color.white, 240f, PopupBtnY, 420f, 104f, ConfirmPopup);
|
||
popupGoLabel = TKS.OutGame.UiKit.LabelOf(popupGo);
|
||
popupCost = TKS.OutGame.UiKit.BtnCost(popupGo, "cur_stamina", "", 44f, 38);
|
||
popupSweep = TKS.OutGame.UiKit.SkinBtn(panel.transform, "Sweep", "btn_s_green", Color.white, "소탕", 40, Color.white, 90f, PopupBtnY + 4f, 280f, 100f, OnSweepClicked);
|
||
popupSweepLabel = TKS.OutGame.UiKit.LabelOf(popupSweep);
|
||
popupSweep.gameObject.SetActive(false);
|
||
popup.SetActive(false);
|
||
return;
|
||
}
|
||
popupGo = WideButton("Go", panel.transform, "출전", new Vector2(-180f, 80f), UISkin.Col("red", new Color(0.89f, 0.33f, 0.23f)), ConfirmPopup, out popupGoLabel);
|
||
Text closeLabel;
|
||
WideButton("Close", panel.transform, "닫기", new Vector2(180f, 80f), UISkin.Col("dark", new Color(0.6f, 0.55f, 0.5f)), ClosePopup, out closeLabel);
|
||
popupSweep = WideButton("Sweep", panel.transform, "소탕", new Vector2(0f, 200f), UISkin.Col("ok", new Color(0.35f, 0.5f, 0.35f)), OnSweepClicked, out popupSweepLabel);
|
||
popupSweep.gameObject.SetActive(false);
|
||
popup.SetActive(false);
|
||
}
|
||
|
||
// ---------- 그리기 도우미 ----------
|
||
|
||
void Update()
|
||
{
|
||
if (cursor != null) cursor.anchoredPosition = cursorBase + new Vector2(0f, 10f * Mathf.Abs(Mathf.Sin(Time.unscaledTime * 4f)));
|
||
bool interact = Time.unscaledTime >= inputBlockUntil;
|
||
if (group != null && group.interactable != interact) group.interactable = interact; // 바뀔 때만(버튼 색 전환 → 재구성 방지)
|
||
}
|
||
|
||
static Vector2 V(float[] a)
|
||
{
|
||
return a != null && a.Length >= 2 ? new Vector2(a[0], a[1]) : Vector2.zero;
|
||
}
|
||
|
||
static Sprite Sprite(string name)
|
||
{
|
||
return Resources.Load<Sprite>(UiRoot + name);
|
||
}
|
||
|
||
/// <summary>맵 px 위치(y 아래로)에 부품 1개. 크기 = kit refSize · 피벗 = kit pivot.</summary>
|
||
Image Piece(string name, Vector2 mapPos, bool raycast)
|
||
{
|
||
var sp = Sprite(name);
|
||
var kit = data.Kit(name);
|
||
if (sp == null || kit == null) return null;
|
||
var go = new GameObject(name);
|
||
go.transform.SetParent(content, false);
|
||
var rt = go.AddComponent<RectTransform>();
|
||
rt.anchorMin = rt.anchorMax = new Vector2(0f, 1f);
|
||
rt.pivot = new Vector2(kit.pivot[0], 1f - kit.pivot[1]);
|
||
rt.sizeDelta = new Vector2(kit.refSize[0], kit.refSize[1]);
|
||
rt.anchoredPosition = new Vector2(mapPos.x, -mapPos.y);
|
||
var img = go.AddComponent<Image>();
|
||
img.sprite = sp;
|
||
img.raycastTarget = raycast;
|
||
spawned.Add(go);
|
||
return img;
|
||
}
|
||
|
||
static RectTransform Rect(string name, Transform parent, Vector2 aMin, Vector2 aMax, Vector2 pivot, Vector2 pos, Vector2 size)
|
||
{
|
||
var go = new GameObject(name);
|
||
go.transform.SetParent(parent, false);
|
||
var rt = go.AddComponent<RectTransform>();
|
||
rt.anchorMin = aMin;
|
||
rt.anchorMax = aMax;
|
||
rt.pivot = pivot;
|
||
rt.anchoredPosition = pos;
|
||
rt.sizeDelta = size;
|
||
return rt;
|
||
}
|
||
|
||
static Image Img(string name, Transform parent, Sprite sp, Vector2 aMin, Vector2 aMax, Vector2 pivot, Vector2 pos, Vector2 size)
|
||
{
|
||
var rt = Rect(name, parent, aMin, aMax, pivot, pos, size);
|
||
var img = rt.gameObject.AddComponent<Image>();
|
||
img.sprite = sp;
|
||
img.raycastTarget = false;
|
||
return img;
|
||
}
|
||
|
||
/// <summary>부모 안 anchor 지점(비율 · 아래 원점)에 가운데 맞춤 글자(흰 글자 + 검정 테).</summary>
|
||
static Text Label(Transform parent, string text, int size, Color color, Vector2 anchor)
|
||
{
|
||
var rt = Rect("Text", parent, anchor, anchor, new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(600f, size * 2.6f));
|
||
var t = rt.gameObject.AddComponent<Text>();
|
||
t.font = TextUtil.Font;
|
||
t.fontSize = size;
|
||
t.fontStyle = FontStyle.Bold;
|
||
t.alignment = TextAnchor.MiddleCenter;
|
||
t.color = color;
|
||
t.horizontalOverflow = HorizontalWrapMode.Overflow;
|
||
t.verticalOverflow = VerticalWrapMode.Overflow;
|
||
t.raycastTarget = false;
|
||
t.text = text;
|
||
var o = rt.gameObject.AddComponent<Outline>();
|
||
o.effectColor = Ink;
|
||
o.effectDistance = new Vector2(3f, -3f);
|
||
return t;
|
||
}
|
||
|
||
Button SmallButton(string name, Transform parent, string label, Vector2 anchor, Vector2 pos, Action onClick)
|
||
{
|
||
var img = Img(name, parent, Sprite("plate"), anchor, anchor, new Vector2(0.5f, 0.5f), pos, new Vector2(110f, 80f));
|
||
img.type = Image.Type.Sliced;
|
||
img.raycastTarget = true;
|
||
var b = img.gameObject.AddComponent<Button>();
|
||
b.onClick.AddListener(() => onClick());
|
||
var t = Label(img.transform, label, 40, new Color(0.45f, 0.22f, 0.12f), new Vector2(0.5f, 0.55f));
|
||
t.GetComponent<Outline>().enabled = false;
|
||
return b;
|
||
}
|
||
|
||
Button WideButton(string name, Transform parent, string label, Vector2 pos, Color tint, Action onClick, out Text labelText)
|
||
{
|
||
var img = Img(name, parent, Sprite("plate"), new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0.5f, 0.5f), pos, new Vector2(300f, 100f));
|
||
img.type = Image.Type.Sliced;
|
||
bool pack = UISkin.Apply(img, "button");
|
||
if (pack) img.rectTransform.sizeDelta = new Vector2(320f, 112f);
|
||
img.color = tint;
|
||
img.raycastTarget = true;
|
||
var b = img.gameObject.AddComponent<Button>();
|
||
b.onClick.AddListener(() => onClick());
|
||
labelText = Label(img.transform, label, 42, pack ? Color.white : Paper, new Vector2(0.5f, pack ? 0.6f : 0.55f));
|
||
if (pack)
|
||
{
|
||
labelText.font = UISkin.TitleFont;
|
||
labelText.GetComponent<Outline>().effectColor = UISkin.Col("stroke", Ink);
|
||
}
|
||
return b;
|
||
}
|
||
}
|
||
}
|