351 lines
18 KiB
C#
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|