// ─────────────────────────────────────────────────────────────────────────────
// 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);
}
}
}