// ───────────────────────────────────────────────────────────────────────────── // WLPartSkillRunner.cs — 장비 파츠 스킬 실행기 (PD 지시 #879 · 2026-10-16) // // PD 원문: 「스킬은 별도의 조작 없이 쿨타임마다 자동으로 발동되며, 뱀파이어 서바이벌류 스킬 조합」 · 기존 무기 스킬 유지(무기 2칸 = 힘민지 슬롯 경로 그대로). // - 부팅 = WLWeaponSkillSlots 와 같은 방식(AfterSceneLoad · DontDestroyOnLoad · 설정 off 면 생성 0). // - 장착이 바뀌면(WLPartSkillState.Changed) 스킬을 다시 만든다 · 메인 PC 가 바뀌어도 다시 만든다. // - 적 목록 = ActorInfo.Get_EnemyActors 를 enemyCacheSec 마다 1번(모바일) · 스킬은 캐시에서 거리만 본다. // - 패시브: 이동 속도 = Actor.m_NavSpeed(원본 이동 기준값)에 배율(다른 코드가 바꾸면 그 값을 새 기준으로) · 생명의 숨결 = 주기 회복. // - 무기 시너지 = CombatEvents.HitConfirmed(메인 PC 기본 공격 · 스킬 데이터 없는 타격)마다. // - 장비 화면(WLEquipGui)이 열려 있으면 발동을 멈춘다. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using System.Reflection; using UnityEngine; using WL.Combat.Core; namespace WL.PartSkill { public sealed class WLPartSkillRunner : MonoBehaviour { public static WLPartSkillRunner Instance { get; private set; } public static string LastLog { get; private set; } = ""; public static int Builds; public WLPartLoadout L { get { return WLPartSkillState.Current; } } public readonly List Skills = new List(); public SkWeaponSyn WeaponSyn { get; private set; } // WeaponSyns 의 첫 칸(호환) /// #895 성립한 무기 시너지 전부(장착 장비 조건 · 무기 무관) — 기본 공격 타격마다 모두 굴린다. public readonly List WeaponSyns = new List(); public readonly List Enemies = new List(64); public readonly List Tmp = new List(32), Tmp2 = new List(32); public Vector3 PcPos, PcFwd = Vector3.forward, PcRight = Vector3.right; public bool InCombat { get; private set; } public int LiveObjects { get { return _live; } } public readonly Queue PendingBanners = new Queue(); static WLPartSkillSettings S { get { return WLPartSkillData.S; } } int _live, _builtVersion = -1; Object _builtPc; float _scanAt, _combatUntil, _regenAt; readonly HashSet _announced = new HashSet(); static FieldInfo s_fNavSpeed; Object _navPc; float _navBase = -1f, _navSet = -1f; public float NavBase { get { return _navBase; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { if (Instance != null || !WLPartSkillData.Enabled) return; var go = new GameObject("WL_PartSkillRunner"); DontDestroyOnLoad(go); Instance = go.AddComponent(); } void OnEnable() { CombatEvents.Killed.Add(OnKilled); CombatEvents.HitConfirmed.Add(OnHitConfirmed); WLPartSkillState.EnsureHooked(); WLPartSkillState.SynergyFound += OnSynergyFound; } void OnDisable() { CombatEvents.Killed.Remove(OnKilled); CombatEvents.HitConfirmed.Remove(OnHitConfirmed); WLPartSkillState.SynergyFound -= OnSynergyFound; DisposeAll(); } void OnDestroy() { if (Instance == this) Instance = null; } void OnSynergyFound(string key) { /* 전투 밖에서 찾으면 장비 화면이 알린다 · 전투 연출은 BuildSkills 의 첫 시너지 대기열 */ } // ── 메인 루프 ───────────────────────────────────────────────────────── void Update() { WLPartSkillFx.Tick(); WLPartSkillClone.Tick(); if (!WLPartSkillData.Enabled) return; var pc = WLPartSkillCombat.PC; if (pc == null || !pc.gameObject.activeInHierarchy) { if (Skills.Count > 0) DisposeAll(); _builtPc = null; return; } if (WLPartSkillState.Version == 0) WLPartSkillState.Rebuild("runner"); if (_builtVersion != WLPartSkillState.Version || !ReferenceEquals(_builtPc, pc)) BuildSkills(pc); var t = pc.transform; PcPos = t.position; PcFwd = t.forward; PcFwd.y = 0f; if (PcFwd.sqrMagnitude < 0.01f) PcFwd = Vector3.forward; PcFwd.Normalize(); PcRight = Vector3.Cross(Vector3.up, PcFwd); if (Time.time >= _scanAt) { Scan(pc); _scanAt = Time.time + Mathf.Max(0.05f, S.enemyCacheSec); } ApplyMove(pc); bool paused = WL.Equip.WLEquipGui.IsOpen || pc.IsDead(); if (paused) return; float dt = Time.deltaTime; Regen(pc); for (int i = 0; i < Skills.Count; i++) { var sk = Skills[i]; try { sk.Tick(dt); if (sk.Persistent) continue; sk.cd -= dt; if (WL.SkillTest.WLTestScene.ManualSkills) continue; // #958 스킬 테스트 씬 = 자동 시전 끔(Q · E 치트키 WLSkillHotkey.FirePartSkill 만 Cast) if (sk.cd > 0f || !sk.WantCast()) continue; sk.Cast(); sk.cd = Mathf.Max(0.2f, sk.CoolSec); } catch (System.Exception ex) { Debug.LogWarning("[WL-879] 스킬 " + (sk.def != null ? sk.def.key : "?") + " 예외 — " + ex.Message); sk.cd = 3f; } } } void Scan(PCActor pc) { Enemies.Clear(); var info = ActorInfo.Ins; if (info != null) { var l = info.Get_EnemyActors(pc.IsEnemy(), PcPos, S.combatRange + 6f); if (l != null) for (int i = 0; i < l.Count; i++) if (l[i] != null) Enemies.Add(l[i]); } if (AnyEnemyWithin(PcPos, S.combatRange * 1.3f)) _combatUntil = Time.time + 3f; InCombat = Time.time < _combatUntil; } // ── 적 질의(캐시) ───────────────────────────────────────────────────── public bool AnyEnemyWithin(Vector3 p, float r) { float rr = r * r; for (int i = 0; i < Enemies.Count; i++) { var e = Enemies[i]; if (WLPartSkillCombat.Valid(e) && Flat(e.Get_position() - p) <= rr) return true; } return false; } static float Flat(Vector3 d) { d.y = 0f; return d.sqrMagnitude; } public void Collect(Vector3 p, float r, List into) { into.Clear(); float rr = r * r; for (int i = 0; i < Enemies.Count; i++) { var e = Enemies[i]; if (WLPartSkillCombat.Valid(e) && Flat(e.Get_position() - p) <= rr) into.Add(e); } } public Actor Nearest(Vector3 p, float r) { Actor best = null; float bd = r * r; for (int i = 0; i < Enemies.Count; i++) { var e = Enemies[i]; if (!WLPartSkillCombat.Valid(e)) continue; float d = Flat(e.Get_position() - p); if (d <= bd) { bd = d; best = e; } } return best; } public Actor NearestNotRecent(Vector3 p, float r, Dictionary hitAt, float gap) { Actor best = null; float bd = r * r; for (int i = 0; i < Enemies.Count; i++) { var e = Enemies[i]; if (!WLPartSkillCombat.Valid(e)) continue; float t; if (hitAt != null && hitAt.TryGetValue(e.GetInstanceID(), out t) && Time.time - t < gap) continue; float d = Flat(e.Get_position() - p); if (d <= bd) { bd = d; best = e; } } return best; } public Actor RandomEnemy(Vector3 p, float r) { Collect(p, r, Tmp2); return Tmp2.Count > 0 ? Tmp2[Random.Range(0, Tmp2.Count)] : null; } /// p 에서 maxDist 안 적 중 주변(반경 r)에 적이 가장 많은 적의 자리. public bool DensestPoint(Vector3 p, float maxDist, float r, out Vector3 at) { at = p; int best = 0; Collect(p, maxDist, Tmp2); float rr = r * r; for (int i = 0; i < Tmp2.Count; i++) { var c = Tmp2[i].Get_position(); int n = 0; for (int k = 0; k < Tmp2.Count; k++) if (Flat(Tmp2[k].Get_position() - c) <= rr) n++; if (n > best) { best = n; at = c; } } return best > 0; } // ── 오브젝트 상한 ──────────────────────────────────────────────────── public bool CanSpawn(int n) { return _live + n <= Mathf.Max(4, S.maxLiveObjects); } public void Track(int n) { _live += n; } public void Untrack(int n) { _live = Mathf.Max(0, _live - n); } /// 원하는 반경(m) → 이펙트 배율(fxRadius = 배율 1 일 때 그 프리팹의 반경 · 설정). public float FxScale(string key, float radius) { float b; var d = S.fxRadius; if (string.IsNullOrEmpty(key) || d == null || !d.TryGetValue(key, out b) || b <= 0f) b = 1f; return Mathf.Max(0.05f, radius / b); } // ── 구성 ──────────────────────────────────────────────────────────── void BuildSkills(PCActor pc) { DisposeAll(); _builtVersion = WLPartSkillState.Version; _builtPc = pc; var lo = L; float stagger = 0.6f; WLPartSlot fireSlot = null; foreach (var slot in lo.actives) { if (!slot.Open) continue; if (lo.spiritKing && (slot.def.key == "fire_spirit" || slot.def.key == "water_spirit")) { if (slot.def.key == "fire_spirit") fireSlot = slot; continue; } Add(slot, slot.def, slot.synergy, ref stagger); } if (lo.spiritKing && fireSlot != null) { var kd = WLPartSkillData.Def("spirit_king"); if (kd != null) Add(fireSlot, kd, true, ref stagger); } WeaponSyn = null; WeaponSyns.Clear(); foreach (var ws in lo.weaponSyns) { if (!ws.Open || !ws.synergy) continue; // 대기 칸은 실행기에 안 넣는다(효과 0 · HUD 도 성립한 것만 그린다) var sk = Add(ws, ws.def, true, ref stagger) as SkWeaponSyn; if (sk != null) WeaponSyns.Add(sk); } WeaponSyn = WeaponSyns.Count > 0 ? WeaponSyns[0] : null; Builds++; var sb = new System.Text.StringBuilder(); foreach (var s in Skills) sb.Append(s.def.key).Append(s.syn ? "★" : "").Append(" L").Append(s.row != null ? s.row.level : 0).Append(' '); LastLog = "스킬 " + Skills.Count + " — " + sb; if (S.verbose != 0) Debug.Log("[WL-879] 파츠 스킬 실행기 구성 " + LastLog); } PSkill Add(WLPartSlot slot, WLPartSkillDef def, bool syn, ref float stagger) { var sk = PSkillFactory.Create(def.behavior); if (sk == null) { Debug.LogWarning("[WL-879] 동작 없음 — " + def.key + " / " + def.behavior); return null; } sk.R = this; sk.slot = slot; sk.def = def; sk.syn = syn; sk.mult = slot.mult; int lv; if (def.kind == WLPartKind.WeaponSyn || def.kind == WLPartKind.Combine) lv = Mathf.Max(1, def.synLevel); else lv = syn && def.synLevel > 0 ? def.synLevel : Mathf.Max(1, slot.level); sk.row = WLPartSkillData.Level(def.lvSrc, def.lvId, lv); if (def.subId > 0) sk.sub = WLPartSkillData.Level("sub", def.subId, Mathf.Max(1, slot.level)); if (sk.row == null) { Debug.LogWarning("[WL-879] 레벨표 없음 — " + def.key + " " + def.lvSrc + ":" + def.lvId + " L" + lv); return null; } sk.Init(); sk.cd = stagger; stagger += 0.35f; Skills.Add(sk); if (syn && !_announced.Contains(def.key)) PendingBanners.Enqueue(def.key); // 첫 시너지 = 전투 중 「시너지!」 1회 return sk; } /// HUD 가 배너를 띄울 때 부른다 — 표시할 이름(없으면 null). public string TakeBanner() { while (PendingBanners.Count > 0) { var k = PendingBanners.Dequeue(); if (!_announced.Add(k)) continue; var d = WLPartSkillData.Def(k); if (d == null) continue; if (!string.IsNullOrEmpty(S.synergyFx)) WLPartSkillFx.Play(S.synergyFx, PcPos, 1f, 2f); return string.IsNullOrEmpty(d.synName) ? d.name : d.synName; } return null; } void DisposeAll() { for (int i = 0; i < Skills.Count; i++) { try { Skills[i].Dispose(); } catch { } } Skills.Clear(); WLPartSkillClone.ReleaseAll(); WeaponSyn = null; WeaponSyns.Clear(); StopAllCoroutines(); WLPartSkillFx.ReleaseAll(); // #881 끊긴 코루틴이 들고 있던 이펙트(역병 장판 등)까지 거둔다 _live = 0; } // ── 이벤트 ────────────────────────────────────────────────────────── void OnKilled(in KilledEvent e) { if (Skills.Count == 0 || e.victim == null) return; var pc = WLPartSkillCombat.PC; if (pc == null || e.victim.IsEnemy() == pc.IsEnemy()) return; for (int i = 0; i < Skills.Count; i++) Skills[i].OnKilled(e.position); } void OnHitConfirmed(in HitConfirmedEvent e) { if (WeaponSyns.Count == 0 || !e.byMainPC || e.victim == null) return; var pd = e.projectile; if (pd != null && pd.m_SkillListTableData != null) return; // 무기 발동형 스킬 · 다른 스킬 타격은 제외(기본 공격만) if (WL.Equip.WLEquipGui.IsOpen) return; for (int i = 0; i < WeaponSyns.Count; i++) { try { WeaponSyns[i].OnBasicHit(e.victim); } catch (System.Exception ex) { Debug.LogWarning("[WL-879] 무기 시너지 예외 — " + ex.Message); } } } // ── 패시브 ────────────────────────────────────────────────────────── void ApplyMove(PCActor pc) { if (s_fNavSpeed == null) s_fNavSpeed = typeof(Actor).GetField("m_NavSpeed", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); if (s_fNavSpeed == null) return; float cur = (float)s_fNavSpeed.GetValue(pc); if (!ReferenceEquals(_navPc, pc) || Mathf.Abs(cur - _navSet) > 0.0005f) { _navBase = cur; _navPc = pc; } // 원본이 다시 세웠으면 그 값이 새 기준 float want = _navBase * (1f + Mathf.Max(-0.5f, L.moveBonus)); if (Mathf.Abs(want - cur) > 0.0005f) s_fNavSpeed.SetValue(pc, want); _navSet = want; } void Regen(PCActor pc) { var lo = L; if (lo.regenPct <= 0f || lo.regenPeriod <= 0f) return; if (_regenAt <= 0f) _regenAt = Time.time + lo.regenPeriod; if (Time.time < _regenAt) return; _regenAt = Time.time + lo.regenPeriod; try { pc.Heal(lo.regenPct, true); } catch { } } /// 프로브 · 디버그 — 스킬별 상태 한 줄. public string Dump() { var sb = new System.Text.StringBuilder(); sb.Append("combat=").Append(InCombat).Append(" enemies=").Append(Enemies.Count).Append(" live=").Append(_live).Append(" nav=").Append(_navBase.ToString("F2")).Append("→").Append(_navSet.ToString("F2")).Append(" | "); foreach (var s in Skills) sb.Append(s.def.key).Append(s.syn ? "★" : "").Append(" L").Append(s.row != null ? s.row.level : 0).Append(" cd=").Append(s.cd.ToString("F1")).Append(" casts=").Append(s.casts).Append(' ').Append(s.State).Append(" | "); return sb.ToString(); } } /// 원본 · WL 코드에서 부르는 1줄 훅 모음. public static class WLPartSkillHooks { public static int Absorbed; /// Survival.TakeDamage — 수호 보호막이 이 타격을 막으면 true(피해 0 · 원본 무적 경로). public static bool AbsorbPcHit(PCActor pc, DamageInfo d) { var r = WLPartSkillRunner.Instance; if (r == null || pc == null) return false; for (int i = 0; i < r.Skills.Count; i++) { var sh = r.Skills[i] as SkShield; if (sh != null && sh.Absorb()) { Absorbed++; return true; } } return false; } } }