// ───────────────────────────────────────────────────────────────────────────── // WLPartSkillData.cs — 장비 파츠 스킬 데이터 로더 (PD 지시 #879 · 2026-10-16) // // PD 원문(10-15 · 10-16): 무기 = 기본 공격 + 공격 스킬 · 갑옷 = 방어 스킬 · 장갑 · 신발 = 패시브 · 목걸이 · 반지 = 공격 또는 방어 스킬. // 스킬은 쿨마다 자동 발동(VS 류) · FR 스킬 이식 · 액티브 · 패시브 모두 장비 등급으로 개방 + 레벨 상승 · // 액티브 5단계 + 짝 패시브 5단계 = 시너지 스킬(임시 버프 수준 수치 OK) · 이후 FR 룬으로 엑스트라 버프(데이터만으로 붙도록 효과 목록 구조 유지). // 데이터(C45 · 코드 상수 0) = Resources/WL/PartSkill/ // WLPartSkill.json 설정(등급 → 단계 · 배율 · 거리 환산 · HUD) // WLPartSkillDefs.csv 스킬 정의(부위 · 동작 · 레벨표 출처 · 시너지 짝 · 이펙트 · 아이콘 · 설명) // WLPartSkillItemMap.csv 장비 그룹(GroupNo) → 스킬 // WLPartSkillLevels.csv FR 원본 단계별 수치(생성 = Tools/PartSkill/build_partskill_levels.py) · WLPartSkillLevelsWL.csv = WL 변형 스킬 // WLPartPassiveLevels.csv FR 패시브 Lv1~5 // WLPartStats.json 부위 기본 능력치(반지 근접/원거리 · 목걸이 랜덤 풀 · 신발 이동 속도) // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using System.Globalization; using Newtonsoft.Json; using UnityEngine; using WL.Equip; namespace WL.PartSkill { public sealed class WLPartSkillHudCfg { public int enabled = 1, sortingOrder = 30; public float anchorX, anchorY, x = 24, y = 300, size = 92, gap = 12, passiveSize = 64, fontPx = 26, bannerFontPx = 56, bannerY = -420; public Dictionary colors = new Dictionary(); public Color Col(string key, Color fb) { float[] c; if (colors != null && colors.TryGetValue(key, out c) && c != null && c.Length >= 3) return new Color(c[0], c[1], c[2], c.Length > 3 ? c[3] : 1f); return fb; } } public sealed class WLPartSkillSettings { public int enabled = 1, verbose = 1; public string defsCsv = "WL/PartSkill/WLPartSkillDefs", itemMapCsv = "WL/PartSkill/WLPartSkillItemMap", passiveCsv = "WL/PartSkill/WLPartPassiveLevels"; public string statsJson = "WL/PartSkill/WLPartStats", fxFolder = "WL/PartSkill/Fx/", iconFolder = "WL/PartSkill/Icons/"; public List levelCsvs = new List(); public float[] tierLevel = new float[0], tierMult = new float[0]; public int synergyLevel = 5; public float distanceScale = 0.6f, speedScale = 0.6f, minRadius = 0.6f, damageScale = 1f, intervalUnit = 0.1f, combatRange = 9f; public int castOnlyNearEnemies = 1; public float enemyCacheSec = 0.15f, perTargetHitSec = 0.5f; public int maxLiveObjects = 48; public float projectileMaxTravel = 14f, pickupRadius = 1.2f, fxGroundY = 0.08f, hitFxScale = 0.7f; public int fxPoolPerKey = 8; public string synergyFx = "fx_synergy"; public float synergyBannerSec = 2.2f, spiritFollowDist = 1.6f, spiritHeight = 1.4f, spiritKingHeight = 2.2f, spiritKingFollowDist = 2.2f; public float projectileBlockRadiusPad = 0.3f, bombFlightSec = 0.45f, corpseMemorySec = 4f; /// 회전 도끼 · 작은 도끼 — 모델을 눕히는 각(X 축) · 초당 회전(Y 축). public float axeTiltDeg = 90f, axeSpinDegPerSec = 900f; /// #881 해머 무기 시너지 분신. public WLPartSkillCloneCfg clone = new WLPartSkillCloneCfg(); /// 이펙트 키 → 배율 1 일 때 그 프리팹의 반경(m) — 스킬 범위에 맞춰 크기를 정한다. public Dictionary fxRadius = new Dictionary(); /// 이펙트 키 → 켤 때 끌 하위 오브젝트 이름(지나치게 넓은 바닥 빛 등). public Dictionary> fxHide = new Dictionary>(); public WLPartSkillHudCfg hud = new WLPartSkillHudCfg(); } public enum WLPartKind { Active, Passive, WeaponSyn, Combine } public sealed class WLPartSkillDef { public string key, name, synName, kindText, attr, behavior, lvSrc, synKey, combineKey, fxMain, fxHit, fxExtra, icon, desc, synDesc; /// #895 무기 시너지의 두 번째 장비 조건(파츠 스킬 키 · 비우면 짝 패시브 syn_key 하나만). 무기와 무관하게 장착 장비로만 성립한다. public string reqKey = ""; public eFREquipmentType part; public WLPartKind kind; public int lvId, subId, synLevel; public int hmSkill; // #883 behavior hm_skill 이 쏘는 힘민지 SkillList ID(예: 702014 단검 투척) public float v1, v2, v3; public bool HasSynergy { get { return !string.IsNullOrEmpty(synKey) || !string.IsNullOrEmpty(combineKey); } } } /// 레벨표 1행(FR CommonSkillTable · SkillTable 칸 그대로). 효과 칸은 「이름[키:값;…] | …」 → effects 사전. public sealed class WLPartLevelRow { public string src, name, effectsText; public int id, level; public float count, intMin, intMax, newSpawn, spawnMin, spawnMax, duration, cool, hitRange, hitAngle, splash, targetRange, projSpeed, projRandAngle, projTargetRange, orbitSpeed, stopSkill, multiHit, seqInterval, dmgPct; public readonly Dictionary> effects = new Dictionary>(); public bool Has(string effect) { return effects.ContainsKey(effect); } public float Eff(string effect, string param, float fallback) { Dictionary p; float v; return effects.TryGetValue(effect, out p) && p != null && p.TryGetValue(param, out v) ? v : fallback; } } public sealed class WLPartPassiveRow { public int id, level, ability; public string name; public float v0, v1; } public sealed class WLPartStatPool { public string id, name, stat; public float[] perTier = new float[0]; public float pct = 100f; } public sealed class WLPartRingKind { public string id, name; } public sealed class WLPartRename { public int equipType; public string from, to; } public sealed class WLPartStatsCfg { public List meleeFamilies = new List(), rangedFamilies = new List(); public List ringKinds = new List(); public List renameItems = new List(); public float[] shoesMovePerTier = new float[0]; public List necklacePool = new List(); /// 부위 번호 → 기본 능력치 종류 이름(빈 슬롯 안내 · PD 10-16 원문). public Dictionary partStatNames = new Dictionary(); } public static class WLPartSkillData { public const string SettingsPath = "WL/PartSkill/WLPartSkill"; public static bool Loaded { get; private set; } public static string LastLog { get; private set; } = ""; static bool s_tried; static WLPartSkillSettings s_settings; static WLPartStatsCfg s_stats; static readonly List s_defs = new List(); static readonly Dictionary s_defByKey = new Dictionary(); static readonly Dictionary s_groupToKey = new Dictionary(); static readonly Dictionary> s_levels = new Dictionary>(); static readonly Dictionary> s_passives = new Dictionary>(); static readonly Dictionary s_icons = new Dictionary(); public static WLPartSkillSettings S { get { EnsureLoaded(); return s_settings ?? (s_settings = new WLPartSkillSettings { enabled = 0 }); } } public static WLPartStatsCfg Stats { get { EnsureLoaded(); return s_stats ?? (s_stats = new WLPartStatsCfg()); } } public static bool Enabled { get { return EnsureLoaded() && s_settings != null && s_settings.enabled != 0; } } public static IList Defs { get { EnsureLoaded(); return s_defs; } } public static void Reload() { Loaded = false; s_tried = false; s_icons.Clear(); EnsureLoaded(); } public static bool EnsureLoaded() { if (Loaded) return true; if (s_tried) return false; s_tried = true; try { var ta = Resources.Load(SettingsPath); if (ta == null) throw new System.IO.FileNotFoundException("Resources/" + SettingsPath + ".json 없음"); s_settings = JsonConvert.DeserializeObject(ta.text) ?? new WLPartSkillSettings(); if (s_settings.hud == null) s_settings.hud = new WLPartSkillHudCfg(); var st = Resources.Load(s_settings.statsJson); s_stats = st != null ? (JsonConvert.DeserializeObject(st.text) ?? new WLPartStatsCfg()) : new WLPartStatsCfg(); LoadDefs(); LoadItemMap(); LoadLevels(); LoadPassives(); Loaded = true; LastLog = "파츠 스킬 표 로드 — 정의 " + s_defs.Count + " · 아이템 " + s_groupToKey.Count + " · 레벨표 " + s_levels.Count + " · 패시브 " + s_passives.Count; if (s_settings.verbose != 0) Debug.Log("[WL-879] " + LastLog); } catch (System.Exception ex) { LastLog = "파츠 스킬 표 로드 실패 — " + ex.Message; Debug.LogError("[WL-879] " + LastLog); } return Loaded; } // ── CSV ───────────────────────────────────────────────────────────── static List> ReadCsv(string path) { var rows = new List>(); var ta = Resources.Load(path); if (ta == null) { Debug.LogWarning("[WL-879] CSV 없음 — Resources/" + path); return rows; } string text = ta.text; if (text.Length > 0 && text[0] == '') text = text.Substring(1); var lines = text.Replace("\r", "").Split('\n'); if (lines.Length == 0) return rows; var head = SplitCsv(lines[0]); for (int i = 1; i < lines.Length; i++) { if (string.IsNullOrWhiteSpace(lines[i])) continue; var cells = SplitCsv(lines[i]); var d = new Dictionary(head.Count); for (int c = 0; c < head.Count; c++) d[head[c].Trim()] = c < cells.Count ? cells[c].Trim() : ""; rows.Add(d); } return rows; } static List SplitCsv(string line) { var list = new List(24); var sb = new System.Text.StringBuilder(); bool q = false; for (int i = 0; i < line.Length; i++) { char ch = line[i]; if (q) { if (ch == '"') { if (i + 1 < line.Length && line[i + 1] == '"') { sb.Append('"'); i++; } else q = false; } else sb.Append(ch); } else if (ch == '"') q = true; else if (ch == ',') { list.Add(sb.ToString()); sb.Length = 0; } else sb.Append(ch); } list.Add(sb.ToString()); return list; } static string G(Dictionary d, string k) { string v; return d.TryGetValue(k, out v) ? v : ""; } public static float F(string s, float fb = 0f) { float v; return float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out v) ? v : fb; } static int I(string s, int fb = 0) { int v; return int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out v) ? v : fb; } static void LoadDefs() { s_defs.Clear(); s_defByKey.Clear(); foreach (var r in ReadCsv(s_settings.defsCsv)) { var d = new WLPartSkillDef { key = G(r, "key"), name = G(r, "name"), synName = G(r, "syn_name"), kindText = G(r, "kind"), attr = G(r, "attr"), behavior = G(r, "behavior"), lvSrc = G(r, "lv_src"), lvId = I(G(r, "lv_id")), subId = I(G(r, "sub_id")), synKey = G(r, "syn_key"), combineKey = G(r, "combine_key"), synLevel = I(G(r, "syn_level")), fxMain = G(r, "fx_main"), fxHit = G(r, "fx_hit"), fxExtra = G(r, "fx_extra"), icon = G(r, "icon"), v1 = F(G(r, "v1")), v2 = F(G(r, "v2")), v3 = F(G(r, "v3")), desc = G(r, "desc"), synDesc = G(r, "syn_desc"), hmSkill = I(G(r, "hm_skill")), reqKey = G(r, "req_key") }; if (string.IsNullOrEmpty(d.key)) continue; eFREquipmentType part; d.part = System.Enum.TryParse(G(r, "part"), out part) ? part : eFREquipmentType.None; switch (d.kindText) { case "passive": d.kind = WLPartKind.Passive; break; case "weapon_syn": d.kind = WLPartKind.WeaponSyn; break; case "combine": d.kind = WLPartKind.Combine; break; default: d.kind = WLPartKind.Active; break; } s_defs.Add(d); s_defByKey[d.key] = d; } } static void LoadItemMap() { s_groupToKey.Clear(); foreach (var r in ReadCsv(s_settings.itemMapCsv)) { int g = I(G(r, "group_no")); string k = G(r, "key"); if (g > 0 && !string.IsNullOrEmpty(k)) s_groupToKey[g] = k; } } static void LoadLevels() { s_levels.Clear(); if (s_settings.levelCsvs == null) return; foreach (var path in s_settings.levelCsvs) foreach (var r in ReadCsv(path)) { var row = new WLPartLevelRow { src = G(r, "src"), id = I(G(r, "id")), name = G(r, "name"), level = I(G(r, "level"), 1), count = F(G(r, "count")), intMin = F(G(r, "int_min")), intMax = F(G(r, "int_max")), newSpawn = F(G(r, "new_spawn")), spawnMin = F(G(r, "spawn_min")), spawnMax = F(G(r, "spawn_max")), duration = F(G(r, "duration")), cool = F(G(r, "cool")), hitRange = F(G(r, "hit_range")), hitAngle = F(G(r, "hit_angle")), splash = F(G(r, "splash")), targetRange = F(G(r, "target_range")), projSpeed = F(G(r, "proj_speed")), projRandAngle = F(G(r, "proj_rand_angle")), projTargetRange = F(G(r, "proj_target_range")), orbitSpeed = F(G(r, "orbit_speed")), stopSkill = F(G(r, "stop_skill")), multiHit = F(G(r, "multi_hit")), seqInterval = F(G(r, "seq_interval")), dmgPct = F(G(r, "dmg_pct")), effectsText = G(r, "effects") }; ParseEffects(row); string key = row.src + ":" + row.id; List list; if (!s_levels.TryGetValue(key, out list)) s_levels[key] = list = new List(); list.Add(row); } foreach (var l in s_levels.Values) l.Sort((a, b) => a.level.CompareTo(b.level)); } /// 「Knock[Distance:5;Speed:10] | Penetration」 → {Knock:{Distance:5,Speed:10}, Penetration:{}} static void ParseEffects(WLPartLevelRow row) { row.effects.Clear(); if (string.IsNullOrEmpty(row.effectsText)) return; foreach (var part in row.effectsText.Split('|')) { var p = part.Trim(); if (p.Length == 0) continue; int b = p.IndexOf('['); string name = b < 0 ? p : p.Substring(0, b).Trim(); var args = new Dictionary(); if (b >= 0) { int e = p.LastIndexOf(']'); string inner = e > b ? p.Substring(b + 1, e - b - 1) : p.Substring(b + 1); foreach (var kv in inner.Split(';')) { int c = kv.IndexOf(':'); if (c <= 0) continue; args[kv.Substring(0, c).Trim()] = F(kv.Substring(c + 1).Trim()); } } row.effects[name] = args; } } static void LoadPassives() { s_passives.Clear(); foreach (var r in ReadCsv(s_settings.passiveCsv)) { var p = new WLPartPassiveRow { id = I(G(r, "id")), name = G(r, "name"), level = I(G(r, "level"), 1), ability = I(G(r, "ability")), v0 = F(G(r, "v0")), v1 = F(G(r, "v1")) }; List list; if (!s_passives.TryGetValue(p.id, out list)) s_passives[p.id] = list = new List(); list.Add(p); } foreach (var l in s_passives.Values) l.Sort((a, b) => a.level.CompareTo(b.level)); } // ── 조회 ──────────────────────────────────────────────────────────── public static WLPartSkillDef Def(string key) { EnsureLoaded(); WLPartSkillDef d; return key != null && s_defByKey.TryGetValue(key, out d) ? d : null; } public static WLPartSkillDef DefForGroup(int groupNo) { EnsureLoaded(); string k; return s_groupToKey.TryGetValue(groupNo, out k) ? Def(k) : null; } /// 그 스킬을 주는 장비 그룹들(도감 · 상세 안내). public static List GroupsOf(string key) { EnsureLoaded(); var l = new List(); foreach (var kv in s_groupToKey) if (kv.Value == key) l.Add(kv.Key); return l; } /// 레벨표 행 — 그 레벨이 없으면 가장 가까운 낮은 레벨(없으면 첫 행). public static WLPartLevelRow Level(string src, int id, int level) { EnsureLoaded(); List l; if (!s_levels.TryGetValue(src + ":" + id, out l) || l.Count == 0) return null; WLPartLevelRow best = l[0]; for (int i = 0; i < l.Count; i++) if (l[i].level <= level) best = l[i]; return best; } public static int MaxLevel(string src, int id) { EnsureLoaded(); List l; return s_levels.TryGetValue(src + ":" + id, out l) && l.Count > 0 ? l[l.Count - 1].level : 0; } public static WLPartPassiveRow Passive(int id, int level) { EnsureLoaded(); List l; if (!s_passives.TryGetValue(id, out l) || l.Count == 0) return null; WLPartPassiveRow best = l[0]; for (int i = 0; i < l.Count; i++) if (l[i].level <= level) best = l[i]; return best; } public static int TierLevel(int tier) { var t = S.tierLevel; if (t == null || t.Length == 0) return 0; return Mathf.RoundToInt(t[Mathf.Clamp(tier, 0, t.Length - 1)]); } public static float TierMult(int tier) { var t = S.tierMult; if (t == null || t.Length == 0) return 1f; return t[Mathf.Clamp(tier, 0, t.Length - 1)]; } public static Sprite Icon(string key) { if (string.IsNullOrEmpty(key)) return null; Sprite s; if (s_icons.TryGetValue(key, out s)) return s; s = Resources.Load(S.iconFolder + key); s_icons[key] = s; return s; } } }