179 lines
9.4 KiB
C#
179 lines
9.4 KiB
C#
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
// 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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|