fix(enemy): 无受击/僵直动画时不再永久卡死

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。
This commit is contained in:
2026-07-31 09:40:47 +08:00
parent 691b19fd11
commit 9120964be0
6 changed files with 190 additions and 21 deletions
@@ -2,7 +2,8 @@ namespace BaseGames.Enemies.States
{
/// <summary>
/// 受击状态。播放受击动画,动画结束后自动回到 Controlled。
/// 原 EnemyBase.ForceState 中的 Hurt if-else 逻辑已迁移至此。
/// 无受击动画时按 <see cref="EnemyBase.PendingHitStun"/>(来自攻击方的硬直时长)恢复——
/// 二者必须都安排恢复,否则敌人会永久停在本态,决策层被 IsControllable 门永久挂起。
/// </summary>
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) { }
@@ -3,21 +3,35 @@ namespace BaseGames.Enemies.States
/// <summary>
/// 僵直状态(霸体耗尽时触发)。
/// 播放 Stagger 动画(若配置),动画结束后自动回到 Controlled。
/// 无僵直动画时按 <see cref="EnemyStatsSO.HitTiers"/> 的 staggerDuration 恢复——
/// 二者必须都安排恢复,否则敌人会永久停在本态,决策层被 IsControllable 门永久挂起。
/// </summary>
public sealed class EnemyStaggerState : IEnemyState
{
/// <summary>未配置 staggerDuration 时的兜底时长(与 KnockUp 同规格)。</summary>
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) { }