using System; using System.Collections.Generic; using System.Globalization; using UnityEngine; namespace TKS.OutGame { /// 아웃게임 데이터 18 자산 묶음(Resources/OutGame/<형식 이름>.asset · 임포트 = TKS/Import OutGame Data · A 9 + C 9 내정·보물·연무장). public class OutGameData { public const string ResourcesFolder = "OutGame/"; public HeroDb Heroes; public GearDb Gear; public GachaConfig Gacha; public EconomyConfig Economy; public RewardTable Rewards; public LevelTable Levels; public PromotionTable Promotion; public EvolveTable Evolve; public SkillInfoDb Skills; // C 단계(PD #1093 C) — 내정 · 보물 · 연무장 public TrainingTable Training; public PatrolTable Patrol; public DevTree Dev; public TreasureDb Treasures; public TreasureSetDb TreasureSets; public ChestDb Chests; public TowerTable Tower; public DungeonDb Dungeons; public ArenaConfig Arena; public bool Complete { get { return Heroes != null && Gear != null && Gacha != null && Economy != null && Rewards != null && Levels != null && Promotion != null && Evolve != null && Skills != null && Training != null && Patrol != null && Dev != null && Treasures != null && TreasureSets != null && Chests != null && Tower != null && Dungeons != null && Arena != null; } } public ScriptableObject[] All { get { return new ScriptableObject[] { Heroes, Gear, Gacha, Economy, Rewards, Levels, Promotion, Evolve, Skills, Training, Patrol, Dev, Treasures, TreasureSets, Chests, Tower, Dungeons, Arena }; } } static OutGameData cached; /// Resources 의 임포트 자산(한 번 읽고 캐시). public static OutGameData Default { get { if (cached == null || !cached.Complete) cached = LoadResources(); return cached; } } public static OutGameData LoadResources() { var d = new OutGameData { Heroes = Resources.Load(ResourcesFolder + "HeroDb"), Gear = Resources.Load(ResourcesFolder + "GearDb"), Gacha = Resources.Load(ResourcesFolder + "GachaConfig"), Economy = Resources.Load(ResourcesFolder + "EconomyConfig"), Rewards = Resources.Load(ResourcesFolder + "RewardTable"), Levels = Resources.Load(ResourcesFolder + "LevelTable"), Promotion = Resources.Load(ResourcesFolder + "PromotionTable"), Evolve = Resources.Load(ResourcesFolder + "EvolveTable"), Skills = Resources.Load(ResourcesFolder + "SkillInfoDb"), Training = Resources.Load(ResourcesFolder + "TrainingTable"), Patrol = Resources.Load(ResourcesFolder + "PatrolTable"), Dev = Resources.Load(ResourcesFolder + "DevTree"), Treasures = Resources.Load(ResourcesFolder + "TreasureDb"), TreasureSets = Resources.Load(ResourcesFolder + "TreasureSetDb"), Chests = Resources.Load(ResourcesFolder + "ChestDb"), Tower = Resources.Load(ResourcesFolder + "TowerTable"), Dungeons = Resources.Load(ResourcesFolder + "DungeonDb"), Arena = Resources.Load(ResourcesFolder + "ArenaConfig"), }; if (!d.Complete) Debug.LogError("[TKS] 아웃게임 데이터 자산 없음(Resources/" + ResourcesFolder + ") — 메뉴 TKS/Import OutGame Data 를 실행하세요"); return d; } } /// /// 아웃게임 표(Assets/Data/OutGame/*.csv · utf-8-sig · 기획 tables/ 사본 22장 = A 12 + C 10) 해석·검증 → 18 자산(C 표 = OutGameTables.C.cs). 형식 오류·참조 오류가 하나라도 있으면 /// errors 에 모으고 에디터 임포터는 자산을 바꾸지 않는다(기획서 §12 임포터 검증: 행 수·필수 열·id 유일·참조 무결 hero↔skill↔gear). /// public static partial class OutGameTables { public const string Folder = "Data/OutGame"; // Assets/ 아래 public static readonly string[] Files = { "heroes.csv", "skills.csv", "hero_levels.csv", "promotion.csv", "evolve.csv", "gear_items.csv", "hero_gear.csv", "skill_levels.csv", "gacha.csv", "gacha_pool.csv", "stage_rewards.csv", "economy.csv", "skill_effects.csv", // 스킬 효과 1줄 표(2026-11-10 #1112) // C 단계(PD #1093 C) "training.csv", "training_ranks.csv", "patrol.csv", "dev_tree.csv", "treasures.csv", "treasure_sets.csv", "chests.csv", "tower.csv", "dungeons.csv", "arena.csv", }; public class Result { public readonly List errors = new List(); public readonly Dictionary rowCounts = new Dictionary(); public readonly OutGameData data = new OutGameData(); } /// texts = Files 순서의 CSV 내용. public static Result Parse(string[] texts) { var r = new Result(); var e = r.errors; var rows = new Dictionary>>(); for (int i = 0; i < Files.Length; i++) { var text = texts != null && i < texts.Length ? texts[i] : null; if (text == null) { e.Add(Files[i] + ": 파일 없음"); rows[Files[i]] = new List>(); continue; } rows[Files[i]] = StageTable.ReadCsv(text, Files[i], e); r.rowCounts[Files[i]] = rows[Files[i]].Count; } var d = r.data; d.Promotion = ParsePromotion(rows["promotion.csv"], e); d.Evolve = ParseEvolve(rows["evolve.csv"], e); d.Levels = ParseLevels(rows["hero_levels.csv"], e); d.Gear = ParseGear(rows["gear_items.csv"], e); d.Skills = ParseSkills(rows["skills.csv"], rows["skill_levels.csv"], rows["skill_effects.csv"], e); d.Heroes = ParseHeroes(rows["heroes.csv"], rows["hero_gear.csv"], d, e); d.Gacha = ParseGacha(rows["gacha.csv"], rows["gacha_pool.csv"], d, e); d.Rewards = ParseRewards(rows["stage_rewards.csv"], d, e); d.Economy = ParseEconomy(rows["economy.csv"], d, e); CheckSkillOwners(d, e); ParseC(rows, d, e); return r; } // ---------- 표별 ---------- static PromotionTable ParsePromotion(List> rows, List e) { var t = ScriptableObject.CreateInstance(); const string f = "promotion.csv"; if (!Cols(rows, f, e, "grade", "order", "promo_pct", "next_grade")) return t; var seen = new HashSet(); foreach (var r in rows) { var row = new PromotionTable.Row { grade = Get(r, "grade"), order = Int(r, "order", f, e), promoPct = Float(r, "promo_pct", f, e), nextGrade = Get(r, "next_grade") }; if (!Unique(seen, row.grade, f, r, e)) continue; t.rows.Add(row); } int tops = 0; foreach (var row in t.rows) { if (row.nextGrade.Length == 0) tops++; else if (t.Get(row.nextGrade) == null) e.Add(f + ": " + row.grade + " next_grade 「" + row.nextGrade + "」 없음"); } if (t.rows.Count > 0 && tops != 1) e.Add(f + ": 최고 등급(next_grade 빈 행)이 1개가 아님(" + tops + ")"); return t; } static EvolveTable ParseEvolve(List> rows, List e) { var t = ScriptableObject.CreateInstance(); const string f = "evolve.csv"; if (!Cols(rows, f, e, "star", "shards_needed", "star_pct", "skill_unlock")) return t; foreach (var r in rows) t.rows.Add(new EvolveTable.Row { star = Int(r, "star", f, e), shardsNeeded = Int(r, "shards_needed", f, e), starPct = Float(r, "star_pct", f, e), skillUnlock = Get(r, "skill_unlock") }); for (int i = 0; i < t.rows.Count; i++) if (t.rows[i].star != i + 1) e.Add(f + ": star 가 1부터 연속이 아님(" + (i + 1) + "번째 = " + t.rows[i].star + ")"); return t; } static LevelTable ParseLevels(List> rows, List e) { var t = ScriptableObject.CreateInstance(); const string f = "hero_levels.csv"; if (!Cols(rows, f, e, "lv", "exp_to_next", "gold_cost", "stat_pct", "cap_chapter")) return t; foreach (var r in rows) t.rows.Add(new LevelTable.Row { lv = Int(r, "lv", f, e), expToNext = Int(r, "exp_to_next", f, e), goldCost = Int(r, "gold_cost", f, e), statPct = Float(r, "stat_pct", f, e), capChapter = Int(r, "cap_chapter", f, e) }); for (int i = 0; i < t.rows.Count; i++) if (t.rows[i].lv != i + 1) e.Add(f + ": lv 가 1부터 연속이 아님(" + (i + 1) + "번째 = " + t.rows[i].lv + ")"); return t; } static GearDb ParseGear(List> rows, List e) { var t = ScriptableObject.CreateInstance(); const string f = "gear_items.csv"; if (!Cols(rows, f, e, "id", "name_ko", "kind", "grade", "sell_gold", "drop_from_chapter", "craft_from", "craft_count", "craft_blueprint")) return t; var seen = new HashSet(); foreach (var r in rows) { var it = new GearDb.Item { id = Get(r, "id"), nameKo = Get(r, "name_ko"), kind = Get(r, "kind"), grade = Get(r, "grade"), sellGold = Int(r, "sell_gold", f, e), dropFromChapter = Int(r, "drop_from_chapter", f, e), craftFrom = Get(r, "craft_from"), craftCount = Int(r, "craft_count", f, e), craftBlueprint = Int(r, "craft_blueprint", f, e), }; if (!Unique(seen, it.id, f, r, e)) continue; t.items.Add(it); } // 등급 순서 = 합성 사슬 깊이(흰 = craft_from 없음 = 1) foreach (var it in t.items) { int depth = 1; var cur = it; while (cur != null && cur.craftFrom.Length > 0 && depth <= t.items.Count) { var prev = t.Get(cur.craftFrom); if (prev == null) { e.Add(f + ": " + cur.id + " craft_from 「" + cur.craftFrom + "」 없음"); break; } cur = prev; depth++; } it.order = depth; } var orderOf = new Dictionary(); foreach (var it in t.items) { int o; if (orderOf.TryGetValue(it.grade, out o) && o != it.order) e.Add(f + ": 등급 " + it.grade + " 의 합성 깊이가 행마다 다름(" + it.id + ")"); orderOf[it.grade] = it.order; } return t; } static SkillInfoDb ParseSkills(List> rows, List> lvRows, List> fxRows, List e) { var t = ScriptableObject.CreateInstance(); // 효과 1줄 표(2026-11-10 #1112) — 키 · 대상 · 조건 · 지속 · p1~p3 · 스킬마다 1줄 이상 const string fx = "skill_effects.csv"; var fxKeys = new Dictionary>(); if (Cols(fxRows, fx, e, "skill_id", "key", "target", "cond", "dur", "p1", "p2", "p3", "note")) { foreach (var r in fxRows) { var x = new SkillInfoDb.Effect { skillId = Get(r, "skill_id"), key = Get(r, "key"), target = Get(r, "target"), cond = Get(r, "cond"), note = Get(r, "note"), dur = Get(r, "dur").Length == 0 ? 0 : Int(r, "dur", fx, e), }; string at = fx + " 행 " + Get(r, "#") + " " + x.skillId + "/" + x.key; if (!SkillKeys.Known(x.key)) e.Add(at + ": 처리기 없는 키"); var pc = new[] { "p1", "p2", "p3" }; var pv = new float[3]; for (int i = 0; i < 3; i++) { var v = Get(r, pc[i]); if (v.Length == 0) continue; if (!float.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out pv[i])) e.Add(at + ": " + pc[i] + " 숫자 아님(「" + v + "」)"); else x.pMask |= 1 << i; } x.p1 = pv[0]; x.p2 = pv[1]; x.p3 = pv[2]; if (!x.HasP(0)) e.Add(at + ": p1 비었음"); if (x.dur < 0) e.Add(at + ": dur < 0"); var probe = new SkillEffect(); var te = SkillEffect.ParseTarget(x.target, probe); if (te != null) e.Add(at + ": target " + te); var ce = SkillEffect.ParseCond(x.cond, probe); if (ce != null) e.Add(at + ": cond " + ce); List l; if (!fxKeys.TryGetValue(x.skillId, out l)) fxKeys[x.skillId] = l = new List(); if (!l.Contains(x.key)) l.Add(x.key); t.effects.Add(x); } } const string f = "skills.csv"; if (Cols(rows, f, e, "id", "hero_id", "kind", "name_ko", "hanja", "trigger", "effect_key", "p1", "p2", "p3", "cooldown", "lv_max", "lv_scale", "desc_ko", "needs_impl")) { var seen = new HashSet(); foreach (var r in rows) { var s = new SkillInfoDb.Skill { id = Get(r, "id"), heroId = Get(r, "hero_id"), kind = Get(r, "kind"), nameKo = Get(r, "name_ko"), hanja = Get(r, "hanja"), trigger = Get(r, "trigger"), cooldown = Int(r, "cooldown", f, e), lvMax = Int(r, "lv_max", f, e), descKo = Get(r, "desc_ko"), effectKey = Get(r, "effect_key"), lvScale = Float(r, "lv_scale", f, e), }; if (!Unique(seen, s.id, f, r, e)) continue; string at = f + " 행 " + Get(r, "#") + " " + s.id; if (s.kind != "active" && s.kind != "passive") e.Add(at + ": kind 「" + s.kind + "」 ≠ active|passive"); // 스킬 처리기 열(B 단계) — trigger · effect_key 51종 · p 칸 · needs_impl bool active = s.kind == "active"; if (active ? (s.trigger != "next_action" && s.trigger != "instant") : s.trigger != "always") e.Add(at + ": trigger 「" + s.trigger + "」 (active = next_action|instant · passive = always)"); var ni = Get(r, "needs_impl"); if (ni != "0" && ni != "1") e.Add(at + ": needs_impl ≠ 0|1"); s.needsImpl = ni == "1"; var cols = new[] { "p1", "p2", "p3" }; var ps = new float[3]; for (int i = 0; i < 3; i++) { var v = Get(r, cols[i]); if (v.Length == 0) continue; if (!float.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out ps[i])) e.Add(at + ": " + cols[i] + " 숫자 아님(「" + v + "」)"); else s.pMask |= 1 << i; } s.p1 = ps[0]; s.p2 = ps[1]; s.p3 = ps[2]; // effect_key 칸 = 효과 표(skill_effects.csv) 키 목록(;) — 수치는 효과 표에만(2026-11-10 #1112) var keys = s.EffectKeys; List fk; if (!fxKeys.TryGetValue(s.id, out fk)) e.Add(at + ": skill_effects.csv 에 효과 줄 없음"); else if (string.Join(";", keys) != string.Join(";", fk.ToArray())) e.Add(at + ": effect_key 「" + s.effectKey + "」 ≠ 효과 표 키 「" + string.Join(";", fk.ToArray()) + "」"); if (active && s.cooldown < 1) e.Add(at + ": 액티브 cooldown < 1"); if (s.lvMax < 1) e.Add(at + ": lv_max < 1"); t.skills.Add(s); } } const string g = "skill_levels.csv"; if (Cols(lvRows, g, e, "lv", "books", "gold")) foreach (var r in lvRows) t.levels.Add(new SkillInfoDb.LevelCost { lv = Int(r, "lv", g, e), books = Int(r, "books", g, e), gold = Int(r, "gold", g, e) }); for (int i = 0; i < t.levels.Count; i++) if (t.levels[i].lv != i + 1) e.Add(g + ": lv 가 1부터 연속이 아님(" + (i + 1) + "번째 = " + t.levels[i].lv + ")"); foreach (var s in t.skills) if (s.lvMax > t.levels.Count) e.Add(f + ": " + s.id + " lv_max " + s.lvMax + " > skill_levels.csv 행 " + t.levels.Count); foreach (var x in t.effects) if (t.Get(x.skillId) == null) e.Add(fx + ": " + x.skillId + " 이 skills.csv 에 없음"); return t; } static HeroDb ParseHeroes(List> rows, List> gearRows, OutGameData d, List e) { var t = ScriptableObject.CreateInstance(); const string f = "heroes.csv"; if (!Cols(rows, f, e, "id", "name_ko", "hanja", "faction", "unit_type", "rarity", "hp_mult", "atk_mult", "def_mult", "art", "skill_active", "skill_passive", "is_soldier", "first_stage", "gender")) return t; var seen = new HashSet(); foreach (var r in rows) { UnitType type; if (!UnitTypeInfo.TryParseKey(Get(r, "unit_type"), out type)) e.Add(f + " 행 " + Get(r, "#") + ": unit_type 「" + Get(r, "unit_type") + "」 모름"); var soldier = Get(r, "is_soldier"); if (soldier != "0" && soldier != "1") e.Add(f + " 행 " + Get(r, "#") + ": is_soldier ≠ 0|1"); var h = new HeroDb.Hero { id = Get(r, "id"), nameKo = Get(r, "name_ko"), hanja = Get(r, "hanja"), faction = Get(r, "faction"), type = type, rarity = Int(r, "rarity", f, e), hpMult = Float(r, "hp_mult", f, e), atkMult = Float(r, "atk_mult", f, e), defMult = Float(r, "def_mult", f, e), art = Get(r, "art"), skillActive = Get(r, "skill_active"), skillPassive = Get(r, "skill_passive"), isSoldier = soldier == "1", firstStage = Get(r, "first_stage"), gender = Get(r, "gender") == "f" ? "f" : "m", }; var gd = Get(r, "gender"); if (gd != "m" && gd != "f") e.Add(f + " 행 " + Get(r, "#") + ": gender 「" + gd + "」 ≠ m|f"); if (!Unique(seen, h.id, f, r, e)) continue; if (h.rarity < 1 || h.rarity > 3) e.Add(f + ": " + h.id + " rarity " + h.rarity + " ≠ 1~3"); foreach (var sid in new[] { h.skillActive, h.skillPassive }) { if (sid.Length == 0) continue; var s = d.Skills != null ? d.Skills.Get(sid) : null; if (s == null) e.Add(f + ": " + h.id + " 스킬 「" + sid + "」 이 skills.csv 에 없음"); else if (s.heroId != h.id) e.Add(f + ": " + h.id + " 스킬 「" + sid + "」 의 hero_id = " + s.heroId); } if (!h.isSoldier && (h.skillActive.Length == 0 || h.skillPassive.Length == 0)) e.Add(f + ": 영웅 " + h.id + " 액티브·패시브 스킬 칸이 비었음"); t.heroes.Add(h); } const string g = "hero_gear.csv"; if (!Cols(gearRows, g, e, "hero_id", "grade", "slot", "gear_id")) return t; int slotCount = 0; foreach (var r in gearRows) slotCount = Mathf.Max(slotCount, Int(r, "slot", g, e)); foreach (var r in gearRows) { var h = t.Get(Get(r, "hero_id")); var grade = Get(r, "grade"); int slot = Int(r, "slot", g, e); var gearId = Get(r, "gear_id"); string at = g + " 행 " + Get(r, "#"); if (h == null) { e.Add(at + ": hero_id 「" + Get(r, "hero_id") + "」 없음"); continue; } if (h.isSoldier) { e.Add(at + ": 병사 유닛 " + h.id + " 은 승급 없음"); continue; } if (d.Promotion == null || d.Promotion.Get(grade) == null) { e.Add(at + ": grade 「" + grade + "」 가 promotion.csv 에 없음"); continue; } if (d.Gear == null || d.Gear.Get(gearId) == null) { e.Add(at + ": gear_id 「" + gearId + "」 가 gear_items.csv 에 없음"); continue; } if (slot < 1) { e.Add(at + ": slot " + slot); continue; } HeroDb.GearSet set = null; foreach (var s in h.gear) if (s.grade == grade) set = s; if (set == null) { set = new HeroDb.GearSet { grade = grade }; for (int i = 0; i < slotCount; i++) set.slots.Add(""); h.gear.Add(set); } if (set.slots[slot - 1].Length > 0) e.Add(at + ": " + h.id + " " + grade + " slot " + slot + " 중복"); set.slots[slot - 1] = gearId; } // 영웅마다 승급 가능한 등급(next_grade 있음) 전부 · 칸 전부 채움 if (d.Promotion != null) foreach (var h in t.heroes) { if (h.isSoldier) continue; foreach (var p in d.Promotion.rows) { if (p.nextGrade.Length == 0) continue; var slots = t.GearFor(h, p.grade); if (slots == null) { e.Add(g + ": " + h.id + " " + p.grade + " 요구 장비 없음"); continue; } for (int i = 0; i < slots.Count; i++) if (slots[i].Length == 0) e.Add(g + ": " + h.id + " " + p.grade + " slot " + (i + 1) + " 비었음"); } } return t; } static GachaConfig ParseGacha(List> rows, List> poolRows, OutGameData d, List e) { var t = ScriptableObject.CreateInstance(); KeyValues(rows, "gacha.csv", GachaConfig.RequiredKeys, t.values, e); if (AllKeys(t.values, GachaConfig.RequiredKeys)) { float sum = t.P(1) + t.P(2) + t.P(3); if (Mathf.Abs(sum - 1f) > 1e-4f) e.Add("gacha.csv: p_r1 + p_r2 + p_r3 = " + sum.ToString("0.####", CultureInfo.InvariantCulture) + " ≠ 1"); } const string f = "gacha_pool.csv"; if (!Cols(poolRows, f, e, "hero_id", "in_pool", "pickup")) return t; var seen = new HashSet(); foreach (var r in poolRows) { var p = new GachaConfig.PoolEntry { heroId = Get(r, "hero_id"), inPool = Get(r, "in_pool") == "1", pickup = Get(r, "pickup") == "1" }; if (!Unique(seen, p.heroId, f, r, e)) continue; if (d.Heroes == null || d.Heroes.Get(p.heroId) == null) e.Add(f + " 행 " + Get(r, "#") + ": hero_id 「" + p.heroId + "」 가 heroes.csv 에 없음"); t.pool.Add(p); } return t; } static RewardTable ParseRewards(List> rows, OutGameData d, List e) { var t = ScriptableObject.CreateInstance(); const string f = "stage_rewards.csv"; if (!Cols(rows, f, e, "chapter", "gold_base", "exp_base", "gem_first", "gem_boss", "gear_drop_pct", "gear_grade_max", "star_turns")) return t; var seen = new HashSet(); foreach (var r in rows) { var row = new RewardTable.Row { chapter = Int(r, "chapter", f, e), goldBase = Int(r, "gold_base", f, e), expBase = Int(r, "exp_base", f, e), gemFirst = Int(r, "gem_first", f, e), gemBoss = Int(r, "gem_boss", f, e), gearDropPct = Float(r, "gear_drop_pct", f, e), gearGradeMax = Get(r, "gear_grade_max"), starTurns = Int(r, "star_turns", f, e), }; if (!Unique(seen, row.chapter.ToString(CultureInfo.InvariantCulture), f, r, e)) continue; if (d.Gear == null || d.Gear.OrderOf(row.gearGradeMax) == 0) e.Add(f + ": 챕터 " + row.chapter + " gear_grade_max 「" + row.gearGradeMax + "」 가 gear_items.csv 등급에 없음"); t.rows.Add(row); } return t; } static EconomyConfig ParseEconomy(List> rows, OutGameData d, List e) { var t = ScriptableObject.CreateInstance(); KeyValues(rows, "economy.csv", EconomyConfig.RequiredKeys, t.values, e); if (!AllKeys(t.values, EconomyConfig.RequiredKeys)) return t; var starts = t.StartHeroes; if (starts.Length == 0) e.Add("economy.csv: start_heroes 비었음"); foreach (var id in starts) if (d.Heroes == null || d.Heroes.Get(id) == null) e.Add("economy.csv: start_heroes 「" + id + "」 가 heroes.csv 에 없음"); foreach (var g in t.ShopGrades) if (d.Gear == null || d.Gear.OrderOf(g) == 0) e.Add("economy.csv: shop_grades 「" + g + "」 가 gear_items.csv 등급에 없음"); if (t.StaminaRegenSec <= 0) e.Add("economy.csv: stamina_regen_sec ≤ 0"); if (t.PresetCount < 1) e.Add("economy.csv: preset_count < 1"); return t; } static void CheckSkillOwners(OutGameData d, List e) { if (d.Skills == null || d.Heroes == null) return; foreach (var s in d.Skills.skills) if (d.Heroes.Get(s.heroId) == null) e.Add("skills.csv: " + s.id + " hero_id 「" + s.heroId + "」 가 heroes.csv 에 없음"); } // ---------- 도우미 ---------- static void KeyValues(List> rows, string f, string[] required, KeyValueList into, List e) { if (!Cols(rows, f, e, "key", "value")) return; var seen = new HashSet(); foreach (var r in rows) { var k = Get(r, "key"); if (!Unique(seen, k, f, r, e)) continue; into.entries.Add(new KeyValueList.Entry { key = k, value = Get(r, "value") }); } foreach (var k in required) if (!into.Has(k)) e.Add(f + ": 필수 키 「" + k + "」 없음"); } static bool AllKeys(KeyValueList kv, string[] keys) { foreach (var k in keys) if (!kv.Has(k)) return false; return true; } static bool Cols(List> rows, string f, List e, params string[] cols) { if (rows.Count == 0) { e.Add(f + ": 행 없음"); return false; } bool ok = true; foreach (var c in cols) { if (rows[0].ContainsKey(c)) continue; e.Add(f + ": 필수 열 「" + c + "」 없음"); ok = false; } return ok; } static bool Unique(HashSet seen, string id, string f, Dictionary r, List e) { if (string.IsNullOrEmpty(id)) { e.Add(f + " 행 " + Get(r, "#") + ": id 비었음"); return false; } if (!seen.Add(id)) { e.Add(f + " 행 " + Get(r, "#") + ": id 「" + id + "」 중복"); return false; } return true; } static string Get(Dictionary r, string key) { string v; return r.TryGetValue(key, out v) ? v : ""; } static int Int(Dictionary r, string key, string f, List e) { int v; if (int.TryParse(Get(r, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) return v; e.Add(f + " 행 " + Get(r, "#") + ": " + key + " 정수 아님(「" + Get(r, key) + "」)"); return 0; } static float Float(Dictionary r, string key, string f, List e) { float v; if (float.TryParse(Get(r, key), NumberStyles.Float, CultureInfo.InvariantCulture, out v)) return v; e.Add(f + " 행 " + Get(r, "#") + ": " + key + " 숫자 아님(「" + Get(r, key) + "」)"); return 0f; } /// 파싱 중 만든 자산 인스턴스 정리(임포트 실패·테스트). public static void Destroy(Result r) { if (r == null) return; foreach (var so in r.data.All) { if (so == null) continue; if (Application.isPlaying) UnityEngine.Object.Destroy(so); else UnityEngine.Object.DestroyImmediate(so); } } } }