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";
}
}