diff --git a/Assets/WL/Combat/Reaction/MobHitFlash.cs b/Assets/WL/Combat/Reaction/MobHitFlash.cs index 0bf660cfc..ffeb240ce 100644 --- a/Assets/WL/Combat/Reaction/MobHitFlash.cs +++ b/Assets/WL/Combat/Reaction/MobHitFlash.cs @@ -37,6 +37,8 @@ namespace WL.Combat.Reaction 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; @@ -103,13 +105,15 @@ namespace WL.Combat.Reaction float colorMul, timeMul; ImpactTier.GetBlinkModifiers(victim, out colorMul, out timeMul); float now = Time.unscaledTime; - if (s.blinkOn) { s.blinkOffAt = now + st.blinkOnSeconds * timeMul; ExtendCount++; return; } // 같은 프레임 다중 히트 → 연장 + // 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.blinkOffAt = now + st.blinkOnSeconds * timeMul; + s.blinkOn = true; s.blinkStartAt = now; + s.blinkOffAt = CapOffAt(st, now, now + st.blinkOnSeconds * timeMul); Apply(s, st); Activate(s); OnCount++; @@ -265,6 +269,18 @@ namespace WL.Combat.Reaction } } + /// + /// WL #816zb — 점멸 종료 시각의 상한. startAt + blinkMaxSeconds 를 넘지 못한다. + /// blinkMaxSeconds ≤ 0 이면 상한 없음 = 기존 동작 그대로(C8 롤백 경로). + /// + 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; + } + /// WL #811gh — RGB 만 배수. 알파는 건드리지 않는다(배수 1 이면 원본 Color 를 그대로 돌려준다 = 오차 0). static Color ScaleRgb(Color c, float m) { diff --git a/Assets/WL/Combat/Reaction/MobStagger.cs b/Assets/WL/Combat/Reaction/MobStagger.cs new file mode 100644 index 000000000..c5c6827a8 --- /dev/null +++ b/Assets/WL/Combat/Reaction/MobStagger.cs @@ -0,0 +1,261 @@ +// ───────────────────────────────────────────────────────────────────────────── +// MobStagger.cs — 피격 몹 짧은 경직(스태거) · 원본 훅 0줄 · 코루틴 0 · GC 0 +// +// PD 지시 #816zb : 「피격 된 적에게 경직이 없는 것 같아. 밀린 상태에서 다시 이동하려고 하기 때문에 어색하고 +// 액션 게임의 타격감을 느끼기 어려워. 피격 된 적은 짧게 경직 효과을 적용해서 타격감을 살려줘.」 +// +// ■ 무엇을 하나 (HitConfirmed / Killed / Spawned 구독 · Assets/Script 무수정) +// HitConfirmed(몹 · 피해 > 0 · 이 타격으로 죽지 않음) +// → 그 몹의 NavMeshAgent.speed 를 0 으로 잠그고 velocity 를 지우고 Animator.speed 를 0 으로 세운다. +// 등급별 시간(일반 0.18 · 엘리트 0.12 · 보스 0.08 s · WLHitFeelSettings)이 지나면 원래 값으로 되돌린다. +// → 다시 맞으면 시간이 갱신된다(연타 중에는 계속 붙잡혀 있다 = 히트 락). +// Killed / Spawned(풀 재사용) → 즉시 원복하고 상태를 놓는다. +// +// ■ 왜 isStopped 가 아니라 speed 인가 +// MobActor 의 상태 기계가 Chase/Patrol 전이마다 `m_NavMeshAgent.isStopped = false` 를 직접 쓴다(MobActor.cs:304·309·339). +// isStopped 로 막으면 같은 프레임에 되돌려져 경직이 먹지 않고, NavMesh 밖/비활성 에이전트에 쓰면 "Resume" 예외가 난다(Q3 D-1). +// speed 는 몹 스폰(MobActor.cs:113)·이동 버프(Actor.Add_MoveSpeed) 외에는 매 프레임 덮어쓰이지 않으므로 +// 매 틱 0 을 다시 써 두면 AI 와 싸우지 않고 확실히 멈춘다. 회전(angularSpeed)은 그대로 둬 바라보기는 유지된다. +// +// ■ 넉백(원본)과의 관계 — PD 「밀린 상태에서 다시 이동하려고 하기 때문에 어색하고」 +// 원본 넉백은 transform.Translate 로 미는 코루틴(Actor.Co_KnockBack)이라 speed 0 과 충돌하지 않는다. +// 넉백이 진행 중인 동안은 경직 종료 시각을 계속 뒤로 밀어, **넉백이 끝난 시점부터** 경직 시간을 다시 준다 +// (밀리다 멈추자마자 걸어 나오는 그 어색함이 원인). 무한 연장 방지로 피격 후 HoldMaxSeconds 까지만 민다. +// 스턴·빙결 등 게임 자체 CC(IsCC(ignoreknockback:true))가 걸리면 그 쪽이 몹을 소유하므로 우리는 즉시 손을 뗀다 +// (긴 스턴 동안 애니메이터를 얼려 두는 사고 방지). +// +// ■ 되돌리기 안전 — 우리가 쓴 0 이 그대로 남아 있을 때만 원래 값으로 복원한다(그 사이 버프·사망·리스폰이 바꿨으면 건드리지 않는다). +// WLHitFeelSettings.mobStaggerEnabled = 0(또는 enabled = 0 · RuntimeDisabled)이면 아무 것도 하지 않는다 = 기존 동작 100 %. 롤백 스위치. +// ───────────────────────────────────────────────────────────────────────────── + +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.AI; +using WL.Combat.Core; + +namespace WL.Combat.Reaction +{ + public static class MobStagger + { + /// 넉백이 비정상적으로 길 때를 대비한 연장 상한(초 · 피격 시각 기준). 기본 넉백 f_KnockbackTime 은 이보다 훨씬 짧다. + const float HoldMaxSeconds = 1f; + + sealed class State + { + public Actor actor; + public NavMeshAgent agent; + public Animator anim; + public float until, hitAt, duration; + public float savedSpeed, savedAnimSpeed; + public bool speedWritten, animWritten; + public int activeIndex = -1; + } + + static readonly Dictionary s_states = new Dictionary(64); + static State[] s_active = new State[32]; + static int s_activeCount; + + // ── 진단(프로브가 읽는다) + public static bool Subscribed; + public static int StaggerCount, RefreshCount, ReleasedByCcCount, HoldCount, SkippedFilter, SkippedNoAgent, EndedCount; + public static float LastDuration; + public static int ActiveCount { get { return s_activeCount; } } + public static int StateCount { get { return s_states.Count; } } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + static void Register() + { + CombatEvents.HitConfirmed.Add(OnHitConfirmed); + CombatEvents.Killed.Add(OnKilled); + CombatEvents.Spawned.Add(OnSpawned); + Subscribed = true; + } + + static WLHitFeelSettings St { get { return WL.Feel.WLHitFeel.Settings; } } + + /// 설정 에셋이 없거나 전체 스위치·경직 스위치가 꺼져 있으면 이 시스템은 통째로 비활성. + public static bool Enabled + { + get { var st = St; return st != null && st.enabled && st.mobStaggerEnabled && !WL.Feel.WLHitFeel.RuntimeDisabled; } + } + + // ───────────────────────────────────────── 이벤트 + static void OnHitConfirmed(in HitConfirmedEvent e) + { + if (!Enabled) return; + var victim = e.victim; + if (victim == null || !(victim is MobActor)) { SkippedFilter++; return; } + if (e.damage <= 0d || e.isKill || victim.IsDead()) { SkippedFilter++; return; } // 무효타·처치타는 경직 없음(사망 모션을 얼리지 않는다) + + var st = St; + float dur = victim.IsSubRole(eSubRol.Boss) ? st.mobStaggerSecondsBoss + : victim.IsSubRole(eSubRol.Elite) ? st.mobStaggerSecondsElite + : st.mobStaggerSeconds; + if (dur <= 0.0001f) { SkippedFilter++; return; } + + var s = GetState(victim); + if (s == null) { SkippedNoAgent++; return; } + + float now = Time.unscaledTime; + if (s.activeIndex >= 0) RefreshCount++; else StaggerCount++; + s.hitAt = now; + s.duration = dur; + s.until = now + dur; + LastDuration = dur; + + WLReactionRunner.Ensure(); + Freeze(s); + Activate(s); + } + + static void OnKilled(in KilledEvent e) { Release(e.victim); } + + static void OnSpawned(in SpawnedEvent e) { Release(e.actor); } + + static void Release(Actor actor) + { + State s; + if (actor == null || !s_states.TryGetValue(actor, out s)) return; + Restore(s); + Deactivate(s); + } + + // ───────────────────────────────────────── 상태 + static State GetState(Actor actor) + { + State s; + if (s_states.TryGetValue(actor, out s)) + { + if (s.agent == null) s.agent = actor.Get_Agent(); // 풀 재사용 중 에이전트가 붙었을 수 있다 + if (s.anim == null) s.anim = actor.m_animation; + return (s.agent != null || s.anim != null) ? s : null; + } + var agent = actor.Get_Agent(); + var anim = actor.m_animation; + if (agent == null && anim == null) return null; // 멈출 수단이 없다 — 상태를 만들지 않는다 + s = new State { actor = actor, agent = agent, anim = anim }; + s_states.Add(actor, s); + return s; + } + + /// speed 0 · velocity 0 · Animator.speed 0. 원래 값은 처음 얼릴 때 1회만 기억한다. + static void Freeze(State s) + { + var a = s.agent; + if (a != null) + { + if (!s.speedWritten) { s.savedSpeed = a.speed; s.speedWritten = true; } + a.speed = 0f; + // 비활성(넉백_byHitter 가 agent.enabled = false)·NavMesh 밖 에이전트에 velocity 를 쓰면 예외가 난다. + if (a.enabled && a.isOnNavMesh) a.velocity = Vector3.zero; + } + var an = s.anim; + if (an != null) + { + if (!s.animWritten) { s.savedAnimSpeed = an.speed; s.animWritten = true; } + an.speed = 0f; + } + } + + /// 우리가 쓴 0 이 그대로 남아 있을 때만 원래 값으로 되돌린다(버프·사망·리스폰이 바꿨으면 손대지 않는다). + static void Restore(State s) + { + if (s.speedWritten) + { + var a = s.agent; + if (a != null && a.speed == 0f) a.speed = s.savedSpeed; + s.speedWritten = false; + } + if (s.animWritten) + { + var an = s.anim; + if (an != null && an.speed == 0f) an.speed = s.savedAnimSpeed; + s.animWritten = false; + } + } + + 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; + bool on = Enabled; + for (int i = s_activeCount - 1; i >= 0; i--) + { + var s = s_active[i]; + var actor = s.actor; + + if (!on || actor == null || !actor.isActiveAndEnabled || actor.IsDead()) + { + Restore(s); Deactivate(s); EndedCount++; continue; + } + + // 스턴·빙결·공중 띄우기 등 게임 자체 CC 가 걸리면 그 쪽이 몹을 소유한다 — 우리는 손을 뗀다. + if (actor.IsCC(true)) + { + Restore(s); Deactivate(s); ReleasedByCcCount++; continue; + } + + // 남은 CC = 넉백. 밀리는 동안은 종료 시각을 뒤로 밀어 '넉백이 끝난 시점부터' 경직 시간을 준다. + if (actor.IsCC() && now < s.hitAt + HoldMaxSeconds) + { + s.until = now + s.duration; + HoldCount++; + } + + if (now >= s.until) + { + Restore(s); Deactivate(s); EndedCount++; continue; + } + + Freeze(s); // AI 상태 전이가 speed 를 되돌려도 매 틱 다시 0 으로 잠근다 + } + } + + /// 모든 몹의 speed·Animator 를 되돌린다(러너 파괴 · 앱 종료 · 프로브 정리). 상태 캐시는 유지. + public static void RestoreAll() + { + foreach (var kv in s_states) Restore(kv.Value); + for (int i = 0; i < s_activeCount; i++) { s_active[i].activeIndex = -1; s_active[i] = null; } + s_activeCount = 0; + } + + /// 씬 전환: 되돌리고 상태 캐시(파괴된 Actor 참조)를 버린다. + public static void ClearAll() + { + RestoreAll(); + s_states.Clear(); + } + + /// 프로브용: 이 몹이 지금 경직 중인가. + public static bool IsStaggered(Actor actor) + { + State s; + return actor != null && s_states.TryGetValue(actor, out s) && s.activeIndex >= 0; + } + + /// 프로브용 카운터 초기화. + public static void ResetDiagnostics() + { + StaggerCount = RefreshCount = ReleasedByCcCount = HoldCount = SkippedFilter = SkippedNoAgent = EndedCount = 0; + LastDuration = 0f; + } + } +} diff --git a/Assets/WL/Combat/Reaction/MobStagger.cs.meta b/Assets/WL/Combat/Reaction/MobStagger.cs.meta new file mode 100644 index 000000000..0934d30fa --- /dev/null +++ b/Assets/WL/Combat/Reaction/MobStagger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5c7d057d17c2430eaa8341432116bebb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs b/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs index 5c146bae2..d01dee3f2 100644 --- a/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs +++ b/Assets/WL/Combat/Reaction/WLHitBlinkSettings.cs @@ -72,18 +72,24 @@ namespace WL.Combat.Reaction // ─────────────────────────────────────────── 점멸 [Header("점멸 (HitConfirmed → MPB on → blinkOnSeconds 뒤 off)")] - [Tooltip("점멸 색(HDR · 알베도 색 프로퍼티에 쓴다). 1 보다 크면 텍스처가 밝게 뜬다 = 흰 번쩍임. 기준서 「흰색 0.03~0.06 s」.")] - [ColorUsage(true, true)] public Color blinkColor = new Color(2.2f, 2.2f, 2.2f, 1f); + [Tooltip("점멸 색(HDR · 알베도 색 프로퍼티에 쓴다). 1 보다 크면 텍스처가 밝게 뜬다 = 흰 번쩍임. 기준서 「흰색 0.03~0.06 s」.\n" + + "PD #816zb — '짧게, 대신 세게'. 2.2 → 3.0.")] + [ColorUsage(true, true)] public Color blinkColor = new Color(3f, 3f, 3f, 1f); [Tooltip("에미션 프로퍼티도 함께 올린다(MK Toon 은 키워드 없이 가산 · URP Lit 은 _EMISSION 키워드가 켜진 머티리얼만).")] public bool useEmission = true; - [Tooltip("점멸 에미션 색(HDR).")] - [ColorUsage(true, true)] public Color blinkEmission = new Color(0.6f, 0.6f, 0.6f, 1f); + [Tooltip("점멸 에미션 색(HDR). PD #816zb — 0.6 → 0.9 (같은 시간에 더 세게).")] + [ColorUsage(true, true)] public Color blinkEmission = new Color(0.9f, 0.9f, 0.9f, 1f); [Tooltip("켜져 있는 시간(초 · unscaled). Feel 프리셋 Blink On 0.03.")] public float blinkOnSeconds = 0.03f; + [Tooltip("한 번 켜진 점멸이 이어질 수 있는 **총 상한**(초 · unscaled · 0 이하면 상한 없음).\n" + + "PD #816zb — 몹이 계속 하얗게 보이던 원인은 ① 임팩트 티어 시간 배수(최대 2.4 → 0.072 s)와\n" + + "② 점멸 중 추가 타격마다 blinkOffAt 을 다시 미루는 연장 경로였다. 연장까지 포함해 첫 점멸 시작 시각 + 이 값에서 끊는다. 0.06 = 발주 규정값.")] + public float blinkMaxSeconds = 0.06f; + [Tooltip("꺼진 뒤 다시 켤 수 있기까지의 최소 간격(초 · unscaled). Feel 프리셋 Blink Off 0.03 — 같은 프레임 다중 히트(스윙 1회 5~7타)를 1회 점멸로 묶는다.")] public float blinkOffSeconds = 0.03f; diff --git a/Assets/WL/Combat/Reaction/WLReactionRunner.cs b/Assets/WL/Combat/Reaction/WLReactionRunner.cs index 197514d1c..c7d135b09 100644 --- a/Assets/WL/Combat/Reaction/WLReactionRunner.cs +++ b/Assets/WL/Combat/Reaction/WLReactionRunner.cs @@ -32,6 +32,7 @@ namespace WL.Combat.Reaction KillChain.ClearAll(); DamageAggregator.ClearAll(); MobHitFlash.ClearAll(); + MobStagger.ClearAll(); // WL #816zb — 씬을 넘기며 speed 0 인 몹이 남지 않게 } void Update() @@ -40,9 +41,10 @@ namespace WL.Combat.Reaction KillChain.Tick(now); DamageAggregator.Tick(now); MobHitFlash.Tick(now); + MobStagger.Tick(now); // WL #816zb — 피격 경직 해제 } - void OnDisable() { MobHitFlash.RestoreAll(); KillChain.ReleaseBurst(); } - void OnApplicationQuit() { MobHitFlash.RestoreAll(); KillChain.ReleaseBurst(); } + void OnDisable() { MobHitFlash.RestoreAll(); MobStagger.RestoreAll(); KillChain.ReleaseBurst(); } + void OnApplicationQuit() { MobHitFlash.RestoreAll(); MobStagger.RestoreAll(); KillChain.ReleaseBurst(); } } } diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset index 19ef0801c..7cd2c5940 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLHitBlinkSettings.asset @@ -17,10 +17,11 @@ MonoBehaviour: targetMobsOnly: 1 onlyByMainPC: 0 skipWhenDamageZero: 1 - blinkColor: {r: 2.2, g: 2.2, b: 2.2, a: 1} + blinkColor: {r: 3, g: 3, b: 3, a: 1} useEmission: 1 - blinkEmission: {r: 0.6, g: 0.6, b: 0.6, a: 1} + blinkEmission: {r: 0.9, g: 0.9, b: 0.9, a: 1} blinkOnSeconds: 0.03 + blinkMaxSeconds: 0.06 blinkOffSeconds: 0.03 colorProperties: - _AlbedoColor diff --git a/Assets/WL/Feel/Settings/Resources/WL/WLHitFeelSettings.asset b/Assets/WL/Feel/Settings/Resources/WL/WLHitFeelSettings.asset index 570d0d6cc..0806f2dc7 100644 --- a/Assets/WL/Feel/Settings/Resources/WL/WLHitFeelSettings.asset +++ b/Assets/WL/Feel/Settings/Resources/WL/WLHitFeelSettings.asset @@ -37,4 +37,8 @@ MonoBehaviour: flashColor: {r: 1, g: 1, b: 1, a: 0.1} flashDuration: 0.06 hapticEnabled: 0 + mobStaggerEnabled: 1 + mobStaggerSeconds: 0.18 + mobStaggerSecondsElite: 0.12 + mobStaggerSecondsBoss: 0.08 logHits: 0 diff --git a/Assets/WL/Feel/WLHitFeelSettings.cs b/Assets/WL/Feel/WLHitFeelSettings.cs index 54adf6a27..7634e5061 100644 --- a/Assets/WL/Feel/WLHitFeelSettings.cs +++ b/Assets/WL/Feel/WLHitFeelSettings.cs @@ -141,6 +141,24 @@ public class WLHitFeelSettings : ScriptableObject [Tooltip("NiceVibrations 햅틱. 모바일 배터리 때문에 기본 false.")] public bool hapticEnabled = false; + // ─────────────────────────────────────────── ⑥ 피격 몹 경직 (스태거) + + [Header("⑥ 피격 몹 경직 — 맞은 몹이 아주 짧게 멈춘다")] + [Tooltip("PD #816zb — 맞은 몹이 넉백으로 밀린 직후 바로 걸어 나와 타격감이 죽는 문제.\n" + + "직접 타격(HitConfirmed) 마다 NavMeshAgent 속도를 0 으로 잠그고 애니메이터를 멈춘다(WL.Combat.Reaction.MobStagger).\n" + + "끄면 그 구독자가 아무 것도 하지 않는다 = 기존 동작 100 %. 롤백 스위치.")] + public bool mobStaggerEnabled = true; + + [Tooltip("일반 몹 경직 길이(초 · unscaled). 콤보 간격 0.42~0.47 s 의 절반 이하로 둔다 — 0.18 = 발주 규정값.\n" + + "넉백 중에 맞으면 넉백이 끝난 시점부터 이 길이를 1회 다시 준다(밀리다 말고 곧장 걷지 않게).")] + public float mobStaggerSeconds = 0.18f; + + [Tooltip("엘리트 경직 길이(초). 일반보다 짧게 — 압박감을 유지한다.")] + public float mobStaggerSecondsElite = 0.12f; + + [Tooltip("보스 경직 길이(초). 가장 짧게 — 보스 패턴이 끊기면 안 된다.")] + public float mobStaggerSecondsBoss = 0.08f; + // ─────────────────────────────────────────── 진단 [Header("진단 (검증용 · 배포 시 false)")]