From 9120964be04e58b60939e386320130982e11a8aa Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 31 Jul 2026 09:40:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(enemy):=20=E6=97=A0=E5=8F=97=E5=87=BB/?= =?UTF-8?q?=E5=83=B5=E7=9B=B4=E5=8A=A8=E7=94=BB=E6=97=B6=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=B0=B8=E4=B9=85=E5=8D=A1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnemyHurtState 与 EnemyStaggerState 的 Enter 在对应 Clip 为空时守卫式早退, **同时丢掉了恢复安排**:_currentState 永远停在该态,IsControllable 恒假, AiRuntime 的让位门永久挡住条件边求值——敌人被打一次即永久失能。 美术未接入时这影响的是大部分敌人。 TakeDamage 是分发点:霸体被破进 Stagger、否则进 Hurt,两条都可达, 所以这是两个活 bug 而非一个。EnemyKnockUpState 本就有 else 分支走 ScheduleStateRecovery,是正确范式;本次把该分支补给另外两个,并加一条 KnockUp 回归测试守住它。 时长权威没有新造: - Hurt 接上 DamageSourceSO.HitStunDuration——该字段一路铺到 DamageInfo 却零消费者,本就是「这一击让对方僵多久」的既有权威;经 PendingHitStun 传给状态,形状与既有的 PendingLaunchDir 一致。 - Stagger 用 HitTierConfig.staggerDuration——该结构 tooltip 本就写着 「Stagger / KnockUp 参数」却只有 KnockUp 的字段,属补齐而非发明。 顺带修 ScheduleStateRecovery 不取消前一次安排的问题:两个重叠安排里较短的 会先把状态改回 Controlled,静默缩短调用方想要的时长(ReceiveParry 接线后 就会撞上)。并在 OnSpawn 清掉跨池化周期残留的协程句柄。 注:Knockback 目前零调用者,其中的 PendingHitStun 赋值今天是死代码, 但它是 public virtual 且会进 Hurt 态,不设则将来接线时会用到陈旧时长。 验证:编译 0 错;EditMode 263/263(260 + 新增 3)。 变异验证——移除两个 else 分支后恰好 Hurt/Stagger 两条测试变红、 KnockUp 回归测试保持绿,恢复后复验 263/263。 --- .../Enemies/EnemyHitStateRecoveryTests.cs | 107 ++++++++++++++++++ .../EnemyHitStateRecoveryTests.cs.meta | 11 ++ Assets/_Game/Scripts/Enemies/EnemyBase.cs | 36 +++++- Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs | 3 + .../Scripts/Enemies/States/EnemyHurtState.cs | 26 +++-- .../Enemies/States/EnemyStaggerState.cs | 28 +++-- 6 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 Assets/Tests/EditMode/Enemies/EnemyHitStateRecoveryTests.cs create mode 100644 Assets/Tests/EditMode/Enemies/EnemyHitStateRecoveryTests.cs.meta diff --git a/Assets/Tests/EditMode/Enemies/EnemyHitStateRecoveryTests.cs b/Assets/Tests/EditMode/Enemies/EnemyHitStateRecoveryTests.cs new file mode 100644 index 00000000..a9078d3b --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyHitStateRecoveryTests.cs @@ -0,0 +1,107 @@ +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; +using BaseGames.Enemies; +using BaseGames.Enemies.States; + +namespace BaseGames.Tests.EditMode.Enemies +{ + /// + /// 受击类状态(Hurt / Stagger / KnockUp)在**没有对应动画 Clip** 时, + /// 必须仍然安排回到 Controlled——否则敌人被打一次就永久停在该态, + /// IsControllable 恒假、AI 永久停摆。 + /// + /// 这是本项目「禁止下游兜底掩盖问题」的反面:早退本身不是兜底, + /// 但早退**同时丢掉了恢复安排**,把「动画没配」这个可恢复的缺失 + /// 变成了「敌人永久失能」这个不可恢复的故障。 + /// + public class EnemyHitStateRecoveryTests + { + /// + /// 记录 ScheduleStateRecovery 调用而不真正启协程——EditMode 下没有播放循环, + /// 协程不会推进,直接断言"是否安排了恢复"才是这个缺陷的可测形态。 + /// + private sealed class RecordingEnemy : EnemyBase + { + public int Calls; + public EnemyStateType LastFromState; + public float LastDelay; + + public override void ScheduleStateRecovery(EnemyStateType fromState, float delay) + { + Calls++; + LastFromState = fromState; + LastDelay = delay; + } + } + + private GameObject _host; + + [SetUp] + public void SetUp() => LogAssert.ignoreFailingMessages = true; + + [TearDown] + public void TearDown() + { + // 项目关闭了 Domain/Scene Reload:断言失败会跳过用例后续语句, + // 清理必须放在 TearDown,否则失败一次就往编辑器场景里漏一个对象。 + if (_host != null) Object.DestroyImmediate(_host); + _host = null; + LogAssert.ignoreFailingMessages = false; + } + + /// + /// EditMode 下 AddComponent 不触发 Awake,动画引用保持为空—— + /// 这正好等价于"美术未接入"的生产场景,无需再造假配置。 + /// 前提用 AnimConfig 断言(而非 Animancer):后者会把 Animancer 程序集 + /// 拖进测试程序集的引用,而 AnimConfig 为空已足以让三个状态都走无动画分支。 + /// + private RecordingEnemy MakeEnemyWithoutAnimation() + { + _host = new GameObject("enemy-without-anim"); + var e = _host.AddComponent(); + Assert.IsNull(e.AnimConfig, "前提:本用例要覆盖的是无动画路径"); + return e; + } + + [Test] + public void HurtState_WithoutClip_SchedulesRecovery() + { + var enemy = MakeEnemyWithoutAnimation(); + + new EnemyHurtState().Enter(enemy); + + Assert.AreEqual(1, enemy.Calls, + "无受击动画时必须安排回到 Controlled,否则敌人被打一次即永久失能"); + Assert.AreEqual(EnemyStateType.Hurt, enemy.LastFromState, + "恢复必须限定为从 Hurt 态出发,避免覆盖其后可能已切换的状态"); + } + + [Test] + public void StaggerState_WithoutClip_SchedulesRecovery() + { + var enemy = MakeEnemyWithoutAnimation(); + + new EnemyStaggerState().Enter(enemy); + + Assert.AreEqual(1, enemy.Calls, + "无僵直动画时必须安排回到 Controlled——霸体被破也是 TakeDamage 的常规分支"); + Assert.AreEqual(EnemyStateType.Stagger, enemy.LastFromState); + Assert.Greater(enemy.LastDelay, 0f, + "僵直是给玩家的惩罚窗口,时长必须为正,否则等同于没有僵直"); + } + + [Test] + public void KnockUpState_WithoutClip_StillSchedulesRecovery() + { + // 回归保护:KnockUp 本来就有 else 分支,本次修复不得破坏它 + var enemy = MakeEnemyWithoutAnimation(); + + new EnemyKnockUpState().Enter(enemy); + + Assert.AreEqual(1, enemy.Calls); + Assert.AreEqual(EnemyStateType.KnockUp, enemy.LastFromState); + Assert.Greater(enemy.LastDelay, 0f); + } + } +} diff --git a/Assets/Tests/EditMode/Enemies/EnemyHitStateRecoveryTests.cs.meta b/Assets/Tests/EditMode/Enemies/EnemyHitStateRecoveryTests.cs.meta new file mode 100644 index 00000000..f10d3423 --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyHitStateRecoveryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: df7e877126c6c344f8cbf5fe74c4b324 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/EnemyBase.cs b/Assets/_Game/Scripts/Enemies/EnemyBase.cs index f51e2055..868d5602 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyBase.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyBase.cs @@ -179,6 +179,8 @@ namespace BaseGames.Enemies } else { + // 存储硬直时长供 EnemyHurtState 在无受击动画时使用 + _pendingHitStun = info.HitStunDuration; nextState = EnemyStateType.Hurt; reason = InterruptReason.Hurt; } @@ -200,17 +202,37 @@ namespace BaseGames.Enemies private Vector2 _pendingLaunchDir; /// - /// 协程兜底:在无对应 Animancer 动画时按时长自动恢复到 Controlled 状态。 - /// 仅在 AnimConfig 对应 Clip 为 null 时由状态类调用。 + /// 本次受击的硬直时长(由 TakeDamage / Knockback 从 写入, + /// 供 在无受击动画时作为恢复时长)。 + /// 权威在攻击方:同一敌人挨重击比挨轻击僵得久。有受击动画时以动画长度为准。 /// - public void ScheduleStateRecovery(EnemyStateType fromState, float delay) + internal float PendingHitStun => _pendingHitStun; + private float _pendingHitStun; + + /// + /// 在无对应 Animancer 动画时按时长自动恢复到 Controlled 状态。 + /// 由受击类状态(Hurt / Stagger / KnockUp)在其 Clip 为 null 时调用—— + /// 缺了它,敌人会永久停在该态,IsControllable 恒假、决策层永久停摆。 + /// + /// 同一时刻只保留一个待恢复安排:后一次调用取消前一次。 + /// 否则两次重叠安排里较短的那个会先把状态改回 Controlled, + /// 静默缩短调用方本想要的时长(如弹反惩罚窗口)。 + /// + /// virtual:供 EditMode 测试替换掉协程,直接断言"是否安排了恢复"。 + /// + public virtual void ScheduleStateRecovery(EnemyStateType fromState, float delay) { - StartCoroutine(StateRecoveryRoutine(fromState, delay)); + if (_stateRecovery != null) StopCoroutine(_stateRecovery); + _stateRecovery = StartCoroutine(StateRecoveryRoutine(fromState, delay)); } + private Coroutine _stateRecovery; + private System.Collections.IEnumerator StateRecoveryRoutine(EnemyStateType fromState, float delay) { - yield return new WaitForSeconds(delay); + if (delay > 0f) yield return new WaitForSeconds(delay); + else yield return null; // 时长为 0 = 不硬直,下一帧即恢复 + _stateRecovery = null; if (_currentState == fromState) ForceState(EnemyStateType.Controlled); } @@ -385,6 +407,7 @@ namespace BaseGames.Enemies if (info.Flags.HasFlag(DamageFlags.NoKnockback)) return; _movement?.ApplyKnockback(info.KnockbackDirection, info.KnockbackForce); // 统一路径:击退必须经过状态机,确保能力被中断且动画一致。 + _pendingHitStun = info.HitStunDuration; // 无受击动画时的恢复时长 ForceState(EnemyStateType.Hurt); _abilities.InterruptAll(InterruptReason.Hurt); } @@ -727,6 +750,9 @@ namespace BaseGames.Enemies // 重置状态(对象池复用:跳过 Dead 终态守卫,强制恢复到 Controlled) ForceStateRespawn(EnemyStateType.Controlled); IsEngaged = false; + // 上一条命若在受击恢复途中被回收,协程已随物体停用而终止, + // 这里清掉句柄,避免跨池化周期持有一个已死的 Coroutine 引用 + _stateRecovery = null; // 重置对象池复用相关的运行时感知数据 // 注意:_playerTransform 不重置(场景中玩家仍存在),只重置追踪历史 diff --git a/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs b/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs index 78dea70d..c7882ee4 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs @@ -18,6 +18,9 @@ namespace BaseGames.Enemies [Min(0f)] public float launchHorzForce; [Tooltip("无 KnockUp 动画时的状态持续时长(s)")] [Min(0f)] public float knockUpDuration; + [Tooltip("无 Stagger 动画时的僵直持续时长(s);0 = 用默认 0.5s。" + + "受击(Hurt)的时长不在这里——它取攻击方 DamageSourceSO 的 HitStunDuration")] + [Min(0f)] public float staggerDuration; } /// diff --git a/Assets/_Game/Scripts/Enemies/States/EnemyHurtState.cs b/Assets/_Game/Scripts/Enemies/States/EnemyHurtState.cs index f25831f8..1ad3fe30 100644 --- a/Assets/_Game/Scripts/Enemies/States/EnemyHurtState.cs +++ b/Assets/_Game/Scripts/Enemies/States/EnemyHurtState.cs @@ -2,7 +2,8 @@ namespace BaseGames.Enemies.States { /// /// 受击状态。播放受击动画,动画结束后自动回到 Controlled。 - /// 原 EnemyBase.ForceState 中的 Hurt if-else 逻辑已迁移至此。 + /// 无受击动画时按 (来自攻击方的硬直时长)恢复—— + /// 二者必须都安排恢复,否则敌人会永久停在本态,决策层被 IsControllable 门永久挂起。 /// public sealed class EnemyHurtState : IEnemyState { @@ -10,15 +11,22 @@ namespace BaseGames.Enemies.States public void Enter(EnemyBase owner) { - if (owner.Animancer == null || owner.AnimConfig?.Hurt == null) return; - - var animState = owner.Animancer.Play(owner.AnimConfig.Hurt); - animState.Events(owner).OnEnd = () => + if (owner.Animancer != null && owner.AnimConfig?.Hurt != null) { - // 只在仍处于 Hurt 时才回 Controlled,避免 Die 时被覆盖 - if (owner.CurrentState == EnemyStateType.Hurt) - owner.ForceState(EnemyStateType.Controlled); - }; + var animState = owner.Animancer.Play(owner.AnimConfig.Hurt); + animState.Events(owner).OnEnd = () => + { + // 只在仍处于 Hurt 时才回 Controlled,避免 Die 时被覆盖 + if (owner.CurrentState == EnemyStateType.Hurt) + owner.ForceState(EnemyStateType.Controlled); + }; + } + else + { + // 无受击动画:按本次攻击的硬直时长恢复。 + // 时长为 0 是合法的"不硬直",由 ScheduleStateRecovery 处理为下一帧恢复。 + owner.ScheduleStateRecovery(EnemyStateType.Hurt, owner.PendingHitStun); + } } public void Exit(EnemyBase owner) { } diff --git a/Assets/_Game/Scripts/Enemies/States/EnemyStaggerState.cs b/Assets/_Game/Scripts/Enemies/States/EnemyStaggerState.cs index 4b08c01c..fa42820a 100644 --- a/Assets/_Game/Scripts/Enemies/States/EnemyStaggerState.cs +++ b/Assets/_Game/Scripts/Enemies/States/EnemyStaggerState.cs @@ -3,21 +3,35 @@ namespace BaseGames.Enemies.States /// /// 僵直状态(霸体耗尽时触发)。 /// 播放 Stagger 动画(若配置),动画结束后自动回到 Controlled。 + /// 无僵直动画时按 的 staggerDuration 恢复—— + /// 二者必须都安排恢复,否则敌人会永久停在本态,决策层被 IsControllable 门永久挂起。 /// public sealed class EnemyStaggerState : IEnemyState { + /// 未配置 staggerDuration 时的兜底时长(与 KnockUp 同规格)。 + private const float DefaultStaggerDuration = 0.5f; + public EnemyStateType StateType => EnemyStateType.Stagger; public void Enter(EnemyBase owner) { - if (owner.Animancer == null || owner.AnimConfig?.Stagger == null) return; - - var animState = owner.Animancer.Play(owner.AnimConfig.Stagger); - animState.Events(owner).OnEnd = () => + if (owner.Animancer != null && owner.AnimConfig?.Stagger != null) { - if (owner.CurrentState == EnemyStateType.Stagger) - owner.ForceState(EnemyStateType.Controlled); - }; + var animState = owner.Animancer.Play(owner.AnimConfig.Stagger); + animState.Events(owner).OnEnd = () => + { + if (owner.CurrentState == EnemyStateType.Stagger) + owner.ForceState(EnemyStateType.Controlled); + }; + } + else + { + // 无僵直动画:按配置时长恢复。僵直是给玩家的惩罚窗口, + // 时长必须为正,否则等同于霸体被破却没有任何回报。 + float cfg = owner.StatsSO?.HitTiers.staggerDuration ?? 0f; + float duration = cfg > 0f ? cfg : DefaultStaggerDuration; + owner.ScheduleStateRecovery(EnemyStateType.Stagger, duration); + } } public void Exit(EnemyBase owner) { }