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。
108 lines
4.3 KiB
C#
108 lines
4.3 KiB
C#
using NUnit.Framework;
|
|
using UnityEngine;
|
|
using UnityEngine.TestTools;
|
|
using BaseGames.Enemies;
|
|
using BaseGames.Enemies.States;
|
|
|
|
namespace BaseGames.Tests.EditMode.Enemies
|
|
{
|
|
/// <summary>
|
|
/// 受击类状态(Hurt / Stagger / KnockUp)在**没有对应动画 Clip** 时,
|
|
/// 必须仍然安排回到 Controlled——否则敌人被打一次就永久停在该态,
|
|
/// IsControllable 恒假、AI 永久停摆。
|
|
///
|
|
/// 这是本项目「禁止下游兜底掩盖问题」的反面:早退本身不是兜底,
|
|
/// 但早退**同时丢掉了恢复安排**,把「动画没配」这个可恢复的缺失
|
|
/// 变成了「敌人永久失能」这个不可恢复的故障。
|
|
/// </summary>
|
|
public class EnemyHitStateRecoveryTests
|
|
{
|
|
/// <summary>
|
|
/// 记录 ScheduleStateRecovery 调用而不真正启协程——EditMode 下没有播放循环,
|
|
/// 协程不会推进,直接断言"是否安排了恢复"才是这个缺陷的可测形态。
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// EditMode 下 AddComponent 不触发 Awake,动画引用保持为空——
|
|
/// 这正好等价于"美术未接入"的生产场景,无需再造假配置。
|
|
/// 前提用 AnimConfig 断言(而非 Animancer):后者会把 Animancer 程序集
|
|
/// 拖进测试程序集的引用,而 AnimConfig 为空已足以让三个状态都走无动画分支。
|
|
/// </summary>
|
|
private RecordingEnemy MakeEnemyWithoutAnimation()
|
|
{
|
|
_host = new GameObject("enemy-without-anim");
|
|
var e = _host.AddComponent<RecordingEnemy>();
|
|
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);
|
|
}
|
|
}
|
|
}
|