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) { }