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

185 lines
9.8 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;
static bool s_dispatching; // 리뷰 Minor-5(811b FIX-5 계열) 재진입 가드 — Flushed 구독자가 새 피해를 일으켜도 슬롯이 깨지지 않게
// ── 진단
public static bool Subscribed;
public static int HitCount, FlushCount, MaxHitsMerged, EvictCount, ReentrantHits;
public static string LastInfo = "";
public static int ActiveSlots { get { return s_used; } }
public static bool Dispatching { get { return s_dispatching; } }
[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)
{
if (s_slots != null && s_dispatching) return; // Dispatch 중에는 배열을 갈아끼우지 않는다 — 바깥 순회가 보던 배열이 그대로 유지된다
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++;
if (s_dispatching) ReentrantHits++; // 구독자가 Flushed 안에서 새 피해를 냈다(연쇄 피해 등) — 아래 슬롯 조작은 재진입 안전해야 한다
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; }
}
// 리뷰 Minor-5: 슬롯을 잡기 **전에** Dispatch 하면 구독자가 그 빈 슬롯을 먼저 가져가 s_used 가 어긋난다
// → 밀어내기는 Harvest(슬롯 비우기)만 먼저 하고, Dispatch 는 슬롯 조작이 다 끝난 뒤에 한다.
DamageMergeEvent evicted = default(DamageMergeEvent); bool hasEvicted = false;
if (idx < 0)
{
if (free < 0) { hasEvicted = Harvest(oldest, now, out evicted); if (hasEvicted) 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();
// 슬롯 조작 완료 — 여기서부터 Dispatch(재진입해도 안전)
if (hasEvicted) Dispatch(in evicted);
if (e.isKill && st.damageMergeFlushOnKill) Flush(idx, now);
}
static void Flush(int i, float now)
{
DamageMergeEvent ev;
if (Harvest(i, now, out ev)) Dispatch(in ev);
}
/// <summary>슬롯을 비우고 결과 이벤트를 만든다(Dispatch 없음 = 재진입 안전).</summary>
static bool Harvest(int i, float now, out DamageMergeEvent ev)
{
if (i < 0 || s_slots == null || i >= s_slots.Length || !s_slots[i].used) { ev = default(DamageMergeEvent); return false; }
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--;
return true;
}
/// <summary>재진입 가드(811b FIX-5 계열 = 이전 값을 백업하고 복원)를 걸고 구독자에게 알린다.</summary>
static void Dispatch(in DamageMergeEvent ev)
{
FlushCount++;
bool prev = s_dispatching; s_dispatching = true;
try { Flushed.Dispatch(in ev); }
finally { s_dispatching = prev; }
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;
if (st == null) return; // 리뷰 Minor-7: 폴백 상수(값 이중 출처) 대신 그냥 반환 — 에셋이 없으면 Enabled 가 false 라 슬롯도 없다
float win = st.damageMergeWindowSeconds;
// Dispatch 안에서 새 피해가 들어와도 EnsureSlots 가 배열 재할당을 막으므로 s_slots 는 순회 내내 같은 배열이다.
for (int i = 0; i < s_slots.Length; i++)
{
DamageMergeEvent ev;
if (s_slots[i].used && now - s_slots[i].start >= win && Harvest(i, now, out ev)) Dispatch(in ev);
}
}
/// <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 = ReentrantHits = 0; LastInfo = "";
Flushed.Raised = Flushed.Dispatched = 0;
}
}
}