357 lines
18 KiB
C#
357 lines
18 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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;
|
|
}
|
|
}
|
|
}
|