// ───────────────────────────────────────────────────────────────────────────── // WLWeaponAttackTable.cs — 무기별 기본 공격 파라미터 표 로더 (#825 · 2026-10-09) // // SOT = `Resources/WL/WLWeaponAttackTable.csv`(기획 `공유/기획팀_산출물/WL/무기스킬_기획_2026-10-09/WL_무기_기본공격_파라미터_v1.csv` 사본 · C45). // 한 행 = 무기(weapon_id = FR GroupNo · 신규 9001~) × 콤보 단계(step). class_id = 그 무기가 쓰는 힘민지 직업(모션 세트). // src 칸 = 그 행에서 실측인 칸 이름(M:…) · 나머지는 기획 추정값. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using System.Globalization; using UnityEngine; namespace WL.Combat.Weapon { public class WLWeaponAttackStep { public int weaponId; public string weaponName = "", family = ""; public int classId; public string animController = ""; public int unlockPhase; public string mainStat = ""; public float attackSpeedMult = 1f; public int step; public string animState = "", hitType = ""; public int hits = 1; public float dmgRatioPerHit = 1f, rangeM, arcDeg, aoeRadiusM, projSpeed; public int pierce; public float moveM; public string hitboxPrefab = "", slashVfx = "", hitVfx = "", refSkillIds = "", src = ""; } public static class WLWeaponAttackTable { public static bool Loaded { get; private set; } public static string LastLog { get; private set; } = ""; static readonly Dictionary> s_byWeapon = new Dictionary>(); static readonly List s_weaponIds = new List(); public static IList WeaponIds { get { EnsureLoaded(); return s_weaponIds; } } public static bool EnsureLoaded() { if (Loaded) return true; var cfg = WLWeaponSettings.Instance; string path = cfg != null && !string.IsNullOrEmpty(cfg.attackTablePath) ? cfg.attackTablePath : "WL/WLWeaponAttackTable"; try { var ta = Resources.Load(path); if (ta == null) throw new System.IO.FileNotFoundException("Resources/" + path + ".csv 없음"); Parse(ta.text); Loaded = s_byWeapon.Count > 0; LastLog = "무기 공격 표 로드 — 무기 " + s_byWeapon.Count + " · 행 " + RowCount(); 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_weaponIds.Clear(); EnsureLoaded(); } static int RowCount() { int n = 0; foreach (var kv in s_byWeapon) n += kv.Value.Count; return n; } 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; } static void Parse(string text) { s_byWeapon.Clear(); s_weaponIds.Clear(); var lines = text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); if (lines.Length < 2) return; var header = lines[0].TrimStart('').Split(','); var col = new Dictionary(); for (int i = 0; i < header.Length; i++) col[header[i].Trim()] = i; System.Func g = (cells, name) => { int ix; return col.TryGetValue(name, out ix) && ix < cells.Length ? cells[ix].Trim() : ""; }; for (int li = 1; li < lines.Length; li++) { if (string.IsNullOrWhiteSpace(lines[li])) continue; var c = lines[li].Split(','); var s = new WLWeaponAttackStep { weaponId = I(g(c, "weapon_id")), weaponName = g(c, "weapon_name"), family = g(c, "family"), classId = I(g(c, "class_id")), animController = g(c, "anim_controller"), unlockPhase = I(g(c, "unlock_phase")), mainStat = g(c, "main_stat"), attackSpeedMult = F(g(c, "attack_speed_mult")), step = I(g(c, "step")), animState = g(c, "anim_state"), hitType = g(c, "hit_type"), hits = Mathf.Max(1, I(g(c, "hits"))), dmgRatioPerHit = F(g(c, "dmg_ratio_per_hit")), rangeM = F(g(c, "range_m")), arcDeg = F(g(c, "arc_deg")), aoeRadiusM = F(g(c, "aoe_radius_m")), projSpeed = F(g(c, "proj_speed")), pierce = I(g(c, "pierce")), moveM = F(g(c, "move_m")), hitboxPrefab = g(c, "hitbox_prefab"), slashVfx = g(c, "slash_vfx"), hitVfx = g(c, "hit_vfx"), refSkillIds = g(c, "ref_skill_ids"), src = g(c, "src"), }; if (s.weaponId <= 0) continue; List l; if (!s_byWeapon.TryGetValue(s.weaponId, out l)) { l = new List(); s_byWeapon[s.weaponId] = l; s_weaponIds.Add(s.weaponId); } l.Add(s); } foreach (var kv in s_byWeapon) kv.Value.Sort((a, b) => a.step.CompareTo(b.step)); } /// 무기(FR GroupNo)의 콤보 단계 목록. 없으면 null. public static List Steps(int weaponId) { EnsureLoaded(); List l; return s_byWeapon.TryGetValue(weaponId, out l) ? l : null; } public static WLWeaponAttackStep First(int weaponId) { var l = Steps(weaponId); return l != null && l.Count > 0 ? l[0] : null; } /// #893 — 그 계열(family) 행들의 사거리(range_m) 최댓값. 계열이 비었거나 행이 없으면 0. public static float MaxRangeOfFamily(string family) { if (string.IsNullOrEmpty(family)) return 0f; EnsureLoaded(); float best = 0f; foreach (var kv in s_byWeapon) for (int i = 0; i < kv.Value.Count; i++) if (kv.Value[i].family == family && kv.Value[i].rangeM > best) best = kv.Value[i].rangeM; return best; } /// 무기가 쓰는 힘민지 직업 ID. 표에 없으면 0. public static int ClassIdFor(int weaponId) { var f = First(weaponId); return f != null ? f.classId : 0; } public static string HitTypeName(string hitType) { switch (hitType) { case "MeleeArc": return "근접 호"; case "Projectile": return "투사체"; case "Area": return "범위"; case "MultiHit": return "연타"; default: return hitType; } } /// UI 한 줄 — 「검 · 직업 10101 · 근접 호 3단 · 사거리 1.5 m · 배율 1.0/1.1/1.6」. public static string Describe(int weaponId) { var l = Steps(weaponId); if (l == null || l.Count == 0) return "공격 표 없음 (그룹 " + weaponId + ")"; var f = l[0]; var sb = new System.Text.StringBuilder(); sb.Append(f.family).Append(" · 직업 ").Append(f.classId).Append(" · "); for (int i = 0; i < l.Count; i++) { if (i > 0) sb.Append('/'); sb.Append(HitTypeName(l[i].hitType)); if (l[i].hits > 1) sb.Append('×').Append(l[i].hits); } sb.Append(" · 사거리 ").Append(f.rangeM.ToString("0.#")).Append(" m · 배율 "); for (int i = 0; i < l.Count; i++) { if (i > 0) sb.Append('/'); sb.Append(l[i].dmgRatioPerHit.ToString("0.##")); } if (f.attackSpeedMult != 1f) sb.Append(" · 속도 ×").Append(f.attackSpeedMult.ToString("0.##")); return sb.ToString(); } } }