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

326 lines
16 KiB
C#
Raw Normal View History

// ─────────────────────────────────────────────────────────────────────────────
// MobHitFlash.cs — 몹 피격 점멸(MaterialPropertyBlock · 머티리얼 인스턴스 0 · GC 0) + 처치 디졸브 엣지 세팅
//
// PD 지시 #813 · 발주서 WL-811def §1-3 · 구 발주서 WL-802d(주석 처리된 Actor.HitEffect_Color 의 MPB 복원) · 설계안 §C 811f 행 (2026-09-09)
//
// ■ 무엇을 하나 (HitConfirmed / Killed / Spawned 구독 · 원본 훅 0 · Actor.cs/MobActor.cs 무수정)
// HitConfirmed(몹) → 렌더러별 MPB 에 알베도 색(HDR 흰색) + 에미션을 써서 blinkOnSeconds 뒤 비운다.
// 같은 프레임 다중 히트(스윙 1회 5~7타)는 켜진 동안 연장 · 꺼진 뒤 blinkOffSeconds 안은 재점멸 안 함.
// Killed(몹) → 같은 MPB 에 디졸브 엣지 세기/범위(등급별)를 얹는다. 0.5 s 뒤 원본 Co_Dissolve 가 렌더러 머티리얼을
// 디졸브 머티리얼로 바꾸는 순간 그 프로퍼티가 적용된다(MPB 는 렌더러에 붙어 머티리얼 교체와 무관).
// Spawned(몹) → 풀 재사용 몹의 MPB 를 비운다(잔류 0).
// ■ 왜 원본 방식(머티리얼 Add/Remove)이 아니라 MPB 인가
// 원본 HitEffect_Color 는 sharedMaterials.ToList()+materials= 대입으로 매 타격 2.3 KB GC + 머티리얼 인스턴스가 생겨 주석 처리됐다.
// MPB 는 렌더러당 1개를 캐시해 재사용하므로 타격당 할당 0 · 머티리얼 인스턴스 0 · 드로우 +0(프로퍼티만).
// 대신 MPB 가 붙은 렌더러는 그 동안 SRP Batcher 에서 빠진다(≤ 0.03 s · 비운 뒤 복귀 · 디졸브 중 사망체 ≤ 6).
// ■ 렌더러 캐시 = Actor.arr_Renderer(protected · Awake 에서 ParticleSystemRenderer 를 뺀 캐시)를 리플렉션으로 1회 읽는다(원본 무수정 ·
// 같은 필터 결과). 필드를 못 찾으면 GetComponentsInChildren 로 같은 필터를 1회 적용. 액터당 상태는 처음 볼 때 1회만 할당(풀이라 유한).
// ■ 프로퍼티는 셰이더별로 1회 Shader.FindPropertyIndex 로 실측해 캐시 · 없으면 그 렌더러는 건너뛴다(예외 0).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using WL.Combat.Core;
namespace WL.Combat.Reaction
{
public static class MobHitFlash
{
struct Resolved { public bool hasColor, hasEmission; public int colorId, emissionId; }
sealed class State
{
public Actor actor;
public Renderer[] renderers;
public MaterialPropertyBlock[] blocks;
public Resolved[] resolved;
public bool blinkOn, tint;
public float blinkOffAt, blinkReadyAt, tintHdr, tintRange;
public int activeIndex = -1;
}
static readonly Dictionary<Actor, State> s_states = new Dictionary<Actor, State>(64);
static readonly Dictionary<Shader, Resolved> s_byShader = new Dictionary<Shader, Resolved>(8);
static State[] s_active = new State[32];
static int s_activeCount;
static FieldInfo s_rendererField;
static bool s_rendererFieldLooked;
static bool s_dissolveResolved, s_dissolveHasHdr, s_dissolveHasRange;
static int s_edgeHdrId, s_edgeRangeId;
static readonly List<Renderer> s_tmpRenderers = new List<Renderer>(16);
// ── 진단(프로브가 읽는다)
public static bool Subscribed;
public static int OnCount, OffCount, ExtendCount, ThrottledCount, TintCount, ClearCount, SkippedFilter, SkippedNoProperty, StatesCreated, RendererFieldViaReflection;
public static string LastInfo = "";
public static int ActiveCount { get { return s_activeCount; } }
public static int StateCount { get { return s_states.Count; } }
public static bool DissolveResolved { get { return s_dissolveResolved; } }
public static bool DissolveHasHdr { get { return s_dissolveHasHdr; } }
public static bool DissolveHasRange { get { return s_dissolveHasRange; } }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void Register()
{
CombatEvents.HitConfirmed.Add(OnHitConfirmed);
CombatEvents.Killed.Add(OnKilled);
CombatEvents.Spawned.Add(OnSpawned);
Subscribed = true;
}
static WLHitBlinkSettings St { get { return WLHitBlinkSettings.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("[MobHitFlash] " + msg);
}
// ───────────────────────────────────────── 이벤트
static void OnHitConfirmed(in HitConfirmedEvent e)
{
if (!WLHitBlinkSettings.Enabled) return;
var st = St;
var victim = e.victim;
if (victim == null) return;
if (st.targetMobsOnly ? !(victim is MobActor) : victim.IsMainPC()) { SkippedFilter++; return; }
if (st.onlyByMainPC && !e.byMainPC) { SkippedFilter++; return; }
if (st.skipWhenDamageZero && e.damage <= 0d) { SkippedFilter++; return; }
var s = GetState(victim, st);
if (s == null) return;
float now = Time.unscaledTime;
if (s.blinkOn) { s.blinkOffAt = now + st.blinkOnSeconds; ExtendCount++; return; } // 같은 프레임 다중 히트 → 연장
if (now < s.blinkReadyAt) { ThrottledCount++; return; }
if (s.tint && !victim.IsDead()) s.tint = false; // 부활·재사용 뒤 남은 엣지 세팅 정리
WLReactionRunner.Ensure();
s.blinkOn = true; s.blinkOffAt = now + st.blinkOnSeconds;
Apply(s, st);
Activate(s);
OnCount++;
LastInfo = "on";
if (Verbose) Log("점멸 on " + victim.name + " renderers=" + s.renderers.Length);
}
static void OnKilled(in KilledEvent e)
{
if (!WLHitBlinkSettings.Enabled) return;
var st = St;
if (!st.dissolveEdgeEnabled) return;
var victim = e.victim;
if (victim == null || !(victim is MobActor)) return;
ResolveDissolve(st);
if (!s_dissolveHasHdr && !s_dissolveHasRange) { SkippedNoProperty++; return; }
var s = GetState(victim, st);
if (s == null) return;
float hdr = e.subRole == eSubRol.Boss ? st.dissolveEdgeHdrBoss : e.subRole == eSubRol.Elite ? st.dissolveEdgeHdrElite : st.dissolveEdgeHdrNormal;
float range = e.subRole == eSubRol.Boss ? st.dissolveEdgeRangeBoss : e.subRole == eSubRol.Elite ? st.dissolveEdgeRangeElite : st.dissolveEdgeRangeNormal;
if (hdr < 0f && range < 0f) return;
s.tint = true; s.tintHdr = hdr; s.tintRange = range;
Apply(s, st);
TintCount++;
if (Verbose) Log("디졸브 엣지 " + victim.name + " sub=" + e.subRole + " hdr=" + hdr + " range=" + range);
}
static void OnSpawned(in SpawnedEvent e)
{
if (e.actor == null) return;
State s;
if (!s_states.TryGetValue(e.actor, out s)) return;
if (!s.blinkOn && !s.tint) return;
s.blinkOn = false; s.tint = false; s.blinkReadyAt = 0f;
var st = St;
if (st != null) Apply(s, st);
Deactivate(s);
ClearCount++;
}
// ───────────────────────────────────────── 상태
static State GetState(Actor actor, WLHitBlinkSettings st)
{
State s;
if (s_states.TryGetValue(actor, out s)) return s;
var renderers = FindRenderers(actor);
if (renderers == null || renderers.Length == 0) return null;
s = new State { actor = actor, renderers = renderers, blocks = new MaterialPropertyBlock[renderers.Length], resolved = new Resolved[renderers.Length] };
for (int i = 0; i < renderers.Length; i++)
{
s.blocks[i] = new MaterialPropertyBlock();
var r = renderers[i];
var mat = r != null ? r.sharedMaterial : null;
s.resolved[i] = Resolve(mat != null ? mat.shader : null, st);
}
s_states.Add(actor, s);
StatesCreated++;
return s;
}
/// <summary>Actor.arr_Renderer(protected 캐시)를 리플렉션으로 1회 읽는다. 실패하면 같은 필터로 직접 수집.</summary>
static Renderer[] FindRenderers(Actor actor)
{
if (!s_rendererFieldLooked)
{
s_rendererFieldLooked = true;
s_rendererField = typeof(Actor).GetField("arr_Renderer", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
}
if (s_rendererField != null)
{
var arr = s_rendererField.GetValue(actor) as Renderer[];
if (arr != null && arr.Length > 0) { RendererFieldViaReflection++; return arr; }
}
s_tmpRenderers.Clear();
actor.GetComponentsInChildren<Renderer>(true, s_tmpRenderers);
int n = 0;
for (int i = 0; i < s_tmpRenderers.Count; i++) if (!(s_tmpRenderers[i] is ParticleSystemRenderer)) n++;
var result = new Renderer[n];
n = 0;
for (int i = 0; i < s_tmpRenderers.Count; i++) if (!(s_tmpRenderers[i] is ParticleSystemRenderer)) result[n++] = s_tmpRenderers[i];
s_tmpRenderers.Clear();
return result;
}
static Resolved Resolve(Shader shader, WLHitBlinkSettings st)
{
Resolved r;
if (shader == null) return default(Resolved);
if (s_byShader.TryGetValue(shader, out r)) return r;
r = default(Resolved);
var names = st.colorProperties;
if (names != null)
for (int i = 0; i < names.Length; i++)
if (!string.IsNullOrEmpty(names[i]) && shader.FindPropertyIndex(names[i]) >= 0) { r.hasColor = true; r.colorId = Shader.PropertyToID(names[i]); break; }
names = st.emissionProperties;
if (names != null)
for (int i = 0; i < names.Length; i++)
if (!string.IsNullOrEmpty(names[i]) && shader.FindPropertyIndex(names[i]) >= 0) { r.hasEmission = true; r.emissionId = Shader.PropertyToID(names[i]); break; }
s_byShader.Add(shader, r);
if (!r.hasColor && !r.hasEmission) SkippedNoProperty++;
if (Verbose) Log("셰이더 실측 " + shader.name + " color=" + r.hasColor + " emission=" + r.hasEmission);
return r;
}
static void ResolveDissolve(WLHitBlinkSettings st)
{
if (s_dissolveResolved) return;
Shader sh = null;
var info = InGameInfo.Ins;
if (info != null && info.mat_Dissolve != null) sh = info.mat_Dissolve.shader;
if (sh == null && !string.IsNullOrEmpty(st.dissolveShaderName)) sh = Shader.Find(st.dissolveShaderName);
if (sh == null) return; // 아직 못 찾음(타이틀 등) — 다음 Killed 때 재시도
s_dissolveResolved = true;
s_dissolveHasHdr = !string.IsNullOrEmpty(st.dissolveEdgeHdrProperty) && sh.FindPropertyIndex(st.dissolveEdgeHdrProperty) >= 0;
s_dissolveHasRange = !string.IsNullOrEmpty(st.dissolveEdgeRangeProperty) && sh.FindPropertyIndex(st.dissolveEdgeRangeProperty) >= 0;
if (s_dissolveHasHdr) s_edgeHdrId = Shader.PropertyToID(st.dissolveEdgeHdrProperty);
if (s_dissolveHasRange) s_edgeRangeId = Shader.PropertyToID(st.dissolveEdgeRangeProperty);
if (Verbose) Log("디졸브 셰이더 실측 " + sh.name + " hdr=" + s_dissolveHasHdr + " range=" + s_dissolveHasRange);
}
/// <summary>상태(blinkOn · tint)로 렌더러별 MPB 를 다시 구성해 쓴다. 둘 다 꺼져 있으면 빈 블록 = 오버라이드 0.</summary>
static void Apply(State s, WLHitBlinkSettings st)
{
for (int i = 0; i < s.renderers.Length; i++)
{
var r = s.renderers[i];
if (r == null) continue;
var mpb = s.blocks[i];
mpb.Clear();
if (s.blinkOn)
{
var res = s.resolved[i];
if (res.hasColor) mpb.SetColor(res.colorId, st.blinkColor);
if (st.useEmission && res.hasEmission) mpb.SetColor(res.emissionId, st.blinkEmission);
}
if (s.tint)
{
if (s_dissolveHasHdr && s.tintHdr >= 0f) mpb.SetFloat(s_edgeHdrId, s.tintHdr);
if (s_dissolveHasRange && s.tintRange >= 0f) mpb.SetFloat(s_edgeRangeId, s.tintRange);
}
r.SetPropertyBlock(mpb);
}
}
static void Activate(State s)
{
if (s.activeIndex >= 0) return;
if (s_activeCount == s_active.Length) System.Array.Resize(ref s_active, s_active.Length * 2);
s.activeIndex = s_activeCount;
s_active[s_activeCount++] = s;
}
static void Deactivate(State s)
{
int i = s.activeIndex;
if (i < 0) return;
int last = --s_activeCount;
s_active[i] = s_active[last];
if (s_active[i] != null) s_active[i].activeIndex = i;
s_active[last] = null;
s.activeIndex = -1;
}
internal static void Tick(float now)
{
if (s_activeCount == 0) return;
var st = St;
for (int i = s_activeCount - 1; i >= 0; i--)
{
var s = s_active[i];
if (s.blinkOn && (now >= s.blinkOffAt || st == null || !WLHitBlinkSettings.Enabled))
{
s.blinkOn = false;
s.blinkReadyAt = now + (st != null ? st.blinkOffSeconds : 0f);
if (st != null) Apply(s, st);
OffCount++;
LastInfo = "off";
}
if (!s.blinkOn) Deactivate(s);
}
}
/// <summary>모든 몹의 MPB 를 비운다(러너 파괴 · 앱 종료 · 프로브 정리). 상태 캐시는 유지.</summary>
public static void RestoreAll()
{
var st = St;
foreach (var kv in s_states)
{
var s = kv.Value;
if (!s.blinkOn && !s.tint) continue;
s.blinkOn = false; s.tint = false;
if (st != null) Apply(s, st);
else for (int i = 0; i < s.renderers.Length; i++) if (s.renderers[i] != null) { s.blocks[i].Clear(); s.renderers[i].SetPropertyBlock(s.blocks[i]); }
}
for (int i = 0; i < s_activeCount; i++) { s_active[i].activeIndex = -1; s_active[i] = null; }
s_activeCount = 0;
}
/// <summary>씬 전환: MPB 를 비우고 상태 캐시(파괴된 Actor 참조)를 버린다.</summary>
public static void ClearAll()
{
RestoreAll();
s_states.Clear();
}
/// <summary>프로브용: 특정 액터의 렌더러가 지금 프로퍼티 블록을 갖고 있는가(잔류 검사).</summary>
public static bool HasAnyBlock(Actor actor)
{
State s;
if (actor == null || !s_states.TryGetValue(actor, out s)) return false;
for (int i = 0; i < s.renderers.Length; i++) if (s.renderers[i] != null && s.renderers[i].HasPropertyBlock()) return true;
return false;
}
/// <summary>프로브용 카운터 초기화.</summary>
public static void ResetDiagnostics()
{
OnCount = OffCount = ExtendCount = ThrottledCount = TintCount = ClearCount = SkippedFilter = SkippedNoProperty = StatesCreated = RendererFieldViaReflection = 0;
LastInfo = "";
}
}
}