// ───────────────────────────────────────────────────────────────────────────── // WLPartSkillState.cs — 장착 장비 → 파츠 스킬 구성(등급 → 개방 · 단계 · 배율 · 시너지) (PD 지시 #879 · 2026-10-16) // // 규칙(PD 10-16): 액티브 · 패시브 모두 장비 등급으로 개방(일반 = Lv1) + 단계 상승(전설 = Lv5) · 불멸 ×1.2 · 신화 ×1.5(제안값 · WLPartSkill.json) // 시너지 = 액티브 5단계 + 짝 패시브 5단계(장갑 · 신발) → FR 각성(Lv6) 수치 · 이름으로 동작 // 무기 시너지 = #895(2026-10-17) 무기와 무관 — 장착 장비 조건(짝 패시브 5단계 [+ req_key 5단계])만 · 여러 개 동시(EvalWeaponSynergies) // 결합 = 불의 정령(목걸이) 5단계 + 물의 정령(반지) 5단계 → 정령왕 // 장착이 바뀌면(WLInventory.Changed) 다시 계산하고 Changed 를 낸다 — 러너 · HUD · 장비 화면이 구독한다. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using WL.Equip; namespace WL.PartSkill { /// 장착 1칸의 스킬 상태. public sealed class WLPartSlot { public eFREquipmentType part; public Item item; public WLPartSkillDef def; public int tier, level; public float mult = 1f; public bool synergy; // 시너지 성립(액티브 · 무기 시너지) public WLPartSkillDef synPartner; // 짝 패시브(또는 결합 상대) public int synPartnerLevel; public bool Open { get { return def != null && level > 0; } } } public sealed class WLPartLoadout { public readonly List actives = new List(); public readonly List passives = new List(); public readonly Dictionary byPart = new Dictionary(); public WLPartSlot weaponSyn; // weaponSyns 의 첫 칸(호환) /// #895 무기 시너지 = 장착 장비 조건(짝 패시브 syn_key [+ req_key])으로만 성립 · 무기 무관 · 여러 개 동시. 조건 장비를 하나라도 끼면 후보(대기)로 들어간다. public readonly List weaponSyns = new List(); public bool spiritKing; public int spiritKingLevel = 1; // 패시브 · 목걸이 합계(파츠 스킬 배율) public float coolMul = 1f, rangeMul = 1f, durMul = 1f, projSpeedMul = 1f, dmgMul = 1f, moveBonus, regenPct, regenPeriod; // 힘민지 스탯 가산(훅 1 · ActorStatInfo.Reset 안) public readonly Dictionary statAdds = new Dictionary(); public int synergyCount; public string summary = ""; public void AddStat(eStat s, double v) { double cur; statAdds.TryGetValue(s, out cur); statAdds[s] = cur + v; } public WLPartSlot PassiveByKey(string key) { for (int i = 0; i < passives.Count; i++) if (passives[i].def != null && passives[i].def.key == key) return passives[i]; return null; } public WLPartSlot ActiveByKey(string key) { for (int i = 0; i < actives.Count; i++) if (actives[i].def != null && actives[i].def.key == key) return actives[i]; return null; } } public static class WLPartSkillState { public static WLPartLoadout Current { get; private set; } = new WLPartLoadout(); public static int Version { get; private set; } public static event System.Action Changed; public static string LastLog { get; private set; } = ""; /// 이번 실행에서 한 번이라도 성립한 시너지 키(도감 ✓ · 「시너지!」 연출 1회). public static readonly HashSet SeenSynergies = new HashSet(); public static event System.Action SynergyFound; static bool s_hooked; public static void EnsureHooked() { if (s_hooked) return; s_hooked = true; WLInventory.Changed += () => Rebuild("inventory"); LoadSeen(); } public static WLPartLoadout Rebuild(string why) { var L = new WLPartLoadout(); if (!WLPartSkillData.Enabled || !WLInventory.EnsureLoaded()) { Current = L; return L; } var S = WLPartSkillData.S; for (int t = (int)eFREquipmentType.Weapon; t < (int)eFREquipmentType.Max; t++) { var part = (eFREquipmentType)t; var it = WLInventory.EquippedInSlot(part); if (it == null || it.itemJson == null) continue; var def = WLPartSkillData.DefForGroup(it.itemJson.GroupNo); // #895 무기 시너지는 무기에서 오지 않는다(아래 장비 조건) · 일반 무기 = 파츠 스킬 0(WLGradeTable noEffectTiers) if (def != null && def.kind == WLPartKind.WeaponSyn) def = null; if (def != null && WLEquipGrades.NoEffect(it.itemJson)) def = null; var slot = new WLPartSlot { part = part, item = it, def = def }; WLGradeTier gt; int plus; slot.tier = WLEquipGrades.TryMap(it.itemJson, out gt, out plus) ? gt.tier : 1; slot.level = WLPartSkillData.TierLevel(slot.tier); slot.mult = WLPartSkillData.TierMult(slot.tier); L.byPart[part] = slot; if (def == null) continue; switch (def.kind) { case WLPartKind.Passive: L.passives.Add(slot); break; case WLPartKind.Active: L.actives.Add(slot); break; } } // #883 무기 기술 표가 WL 파츠 스킬을 지정한 칸(파괴자 1번 = 수호 보호막) → 그 무기에서 액티브 하나 더(칸 해금 등급부터 · 단계 = 무기 등급) WLPartSlot wslot, shadowed = null; if (L.byPart.TryGetValue(eFREquipmentType.Weapon, out wslot) && wslot.item != null && wslot.item.itemJson != null) { var wrows = WL.Combat.Weapon.WLWeaponSkillTable.Rows(wslot.item.itemJson.GroupNo); if (wrows != null) foreach (var r in wrows) { if (!r.IsWlSkill || wslot.tier < r.unlockTier) continue; var wd = WLPartSkillData.Def(r.wlSkill); if (wd == null || wd.kind != WLPartKind.Active) continue; var ws = new WLPartSlot { part = eFREquipmentType.Weapon, item = wslot.item, def = wd, tier = wslot.tier, level = wslot.level, mult = wslot.mult }; // 같은 스킬이 다른 부위에도 있으면(갑옷 구원 = 수호 보호막) 하나만 돈다 — 등급 높은 쪽(같으면 그 부위 것) var same = L.ActiveByKey(wd.key); if (same == null) L.actives.Add(ws); else if (ws.tier > same.tier) { L.actives[L.actives.IndexOf(same)] = ws; shadowed = same; } } } // 패시브 합계 foreach (var p in L.passives) ApplyPassive(L, p); // 시너지 foreach (var a in L.actives) EvalSynergy(L, a, S.synergyLevel); if (shadowed != null) EvalSynergy(L, shadowed, S.synergyLevel); // 장비 화면 부위 칸 표시용 EvalWeaponSynergies(L, S.synergyLevel); var fire = L.ActiveByKey("fire_spirit"); var water = null as WLPartSlot; if (fire != null && !string.IsNullOrEmpty(fire.def.combineKey)) water = L.ActiveByKey(fire.def.combineKey); if (fire != null && water != null && fire.level >= S.synergyLevel && water.level >= S.synergyLevel) { L.spiritKing = true; fire.synergy = water.synergy = true; fire.synPartner = water.def; water.synPartner = fire.def; fire.synPartnerLevel = water.level; water.synPartnerLevel = fire.level; } else { if (fire != null && water != null) { fire.synPartner = water.def; water.synPartner = fire.def; fire.synPartnerLevel = water.level; water.synPartnerLevel = fire.level; } } // 목걸이 랜덤 · 신발 이동 · 반지는 WLPartStats 가 더한다(부위 기본 능력치) WLPartStats.AddPartStats(L); L.synergyCount = 0; foreach (var a in L.actives) if (a.synergy) L.synergyCount++; foreach (var w in L.weaponSyns) if (w.synergy) L.synergyCount++; var sb = new System.Text.StringBuilder(); foreach (var a in L.actives) sb.Append(a.def.name).Append(" Lv").Append(a.level).Append(a.synergy ? "★" : "").Append(' '); foreach (var p in L.passives) sb.Append(p.def.name).Append(" Lv").Append(p.level).Append(' '); foreach (var w in L.weaponSyns) sb.Append("무기시너지 ").Append(w.def.name).Append(w.synergy ? "★" : "(대기)").Append(' '); if (L.spiritKing) sb.Append("정령왕★"); L.summary = sb.ToString(); Current = L; Version++; LastLog = why + " → " + L.summary; if (S.verbose != 0) Debug.Log("[WL-879] 파츠 스킬 구성 " + LastLog); // 새 시너지 = 「시너지!」 연출 1회 + 도감 기록 NoteFound(L); try { Changed?.Invoke(); } catch (System.Exception ex) { Debug.LogException(ex); } return L; } static void NoteFound(WLPartLoadout L) { var found = new List(); foreach (var a in L.actives) if (a.synergy && a.def.key != "water_spirit") found.Add(a.def.key == "fire_spirit" ? "spirit_king" : a.def.key); foreach (var w in L.weaponSyns) if (w.synergy) found.Add(w.def.key); bool any = false; foreach (var k in found) if (SeenSynergies.Add(k)) { any = true; try { SynergyFound?.Invoke(k); } catch (System.Exception ex) { Debug.LogException(ex); } } if (any) SaveSeen(); } /// /// #895 PD 「무기 시너지 효과는 무기와 관계 없이 장비한 아이템에 따라 발생」 — 모든 무기 시너지 정의를 장착 장비로만 본다. /// 조건 = 짝 패시브(syn_key) need 단계 이상 [+ req_key(패시브 또는 액티브) need 단계 이상]. 조건 장비를 하나라도 끼면 후보(대기)로 넣는다. /// 칸의 단계 · 배율 = 짝 패시브 장비 것(배율은 시너지 효과 세기에 쓰인다). /// static void EvalWeaponSynergies(WLPartLoadout L, int need) { L.weaponSyns.Clear(); foreach (var d in WLPartSkillData.Defs) { if (d == null || d.kind != WLPartKind.WeaponSyn || string.IsNullOrEmpty(d.synKey)) continue; var p = L.PassiveByKey(d.synKey); WLPartSlot q = null; bool hasReq = !string.IsNullOrEmpty(d.reqKey); if (hasReq) q = L.PassiveByKey(d.reqKey) ?? L.ActiveByKey(d.reqKey); if (p == null && q == null) continue; var anchor = p ?? q; var w = new WLPartSlot { part = anchor.part, item = anchor.item, def = d, tier = anchor.tier, level = anchor.level, mult = anchor.mult, synPartner = WLPartSkillData.Def(d.synKey), synPartnerLevel = p != null ? p.level : 0 }; w.synergy = p != null && p.level >= need && (!hasReq || (q != null && q.level >= need)); L.weaponSyns.Add(w); } L.weaponSyn = L.weaponSyns.Count > 0 ? L.weaponSyns[0] : null; } static void EvalSynergy(WLPartLoadout L, WLPartSlot a, int need) { if (a.def == null || string.IsNullOrEmpty(a.def.synKey)) return; a.synPartner = WLPartSkillData.Def(a.def.synKey); var p = L.PassiveByKey(a.def.synKey); a.synPartnerLevel = p != null ? p.level : 0; a.synergy = a.level >= need && p != null && p.level >= need; } /// 패시브 값(비율) = FR 값 % ÷ 100 × 등급 배율. public static float PassiveValue(WLPartSlot p, bool second = false) { if (p == null || p.def == null) return 0f; var row = WLPartSkillData.Passive(p.def.lvId, Mathf.Max(1, p.level)); if (row == null) return 0f; return (second ? row.v1 : row.v0) / 100f * p.mult; } static void ApplyPassive(WLPartLoadout L, WLPartSlot p) { if (!p.Open) return; float v = PassiveValue(p), v2 = PassiveValue(p, true); switch (p.def.behavior) { case "atk_mul": L.AddStat(eStat.ATK_Multiplier, v); break; case "hp_mul": L.AddStat(eStat.HP_Multiplier, v); break; case "def_mul": L.AddStat(eStat.DEF_Multiplier, v); break; case "fate": L.AddStat(eStat.EXP_Multiplier, v); L.AddStat(eStat.GOLD_Multiplier, v2 > 0f ? v2 : v); break; case "cool_down": L.coolMul *= Mathf.Max(0.1f, 1f - v); L.AddStat(eStat.COOLTIME_Multiplier, v); break; case "range_up": L.rangeMul += v; break; case "duration_up": L.durMul += v; break; case "proj_speed_up": L.projSpeedMul += v; break; case "move_up": L.moveBonus += v; break; case "regen": L.regenPct += v; L.regenPeriod = p.def.v1 > 0f ? p.def.v1 : 5f; break; // dodge_up · pickup_up = 대응 아이템 없음(PD 결정 대기) — 값만 보이고 효과 연결은 아이템 배정 뒤 } } // ── 도감(찾은 시너지) — 로컬 저장(서버 무관 · 이번 기기) ───────────── const string SeenKey = "WL_PartSkill_Synergies"; static void LoadSeen() { try { var s = PlayerPrefs.GetString(SeenKey, ""); foreach (var k in s.Split(',')) if (!string.IsNullOrEmpty(k)) SeenSynergies.Add(k); } catch { } } static void SaveSeen() { try { PlayerPrefs.SetString(SeenKey, string.Join(",", new List(SeenSynergies).ToArray())); PlayerPrefs.Save(); } catch { } } /// 표시용 — 이 스킬의 지금 단계 행(시너지면 시너지 단계). public static WLPartLevelRow RowFor(WLPartSlot s) { if (s == null || s.def == null) return null; int lv = s.synergy && s.def.synLevel > 0 ? s.def.synLevel : Mathf.Max(1, s.level); return WLPartSkillData.Level(s.def.lvSrc, s.def.lvId, lv); } } }