From 6414fa2dfe6cef1db672a04934cf2fc340289a67 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 31 Jul 2026 11:13:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(enemy):=20=E6=B1=A0=E5=8C=96=E5=A4=8D?= =?UTF-8?q?=E6=B4=BB=E7=9A=84=E6=95=8C=E4=BA=BA=E4=B8=8D=E5=86=8D=E5=B8=A6?= =?UTF-8?q?=E7=9D=80=E4=B8=8A=E4=B8=80=E6=9D=A1=E5=91=BD=E7=9A=84=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E5=86=B7=E5=8D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Enemies/EnemyAbilityCooldownResetTests.cs | 91 +++++++++++++++++++ .../EnemyAbilityCooldownResetTests.cs.meta | 11 +++ .../Enemies/Abilities/EnemyAbilityBase.cs | 9 ++ .../Enemies/Abilities/EnemyAbilityRegistry.cs | 11 +++ Assets/_Game/Scripts/Enemies/EnemyBase.cs | 5 +- 5 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 Assets/Tests/EditMode/Enemies/EnemyAbilityCooldownResetTests.cs create mode 100644 Assets/Tests/EditMode/Enemies/EnemyAbilityCooldownResetTests.cs.meta diff --git a/Assets/Tests/EditMode/Enemies/EnemyAbilityCooldownResetTests.cs b/Assets/Tests/EditMode/Enemies/EnemyAbilityCooldownResetTests.cs new file mode 100644 index 00000000..33e32b7d --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyAbilityCooldownResetTests.cs @@ -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 +{ + /// + /// 池化敌人复活后不得带着上一条命的能力冷却。 + /// + /// EnemyBase.OnSpawn 里 InterruptAll 那一行的注释写着「重置能力冷却」,实际三处都不成立: + /// EnemyAbilityRegistry.InterruptAll 与 EnemyAbilityBase.Interrupt 各有一道 IsRunning 门, + /// 出生时无一在跑,循环体一次都不执行;即便执行到,末行也是**设置**半冷却而非清零。 + /// 冷却基于绝对 Time.time,于是上一条命的剩余冷却原样活到下一条命。 + /// + public class EnemyAbilityCooldownResetTests + { + /// 最小可实例化能力:不播动画、不碰 HitBox,只为验证冷却的跨池化生命周期。 + private sealed class StubAbility : EnemyAbilityBase + { + /// 编辑模式下 AddComponent 不触发 Awake,手动跑一次真实的依赖解析路径。 + public void RunAwake() => Awake(); + + /// 等价于 Inspector 对 [SerializeField] _config 的赋值(子类可见,非反射伪造)。 + 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; + } + + /// + /// 建一个能力已进入冷却的敌人。冷却全程由生产代码写入: + /// Execute() 启真实协程(编辑模式下跑到首个 yield 即挂起,_isRunning 留 true), + /// 再由 Interrupt() 经真实路径写 _cooldownEndTime。 + /// 不反射直写冷却字段——那样测到的是伪造状态,而非真实时序产出的状态。 + /// + private (EnemyBase enemy, StubAbility ability) MakeEnemyWithAbilityOnCooldown() + { + _host = new GameObject("pooled-enemy"); + var enemy = _host.AddComponent(); // 裸 EnemyBase 的 _currentState=Controlled → IsAlive 为真 + var ab = _host.AddComponent(); + + _config = ScriptableObject.CreateInstance(); + _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); + } + } +} diff --git a/Assets/Tests/EditMode/Enemies/EnemyAbilityCooldownResetTests.cs.meta b/Assets/Tests/EditMode/Enemies/EnemyAbilityCooldownResetTests.cs.meta new file mode 100644 index 00000000..8005e64f --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyAbilityCooldownResetTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b8c4bfbd21220fd429f7f845fee03cff +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs index 814cb5be..bf823aa6 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs @@ -155,6 +155,15 @@ namespace BaseGames.Enemies.Abilities protected virtual void OnInterrupted(InterruptReason reason) { } + /// + /// 把冷却清回出生态,使能力立刻可用。 + /// 供对象池复活()调用:冷却以绝对 Time.time 记时, + /// 不显式清零就会原样活过 despawn/spawn,让新生的敌人带着上一条命的剩余冷却。 + /// 注意不能靠 代劳——它有 _isRunning 门(出生时无一在跑), + /// 且其语义是"中断后计半程冷却",是写入冷却而非清除。 + /// + public void ResetCooldown() => _cooldownEndTime = -1f; + /// 子类辅助:朝向目标(写入输入信号,下一 FixedUpdate 由 EnemyMovement 消费)。 protected void FaceTarget(Transform target) { diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs index 15e9a290..2ea275f6 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs @@ -61,6 +61,17 @@ namespace BaseGames.Enemies.Abilities } } + /// + /// 清空所有能力的冷却。对象池复活时调用。 + /// 与 的区别是**不看 IsRunning**——出生时没有能力在跑, + /// 那道门会让中断路径一次都不执行,正是冷却跨命残留的成因。 + /// + public void ResetAllCooldowns() + { + for (int i = 0; i < _all.Count; i++) + if (_all[i] != null) _all[i].ResetCooldown(); + } + /// /// 中断指定互斥组内所有正在执行的能力。 /// 由 在 Execute 开始时调用,确保同组互斥。 diff --git a/Assets/_Game/Scripts/Enemies/EnemyBase.cs b/Assets/_Game/Scripts/Enemies/EnemyBase.cs index 868d5602..aa6a7f44 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyBase.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyBase.cs @@ -763,8 +763,11 @@ namespace BaseGames.Enemies if (_stats != null && _statsSO != null) _stats.Initialize(_statsSO); - // 重置能力冷却 + // 上一条命若在出招途中被回收,OnDespawn 与组件 OnDisable 已经中断过;此处为兜底中断。 + // 它**不**负责重置冷却:两侧的 IsRunning 门让它在出生时恒为空转(曾被误注释为"重置能力冷却")。 _abilities.InterruptAll(InterruptReason.Dead); + // 冷却以绝对时间记,必须显式清零,否则新生的敌人带着上一条命的剩余冷却出不了招 + _abilities.ResetAllCooldowns(); // 对象池复用不得继承上一条命的防重复记忆(否则新生即带着上次的招被折扣) _attackSelector?.ResetRepeatMemory();