// ───────────────────────────────────────────────────────────────────────────── // PcHitReaction.cs — PC 피격 림라이트(MPB) + 넉백 (811g-① · 원본 훅 0 · 셰이더 교체 0 · 머티리얼 인스턴스 0) // // PD 지시 #813 · 발주서 WL-811gh §1-2 · 설계안 §C 811g 행 · 기준서 §B 요소 8-1 「HP + 연출 2종」 (2026-09-09) // // ■ 무엇을 하나 (CombatEvents.Damaged 구독 · MyActor.Get_Damage 훅은 811b 가 이미 넣어 둔 1줄) // ① 림라이트 — PC 렌더러의 MaterialPropertyBlock 에 색·에미션을 0.15 s 얹었다가 비운다. // MobHitFlash(몹 점멸)와 **완전히 같은 방식**이다: 렌더러당 MPB 1개 캐시 · 타격당 할당 0 · // 머티리얼 인스턴스 0 · 드로우 +0. 새 셰이더/머티리얼 에셋을 만들지 않는다. // ② 넉백 — 피격 방향 반대로 0.15 m 를 0.1 s 동안 민다(기본 = 보스·엘리트 피격만). // · `Set_Warp` 를 쓰지 않는다(순간이동 금지 · 발주서 §1-2). // · 도착 지점은 `NavMesh.SamplePosition` 으로 **NavMesh 위 점** 으로 접어서 쓴다 → NavMesh 밖으로 나갈 수 없다. // · 에이전트가 살아 있으면 `NavMeshAgent.Move`(DashDriver·AttackStepDriver 선례 — transform 직접 대입은 // 에이전트 내부 위치와 어긋나 다음 이동 명령에서 순간이동한다). SO `pcHitKnockbackUseAgentMove` 를 끄면 // 발주서 문구대로 transform 보간만 쓴다. // // ■ 813tj HitVignette(UI 비네트)와의 관계 // 비네트는 UI(캔버스)에서, 이쪽은 캐릭터(월드)에서 나온다. 둘이 합쳐 기준서 8-1 의 「HP + 연출 2종」이 된다. // 이 파일은 UI 를 건드리지 않는다(UI 수정 0). // // ■ C8 롤백 // WLImpactTierSettings 에셋이 없거나 enabled=0 또는 pcHitFlashEnabled/pcHitKnockbackEnabled=0 이면 // 구독만 하고 아무 것도 하지 않는다 = 원본 동작 100 %. // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using System.Reflection; using UnityEngine; using UnityEngine.AI; using WL.Combat.Core; namespace WL.Combat.Reaction { public static class PcHitReaction { 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 flashOn; public float flashOffAt, readyAt; } static readonly Dictionary s_states = new Dictionary(4); static readonly Dictionary s_byShader = new Dictionary(8); static readonly List s_tmpRenderers = new List(16); static FieldInfo s_rendererField; static bool s_rendererFieldLooked; // ── 넉백 진행 상태(동시에 1개 = PC 는 1명) static Actor s_knockActor; static NavMeshAgent s_knockAgent; static Vector3 s_knockDir; static float s_knockRemain, s_knockSpeed, s_knockReadyAt; static bool s_knockUseAgent; // ── 진단(프로브가 읽는다) public static bool Subscribed; public static int FlashOnCount, FlashOffCount, KnockbackCount, SkippedFilter, SkippedNoProperty, SkippedNoNavMesh, ThrottledCount, StatesCreated; public static Vector3 LastKnockbackVector; public static float LastKnockbackDistance; public static bool LastKnockbackUsedAgent; public static string LastInfo = ""; public static bool FlashActive { get { foreach (var kv in s_states) if (kv.Value.flashOn) return true; return false; } } static WLImpactTierSettings St { get { return WLImpactTierSettings.Instance; } } static bool Verbose { get { var st = St; return st != null && st.verboseLog; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] static void Register() { CombatEvents.Damaged.Add(OnDamaged); Subscribed = true; } // ───────────────────────────────────────── 이벤트 static void OnDamaged(in DamagedEvent e) { var st = St; if (st == null || !WLImpactTierSettings.Enabled) return; var victim = e.victim; if (victim == null || !IsMyPc(victim)) { SkippedFilter++; return; } if (st.pcHitSkipWhenInvincible && e.invincible) { SkippedFilter++; return; } if (st.pcHitSkipWhenDamageZero && e.damage <= 0d) { SkippedFilter++; return; } if (st.pcHitFlashEnabled) Flash(st, victim); if (st.pcHitKnockbackEnabled) Knockback(st, victim, e.attacker); } /// 내 PC 인가. MyValue.MyPC 를 우선 보고(발주서 규격), 없으면 IsMainPC() 실측으로 판정한다. static bool IsMyPc(Actor a) { var mine = MyValue.MyPC; if (mine != null) return ReferenceEquals(a, mine); return a.IsMainPC(); } // ───────────────────────────────────────── ① 림라이트(MPB) static void Flash(WLImpactTierSettings st, Actor victim) { var s = GetState(victim, st); if (s == null) return; float now = ImpactRunner.Now; if (s.flashOn) { s.flashOffAt = now + Mathf.Max(0.01f, st.pcHitFlashSeconds); return; } // 연속 피격 = 연장 if (now < s.readyAt) { ThrottledCount++; return; } ImpactRunner.Ensure(); s.flashOn = true; s.flashOffAt = now + Mathf.Max(0.01f, st.pcHitFlashSeconds); Apply(s, st); FlashOnCount++; LastInfo = "flash on"; if (Verbose) Debug.Log("[PcHitReaction] 림라이트 on " + victim.name + " renderers=" + s.renderers.Length); } // ───────────────────────────────────────── ② 넉백 static void Knockback(WLImpactTierSettings st, Actor victim, Actor attacker) { float now = ImpactRunner.Now; if (now < s_knockReadyAt) { ThrottledCount++; return; } if (st.pcHitKnockbackBossEliteOnly) { bool bossElite = attacker != null && (attacker.IsSubRole(eSubRol.Boss) || attacker.IsSubRole(eSubRol.Elite)); if (!bossElite) { SkippedFilter++; return; } } float dist = Mathf.Max(0f, st.pcHitKnockbackDistance); float dur = Mathf.Max(0.01f, st.pcHitKnockbackSeconds); if (dist <= 0.0001f) return; Vector3 pos = victim.Get_position(); Vector3 dir; if (attacker != null) { dir = pos - attacker.Get_position(); dir.y = 0f; if (dir.sqrMagnitude < 0.000001f) dir = -victim.transform.forward; else dir.Normalize(); } else dir = -victim.transform.forward; dir.y = 0f; if (dir.sqrMagnitude < 0.000001f) { SkippedFilter++; return; } dir.Normalize(); // NavMesh 밖 이동 금지 — 목표점을 NavMesh 위 점으로 접는다(못 찾으면 넉백 자체를 포기). Vector3 want = pos + dir * dist; NavMeshHit navHit; if (!NavMesh.SamplePosition(want, out navHit, Mathf.Max(0.05f, st.pcHitKnockbackNavSampleRadius), NavMesh.AllAreas)) { SkippedNoNavMesh++; if (Verbose) Debug.Log("[PcHitReaction] 넉백 취소 — NavMesh 밖"); return; } Vector3 allowed = navHit.position - pos; allowed.y = 0f; float allowedDist = Mathf.Min(allowed.magnitude, dist); if (allowedDist <= 0.0005f) { SkippedNoNavMesh++; return; } if (allowed.sqrMagnitude > 0.000001f) dir = allowed.normalized; var agent = victim.Get_Agent(); s_knockUseAgent = st.pcHitKnockbackUseAgentMove && agent != null && agent.enabled && agent.isOnNavMesh; s_knockActor = victim; s_knockAgent = s_knockUseAgent ? agent : null; s_knockDir = dir; s_knockRemain = dur; s_knockSpeed = allowedDist / dur; s_knockReadyAt = now + Mathf.Max(0f, st.pcHitKnockbackCooldownSeconds); LastKnockbackVector = dir * allowedDist; LastKnockbackDistance = allowedDist; LastKnockbackUsedAgent = s_knockUseAgent; KnockbackCount++; ImpactRunner.Ensure(); LastInfo = "knockback " + allowedDist.ToString("F3"); if (Verbose) Debug.Log("[PcHitReaction] 넉백 " + allowedDist.ToString("F3") + "m dir=" + dir + " agentMove=" + s_knockUseAgent); } // ───────────────────────────────────────── 틱(ImpactRunner) internal static void Tick(float now, float unscaledDelta) { // 림라이트 만료 var st = St; foreach (var kv in s_states) { var s = kv.Value; if (!s.flashOn) continue; if (st != null && WLImpactTierSettings.Enabled && now < s.flashOffAt) continue; s.flashOn = false; s.readyAt = now + (st != null ? Mathf.Max(0f, st.pcHitCooldownSeconds) : 0f); if (st != null) Apply(s, st); else Clear(s); FlashOffCount++; LastInfo = "flash off"; } // 넉백 진행 if (s_knockRemain > 0f) { if (s_knockActor == null || !s_knockActor.isActiveAndEnabled) { s_knockRemain = 0f; s_knockActor = null; s_knockAgent = null; return; } float dt = Mathf.Min(unscaledDelta, s_knockRemain); Vector3 step = s_knockDir * (s_knockSpeed * dt); if (s_knockAgent != null && s_knockAgent.enabled && s_knockAgent.isOnNavMesh) s_knockAgent.Move(step); else s_knockActor.transform.position += step; // 에이전트가 없을 때만: transform 보간 s_knockRemain -= dt; if (s_knockRemain <= 0f) { s_knockActor = null; s_knockAgent = null; } } } // ───────────────────────────────────────── 상태·MPB (MobHitFlash 와 같은 방식) static State GetState(Actor actor, WLImpactTierSettings 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] }; bool any = false; 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); if (s.resolved[i].hasColor || s.resolved[i].hasEmission) any = true; } if (!any) SkippedNoProperty++; s_states.Add(actor, s); StatesCreated++; return s; } /// Actor.arr_Renderer(protected 캐시)를 리플렉션으로 1회 읽는다(MobHitFlash 와 같은 근거 · 원본 무수정). 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) return arr; } s_tmpRenderers.Clear(); actor.GetComponentsInChildren(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, WLImpactTierSettings st) { Resolved r; if (shader == null) return default(Resolved); if (s_byShader.TryGetValue(shader, out r)) return r; r = default(Resolved); var names = st.pcHitColorProperties; 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.pcHitEmissionProperties; 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 (Verbose) Debug.Log("[PcHitReaction] 셰이더 실측 " + shader.name + " color=" + r.hasColor + " emission=" + r.hasEmission); return r; } static void Apply(State s, WLImpactTierSettings 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.flashOn) { var res = s.resolved[i]; if (res.hasColor) mpb.SetColor(res.colorId, st.pcHitColor); if (st.pcHitUseEmission && res.hasEmission) mpb.SetColor(res.emissionId, st.pcHitEmission); } // 빈 블록을 써도 HasPropertyBlock 은 true 로 남는다 → 오버라이드가 없으면 null 로 떼어 SRP Batcher 로 복귀(MobHitFlash 실측 선례). if (mpb.isEmpty) r.SetPropertyBlock(null); else r.SetPropertyBlock(mpb); } } static void Clear(State s) { for (int i = 0; i < s.renderers.Length; i++) if (s.renderers[i] != null) { s.blocks[i].Clear(); s.renderers[i].SetPropertyBlock(null); } } /// MPB 를 전부 비우고 넉백을 접는다(러너 파괴 · 앱 종료 · 프로브 정리). 상태 캐시는 유지. public static void RestoreAll() { var st = St; foreach (var kv in s_states) { var s = kv.Value; if (!s.flashOn) continue; s.flashOn = false; if (st != null) Apply(s, st); else Clear(s); } s_knockRemain = 0f; s_knockActor = null; s_knockAgent = null; } /// 씬 전환: MPB 를 비우고 파괴된 Actor 참조를 버린다. public static void ClearAll() { RestoreAll(); s_states.Clear(); } /// 프로브용: 그 액터의 렌더러가 지금 프로퍼티 블록을 갖고 있는가(잔류 검사). 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; } /// 프로브용: 넉백이 진행 중인가. public static bool KnockbackActive { get { return s_knockRemain > 0f; } } /// /// 프로브용 — 가짜 Damaged 를 **구독자 경로 그대로** 흘린다. /// 에디트 모드에서는 RuntimeInitializeOnLoadMethod 가 돌지 않아 구독 자체가 없으므로 핸들러를 직접 부른다 /// (HitVignette.RaiseFakeDamaged 선례와 같은 목적 · 게임 로직은 이 메서드를 쓰지 않는다). /// public static void ProbeDamaged(Actor victim, Actor attacker, double damage, bool invincible) { var e = new DamagedEvent { victim = victim, attacker = attacker, dinfo = null, damage = damage, invincible = invincible, time = Time.unscaledTime, frame = Time.frameCount }; OnDamaged(in e); } /// 프로브용 카운터 초기화. public static void ResetDiagnostics() { FlashOnCount = FlashOffCount = KnockbackCount = SkippedFilter = SkippedNoProperty = SkippedNoNavMesh = ThrottledCount = StatesCreated = 0; LastKnockbackVector = Vector3.zero; LastKnockbackDistance = 0f; LastKnockbackUsedAgent = false; LastInfo = ""; s_knockReadyAt = -999f; } } }