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;
}
/// Data/<map>.json 의 stages(기획 확정판 맵 · 스테이지 번호·보스·지명·노드 좌표 at). 없으면 null.
public static AnchorStage[] AnchorStages(string map)
{
var t = Resources.Load(DataRoot + map);
if (t == null) return null;
try
{
var f = JsonUtility.FromJson(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;
}
}
/// Data/<map>.json(원화 세션 anchors) 의 slots_n(맵 px · 첫 칸 = 출발). 없으면 null.
public static Vector2[] AnchorSlots(string map, int n)
{
var t = Resources.Load(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;
}
/// 점 p 에 가장 가까운 길 위 지점의 호 길이.
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(Path);
if (t == null) return null;
try
{
return JsonUtility.FromJson(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;
}
/// 길(road) 을 호 길이로 n 등분한 노드 자리(맵 px). 첫 칸 = 출발 · 마지막 = 보스.
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]);
}
}
/// Data/<맵>.json 의 stages 한 칸(챕터맵 보강 세션 기획 확정판 · 기획서 §4 번호·유형·지명 + 노드 좌표).
[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 }
/// 노드 자리 출처: 기획 확정판 stages[].at · 가안 slots_N · 길 위 균등(대체).
public enum NodeSource { Stages, Slots, Road }
/// 진행도(실행 중 메모리) · 챕터별 깬 노드 수. 시작 때 아웃게임 세이브(stages)에서 채운다(OutGameSession.SyncWorldProgress · #1093).
public static class WorldProgress
{
static readonly Dictionary cleared = new Dictionary();
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();
}
}
///
/// 게임 시작 화면 = 챕터 월드맵(스크롤 · 상하좌우로 끌기). 길 위 깃발 노드를 누르면 스테이지 팝업 → 출전 = 전투 시작 ·
/// 전투 결과를 탭하면 월드맵으로 돌아와 다음 노드가 열린다. 그림·길·부품 배치 = world.json · 노드 자리 = Data/<맵>.json stages[].at(없으면 slots_N → 길 위 균등) ·
/// 챕터·노드·전투 = BalanceConfig.chapters·stages(스테이지 표 · C45) · 보스 노드 = type boss.
///
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 nodeStages = new List(); // 현재 챕터 노드 → 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 spawned = new List();
readonly List nodeButtons = new List();
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 가 채움 · 비면 기존 동작) ----------
/// 스테이지 인덱스 → 팝업 추가 줄(군량 · 보상 · 별).
public Func PopupInfo;
/// 스테이지 팝업 본문 그리기(스테이지 인덱스 · 잠김 · 필드) — 있으면 글자 본문 대신(Fable 제안서 §4-3 · #1104).
public Action PopupFill;
/// 출전 군량(스테이지 인덱스) — 출정 버튼 비용 칩.
public Func CostOf;
/// 출전 직전(군량 지불) · false = 출전 안 함(부족 안내는 연결한 쪽이 띄움).
public Func TryEnter;
/// 깬 노드 별 수(0~3) · null 이면 깬 노드 = 별 전부.
public Func StarsOf;
/// 소탕(별 3 · 전투 없음) 가능 여부 · 실행.
public Func CanSweep;
public Action Sweep;
/// 「◀ 본영」(로비로) · null 이면 버튼 숨김.
public Action BackRequested;
/// 위 공통 재화 바 높이(UI px) — 챕터 띠·넘김 버튼을 그만큼 내린다.
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; }
/// 챕터(0부터)의 노드 수 = 스테이지 표의 그 챕터 스테이지 수.
public int NodesIn(int ch)
{
return HasData && ch >= 0 && ch < cfg.chapters.Length ? cfg.StagesOfChapter(cfg.chapters[ch].no).Count : 0;
}
/// 챕터(0부터) · 노드(0부터) → BalanceConfig.stages 인덱스(없으면 −1).
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;
}
/// 챕터(0부터)의 맵 길(world.json · chapters.csv map 열). 없으면 첫 맵.
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];
}
///
/// 노드 자리(맵 px) = Data/<맵>.json stages[].at(스테이지 번호로 맞춤 · 기획 확정판 맵) → 없거나 번호가 빠지면 slots_N → 그것도 없으면 길 위 균등.
///
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 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();
// 스크롤(맵 = 콘텐츠 · 왼쪽 위 기준)
viewport = Rect("Viewport", root.transform, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero);
var vpImg = viewport.gameObject.AddComponent();
vpImg.color = new Color(0.1f, 0.12f, 0.1f);
viewport.gameObject.AddComponent();
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();
mapImage.raycastTarget = true; // 맵 아무 데나 끌기
scroll = viewport.gameObject.AddComponent();
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();
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().enabled = false;
BuildPopup();
root.SetActive(false);
}
// ---------- 화면 전환 ----------
/// 월드맵을 연다(전투 정리 · 전투 HUD·조작 끔).
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);
}
/// 월드맵을 닫는다(로비로 돌아갈 때 · 전투는 건드리지 않음).
public void Hide()
{
ClosePopup();
if (root != null) root.SetActive(false);
}
/// 팝업이 열려 있으면 내용을 다시 채운다(군량 구매·소탕 뒤).
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(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().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();
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]);
}
/// 맵 px 지점을 화면 가운데로(맵 끝에서 멈춤).
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").Append(stage.place).Append(string.IsNullOrEmpty(stage.bgTheme) ? "" : " · " + stage.bgTheme).Append(" ");
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);
}
/// 노드의 전투로 들어간다(월드맵 닫음 · HUD·조작 켬).
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);
}
/// 결과 화면 「다시」(#1104) — 결과 처리(진행 기록 · 월드맵 복귀)를 한 뒤 같은 노드로 다시 출전 · 군량이 모자라면 월드맵에 남음(충전 팝업).
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);
}
/// 적 수·병종 + 장수(보스 · 엘리트) 이름.
static string EnemySummary(BalanceConfig.StageDef stage)
{
var counts = new int[UnitTypeInfo.Count];
var bosses = new List();
var elites = new List();
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().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().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().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().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(UiRoot + name);
}
/// 맵 px 위치(y 아래로)에 부품 1개. 크기 = kit refSize · 피벗 = kit pivot.
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();
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();
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();
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();
img.sprite = sp;
img.raycastTarget = false;
return img;
}
/// 부모 안 anchor 지점(비율 · 아래 원점)에 가운데 맞춤 글자(흰 글자 + 검정 테).
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();
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();
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();
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().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();
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().effectColor = UISkin.Col("stroke", Ink);
}
return b;
}
}
}