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

408 lines
22 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// 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;
// WL #816zb — 이번 점멸이 처음 켜진 시각. 연장(ExtendCount)이 blinkMaxSeconds 를 넘지 못하게 하는 기준점.
public float blinkStartAt;
public int activeIndex = -1;
// WL #811gh — 임팩트 티어별 점멸 강도. 1 = 기존 동작(티어 시스템 off 이면 항상 1).
public float blinkColorMul = 1f, blinkTimeMul = 1f;
// WL #813s — 엘리트 상시 색(EliteMarker 가 등록). false = 기존 동작 그대로.
public bool elite;
public Color eliteAlbedo, eliteEmission;
}
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;
// WL #811gh — 임팩트 티어별 점멸 색/시간 배수. 티어 시스템이 꺼져 있으면 (1, 1) = 기존 동작 그대로.
float colorMul, timeMul;
ImpactTier.GetBlinkModifiers(victim, out colorMul, out timeMul);
float now = Time.unscaledTime;
// WL #816zb — 연장도 '처음 켜진 시각 + blinkMaxSeconds' 를 넘지 못한다(PD: 몹이 계속 하얗게 보이는 현상).
if (s.blinkOn) { s.blinkOffAt = CapOffAt(st, s.blinkStartAt, now + st.blinkOnSeconds * timeMul); ExtendCount++; return; }
if (now < s.blinkReadyAt) { ThrottledCount++; return; }
if (s.tint && !victim.IsDead()) s.tint = false; // 부활·재사용 뒤 남은 엣지 세팅 정리
WLReactionRunner.Ensure();
s.blinkColorMul = colorMul; s.blinkTimeMul = timeMul;
s.blinkOn = true; s.blinkStartAt = now;
s.blinkOffAt = CapOffAt(st, now, now + st.blinkOnSeconds * timeMul);
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;
WLReactionRunner.Ensure(); // 리뷰 Minor-4: 첫 이벤트가 Killed 여도 씬 전환 훅이 붙어 s_states(파괴 액터 참조)가 씬을 넘지 않게
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;
// 리뷰 Minor-8ⓒ: st == null(에셋 자체가 없음)이면 Apply 를 못 부르지만, 그 상태에서는 Enabled 가 false 라 애초에 MPB 를 쓴 적이 없다
// (블록이 있다면 RestoreAll 의 st == null 경로가 SetPropertyBlock(null) 로 뗀다) → 잔류 0. ⓑ s_states 는 씬 전환(ClearAll)까지 유지 = 풀 크기 상한.
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 를 다시 구성해 쓴다. 둘 다 꺼져 있으면 블록을 떼어(null) 오버라이드 0 · SRP Batcher 복귀.</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();
// WL #813s — 엘리트 상시 색을 먼저 깐다. 아래 점멸이 같은 프로퍼티를 덮으므로 우선순위 = 점멸 > 상시.
if (s.elite)
{
var eres = s.resolved[i];
if (eres.hasColor) mpb.SetColor(eres.colorId, s.eliteAlbedo);
if (eres.hasEmission) mpb.SetColor(eres.emissionId, s.eliteEmission);
}
if (s.blinkOn)
{
var res = s.resolved[i];
if (res.hasColor) mpb.SetColor(res.colorId, ScaleRgb(st.blinkColor, s.blinkColorMul));
if (st.useEmission && res.hasEmission) mpb.SetColor(res.emissionId, ScaleRgb(st.blinkEmission, s.blinkColorMul));
}
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);
}
// 빈 블록을 써도 HasPropertyBlock 은 true 로 남는다(2026-09-09 Title Play 실측) → 오버라이드가 없으면 null 로 블록을 떼어 SRP Batcher 로 복귀
if (mpb.isEmpty) r.SetPropertyBlock(null); else r.SetPropertyBlock(mpb);
}
}
/// <summary>
/// WL #816zb — 점멸 종료 시각의 상한. startAt + blinkMaxSeconds 를 넘지 못한다.
/// blinkMaxSeconds ≤ 0 이면 상한 없음 = 기존 동작 그대로(C8 롤백 경로).
/// </summary>
static float CapOffAt(WLHitBlinkSettings st, float startAt, float wantOffAt)
{
float max = st.blinkMaxSeconds;
if (max <= 0f) return wantOffAt;
float limit = startAt + max;
return wantOffAt < limit ? wantOffAt : limit;
}
/// <summary>WL #811gh — RGB 만 배수. 알파는 건드리지 않는다(배수 1 이면 원본 Color 를 그대로 돌려준다 = 오차 0).</summary>
static Color ScaleRgb(Color c, float m)
{
if (m == 1f) return c;
return new Color(c.r * m, c.g * m, c.b * m, c.a);
}
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 && !s.elite) continue;
s.blinkOn = false; s.tint = false; s.elite = 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(null); }
}
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();
}
// ───────────────────────────────────────── WL #813s — 엘리트 상시 색 (EliteMarker 가 부른다)
// 렌더러 MPB 의 소유자를 하나로 유지하기 위한 진입점이다. 별도 MPB 를 쓰면 이 클래스의 Apply(=Clear 후 재구성)가
// 첫 피격 점멸에서 통째로 지운다. 상태만 넣고 합성은 기존 Apply 가 한다(우선순위 = 점멸 > 상시).
// WLHitBlinkSettings 의 enabled 와는 무관하다 — 이 SO 는 프로퍼티명 목록(셰이더 실측)의 출처로만 쓴다.
/// <summary>엘리트 상시 색을 건다. 렌더러·프로퍼티를 못 찾으면 false(색 없음).</summary>
public static bool SetEliteTint(Actor actor, Color albedo, Color emission)
{
var st = St;
if (actor == null || st == null) return false;
var s = GetState(actor, st);
if (s == null) return false;
bool any = false;
for (int i = 0; i < s.resolved.Length; i++) if (s.resolved[i].hasColor || s.resolved[i].hasEmission) { any = true; break; }
if (!any) { SkippedNoProperty++; return false; }
s.elite = true; s.eliteAlbedo = albedo; s.eliteEmission = emission;
Apply(s, st);
if (Verbose) Log("엘리트 상시 색 on " + actor.name + " renderers=" + s.renderers.Length);
return true;
}
/// <summary>엘리트 상시 색을 뗀다(처치·정리). 점멸/디졸브 상태는 건드리지 않는다.</summary>
public static void ClearEliteTint(Actor actor)
{
State s;
if (actor == null || !s_states.TryGetValue(actor, out s) || !s.elite) return;
s.elite = false;
var st = St;
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(null); }
}
/// <summary>프로브용: 이 액터에 엘리트 상시 색이 걸려 있는가.</summary>
public static bool HasEliteTint(Actor actor)
{
State s;
return actor != null && s_states.TryGetValue(actor, out s) && s.elite;
}
/// <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 = "";
}
}
}