using System; using System.Collections.Generic; using System.Globalization; using UnityEngine; namespace TKS { public enum GimmickKind { Block = 0, // 장애물: 이동·(설정에 따라) 투사체를 막는 콜라이더 + 내구도 Zone = 1 // 지대: 겹치면 효과(경로 스윕 판정) } public enum GimmickProjectile { Pass = 0, // 투사체 통과(목책 · 강) Block = 1, // 투사체 막힘(덤불 · 바위 · 성문 · 성벽) Destroy = 2 // 맞으면 파괴(폭약 수레) } public enum GimmickZone { None = 0, Poison = 1, // 독늪 Slow = 2, // 늪지 Burn = 3, // 불타는 잔해 Slide = 4, // 빙판 High = 5, // 고지 Fog = 6, // 안개 Capture = 7, // 군기 Trap = 8, // 함정 River = 9 // 강(장애물 레코드의 효과) } public enum GimmickShapeType { Circle = 0, Rect = 1, Poly = 2 } /// 기믹 정의 1행(gimmicks.csv · SOT = 기획 tables/ · C45 — 코드에는 열 이름과 뜻만). [Serializable] public class GimmickDef { public string id; public string nameKo; public GimmickKind kind; public bool durable; public int hp; public int dmgInf; public int dmgSpear; public int dmgCav; public float spearPush; public float cavSelfPct; public float bouncePct; public GimmickProjectile projectile; public bool strategistClear; public GimmickZone zone; [Tooltip("빈 칸 = NaN (desc_ko 끝 [ ] 메모가 뜻)")] public float p1 = float.NaN; public float p2 = float.NaN; public float p3 = float.NaN; public string art = ""; public string artBroken = ""; public string descKo = ""; /// 병종이 부딪혀 주는 내구 피해(궁·책 = 0 · 내구 없는 기믹 = 0). public int DamageBy(UnitType t) { if (!durable) return 0; switch (t) { case UnitType.Infantry: return dmgInf; case UnitType.Spear: return dmgSpear; case UnitType.Cavalry: return dmgCav; } return 0; } } /// 스테이지 배치 1행(stage_gimmicks.csv). 좌표 = 전장 월드(가운데 0,0 · 아군 아래). [Serializable] public class GimmickPlacement { public string stage = ""; public int chapter; public int stageNo; public string gimmickId = ""; public GimmickShapeType shape; public float cx; public float cy; [Tooltip("circle = 반지름 · rect = 가로")] public float wOrR; public float h; [Tooltip("rect 회전(도 · 반시계)")] public float rot; [Tooltip("poly 꼭짓점(월드) · x:y;x:y;…")] public Vector2[] points = new Vector2[0]; [Tooltip("-1 = 정의 hp")] public int hpOverride = -1; public string paramsRaw = ""; public float pOver1 = float.NaN; public float pOver2 = float.NaN; public float pOver3 = float.NaN; public int line; public GimmickPlacement Clone() { var c = (GimmickPlacement)MemberwiseClone(); c.points = points != null ? (Vector2[])points.Clone() : new Vector2[0]; return c; } } /// /// 기믹 표(Assets/Data/Gimmicks/*.csv · utf-8-sig) 해석·검증(PD #1097). 형식·참조 오류는 errors 에 모은다(하나라도 있으면 임포터가 자산을 바꾸지 않는다). /// 기믹 id 중 동작이 특별한 것(바위 굴림 · 다리 · 불타는 잔해 생성) = GimmickIds — 수치는 모두 CSV. /// public static class GimmickTables { public const string Folder = "Data/Gimmicks"; // Assets/ 아래 /// 정의 · 배치 · 스테이지 시작 팁(1장 튜토리얼 문구 · PD #1098 §3). public static readonly string[] Files = { "gimmicks.csv", "stage_gimmicks.csv", "tutorial_tips.csv" }; public const string ResourcesFolder = "Gimmicks/"; /// 배치 params 에 쓸 수 있는 키(p1~p3 = 정의 p 덮어쓰기 · 별칭 = 불 burn_turns→p2 · spawn_on_splash→p3 · 빙판 slide_max→p1). public static readonly string[] ParamKeys = { "p1", "p2", "p3", "burn_turns", "spawn_on_splash", "slide_max" }; public class Result { public readonly List defs = new List(); public readonly List rows = new List(); public readonly List tips = new List(); public readonly List errors = new List(); } static GimmickDb db; static StageGimmickTable table; /// 임포트한 자산(없으면 null · 전투 시작마다 다시 읽으려면 Invalidate). public static GimmickDb Db { get { if (db == null) db = Resources.Load(ResourcesFolder + "GimmickDb"); return db; } } public static StageGimmickTable Table { get { if (table == null) table = Resources.Load(ResourcesFolder + "StageGimmickTable"); return table; } } public static void Invalidate() { db = null; table = null; } /// texts = Files 순서 · stageExists(장, 번호) = 스테이지 표에 있는가(null = 검사 생략). public static Result Parse(string[] texts, Func stageExists = null) { var r = new Result(); var ids = new HashSet(); foreach (var row in StageTable.ReadCsv(texts.Length > 0 ? texts[0] : null, "gimmicks.csv", r.errors)) { string at = "gimmicks.csv 줄 " + Get(row, "#"); var d = new GimmickDef { id = Get(row, "id"), nameKo = Get(row, "name_ko"), art = Get(row, "art"), artBroken = Get(row, "art_broken"), descKo = Get(row, "desc_ko") }; if (d.id.Length == 0) r.errors.Add(at + ": id 비어 있음"); else if (!ids.Add(d.id)) r.errors.Add(at + ": id 중복 「" + d.id + "」"); switch (Get(row, "kind")) { case "block": d.kind = GimmickKind.Block; break; case "zone": d.kind = GimmickKind.Zone; break; default: r.errors.Add(at + ": kind 는 block|zone(「" + Get(row, "kind") + "」)"); break; } d.durable = Bool(row, "durable", r.errors, at); d.hp = Int(row, "hp", r.errors, at); d.dmgInf = Int(row, "dmg_inf", r.errors, at); d.dmgSpear = Int(row, "dmg_spear", r.errors, at); d.dmgCav = Int(row, "dmg_cav", r.errors, at); d.spearPush = Float(row, "spear_push", r.errors, at); d.cavSelfPct = Float(row, "cav_self_pct", r.errors, at); d.bouncePct = Float(row, "bounce_pct", r.errors, at); switch (Get(row, "projectile")) { case "pass": d.projectile = GimmickProjectile.Pass; break; case "block": d.projectile = GimmickProjectile.Block; break; case "destroy": d.projectile = GimmickProjectile.Destroy; break; default: r.errors.Add(at + ": projectile 는 pass|block|destroy(「" + Get(row, "projectile") + "」)"); break; } d.strategistClear = Bool(row, "strategist_clear", r.errors, at); string z = Get(row, "zone_effect"); if (!TryZone(z, out d.zone)) r.errors.Add(at + ": zone_effect 알 수 없음(「" + z + "」)"); d.p1 = OptFloat(row, "p1", r.errors, at); d.p2 = OptFloat(row, "p2", r.errors, at); d.p3 = OptFloat(row, "p3", r.errors, at); if (d.durable && d.hp <= 0) r.errors.Add(at + ": durable 1 인데 hp ≤ 0"); if (d.kind == GimmickKind.Zone && d.durable) r.errors.Add(at + ": 지대(zone)는 내구도가 없다"); r.defs.Add(d); } foreach (var row in StageTable.ReadCsv(texts.Length > 1 ? texts[1] : null, "stage_gimmicks.csv", r.errors)) { string at = "stage_gimmicks.csv 줄 " + Get(row, "#"); var p = new GimmickPlacement { stage = Get(row, "stage"), gimmickId = Get(row, "gimmick_id"), paramsRaw = Get(row, "params") }; int.TryParse(Get(row, "#"), NumberStyles.Integer, CultureInfo.InvariantCulture, out p.line); var parts = p.stage.Split('-'); if (parts.Length != 2 || !int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out p.chapter) || !int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out p.stageNo) || p.chapter <= 0 || p.stageNo <= 0) { r.errors.Add(at + ": stage 는 「장-번호」(「" + p.stage + "」)"); } else if (stageExists != null && !stageExists(p.chapter, p.stageNo)) { r.errors.Add(at + ": 스테이지 표에 없는 스테이지 「" + p.stage + "」"); } GimmickDef def = null; foreach (var d in r.defs) { if (d.id == p.gimmickId) def = d; } if (def == null) r.errors.Add(at + ": gimmicks.csv 에 없는 gimmick_id 「" + p.gimmickId + "」"); switch (Get(row, "shape")) { case "circle": p.shape = GimmickShapeType.Circle; break; case "rect": p.shape = GimmickShapeType.Rect; break; case "poly": p.shape = GimmickShapeType.Poly; break; default: r.errors.Add(at + ": shape 는 circle|rect|poly(「" + Get(row, "shape") + "」)"); break; } if (p.shape != GimmickShapeType.Poly) { p.cx = Float(row, "cx", r.errors, at); p.cy = Float(row, "cy", r.errors, at); p.wOrR = Float(row, "w_or_r", r.errors, at); if (p.wOrR <= 0f) r.errors.Add(at + ": w_or_r ≤ 0"); } if (p.shape == GimmickShapeType.Rect) { p.h = Float(row, "h", r.errors, at); if (p.h <= 0f) r.errors.Add(at + ": rect h ≤ 0"); } float rot = OptFloat(row, "rot", r.errors, at); p.rot = float.IsNaN(rot) ? 0f : rot; if (p.shape == GimmickShapeType.Poly) { p.points = ParsePoints(Get(row, "points"), r.errors, at); if (p.points.Length < 3) r.errors.Add(at + ": poly 꼭짓점 3개 이상(x:y;x:y;x:y)"); else { Vector2 c = Vector2.zero; foreach (var v in p.points) c += v; c /= p.points.Length; p.cx = c.x; p.cy = c.y; } } string hpo = Get(row, "hp_override"); if (hpo.Length > 0) { if (!int.TryParse(hpo, NumberStyles.Integer, CultureInfo.InvariantCulture, out p.hpOverride) || p.hpOverride <= 0) r.errors.Add(at + ": hp_override 양의 정수(「" + hpo + "」)"); } ParseParams(p, def, r.errors, at); r.rows.Add(p); } if (texts.Length > 2 && !string.IsNullOrEmpty(texts[2])) { foreach (var row in StageTable.ReadCsv(texts[2], "tutorial_tips.csv", r.errors)) { string at = "tutorial_tips.csv 줄 " + Get(row, "#"); var t = new StageGimmickTable.Tip { stage = Get(row, "stage"), text = Get(row, "tip_ko") }; var parts = t.stage.Split('-'); if (parts.Length != 2 || !int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out t.chapter) || !int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out t.stageNo)) r.errors.Add(at + ": stage 는 「장-번호」(「" + t.stage + "」)"); else if (stageExists != null && !stageExists(t.chapter, t.stageNo)) r.errors.Add(at + ": 스테이지 표에 없는 스테이지 「" + t.stage + "」"); if (t.text.Length == 0) r.errors.Add(at + ": tip_ko 비어 있음"); r.tips.Add(t); } } return r; } static void ParseParams(GimmickPlacement p, GimmickDef def, List errors, string at) { if (string.IsNullOrEmpty(p.paramsRaw)) return; foreach (var kv in p.paramsRaw.Split(';')) { if (kv.Trim().Length == 0) continue; var f = kv.Split('='); float v; if (f.Length != 2 || !float.TryParse(f[1].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out v)) { errors.Add(at + ": params 형식 「키=숫자;…」(「" + kv + "」)"); continue; } switch (f[0].Trim()) { case "p1": case "slide_max": p.pOver1 = v; break; case "p2": case "burn_turns": p.pOver2 = v; break; case "p3": case "spawn_on_splash": p.pOver3 = v; break; default: errors.Add(at + ": params 키 「" + f[0].Trim() + "」 는 " + string.Join("·", ParamKeys) + " 중 하나"); break; } } if (def != null && p.paramsRaw.Contains("spawn_on_splash") && def.zone != GimmickZone.Burn) errors.Add(at + ": spawn_on_splash 는 불(burn) 기믹만"); } public static Vector2[] ParsePoints(string s, List errors, string at) { var list = new List(); if (string.IsNullOrEmpty(s)) return list.ToArray(); foreach (var part in s.Split(';')) { if (part.Trim().Length == 0) continue; var xy = part.Split(':'); float x, y; if (xy.Length != 2 || !float.TryParse(xy[0].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out x) || !float.TryParse(xy[1].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out y)) { errors.Add(at + ": points 형식 x:y;x:y(「" + part + "」)"); continue; } list.Add(new Vector2(x, y)); } return list.ToArray(); } public static string FormatPoints(Vector2[] pts) { if (pts == null || pts.Length == 0) return ""; var s = new string[pts.Length]; for (int i = 0; i < pts.Length; i++) s[i] = F(pts[i].x) + ":" + F(pts[i].y); return string.Join(";", s); } public static string F(float v) { return Mathf.Approximately(v, Mathf.Round(v)) ? Mathf.RoundToInt(v).ToString(CultureInfo.InvariantCulture) : v.ToString("0.###", CultureInfo.InvariantCulture); } public static bool TryZone(string s, out GimmickZone z) { switch (s) { case "": z = GimmickZone.None; return true; case "poison": z = GimmickZone.Poison; return true; case "slow": z = GimmickZone.Slow; return true; case "burn": z = GimmickZone.Burn; return true; case "slide": z = GimmickZone.Slide; return true; case "high": z = GimmickZone.High; return true; case "fog": z = GimmickZone.Fog; return true; case "capture": z = GimmickZone.Capture; return true; case "trap": z = GimmickZone.Trap; return true; case "river": z = GimmickZone.River; return true; } z = GimmickZone.None; return false; } static string Get(Dictionary r, string key) { string v; return r.TryGetValue(key, out v) ? v : ""; } static int Int(Dictionary r, string key, List errors, string at) { int v; if (int.TryParse(Get(r, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) return v; errors.Add(at + ": " + key + " 정수 아님(「" + Get(r, key) + "」)"); return 0; } static bool Bool(Dictionary r, string key, List errors, string at) { string s = Get(r, key); if (s == "1") return true; if (s == "0") return false; errors.Add(at + ": " + key + " 는 0|1(「" + s + "」)"); return false; } static float Float(Dictionary r, string key, List errors, string at) { float v; if (float.TryParse(Get(r, key), NumberStyles.Float, CultureInfo.InvariantCulture, out v)) return v; errors.Add(at + ": " + key + " 숫자 아님(「" + Get(r, key) + "」)"); return 0f; } static float OptFloat(Dictionary r, string key, List errors, string at) { return Get(r, key).Length == 0 ? float.NaN : Float(r, key, errors, at); } } /// 동작이 id 로 정해지는 기믹(수치는 CSV · 새 기믹이 같은 동작을 쓰면 여기 한 줄). public static class GimmickIds { /// 보·창이 밀면 굴러가는 바위(남은 거리 × spear_push · 적에 닿으면 공 ×p1 + 밀기 p2). public const string Boulder = "boulder"; /// 강 위 통로(겹친 강 구간은 콜라이더에서 뺀다 · 다리 위에서 옆으로 밀리면 강 판정). public const string Bridge = "bridge"; /// 폭약 수레 폭발 · 책사 광역(spawn_on_splash)이 만드는 불 지대의 정의. public const string BurningDebris = "burning_debris"; /// 목책(노숙 녹각진이 세우는 장애물 · 2026-11-10 #1112). public const string Palisade = "palisade"; } }