Project_WL/Assets/WL/PartSkill/WLPartSkillRunner.cs

351 lines
18 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// 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<PSkill> Skills = new List<PSkill>();
public SkWeaponSyn WeaponSyn { get; private set; } // WeaponSyns 의 첫 칸(호환)
/// <summary>#895 성립한 무기 시너지 전부(장착 장비 조건 · 무기 무관) — 기본 공격 타격마다 모두 굴린다.</summary>
public readonly List<SkWeaponSyn> WeaponSyns = new List<SkWeaponSyn>();
public readonly List<Actor> Enemies = new List<Actor>(64);
public readonly List<Actor> Tmp = new List<Actor>(32), Tmp2 = new List<Actor>(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<string> PendingBanners = new Queue<string>();
static WLPartSkillSettings S { get { return WLPartSkillData.S; } }
int _live, _builtVersion = -1;
Object _builtPc;
float _scanAt, _combatUntil, _regenAt;
readonly HashSet<string> _announced = new HashSet<string>();
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<WLPartSkillRunner>();
}
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 (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<Actor> 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<int, float> 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;
}
/// <summary>p 에서 maxDist 안 적 중 주변(반경 r)에 적이 가장 많은 적의 자리.</summary>
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); }
/// <summary>원하는 반경(m) → 이펙트 배율(fxRadius = 배율 1 일 때 그 프리팹의 반경 · 설정).</summary>
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;
}
/// <summary>HUD 가 배너를 띄울 때 부른다 — 표시할 이름(없으면 null).</summary>
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 { }
}
/// <summary>프로브 · 디버그 — 스킬별 상태 한 줄.</summary>
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();
}
}
/// <summary>원본 · WL 코드에서 부르는 1줄 훅 모음.</summary>
public static class WLPartSkillHooks
{
public static int Absorbed;
/// <summary>Survival.TakeDamage — 수호 보호막이 이 타격을 막으면 true(피해 0 · 원본 무적 경로).</summary>
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;
}
}
}