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

437 lines
19 KiB
C#
Raw Permalink Normal View History

지형 기믹 레이어(PD #1097 세션 D) + 병종 규칙 개정 2건 · 예측 시뮬레이션 적 AI(PD #1098) - 데이터: Assets/Data/Gimmicks/{gimmicks,stage_gimmicks,tutorial_tips}.csv + Assets/Data/AI/ai_weights.csv(기획 tables/ 사본) → 메뉴 TKS/Import Gimmicks(검증 · 오류 시 자산 불변) → Resources/Gimmicks/GimmickDb(16)·StageGimmickTable(16 + 팁 3) · Resources/AI/AiWeights(32) - 레이어: GimmickLayer(전투 시작 = 스테이지 배치 · 배경 위 유닛 아래 · 원/사각/다각 · 색 도형 + 글자 · 내구 핍 · 강 콜라이더 = 다리 구간 제외 · 겹친 유닛 보정) · 장애물 = 병종별 내구 피해·튕김·창 목책 밀기·바위 굴림·기병 정지 · 투사체 pass/block/destroy · 폭약 광역(아군 포함)+밀기+불 · 책사 광역 덤불 제거 · 강 낙하 - 지대: 경로 스윕(독 1→2→3 % · 늪 ×2 · 불 10 % · 빙판 미끄러짐 · 고지 · 안개 · 군기 · 함정) · 양 진영 턴 시작 훅 - 규칙 개정: 보병 튕김 중 다른 적과 연쇄 1회 · 창병 반작용(spearRecoilRatio · 밀며 전진 폐기) · 기존 테스트 기대값 갱신 - 적 AI: Runtime/AI(Sim 가상 실행 · TacticalAI 후보·점수·난이도·턴 운영 · 1부 보스 액티브) · 조준선 = 가상 실행 예상 궤적·기믹 반응·지대 띠 · TKS/Gimmick Painter · 캡처 GimmickImporter.Capture - 검증: 컴파일 0 · 플레이모드 113/113(신규 26) · console error 0 · 적 10기 턴 결정 62 ms Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:20:07 +00:00
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";
장수 스킬 144개 새 기획 구현(#1112 · PD 2026-11-10 엑셀 「장수 목록」) — 효과 표 skill_effects.csv(효과 1줄 · 한글 주석 열) · 이름 56개 새로 - 수치 SOT = Assets/Data/OutGame/skill_effects.csv(skill_id·key·target·cond·dur·p1~p3 + effect_ko·p1_ko~p3_ko 주석) · skills.csv effect_key = 키 목록 · heroes.csv gender(m|f) - 처리기 121종(SkillKeys) · 대상 칸(자신·같은 편·진영·반경·행동 반경) · 조건 칸(치명타·상대 상태 이상·병종·성별·턴·장착 중) · 지속 라운드 - 상태 이상 = 혼란·출혈(이동 거리 비례)·중독(중첩)·화상 + 기절(기믹 행동 불가 재사용) · 정화 · 회복·흡혈·부활·무적·무효화·호위(반격) - 재행동(유비·위연·여몽·손견·곽가·우금·맹획·감녕·고순·장합) · 치명타 확률·피해·무조건 - 이동 변형: 관통(손견·안량·손책 아군 통과) · 핀볼 튕김(손책·동탁) · 복귀(조운·손책·여포·문추·공손찬·순우경 · 하후돈 탭 지점·쓸어 감) · 위치 교환(위연) · 끌어오기(장료) · 아군 통과+탭 방향 밀치기(조조) · 반복(정보) · 난동(동탁) - 특수 사격: 거대 화살 · 직선 관통 · 폭발(화염구·독구름·운석·화살비) · 부채꼴 · 멀티샷 · 도탄 · 회오리 · 목책 · 불 장벽·불꽃·독 지대 · 궁병식 비도 · 광역 회복 - 즉시형: 광역 타격·밀기 · 흡수 · 일제 사격 · 미인계 끌어오기 · 범위 회복·정화 · 재행동 부여 · 변진 - 가상 실행(적 AI) 스킬 상태 사본(TakeState/RestoreState) · 옛 견제 스킬 확장 제거 - 테스트(고친 부분 클래스만): SkillTests 19(새로) · BattleFlowTests 42 · TacticalAITests 7 · GimmickTests 19 · OutGameTests 15 · OutGameCTests 12 전부 통과 · console error 0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:00:18 +00:00
/// <summary>목책(노숙 녹각진이 세우는 장애물 · 2026-11-10 #1112).</summary>
public const string Palisade = "palisade";
지형 기믹 레이어(PD #1097 세션 D) + 병종 규칙 개정 2건 · 예측 시뮬레이션 적 AI(PD #1098) - 데이터: Assets/Data/Gimmicks/{gimmicks,stage_gimmicks,tutorial_tips}.csv + Assets/Data/AI/ai_weights.csv(기획 tables/ 사본) → 메뉴 TKS/Import Gimmicks(검증 · 오류 시 자산 불변) → Resources/Gimmicks/GimmickDb(16)·StageGimmickTable(16 + 팁 3) · Resources/AI/AiWeights(32) - 레이어: GimmickLayer(전투 시작 = 스테이지 배치 · 배경 위 유닛 아래 · 원/사각/다각 · 색 도형 + 글자 · 내구 핍 · 강 콜라이더 = 다리 구간 제외 · 겹친 유닛 보정) · 장애물 = 병종별 내구 피해·튕김·창 목책 밀기·바위 굴림·기병 정지 · 투사체 pass/block/destroy · 폭약 광역(아군 포함)+밀기+불 · 책사 광역 덤불 제거 · 강 낙하 - 지대: 경로 스윕(독 1→2→3 % · 늪 ×2 · 불 10 % · 빙판 미끄러짐 · 고지 · 안개 · 군기 · 함정) · 양 진영 턴 시작 훅 - 규칙 개정: 보병 튕김 중 다른 적과 연쇄 1회 · 창병 반작용(spearRecoilRatio · 밀며 전진 폐기) · 기존 테스트 기대값 갱신 - 적 AI: Runtime/AI(Sim 가상 실행 · TacticalAI 후보·점수·난이도·턴 운영 · 1부 보스 액티브) · 조준선 = 가상 실행 예상 궤적·기믹 반응·지대 띠 · TKS/Gimmick Painter · 캡처 GimmickImporter.Capture - 검증: 컴파일 0 · 플레이모드 113/113(신규 26) · console error 0 · 적 10기 턴 결정 62 ms Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:20:07 +00:00
}
}