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

357 lines
18 KiB
C#
Raw Permalink Normal View History

// ─────────────────────────────────────────────────────────────────────────────
// 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<Actor, State> s_states = new Dictionary<Actor, State>(4);
static readonly Dictionary<Shader, Resolved> s_byShader = new Dictionary<Shader, Resolved>(8);
static readonly List<Renderer> s_tmpRenderers = new List<Renderer>(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);
}
/// <summary>내 PC 인가. MyValue.MyPC 를 우선 보고(발주서 규격), 없으면 IsMainPC() 실측으로 판정한다.</summary>
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;
}
/// <summary>Actor.arr_Renderer(protected 캐시)를 리플렉션으로 1회 읽는다(MobHitFlash 와 같은 근거 · 원본 무수정).</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) 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, 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); }
}
/// <summary>MPB 를 전부 비우고 넉백을 접는다(러너 파괴 · 앱 종료 · 프로브 정리). 상태 캐시는 유지.</summary>
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;
}
/// <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 bool KnockbackActive { get { return s_knockRemain > 0f; } }
/// <summary>
/// 프로브용 — 가짜 Damaged 를 **구독자 경로 그대로** 흘린다.
/// 에디트 모드에서는 RuntimeInitializeOnLoadMethod 가 돌지 않아 구독 자체가 없으므로 핸들러를 직접 부른다
/// (HitVignette.RaiseFakeDamaged 선례와 같은 목적 · 게임 로직은 이 메서드를 쓰지 않는다).
/// </summary>
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);
}
/// <summary>프로브용 카운터 초기화.</summary>
public static void ResetDiagnostics()
{
FlashOnCount = FlashOffCount = KnockbackCount = SkippedFilter = SkippedNoProperty = SkippedNoNavMesh = ThrottledCount = StatesCreated = 0;
LastKnockbackVector = Vector3.zero; LastKnockbackDistance = 0f; LastKnockbackUsedAgent = false;
LastInfo = ""; s_knockReadyAt = -999f;
}
}
}