using System; using System.Collections.Generic; using System.Globalization; using UnityEngine; namespace TKS.OutGame { /// 아웃게임 데이터 9 자산 묶음(Resources/OutGame/<형식 이름>.asset · 임포트 = TKS/Import OutGame Data). 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; public bool Complete { get { return Heroes != null && Gear != null && Gacha != null && Economy != null && Rewards != null && Levels != null && Promotion != null && Evolve != null && Skills != null; } } public ScriptableObject[] All { get { return new ScriptableObject[] { Heroes, Gear, Gacha, Economy, Rewards, Levels, Promotion, Evolve, Skills }; } } 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"), }; if (!d.Complete) Debug.LogError("[TKS] 아웃게임 데이터 자산 없음(Resources/" + ResourcesFolder + ") — 메뉴 TKS/Import OutGame Data 를 실행하세요"); return d; } } /// /// 아웃게임 표(Assets/Data/OutGame/*.csv · utf-8-sig · 기획 tables/ 사본 12장) 해석·검증 → 9 자산. 형식 오류·참조 오류가 하나라도 있으면 /// errors 에 모으고 에디터 임포터는 자산을 바꾸지 않는다(기획서 §12 임포터 검증: 행 수·필수 열·id 유일·참조 무결 hero↔skill↔gear). /// public static 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", }; 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"], 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); 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 e) { var t = ScriptableObject.CreateInstance(); const string f = "skills.csv"; if (Cols(rows, f, e, "id", "hero_id", "kind", "name_ko", "hanja", "trigger", "effect_key", "cooldown", "lv_max", "desc_ko")) { 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"), }; if (!Unique(seen, s.id, f, r, e)) continue; if (s.kind != "active" && s.kind != "passive") e.Add(f + " 행 " + Get(r, "#") + ": kind 「" + s.kind + "」 ≠ active|passive"); 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) }); 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")) 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"), }; 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); } } } }