ThreeKingdomsSlingshot/Assets/Scripts/Runtime/WorldMapScreen.cs

958 lines
45 KiB
C#
Raw Permalink 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 의 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/&lt;map&gt;.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/&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]);
}
}
/// <summary>Data/&lt;맵&gt;.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/&lt;맵&gt;.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/&lt;맵&gt;.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;
}
}
}