// ───────────────────────────────────────────────────────────────────────────── // 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 { /// 티어 도달 통보(813g 가 구독해 문구를 띄운다). 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; } /// 연쇄 종료(윈도우 만료) 통보. 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 TierReached = new CombatEventList(4); public static readonly CombatEventList Ended = new CombatEventList(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++; if (s_chainKills > BestChainEver) BestChainEver = s_chainKills; // 리뷰 Minor-7: 티어 루프 안팎 중복 갱신을 여기 1곳으로 // 위치 링 + 무게중심(연쇄 안 최근 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; 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; } } // ■ 불변식(리뷰 Minor-8ⓐ): s_burstHeld == true ⟺ EffectBudget.ClusterBurst 슬롯 1개를 우리가 잡고 있다. // 획득은 여기(TryAcquire 성공 직후 s_burstHeld = true)뿐이고 반납은 ReleaseBurst 뿐이다. 홀드가 만료됐는데 Tick 이 아직 // 돌지 않은 사이에 다음 처치가 오면 **먼저 반납하고** 다시 잡는다 — 그러지 않으면 상한을 2 이상으로 올렸을 때 획득 2 : 반납 1 로 샌다. 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) { if (now < s_burstReleaseAt) { BurstDeniedBudget++; return false; } // 우리 슬롯이 아직 살아 있음(동시 ≤ 1) ReleaseBurst(); // 홀드 만료 · Tick 보다 먼저 도착 → 반납 후 재획득 } 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; } /// 폭발 슬롯을 돌려준다(홀드 만료 · 러너 파괴 · 씬 전환). 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; // 리뷰 Minor-7: 폴백 상수(SO 기본값의 사본 = 값 이중 출처)를 두지 않는다 — 에셋이 없으면 Enabled 가 false 라 연쇄가 시작되지도 않는다. if (st == null) EndChain(now); else if (now - s_lastKill >= st.killChainWindowSeconds) EndChain(now); } if (s_burstHeld && now >= s_burstReleaseAt) ReleaseBurst(); } /// 씬 전환 · 프로브 정리: 연쇄와 슬롯을 비운다(이벤트 없음). public static void ClearAll() { s_chainKills = 0; s_bestTier = -1; s_stored = 0; s_head = 0; s_lastKill = -999f; ReleaseBurst(); } /// 프로브용 카운터 초기화. 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; } } }