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

156 lines
7.5 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// DamageAggregator.cs — 데미지 텍스트 합산 윈도우(0.25 s · 대상별) 데이터 API — 표시는 813g(U)
//
// PD 지시 #813 · 발주서 WL-811def §1-2 · 기준서 v1 §B 요소 2 「합산 윈도우 0.25 s · 숫자죽 방지」 (2026-09-09)
//
// ■ 무엇을 하나 (HitConfirmed 구독 · 원본 훅 0 · HUDDMGUI 무수정)
// 같은 대상에 첫 타격이 들어온 순간부터 damageMergeWindowSeconds(unscaled) 안의 타격을 한 슬롯에 더한다.
// 윈도우가 끝나면(또는 처치 타격이면 즉시) Flushed(대상 · 합계 · 타수 · 최대 단타 · 크리 여부 · 처치 여부 · 위치)를 1회 낸다.
// 813g 는 Flushed 만 구독하면 "초당 3~5개 겹치는 숫자" 대신 합산 1개를 띄울 수 있다. 원본 HUDDMGUI 는 그대로 돈다(병행).
// ■ GC 0 — 슬롯 배열(고정 · 첫 사용 때 1회 할당) · 선형 탐색(≤ 32) · 구조체 이벤트.
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using WL.Combat.Core;
namespace WL.Combat.Reaction
{
/// <summary>합산 결과(윈도우 만료 또는 처치 즉시).</summary>
public struct DamageMergeEvent
{
public Actor victim; public double total; public int hits; public double maxSingle; public bool anyCritical; public bool isKill; public bool byMainPC;
public Vector3 position; public float firstTime; public float time; public int frame;
}
public static class DamageAggregator
{
public static readonly CombatEventList<DamageMergeEvent> Flushed = new CombatEventList<DamageMergeEvent>(4);
struct Slot
{
public bool used; public Actor victim; public double total, maxSingle; public int hits;
public bool anyCritical, isKill, byMainPC; public float start; public Vector3 pos;
}
static Slot[] s_slots;
static int s_used;
// ── 진단
public static bool Subscribed;
public static int HitCount, FlushCount, MaxHitsMerged, EvictCount;
public static string LastInfo = "";
public static int ActiveSlots { get { return s_used; } }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void Register()
{
CombatEvents.HitConfirmed.Add(OnHitConfirmed);
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("[DamageAggregator] " + msg);
}
static void EnsureSlots(WLReactionSettings st)
{
int n = Mathf.Max(4, st.damageMergeSlots);
if (s_slots == null || s_slots.Length != n) { s_slots = new Slot[n]; s_used = 0; }
}
static void OnHitConfirmed(in HitConfirmedEvent e)
{
if (!WLReactionSettings.Enabled) return;
var st = St;
if (!st.damageMergeEnabled || e.victim == null) return;
if (st.damageMergeMainPCOnly && !e.byMainPC) return;
EnsureSlots(st);
WLReactionRunner.Ensure();
float now = Time.unscaledTime;
HitCount++;
int idx = -1, free = -1, oldest = -1; float oldestStart = float.MaxValue;
for (int i = 0; i < s_slots.Length; i++)
{
if (!s_slots[i].used) { if (free < 0) free = i; continue; }
if (ReferenceEquals(s_slots[i].victim, e.victim)) { idx = i; break; }
if (s_slots[i].start < oldestStart) { oldestStart = s_slots[i].start; oldest = i; }
}
if (idx < 0)
{
if (free < 0) { Flush(oldest, now); EvictCount++; free = oldest; }
idx = free;
s_slots[idx].used = true; s_slots[idx].victim = e.victim; s_slots[idx].total = 0d; s_slots[idx].maxSingle = 0d;
s_slots[idx].hits = 0; s_slots[idx].anyCritical = false; s_slots[idx].isKill = false; s_slots[idx].byMainPC = e.byMainPC; s_slots[idx].start = now;
s_used++;
}
s_slots[idx].total += e.damage;
if (e.damage > s_slots[idx].maxSingle) s_slots[idx].maxSingle = e.damage;
s_slots[idx].hits++;
s_slots[idx].anyCritical |= e.critical;
s_slots[idx].isKill |= e.isKill;
s_slots[idx].byMainPC |= e.byMainPC;
s_slots[idx].pos = e.victim.Get_position();
if (e.isKill && st.damageMergeFlushOnKill) Flush(idx, now);
}
static void Flush(int i, float now)
{
if (i < 0 || !s_slots[i].used) return;
var ev = new DamageMergeEvent
{
victim = s_slots[i].victim, total = s_slots[i].total, hits = s_slots[i].hits, maxSingle = s_slots[i].maxSingle,
anyCritical = s_slots[i].anyCritical, isKill = s_slots[i].isKill, byMainPC = s_slots[i].byMainPC,
position = s_slots[i].pos, firstTime = s_slots[i].start, time = now, frame = Time.frameCount
};
if (ev.hits > MaxHitsMerged) MaxHitsMerged = ev.hits;
s_slots[i].used = false; s_slots[i].victim = null; s_used--;
FlushCount++;
Flushed.Dispatch(in ev);
if (Verbose) Log("플러시 victim=" + (ev.victim != null ? ev.victim.name : "?") + " total=" + ev.total.ToString("F0") + " hits=" + ev.hits + " cri=" + ev.anyCritical + " kill=" + ev.isKill);
}
internal static void Tick(float now)
{
if (s_used <= 0 || s_slots == null) return;
var st = St;
float win = st != null ? st.damageMergeWindowSeconds : 0.25f;
for (int i = 0; i < s_slots.Length; i++)
if (s_slots[i].used && now - s_slots[i].start >= win) Flush(i, now);
}
/// <summary>데이터 API — 대상의 합산 중 값(813g 가 미리 읽고 싶을 때). 슬롯이 없으면 false.</summary>
public static bool TryGetPending(Actor victim, out double total, out int hits)
{
total = 0d; hits = 0;
if (s_slots == null || victim == null) return false;
for (int i = 0; i < s_slots.Length; i++)
if (s_slots[i].used && ReferenceEquals(s_slots[i].victim, victim)) { total = s_slots[i].total; hits = s_slots[i].hits; return true; }
return false;
}
/// <summary>씬 전환 · 프로브 정리: 슬롯을 비운다(이벤트 없음).</summary>
public static void ClearAll()
{
if (s_slots == null) return;
for (int i = 0; i < s_slots.Length; i++) { s_slots[i].used = false; s_slots[i].victim = null; }
s_used = 0;
}
/// <summary>프로브용 카운터 초기화.</summary>
public static void ResetDiagnostics()
{
HitCount = FlushCount = MaxHitsMerged = EvictCount = 0; LastInfo = "";
Flushed.Raised = Flushed.Dispatched = 0;
}
}
}