568 lines
30 KiB
C#
568 lines
30 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using UnityEngine;
|
|
|
|
namespace TKS.OutGame
|
|
{
|
|
/// <summary>아웃게임 데이터 18 자산 묶음(Resources/OutGame/<형식 이름>.asset · 임포트 = TKS/Import OutGame Data · A 9 + C 9 내정·보물·연무장).</summary>
|
|
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;
|
|
|
|
/// <summary>Resources 의 임포트 자산(한 번 읽고 캐시).</summary>
|
|
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<HeroDb>(ResourcesFolder + "HeroDb"),
|
|
Gear = Resources.Load<GearDb>(ResourcesFolder + "GearDb"),
|
|
Gacha = Resources.Load<GachaConfig>(ResourcesFolder + "GachaConfig"),
|
|
Economy = Resources.Load<EconomyConfig>(ResourcesFolder + "EconomyConfig"),
|
|
Rewards = Resources.Load<RewardTable>(ResourcesFolder + "RewardTable"),
|
|
Levels = Resources.Load<LevelTable>(ResourcesFolder + "LevelTable"),
|
|
Promotion = Resources.Load<PromotionTable>(ResourcesFolder + "PromotionTable"),
|
|
Evolve = Resources.Load<EvolveTable>(ResourcesFolder + "EvolveTable"),
|
|
Skills = Resources.Load<SkillInfoDb>(ResourcesFolder + "SkillInfoDb"),
|
|
Training = Resources.Load<TrainingTable>(ResourcesFolder + "TrainingTable"),
|
|
Patrol = Resources.Load<PatrolTable>(ResourcesFolder + "PatrolTable"),
|
|
Dev = Resources.Load<DevTree>(ResourcesFolder + "DevTree"),
|
|
Treasures = Resources.Load<TreasureDb>(ResourcesFolder + "TreasureDb"),
|
|
TreasureSets = Resources.Load<TreasureSetDb>(ResourcesFolder + "TreasureSetDb"),
|
|
Chests = Resources.Load<ChestDb>(ResourcesFolder + "ChestDb"),
|
|
Tower = Resources.Load<TowerTable>(ResourcesFolder + "TowerTable"),
|
|
Dungeons = Resources.Load<DungeonDb>(ResourcesFolder + "DungeonDb"),
|
|
Arena = Resources.Load<ArenaConfig>(ResourcesFolder + "ArenaConfig"),
|
|
};
|
|
if (!d.Complete) Debug.LogError("[TKS] 아웃게임 데이터 자산 없음(Resources/" + ResourcesFolder + ") — 메뉴 TKS/Import OutGame Data 를 실행하세요");
|
|
return d;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 아웃게임 표(Assets/Data/OutGame/*.csv · utf-8-sig · 기획 tables/ 사본 22장 = A 12 + C 10) 해석·검증 → 18 자산(C 표 = OutGameTables.C.cs). 형식 오류·참조 오류가 하나라도 있으면
|
|
/// errors 에 모으고 에디터 임포터는 자산을 바꾸지 않는다(기획서 §12 임포터 검증: 행 수·필수 열·id 유일·참조 무결 hero↔skill↔gear).
|
|
/// </summary>
|
|
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<string> errors = new List<string>();
|
|
public readonly Dictionary<string, int> rowCounts = new Dictionary<string, int>();
|
|
public readonly OutGameData data = new OutGameData();
|
|
}
|
|
|
|
/// <summary>texts = Files 순서의 CSV 내용.</summary>
|
|
public static Result Parse(string[] texts)
|
|
{
|
|
var r = new Result();
|
|
var e = r.errors;
|
|
var rows = new Dictionary<string, List<Dictionary<string, string>>>();
|
|
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<Dictionary<string, string>>();
|
|
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<Dictionary<string, string>> rows, List<string> e)
|
|
{
|
|
var t = ScriptableObject.CreateInstance<PromotionTable>();
|
|
const string f = "promotion.csv";
|
|
if (!Cols(rows, f, e, "grade", "order", "promo_pct", "next_grade")) return t;
|
|
var seen = new HashSet<string>();
|
|
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<Dictionary<string, string>> rows, List<string> e)
|
|
{
|
|
var t = ScriptableObject.CreateInstance<EvolveTable>();
|
|
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<Dictionary<string, string>> rows, List<string> e)
|
|
{
|
|
var t = ScriptableObject.CreateInstance<LevelTable>();
|
|
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<Dictionary<string, string>> rows, List<string> e)
|
|
{
|
|
var t = ScriptableObject.CreateInstance<GearDb>();
|
|
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<string>();
|
|
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<string, int>();
|
|
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<Dictionary<string, string>> rows, List<Dictionary<string, string>> lvRows, List<Dictionary<string, string>> fxRows, List<string> e)
|
|
{
|
|
var t = ScriptableObject.CreateInstance<SkillInfoDb>();
|
|
// 효과 1줄 표(2026-11-10 #1112) — 키 · 대상 · 조건 · 지속 · p1~p3 · 스킬마다 1줄 이상
|
|
const string fx = "skill_effects.csv";
|
|
var fxKeys = new Dictionary<string, List<string>>();
|
|
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<string> l;
|
|
if (!fxKeys.TryGetValue(x.skillId, out l)) fxKeys[x.skillId] = l = new List<string>();
|
|
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<string>();
|
|
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<string> 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<Dictionary<string, string>> rows, List<Dictionary<string, string>> gearRows, OutGameData d, List<string> e)
|
|
{
|
|
var t = ScriptableObject.CreateInstance<HeroDb>();
|
|
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<string>();
|
|
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<Dictionary<string, string>> rows, List<Dictionary<string, string>> poolRows, OutGameData d, List<string> e)
|
|
{
|
|
var t = ScriptableObject.CreateInstance<GachaConfig>();
|
|
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<string>();
|
|
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<Dictionary<string, string>> rows, OutGameData d, List<string> e)
|
|
{
|
|
var t = ScriptableObject.CreateInstance<RewardTable>();
|
|
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<string>();
|
|
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<Dictionary<string, string>> rows, OutGameData d, List<string> e)
|
|
{
|
|
var t = ScriptableObject.CreateInstance<EconomyConfig>();
|
|
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<string> 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<Dictionary<string, string>> rows, string f, string[] required, KeyValueList into, List<string> e)
|
|
{
|
|
if (!Cols(rows, f, e, "key", "value")) return;
|
|
var seen = new HashSet<string>();
|
|
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<Dictionary<string, string>> rows, string f, List<string> 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<string> seen, string id, string f, Dictionary<string, string> r, List<string> 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<string, string> r, string key)
|
|
{
|
|
string v;
|
|
return r.TryGetValue(key, out v) ? v : "";
|
|
}
|
|
|
|
static int Int(Dictionary<string, string> r, string key, string f, List<string> 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<string, string> r, string key, string f, List<string> 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;
|
|
}
|
|
|
|
/// <summary>파싱 중 만든 자산 인스턴스 정리(임포트 실패·테스트).</summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|