Project_WL/Assets/WL/Combat/Reaction/KillChain.cs

179 lines
9.4 KiB
C#
Raw Normal View History

// ─────────────────────────────────────────────────────────────────────────────
// KillChain.cs — 연쇄 처치 카운터(3 s 윈도우 · DOUBLE/TRIPLE/RAMPAGE/MASSACRE 티어) + 군집 폭발 VFX 1개
//
// PD 지시 #813 · 발주서 WL-811def §1-2 · 설계안 WL-811a §C 811e 행 · 기준서 v1 §B 요소 2 (2026-09-09)
//
// ■ 무엇을 하나 (Killed 구독 · 원본 훅 0 · HUD 표시 0 = 데이터·이벤트만 · 표시는 813g U)
// · 마지막 처치로부터 killChainWindowSeconds 안에 다음 처치가 오면 연쇄가 이어진다(아케이드 킬 스트릭 · 시간은 unscaled).
// · 연쇄 수가 tiers[i].kills 와 같아지는 순간 TierReached(티어 · 이름 · 무게중심)를 1회 낸다 · 윈도우가 끝나면 Ended.
// · 티어 인덱스 ≥ clusterBurstMinTier 면 처치 무게중심에 군집 폭발 VFX 1개(InGameInfo.Show_Effect · 풀) —
// EffectBudget.ClusterBurst 슬롯(상한 1)을 clusterBurstHoldSeconds 동안 잡아 동시 폭발을 막는다 · 화면 밖이면 스킵.
// ■ GC 0 — 위치 링 버퍼(고정 크기 · 첫 사용 때 1회 할당) · 이벤트 구조체 · 문자열은 verboseLog 때만.
// ■ 값은 전부 WLReactionSettings 에셋 · enabled=false 면 이벤트 0(C8).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using WL.Combat.Core;
namespace WL.Combat.Reaction
{
/// <summary>티어 도달 통보(813g 가 구독해 문구를 띄운다).</summary>
public struct KillChainTierEvent { public int tier; public string name; public int kills; public Vector3 centroid; public Actor lastVictim; public bool burstSpawned; public float time; public int frame; }
/// <summary>연쇄 종료(윈도우 만료) 통보.</summary>
public struct KillChainEndedEvent { public int kills; public int bestTier; public float duration; public float time; public int frame; }
public static class KillChain
{
public static readonly CombatEventList<KillChainTierEvent> TierReached = new CombatEventList<KillChainTierEvent>(4);
public static readonly CombatEventList<KillChainEndedEvent> Ended = new CombatEventList<KillChainEndedEvent>(4);
// ── 연쇄 상태
static int s_chainKills, s_bestTier = -1;
static float s_chainStart, s_lastKill = -999f;
static Vector3[] s_pos; // 최근 처치 위치 링
static int s_head, s_stored;
static Vector3 s_centroid;
// ── 폭발 슬롯
static bool s_burstHeld;
static float s_burstReleaseAt;
// ── 진단(프로브가 읽는다)
public static bool Subscribed;
public static int KillCount, TierCount, ChainCount, BurstCount, BurstDeniedBudget, BurstSkippedView, BurstNoInGameInfo, SkippedFilter;
public static int BestChainEver;
public static string LastInfo = "";
public static int ChainKills { get { return s_chainKills; } }
public static int BestTier { get { return s_bestTier; } }
public static Vector3 Centroid { get { return s_centroid; } }
public static bool BurstHeld { get { return s_burstHeld; } }
public static float LastKillTime { get { return s_lastKill; } }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void Register()
{
CombatEvents.Killed.Add(OnKilled);
Subscribed = true;
}
static WLReactionSettings St { get { return WLReactionSettings.Instance; } }
static bool Verbose { get { var st = St; return st != null && st.verboseLog; } }
static void Log(string msg)
{
var st = St;
if (st != null && st.verboseLog) Debug.Log("[KillChain] " + msg);
}
static void EnsureBuffer(WLReactionSettings st)
{
int n = Mathf.Max(4, st.killChainBufferSize);
if (s_pos == null || s_pos.Length != n) { s_pos = new Vector3[n]; s_head = 0; s_stored = 0; }
}
static void OnKilled(in KilledEvent e)
{
if (!WLReactionSettings.Enabled) return;
var st = St;
if (!st.killChainEnabled || e.victim == null) return;
if (st.killChainRequireMainPCKiller && (e.killer == null || !e.killer.IsMainPC())) { SkippedFilter++; return; }
if (!st.killChainCountSummons && e.subRole == eSubRol.Summon) { SkippedFilter++; return; }
EnsureBuffer(st);
WLReactionRunner.Ensure();
float now = Time.unscaledTime;
if (s_chainKills > 0 && now - s_lastKill >= st.killChainWindowSeconds) EndChain(now); // 늦게 온 처치 = 새 연쇄
if (s_chainKills == 0) { s_chainStart = now; s_bestTier = -1; s_stored = 0; s_head = 0; ChainCount++; }
s_chainKills++;
s_lastKill = now;
KillCount++;
// 위치 링 + 무게중심(연쇄 안 최근 N개)
s_pos[s_head] = e.position;
s_head = (s_head + 1) % s_pos.Length;
if (s_stored < s_pos.Length) s_stored++;
Vector3 sum = Vector3.zero;
for (int i = 0; i < s_stored; i++) sum += s_pos[i];
s_centroid = sum / s_stored;
// 티어 판정(오름차순 · kills 와 정확히 같아지는 순간 1회)
var tiers = st.tiers;
if (tiers == null) return;
for (int i = 0; i < tiers.Length; i++)
{
if (tiers[i].kills != s_chainKills) continue;
s_bestTier = i;
if (s_chainKills > BestChainEver) BestChainEver = s_chainKills;
bool burst = i >= st.clusterBurstMinTier && TrySpawnBurst(st, now);
var ev = new KillChainTierEvent { tier = i, name = tiers[i].name, kills = s_chainKills, centroid = s_centroid, lastVictim = e.victim, burstSpawned = burst, time = now, frame = Time.frameCount };
TierCount++;
LastInfo = tiers[i].name;
TierReached.Dispatch(in ev);
if (Verbose) Log("티어 " + tiers[i].name + " kills=" + s_chainKills + " centroid=" + s_centroid.ToString("F1") + " burst=" + burst);
break;
}
if (s_chainKills > BestChainEver) BestChainEver = s_chainKills;
}
static bool TrySpawnBurst(WLReactionSettings st, float now)
{
if (!st.clusterBurstEnabled) return false;
if (st.clusterBurstRequireInView && !EffectBudget.IsInView(s_centroid)) { BurstSkippedView++; return false; }
if (s_burstHeld && now < s_burstReleaseAt) { BurstDeniedBudget++; return false; } // 우리 슬롯이 아직 살아 있음(동시 ≤ 1)
if (!EffectBudget.TryAcquire(EffectBudgetKind.ClusterBurst)) { BurstDeniedBudget++; return false; }
s_burstHeld = true;
s_burstReleaseAt = now + Mathf.Max(0.05f, st.clusterBurstHoldSeconds);
var info = InGameInfo.Ins;
if (info == null) { BurstNoInGameInfo++; if (Verbose) Log("폭발 스킵(InGameInfo 없음 · 슬롯은 잡음)"); return false; }
info.Show_Effect(st.clusterBurstPrefab, s_centroid + Vector3.up * st.clusterBurstHeightOffset);
BurstCount++;
return true;
}
/// <summary>폭발 슬롯을 돌려준다(홀드 만료 · 러너 파괴 · 씬 전환).</summary>
public static void ReleaseBurst()
{
if (!s_burstHeld) return;
s_burstHeld = false;
EffectBudget.Release(EffectBudgetKind.ClusterBurst);
}
static void EndChain(float now)
{
if (s_chainKills <= 0) return;
var ev = new KillChainEndedEvent { kills = s_chainKills, bestTier = s_bestTier, duration = now - s_chainStart, time = now, frame = Time.frameCount };
if (Verbose) Log("연쇄 종료 kills=" + s_chainKills + " best=" + s_bestTier + " dur=" + ev.duration.ToString("F2"));
s_chainKills = 0; s_bestTier = -1; s_stored = 0; s_head = 0;
Ended.Dispatch(in ev);
}
internal static void Tick(float now)
{
if (s_chainKills > 0)
{
var st = St;
float win = st != null ? st.killChainWindowSeconds : 3f;
if (now - s_lastKill >= win) EndChain(now);
}
if (s_burstHeld && now >= s_burstReleaseAt) ReleaseBurst();
}
/// <summary>씬 전환 · 프로브 정리: 연쇄와 슬롯을 비운다(이벤트 없음).</summary>
public static void ClearAll()
{
s_chainKills = 0; s_bestTier = -1; s_stored = 0; s_head = 0; s_lastKill = -999f;
ReleaseBurst();
}
/// <summary>프로브용 카운터 초기화.</summary>
public static void ResetDiagnostics()
{
KillCount = TierCount = ChainCount = BurstCount = BurstDeniedBudget = BurstSkippedView = BurstNoInGameInfo = SkippedFilter = 0;
BestChainEver = 0; LastInfo = "";
TierReached.Raised = TierReached.Dispatched = 0; Ended.Raised = Ended.Dispatched = 0;
}
}
}