ThreeKingdomsSlingshot/Assets/Scripts/Runtime/WorldMapScreen.cs

729 lines
32 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 의 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/&lt;map&gt;.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]);
}
}
public enum NodeState { Lock, Open, Clear }
/// <summary>임시 진행도(실행 중 메모리 · 저장 없음). 챕터별 깬 노드 수.</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 · 노드 자리 = anchors 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;
int popupNode = -1;
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) = anchors slots_N · 그 N 이 없으면 길 위 균등.</summary>
public Vector2[] NodeSlots(int ch, out bool fromAnchors)
{
int n = NodesIn(ch);
var wc = MapOf(ch);
var s = wc != null ? WorldData.AnchorSlots(wc.map, n) : null;
fromAnchors = s != null;
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;
root = new GameObject("WorldMapCanvas");
root.transform.SetParent(transform, false);
var canvas = root.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceCamera; // 카메라 캡처(자동 플레이)에도 찍히게
canvas.worldCamera = cam;
canvas.planeDistance = 0.5f;
canvas.sortingOrder = 300;
var scaler = root.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(data.viewSize[0], data.viewSize[1]);
scaler.matchWidthOrHeight = 0.5f;
root.AddComponent<GraphicRaycaster>();
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]));
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));
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));
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);
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; // 결과 화면 탭이 노드 클릭으로 이어지지 않게
ShowChapter(ch);
}
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;
bool fromAnchors;
var slots = NodeSlots(chapter, out fromAnchors);
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;
for (int k = 0; k + 1 < so.Length; k += 2) Piece("star_on", 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;
}
}
}
// 처음 위치 = 도전할 노드(없으면 출발)
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 (st == NodeState.Lock) body.Append("\n\n잠긴 스테이지 — 앞 스테이지를 먼저 깨세요");
popupBody.text = body.ToString();
popupGo.interactable = st != NodeState.Lock;
popupGoLabel.text = st == NodeState.Clear ? "다시 출전" : "출전";
popup.SetActive(true);
}
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;
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);
}
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();
}
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;
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(880f, 760f));
panel.type = Image.Type.Sliced;
panel.raycastTarget = true;
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, "", 34, new Color(0.25f, 0.15f, 0.1f), new Vector2(0.5f, 0.55f));
popupBody.GetComponent<Outline>().enabled = false;
popupBody.rectTransform.sizeDelta = new Vector2(780f, 420f);
popupBody.horizontalOverflow = HorizontalWrapMode.Wrap;
popupBody.supportRichText = true;
popupGo = WideButton("Go", panel.transform, "출전", new Vector2(-180f, 80f), new Color(0.89f, 0.33f, 0.23f), ConfirmPopup, out popupGoLabel);
Text closeLabel;
WideButton("Close", panel.transform, "닫기", new Vector2(180f, 80f), new Color(0.6f, 0.55f, 0.5f), ClosePopup, out closeLabel);
popup.SetActive(false);
}
// ---------- 그리기 도우미 ----------
void Update()
{
if (cursor != null) cursor.anchoredPosition = cursorBase + new Vector2(0f, 10f * Mathf.Abs(Mathf.Sin(Time.unscaledTime * 4f)));
if (group != null) group.interactable = Time.unscaledTime >= inputBlockUntil;
}
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;
img.color = tint;
img.raycastTarget = true;
var b = img.gameObject.AddComponent<Button>();
b.onClick.AddListener(() => onClick());
labelText = Label(img.transform, label, 42, Paper, new Vector2(0.5f, 0.55f));
return b;
}
}
}