// ───────────────────────────────────────────────────────────────────────────── // WLWeaponSkillTable.cs — 무기별 고유 스킬 표 + 등급표 + 계열 축 로더 (#825 · 2026-10-09 · 기획 v2 §15·§16 + PD 답변) // // SOT(C45) = Resources/WL/ // WLWeaponSkillTable.csv : 기획 `WL_무기_스킬_v1.csv` 사본 — 무기(GroupNo) × 슬롯(1 주력 · 2 각성 · 3 기본 #973) → 힘민지 SkillList ID · 쿨 · 해금 등급 // WLWeaponSkillTier.csv : 기획 `WL_무기_스킬_등급표_v1.csv` 사본 — 등급 8단계(#973 G1~G8 · 기본 G3 · 주력 G5 · 각성 G7 · 짝수 = 부가 능력치) // WLWeaponAxis.json : 계열 축(장비 옵션의 「주스탯」 자리) — PD 2026-10-09 「단검 = 치명타 확률 · 검 = 치명타 피해량」 // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using System.Globalization; using Newtonsoft.Json; using UnityEngine; namespace WL.Combat.Weapon { public class WLWeaponSkillRow { public int weaponId; public string weaponName = ""; public int slot; public int skillId; public string skillName = "", skillClass = "", anim = "", nextAnim = ""; public int unlockTier = 1; public float coolS; public int mp; public float rangeM; public float dmgRatioBase = 1f; public string awaken2Extra = "", frRef = "", vfx = "", srcCoolRef = "", src = ""; /// #883 이 칸이 WL 파츠 스킬(WLPartSkillDefs key)이면 힘민지 스킬 슬롯 대신 파츠 스킬로 돈다(skill_id 0 · 예: 파괴자 1번 = guard_shield). public string wlSkill = ""; public bool IsWlSkill { get { return !string.IsNullOrEmpty(wlSkill); } } /// #890 발동 조건(예: hit:Skill_ShieldAttack = 그 스킬 명중 시) — 값이 있으면 자동 시전 슬롯에 넣지 않고 WLSkillChain 이 조건 때 쿨 없이 시전한다. public string trigger = ""; public bool IsTriggered { get { return !string.IsNullOrEmpty(trigger); } } /// #900 dash_cast 1 = 대쉬 도착 때 이 칸 스킬이 준비돼 있으면(쿨 · 사거리) 일반 공격 대신 바로 시전(예: 묵광 방패 치기). public bool dashCast; /// #902 first_cast 1 = 쿨이 차 있으면 기본 공격 · 접근보다 먼저 시전(사거리 밖 적은 스킬이 점프로 붙는다 · 해머 강력한 일격). public bool firstCast; } public class WLWeaponTierRow { public int tier; public string tierName = ""; public float statPercent, skillDmgBonus, coolMult = 1f, rangeMult = 1f; public int awakenStep, awakenOpen, awakenLevel; public string applyPhase = "", note = "", src = ""; } public static class WLWeaponSkillTable { public static bool Loaded { get; private set; } public static string LastLog { get; private set; } = ""; static readonly Dictionary> s_byWeapon = new Dictionary>(); static readonly Dictionary s_tiers = new Dictionary(); static float F(string s) { float v; return float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out v) ? v : 0f; } static int I(string s) { int v; return int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out v) ? v : 0; } /// CSV(BOM 허용 · 쉼표 구분 · 따옴표 없음) → 행별 열 사전. internal static List> ReadCsv(string text) { var list = new List>(); var lines = text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); if (lines.Length < 2) return list; var header = lines[0].TrimStart('').Split(','); for (int li = 1; li < lines.Length; li++) { if (string.IsNullOrWhiteSpace(lines[li])) continue; var c = lines[li].Split(','); var d = new Dictionary(); for (int i = 0; i < header.Length; i++) d[header[i].Trim()] = i < c.Length ? c[i].Trim() : ""; list.Add(d); } return list; } static string G(Dictionary d, string k) { string v; return d.TryGetValue(k, out v) ? v : ""; } public static bool EnsureLoaded() { if (Loaded) return true; var cfg = WLWeaponSettings.Instance; string sp = cfg != null && !string.IsNullOrEmpty(cfg.skillTablePath) ? cfg.skillTablePath : "WL/WLWeaponSkillTable"; string tp = cfg != null && !string.IsNullOrEmpty(cfg.tierTablePath) ? cfg.tierTablePath : "WL/WLWeaponSkillTier"; try { var ta = Resources.Load(sp); if (ta == null) throw new System.IO.FileNotFoundException("Resources/" + sp + ".csv 없음"); s_byWeapon.Clear(); foreach (var d in ReadCsv(ta.text)) { var r = new WLWeaponSkillRow { weaponId = I(G(d, "weapon_id")), weaponName = G(d, "weapon_name"), slot = I(G(d, "slot")), skillId = I(G(d, "skill_id")), skillName = G(d, "skill_name"), skillClass = G(d, "skill_class"), anim = G(d, "anim"), nextAnim = G(d, "next_anim"), unlockTier = Mathf.Max(1, I(G(d, "unlock_tier"))), coolS = F(G(d, "cool_s")), mp = I(G(d, "mp")), rangeM = F(G(d, "range_m")), dmgRatioBase = F(G(d, "dmg_ratio_base")), awaken2Extra = G(d, "awaken2_extra"), frRef = G(d, "fr_ref"), vfx = G(d, "vfx"), srcCoolRef = G(d, "src_cool_ref"), src = G(d, "src"), wlSkill = G(d, "wl_skill"), trigger = G(d, "trigger"), dashCast = I(G(d, "dash_cast")) == 1, firstCast = I(G(d, "first_cast")) == 1, }; if (r.weaponId <= 0 || (r.skillId <= 0 && !r.IsWlSkill)) continue; List l; if (!s_byWeapon.TryGetValue(r.weaponId, out l)) { l = new List(); s_byWeapon[r.weaponId] = l; } l.Add(r); } foreach (var kv in s_byWeapon) kv.Value.Sort((a, b) => a.slot.CompareTo(b.slot)); var tt = Resources.Load(tp); if (tt == null) throw new System.IO.FileNotFoundException("Resources/" + tp + ".csv 없음"); s_tiers.Clear(); foreach (var d in ReadCsv(tt.text)) { var r = new WLWeaponTierRow { tier = I(G(d, "tier")), tierName = G(d, "tier_name"), statPercent = F(G(d, "stat_percent")), skillDmgBonus = F(G(d, "skill_dmg_bonus")), coolMult = F(G(d, "cool_mult")), rangeMult = F(G(d, "range_mult")), awakenStep = I(G(d, "awaken_step")), awakenOpen = I(G(d, "awaken_open")), awakenLevel = I(G(d, "awaken_level")), applyPhase = G(d, "apply_phase"), note = G(d, "note"), src = G(d, "src"), }; if (r.tier > 0) s_tiers[r.tier] = r; } Loaded = s_byWeapon.Count > 0 && s_tiers.Count > 0; LastLog = "무기 스킬 표 로드 — 무기 " + s_byWeapon.Count + " · 스킬 행 " + RowCount() + " · 등급 " + s_tiers.Count; if (cfg != null && cfg.verboseLog != 0) Debug.Log("[WL-825] " + LastLog); } catch (System.Exception ex) { LastLog = "무기 스킬 표 로드 실패 — " + ex.Message; Debug.LogError("[WL-825] " + LastLog); } return Loaded; } public static void Reload() { Loaded = false; s_byWeapon.Clear(); s_tiers.Clear(); EnsureLoaded(); } static int RowCount() { int n = 0; foreach (var kv in s_byWeapon) n += kv.Value.Count; return n; } /// 무기(GroupNo)의 스킬 행(슬롯 순). 없으면 null. public static List Rows(int weaponId) { EnsureLoaded(); List l; return s_byWeapon.TryGetValue(weaponId, out l) ? l : null; } public static WLWeaponTierRow Tier(int tier) { EnsureLoaded(); WLWeaponTierRow r; return s_tiers.TryGetValue(tier, out r) ? r : null; } /// #973 칸 이름 — 1 주력 · 2 각성 · 3 기본(PD 「희귀 G3 기본 · 전설 G5 주력 · 신화 G7 각성」). public static string SlotName(int slot) { return slot == 1 ? "주력" : slot == 2 ? "각성" : slot == 3 ? "기본" : "칸" + slot; } /// #973 표시 순서 = 해금 등급 순(기본 → 주력 → 각성). public static List ByUnlock(List rows) { var l = rows != null ? new List(rows) : new List(); l.Sort((a, b) => a.unlockTier != b.unlockTier ? a.unlockTier.CompareTo(b.unlockTier) : a.slot.CompareTo(b.slot)); return l; } /// #973 무기 표시 이름 = 표 weapon_name(예: 1435 「한손검」 · 월광 · 묵광 통합) · 표에 없으면 null. public static string WeaponName(int weaponId) { var rows = Rows(weaponId); return rows != null && rows.Count > 0 && !string.IsNullOrEmpty(rows[0].weaponName) ? rows[0].weaponName : null; } /// 표의 skill_id 가 힘민지 SkillList 에 전부 있는지(구현 순서 1 스모크 기준). 누락 ID 목록을 돌려준다. public static List MissingSkillIds() { var miss = new List(); if (!EnsureLoaded() || table_skilllist.Ins == null) return miss; foreach (var kv in s_byWeapon) foreach (var r in kv.Value) if (!r.IsWlSkill && table_skilllist.Ins.Get_Data_orNull(r.skillId) == null) miss.Add(r.skillId); return miss; } /// UI 한 줄 — 「주력 회심의 일격(쿨 7s) · 각성 돌진 베기(전설부터 · 쿨 16s)」. public static string Describe(int weaponId, int tier) { var rows = Rows(weaponId); if (rows == null || rows.Count == 0) return "스킬 표 없음"; var sb = new System.Text.StringBuilder(); var ordered = ByUnlock(rows); // #973 기본 → 주력 → 각성 for (int i = 0; i < ordered.Count; i++) { var r = ordered[i]; if (i > 0) sb.Append(" · "); sb.Append(SlotName(r.slot)).Append(' ').Append(r.skillName); if (r.IsWlSkill) sb.Append("(파츠 스킬"); else sb.Append("(쿨 ").Append(r.coolS.ToString("0.#")).Append("s"); if (tier < r.unlockTier) { var t = Tier(r.unlockTier); sb.Append(" · ").Append(t != null ? t.tierName : ("등급 " + r.unlockTier)).Append("부터"); } sb.Append(')'); } return sb.ToString(); } } /// 계열 축 정의 — 장비 옵션 「기본 수치 + 주스탯 × 등급%」의 주스탯 자리에 무기 계열별로 넣는 스탯. public class WLWeaponAxisDef { public string key = ""; public string stat = ""; public float scale = 1f; public string name = ""; [JsonIgnore] public eStat estat; [JsonIgnore] public bool valid; } class WLWeaponAxisJson { public List axes; } public static class WLWeaponAxis { public static bool Loaded { get; private set; } public static string LastLog { get; private set; } = ""; static readonly Dictionary s_axes = new Dictionary(); public static bool EnsureLoaded() { if (Loaded) return true; var cfg = WLWeaponSettings.Instance; string path = cfg != null && !string.IsNullOrEmpty(cfg.axisPath) ? cfg.axisPath : "WL/WLWeaponAxis"; try { var ta = Resources.Load(path); if (ta == null) throw new System.IO.FileNotFoundException("Resources/" + path + ".json 없음"); var j = JsonConvert.DeserializeObject(ta.text); s_axes.Clear(); if (j != null && j.axes != null) foreach (var a in j.axes) { if (a == null || string.IsNullOrEmpty(a.key)) continue; eStat e; a.valid = System.Enum.TryParse(a.stat, out e); a.estat = a.valid ? e : eStat.ATK; s_axes[a.key] = a; } Loaded = s_axes.Count > 0; LastLog = "계열 축 로드 — " + s_axes.Count; } catch (System.Exception ex) { LastLog = "계열 축 로드 실패 — " + ex.Message; Debug.LogError("[WL-825] " + LastLog); } return Loaded; } /// 무기(GroupNo) → 축 정의(공격 표 main_stat 키). 표에 없으면 false(호출자는 직업 주스탯 유지). public static bool TryFor(int weaponId, out WLWeaponAxisDef axis) { axis = null; if (!EnsureLoaded()) return false; var f = WLWeaponAttackTable.First(weaponId); if (f == null || string.IsNullOrEmpty(f.mainStat)) return false; return s_axes.TryGetValue(f.mainStat, out axis) && axis != null && axis.valid; } } }