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