fix(enemy): 池化复活的敌人不再带着上一条命的能力冷却

EnemyBase.OnSpawn 里 InterruptAll 那行的注释写着「重置能力冷却」,
三处都不成立:

1. EnemyAbilityRegistry.InterruptAll 有 `if (ab.IsRunning)` 门;
2. EnemyAbilityBase.Interrupt 里还有一道 `if (!_isRunning) return`;
   出生时没有能力在跑,循环体一次都不执行;
3. 即便执行到,末行是 `_cooldownEndTime = Time.time + cooldown * 0.5f`——
   那是「中断后计半程冷却」的写入语义,本就不是清零。

冷却以绝对 Time.time 记时,于是上一条命的剩余冷却原样活到下一条命。
活路径是 EnemyRespawner.SpawnEnemy():敌人死亡归池、延迟复活后取出复用,
按 1.5–10 秒的冷却量级,新生的敌人有数秒出不了招。

修法是补一条真正的清除路径而非改动中断语义:
EnemyAbilityBase.ResetCooldown() 把 _cooldownEndTime 清回出生态的 -1,
EnemyAbilityRegistry.ResetAllCooldowns() 不看 IsRunning 逐个调用,
OnSpawn 显式调用它。原 InterruptAll 保留为兜底中断,但注释改为陈述它
实际做的事——那句失真的注释正是这个缺陷藏了这么久的原因。

测试的冷却全程由生产代码写入:Execute() 启真实协程(编辑模式下跑到首个
yield 即挂起,_isRunning 留 true),再由 Interrupt() 经真实路径写冷却,
不反射直写字段,否则测到的是伪造状态而非真实时序的产物。

验证:编译 0 错 0 警;EditMode 264/264。
修复前该用例是唯一变红的一条(263/264),失败值 5.0f 恰为
cooldown(10) × 0.5,精确指向上述第 3 点。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 11:13:49 +08:00
co-authored by Claude Opus 5
parent 9120964be0
commit 6414fa2dfe
5 changed files with 126 additions and 1 deletions
@@ -0,0 +1,91 @@
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using BaseGames.Enemies;
using BaseGames.Enemies.Abilities;
namespace BaseGames.Tests.EditMode.Enemies
{
/// <summary>
/// 池化敌人复活后不得带着上一条命的能力冷却。
///
/// EnemyBase.OnSpawn 里 InterruptAll 那一行的注释写着「重置能力冷却」,实际三处都不成立:
/// EnemyAbilityRegistry.InterruptAll 与 EnemyAbilityBase.Interrupt 各有一道 IsRunning 门,
/// 出生时无一在跑,循环体一次都不执行;即便执行到,末行也是**设置**半冷却而非清零。
/// 冷却基于绝对 Time.time,于是上一条命的剩余冷却原样活到下一条命。
/// </summary>
public class EnemyAbilityCooldownResetTests
{
/// <summary>最小可实例化能力:不播动画、不碰 HitBox,只为验证冷却的跨池化生命周期。</summary>
private sealed class StubAbility : EnemyAbilityBase
{
/// <summary>编辑模式下 AddComponent 不触发 Awake,手动跑一次真实的依赖解析路径。</summary>
public void RunAwake() => Awake();
/// <summary>等价于 Inspector 对 [SerializeField] _config 的赋值(子类可见,非反射伪造)。</summary>
public void SetConfig(EnemyAbilitySO cfg) => _config = cfg;
protected override IEnumerator ExecuteCoroutine() { yield break; }
}
private GameObject _host;
private EnemyAbilitySO _config;
// RunAwake 时找不到 AnimancerComponent 会告警,与本测试无关。
[SetUp]
public void SetUp() => LogAssert.ignoreFailingMessages = true;
[TearDown]
public void TearDown()
{
// 项目关闭了 Domain/Scene Reload:断言失败会跳过用例后续语句,
// 清理必须放在 TearDown,否则失败一次就往编辑器场景里漏一个对象。
if (_host != null) Object.DestroyImmediate(_host);
if (_config != null) Object.DestroyImmediate(_config);
_host = null;
_config = null;
LogAssert.ignoreFailingMessages = false;
}
/// <summary>
/// 建一个能力已进入冷却的敌人。冷却全程由生产代码写入:
/// Execute() 启真实协程(编辑模式下跑到首个 yield 即挂起,_isRunning 留 true),
/// 再由 Interrupt() 经真实路径写 _cooldownEndTime。
/// 不反射直写冷却字段——那样测到的是伪造状态,而非真实时序产出的状态。
/// </summary>
private (EnemyBase enemy, StubAbility ability) MakeEnemyWithAbilityOnCooldown()
{
_host = new GameObject("pooled-enemy");
var enemy = _host.AddComponent<EnemyBase>(); // 裸 EnemyBase 的 _currentState=Controlled → IsAlive 为真
var ab = _host.AddComponent<StubAbility>();
_config = ScriptableObject.CreateInstance<EnemyAbilitySO>();
_config.abilityId = "stub_ability";
_config.cooldown = 10f; // 远长于单次用例耗时,排除"跑着跑着自然冷却完"的假绿
ab.SetConfig(_config);
ab.RunAwake();
// EnemyBase.Awake 里的生产写法,编辑模式下手动跑一次
enemy.Abilities.CollectFrom(_host);
Assert.IsTrue(ab.Execute(), "前提:能力应能启动,否则下面写不进冷却");
ab.Interrupt(InterruptReason.ExternalRequest);
Assert.IsTrue(ab.IsOnCooldown, "前提:本用例覆盖的是带冷却复活,此刻必须真的在冷却中");
return (enemy, ab);
}
[Test]
public void OnSpawn_ClearsAbilityCooldown()
{
var (enemy, ability) = MakeEnemyWithAbilityOnCooldown();
enemy.OnSpawn();
Assert.AreEqual(0f, ability.CooldownRemaining,
"池化复活的敌人不得带着上一条命的剩余冷却——否则新生的敌人有数秒出不了招");
Assert.IsFalse(ability.IsOnCooldown);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b8c4bfbd21220fd429f7f845fee03cff
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -155,6 +155,15 @@ namespace BaseGames.Enemies.Abilities
protected virtual void OnInterrupted(InterruptReason reason) { } protected virtual void OnInterrupted(InterruptReason reason) { }
/// <summary>
/// 把冷却清回出生态,使能力立刻可用。
/// 供对象池复活(<see cref="EnemyBase.OnSpawn"/>)调用:冷却以绝对 Time.time 记时,
/// 不显式清零就会原样活过 despawn/spawn,让新生的敌人带着上一条命的剩余冷却。
/// 注意不能靠 <see cref="Interrupt"/> 代劳——它有 _isRunning 门(出生时无一在跑),
/// 且其语义是"中断后计半程冷却",是写入冷却而非清除。
/// </summary>
public void ResetCooldown() => _cooldownEndTime = -1f;
/// <summary>子类辅助:朝向目标(写入输入信号,下一 FixedUpdate 由 EnemyMovement 消费)。</summary> /// <summary>子类辅助:朝向目标(写入输入信号,下一 FixedUpdate 由 EnemyMovement 消费)。</summary>
protected void FaceTarget(Transform target) protected void FaceTarget(Transform target)
{ {
@@ -61,6 +61,17 @@ namespace BaseGames.Enemies.Abilities
} }
} }
/// <summary>
/// 清空所有能力的冷却。对象池复活时调用。
/// 与 <see cref="InterruptAll"/> 的区别是**不看 IsRunning**——出生时没有能力在跑,
/// 那道门会让中断路径一次都不执行,正是冷却跨命残留的成因。
/// </summary>
public void ResetAllCooldowns()
{
for (int i = 0; i < _all.Count; i++)
if (_all[i] != null) _all[i].ResetCooldown();
}
/// <summary> /// <summary>
/// 中断指定互斥组内所有正在执行的能力。 /// 中断指定互斥组内所有正在执行的能力。
/// 由 <see cref="EnemyAbilityBase"/> 在 Execute 开始时调用,确保同组互斥。 /// 由 <see cref="EnemyAbilityBase"/> 在 Execute 开始时调用,确保同组互斥。
+4 -1
View File
@@ -763,8 +763,11 @@ namespace BaseGames.Enemies
if (_stats != null && _statsSO != null) if (_stats != null && _statsSO != null)
_stats.Initialize(_statsSO); _stats.Initialize(_statsSO);
// 重置能力冷却 // 上一条命若在出招途中被回收,OnDespawn 与组件 OnDisable 已经中断过;此处为兜底中断。
// 它**不**负责重置冷却:两侧的 IsRunning 门让它在出生时恒为空转(曾被误注释为"重置能力冷却")。
_abilities.InterruptAll(InterruptReason.Dead); _abilities.InterruptAll(InterruptReason.Dead);
// 冷却以绝对时间记,必须显式清零,否则新生的敌人带着上一条命的剩余冷却出不了招
_abilities.ResetAllCooldowns();
// 对象池复用不得继承上一条命的防重复记忆(否则新生即带着上次的招被折扣) // 对象池复用不得继承上一条命的防重复记忆(否则新生即带着上次的招被折扣)
_attackSelector?.ResetRepeatMemory(); _attackSelector?.ResetRepeatMemory();