Project_WL/Assets/WL/Combat/Weapon/WLWeaponSkillTable.cs

204 lines
13 KiB
C#
Raw Normal View History

// ─────────────────────────────────────────────────────────────────────────────
// WLWeaponSkillTable.cs — 무기별 고유 스킬 표 + 등급표 + 계열 축 로더 (#825 · 2026-10-09 · 기획 v2 §15·§16 + PD 답변)
//
// SOT(C45) = Resources/WL/
// WLWeaponSkillTable.csv : 기획 `WL_무기_스킬_v1.csv` 사본 — 무기(GroupNo) × 슬롯(1 주력 · 2 각성) → 힘민지 SkillList ID · 쿨 · 해금 등급
// WLWeaponSkillTier.csv : 기획 `WL_무기_스킬_등급표_v1.csv` 사본 — 등급 7단계 → 스킬 피해 보너스 · 쿨 배율 · 각성 해금
// 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 = "";
/// <summary>#883 이 칸이 WL 파츠 스킬(WLPartSkillDefs key)이면 힘민지 스킬 슬롯 대신 파츠 스킬로 돈다(skill_id 0 · 예: 파괴자 1번 = guard_shield).</summary>
public string wlSkill = "";
public bool IsWlSkill { get { return !string.IsNullOrEmpty(wlSkill); } }
/// <summary>#890 발동 조건(예: hit:Skill_ShieldAttack = 그 스킬 명중 시) — 값이 있으면 자동 시전 슬롯에 넣지 않고 WLSkillChain 이 조건 때 쿨 없이 시전한다.</summary>
public string trigger = "";
public bool IsTriggered { get { return !string.IsNullOrEmpty(trigger); } }
/// <summary>#900 dash_cast 1 = 대쉬 도착 때 이 칸 스킬이 준비돼 있으면(쿨 · 사거리) 일반 공격 대신 바로 시전(예: 묵광 방패 치기).</summary>
public bool dashCast;
/// <summary>#902 first_cast 1 = 쿨이 차 있으면 기본 공격 · 접근보다 먼저 시전(사거리 밖 적은 스킬이 점프로 붙는다 · 해머 강력한 일격).</summary>
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<int, List<WLWeaponSkillRow>> s_byWeapon = new Dictionary<int, List<WLWeaponSkillRow>>();
static readonly Dictionary<int, WLWeaponTierRow> s_tiers = new Dictionary<int, WLWeaponTierRow>();
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; }
/// <summary>CSV(BOM 허용 · 쉼표 구분 · 따옴표 없음) → 행별 열 사전.</summary>
internal static List<Dictionary<string, string>> ReadCsv(string text)
{
var list = new List<Dictionary<string, string>>();
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<string, string>();
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<string, string> 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<TextAsset>(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<WLWeaponSkillRow> l;
if (!s_byWeapon.TryGetValue(r.weaponId, out l)) { l = new List<WLWeaponSkillRow>(); 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<TextAsset>(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; }
/// <summary>무기(GroupNo)의 스킬 행(슬롯 순). 없으면 null.</summary>
public static List<WLWeaponSkillRow> Rows(int weaponId) { EnsureLoaded(); List<WLWeaponSkillRow> 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; }
/// <summary>표의 skill_id 가 힘민지 SkillList 에 전부 있는지(구현 순서 1 스모크 기준). 누락 ID 목록을 돌려준다.</summary>
public static List<int> MissingSkillIds()
{
var miss = new List<int>();
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;
}
/// <summary>UI 한 줄 — 「주력 회심의 일격(쿨 7s) · 각성 돌진 베기(전설부터 · 쿨 16s)」.</summary>
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();
for (int i = 0; i < rows.Count; i++)
{
var r = rows[i];
if (i > 0) sb.Append(" · ");
sb.Append(r.slot == 1 ? "주력 " : "각성 ").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();
}
}
/// <summary>계열 축 정의 — 장비 옵션 「기본 수치 + 주스탯 × 등급%」의 주스탯 자리에 무기 계열별로 넣는 스탯.</summary>
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<WLWeaponAxisDef> axes; }
public static class WLWeaponAxis
{
public static bool Loaded { get; private set; }
public static string LastLog { get; private set; } = "";
static readonly Dictionary<string, WLWeaponAxisDef> s_axes = new Dictionary<string, WLWeaponAxisDef>();
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<TextAsset>(path);
if (ta == null) throw new System.IO.FileNotFoundException("Resources/" + path + ".json 없음");
var j = JsonConvert.DeserializeObject<WLWeaponAxisJson>(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;
}
/// <summary>무기(GroupNo) → 축 정의(공격 표 main_stat 키). 표에 없으면 false(호출자는 직업 주스탯 유지).</summary>
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;
}
}
}