// ───────────────────────────────────────────────────────────────────────────── // 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 { /// 합산 결과(윈도우 만료 또는 처치 즉시). 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 Flushed = new CombatEventList(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); } /// 슬롯을 비우고 결과 이벤트를 만든다(Dispatch 없음 = 재진입 안전). 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; } /// 재진입 가드(811b FIX-5 계열 = 이전 값을 백업하고 복원)를 걸고 구독자에게 알린다. 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); } } /// 데이터 API — 대상의 합산 중 값(813g 가 미리 읽고 싶을 때). 슬롯이 없으면 false. 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; } /// 씬 전환 · 프로브 정리: 슬롯을 비운다(이벤트 없음). 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; } /// 프로브용 카운터 초기화. public static void ResetDiagnostics() { HitCount = FlushCount = MaxHitsMerged = EvictCount = ReentrantHits = 0; LastInfo = ""; Flushed.Raised = Flushed.Dispatched = 0; } } }