185 lines
9.8 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|