Project_WL/Assets/WL/PartSkill/WLPartSkillState.cs

273 lines
15 KiB
C#
Raw Normal View History

// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// <summary>장착 1칸의 스킬 상태.</summary>
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<WLPartSlot> actives = new List<WLPartSlot>();
public readonly List<WLPartSlot> passives = new List<WLPartSlot>();
public readonly Dictionary<eFREquipmentType, WLPartSlot> byPart = new Dictionary<eFREquipmentType, WLPartSlot>();
public WLPartSlot weaponSyn; // weaponSyns 의 첫 칸(호환)
/// <summary>#895 무기 시너지 = 장착 장비 조건(짝 패시브 syn_key [+ req_key])으로만 성립 · 무기 무관 · 여러 개 동시. 조건 장비를 하나라도 끼면 후보(대기)로 들어간다.</summary>
public readonly List<WLPartSlot> weaponSyns = new List<WLPartSlot>();
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<eStat, double> statAdds = new Dictionary<eStat, double>();
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; } = "";
/// <summary>이번 실행에서 한 번이라도 성립한 시너지 키(도감 ✓ · 「시너지!」 연출 1회).</summary>
public static readonly HashSet<string> SeenSynergies = new HashSet<string>();
public static event System.Action<string> 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)
{
WL #958 PD 지시 6건(테스트 씬 편의 · 대검 · 비수) + 궤적 이펙트 Play 간 막힘 수정 - ① 숫자 키 1~9 · 0 = 그 번호 무기로 즉시 교체(WLSkillTestHarness.SelectWeapon · 번호 = O/P 순환과 같은 종류 순 · 화면 목록) · 이 씬에서 등급 치트 끔 - ② 모든 무기 각성 상태(WLTestScene.AllAwakened → 무기 기술 · 연계 · 파츠 스킬 칸 해금 등급 무시) · 자동 스킬 끔(ManualSkills → PCActor 자동 시전 · WL_TryAutoSkillNow · 파츠 스킬 자동 시전) — Q/E 치트키로만 · 실측 해머 8 s 공격 427 프레임 · 스킬 진입 0 · E = 대지 분쇄 발동 - ③ 대검 주력 모으기 = 검 빛 손잡이 금색 → 칼끝 노랑 축 그라데이션(WL/WeaponGlow _GlowColor2 · _GradAxis · _GradLen · WLWeaponGlowFx 5인자 Set) + 모으기 입자 수명색 노랑 계열(TintGradient · 어두운 층 제외) · 파란 호 = 모으기 사본 루트 ChargePath 렌더러 끔(chargeFxHide) - ④ 대검 각성 회오리 흰 뭉개짐 = WL_Fx_BladeStormTornado 의 HS 재질 _Emission 5(블룸 없는 화면에서 흰색 포화 · 층별 끄기로 확정) → wraps layers 로 몸통 _Emission 0.7 + 푸른 회색 · 회전 줄무늬 2.5 · 바닥 고리 1.8(프로퍼티 블록) - ⑤ 비수 3타 = 이펙트와 같은 반시계 1080°/s 한 바퀴(−360°/0.333 s) · 팔라딘 클립 자체 시계 300°/0.18 s 회전을 덮어씀(WLSwingExtras yaw 덮기 · 끝 0.12 s 섞기) · 실측 엉덩이 각이 코드 각을 그대로 따라감 - ⑥ 비수 주력 = 양손 작은 사본 끔 · 대상 쪽 공격 범위 × 0.5 자리에 1개(폭 = 범위 × 1.6) · 실측 거리 0.50 · 범위 1.00 · 배율 0.304 - 발견 수정: WLImpactFx.s_swingAt(같은 PC·단계 궤적 0.3 s 묶음)가 도메인 리로드 꺼짐으로 Play 사이에 남아 새 Play 의 궤적 이펙트 · 비수 3타 회전이 막힘(실측 swing +0 · spins +0) → Play 시작마다 비움 · 연속 Play 2회 확인(swing +6 · spins +3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 04:05:18 +00:00
if (!r.IsWlSkill || (wslot.tier < r.unlockTier && !WL.SkillTest.WLTestScene.AllAwakened)) continue; // #958 스킬 테스트 씬 = 모든 무기 각성 상태(해머 대지 분쇄 등 파츠 스킬 칸도 해금)
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<string>();
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();
}
/// <summary>
/// #895 PD 「무기 시너지 효과는 무기와 관계 없이 장비한 아이템에 따라 발생」 — 모든 무기 시너지 정의를 장착 장비로만 본다.
/// 조건 = 짝 패시브(syn_key) need 단계 이상 [+ req_key(패시브 또는 액티브) need 단계 이상]. 조건 장비를 하나라도 끼면 후보(대기)로 넣는다.
/// 칸의 단계 · 배율 = 짝 패시브 장비 것(배율은 시너지 효과 세기에 쓰인다).
/// </summary>
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;
}
/// <summary>패시브 값(비율) = FR 값 % ÷ 100 × 등급 배율.</summary>
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<string>(SeenSynergies).ToArray())); PlayerPrefs.Save(); } catch { }
}
/// <summary>표시용 — 이 스킬의 지금 단계 행(시너지면 시너지 단계).</summary>
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);
}
}
}