ThreeKingdomsSlingshot/Assets/Scripts/Runtime/Gimmicks/GimmickData.cs

437 lines
19 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 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
}
/// <summary>기믹 정의 1행(gimmicks.csv · SOT = 기획 tables/ · C45 — 코드에는 열 이름과 뜻만).</summary>
[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 = "";
/// <summary>병종이 부딪혀 주는 내구 피해(궁·책 = 0 · 내구 없는 기믹 = 0).</summary>
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;
}
}
/// <summary>스테이지 배치 1행(stage_gimmicks.csv). 좌표 = 전장 월드(가운데 0,0 · 아군 아래).</summary>
[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;
}
}
/// <summary>
/// 기믹 표(Assets/Data/Gimmicks/*.csv · utf-8-sig) 해석·검증(PD #1097). 형식·참조 오류는 errors 에 모은다(하나라도 있으면 임포터가 자산을 바꾸지 않는다).
/// 기믹 id 중 동작이 특별한 것(바위 굴림 · 다리 · 불타는 잔해 생성) = GimmickIds — 수치는 모두 CSV.
/// </summary>
public static class GimmickTables
{
public const string Folder = "Data/Gimmicks"; // Assets/ 아래
/// <summary>정의 · 배치 · 스테이지 시작 팁(1장 튜토리얼 문구 · PD #1098 §3).</summary>
public static readonly string[] Files = { "gimmicks.csv", "stage_gimmicks.csv", "tutorial_tips.csv" };
public const string ResourcesFolder = "Gimmicks/";
/// <summary>배치 params 에 쓸 수 있는 키(p1~p3 = 정의 p 덮어쓰기 · 별칭 = 불 burn_turns→p2 · spawn_on_splash→p3 · 빙판 slide_max→p1).</summary>
public static readonly string[] ParamKeys = { "p1", "p2", "p3", "burn_turns", "spawn_on_splash", "slide_max" };
public class Result
{
public readonly List<GimmickDef> defs = new List<GimmickDef>();
public readonly List<GimmickPlacement> rows = new List<GimmickPlacement>();
public readonly List<StageGimmickTable.Tip> tips = new List<StageGimmickTable.Tip>();
public readonly List<string> errors = new List<string>();
}
static GimmickDb db;
static StageGimmickTable table;
/// <summary>임포트한 자산(없으면 null · 전투 시작마다 다시 읽으려면 Invalidate).</summary>
public static GimmickDb Db
{
get
{
if (db == null) db = Resources.Load<GimmickDb>(ResourcesFolder + "GimmickDb");
return db;
}
}
public static StageGimmickTable Table
{
get
{
if (table == null) table = Resources.Load<StageGimmickTable>(ResourcesFolder + "StageGimmickTable");
return table;
}
}
public static void Invalidate()
{
db = null;
table = null;
}
/// <summary>texts = Files 순서 · stageExists(장, 번호) = 스테이지 표에 있는가(null = 검사 생략).</summary>
public static Result Parse(string[] texts, Func<int, int, bool> stageExists = null)
{
var r = new Result();
var ids = new HashSet<string>();
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<string> 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<string> errors, string at)
{
var list = new List<Vector2>();
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<string, string> r, string key)
{
string v;
return r.TryGetValue(key, out v) ? v : "";
}
static int Int(Dictionary<string, string> r, string key, List<string> 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<string, string> r, string key, List<string> 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<string, string> r, string key, List<string> 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<string, string> r, string key, List<string> errors, string at)
{
return Get(r, key).Length == 0 ? float.NaN : Float(r, key, errors, at);
}
}
/// <summary>동작이 id 로 정해지는 기믹(수치는 CSV · 새 기믹이 같은 동작을 쓰면 여기 한 줄).</summary>
public static class GimmickIds
{
/// <summary>보·창이 밀면 굴러가는 바위(남은 거리 × spear_push · 적에 닿으면 공 ×p1 + 밀기 p2).</summary>
public const string Boulder = "boulder";
/// <summary>강 위 통로(겹친 강 구간은 콜라이더에서 뺀다 · 다리 위에서 옆으로 밀리면 강 판정).</summary>
public const string Bridge = "bridge";
/// <summary>폭약 수레 폭발 · 책사 광역(spawn_on_splash)이 만드는 불 지대의 정의.</summary>
public const string BurningDebris = "burning_debris";
/// <summary>목책(노숙 녹각진이 세우는 장애물 · 2026-11-10 #1112).</summary>
public const string Palisade = "palisade";
}
}