fix(core): 对象池真正通知 IPoolable,复用的敌人不再以死亡状态出场

IPoolable 的接口文档写着「由 PooledObject 子类或同 GameObject 上的其他
MonoBehaviour 实现,并在 PooledObject.OnSpawn/OnDespawn 中手动驱动」,
但那两个方法是空的 public virtual,全库无任何 PooledObject 子类,
GlobalObjectPool 也从不查找 IPoolable 组件——这份契约从未生效过。

全库 .OnSpawn(/.OnDespawn( 的调用点只有 4 处,真正驱动 EnemyBase
(唯一的 IPoolable 实现者)的只有 EnemyRespawner 一处手动补调。后果:

- EnemyBase.OnDespawn 在生产中从未被调用,其中的 _nav?.Stop() 是死代码;
- 更严重的是取出侧:PerformDeath 会 ForceState(Dead) 并禁用全部碰撞体后归池,
  而 EnemySpawnerOnEvent / ChaoFengBoss 召唤 / RangedEnemy 这些取用方
  没有补调 OnSpawn,拿到的复用敌人仍停在 Dead 态、碰撞体全关、HP 为 0——
  一个不可交互也不会动的幽灵。EnemyRespawner 路径侥幸没暴露,只因为它手动补了。

修法是让池履行它自己声明的契约,而不是让各调用方继续手动补调:
PooledObject.OnSpawn/OnDespawn 转发给本物体上的 IPoolable。只扫本物体、
不向下扫子物体——接口文档限定的就是「同 GameObject」,向下扫会让嵌套的
可池化对象被两个 PooledObject 各通知一次。组件集惰性解析一次并缓存
(池化本就是为省开销,不能每次进出都遍历组件)。

同时统一两条归池路径的顺序:GlobalObjectPool.Despawn 原本先 SetActive(false)
再 OnDespawn,而 PooledObject.ForceReturnToPool 是反过来的。转发接通后这个
顺序就成了可观察行为,两条路径必须给出同一份契约——统一为「先通知再停用」,
清理才跑在还活跃的对象上。

EnemyRespawner 那次手动 OnSpawn 随之改为只在兜底实例化路径调用:池化路径
现在由转发完成,重复调用会让 OnSpawn 末尾的 Spawned?.Invoke() 触发两次,
即重复执行出生能力。(今天重复触发会被 Execute() 的 !_isRunning 门挡住,
但那是运气不是设计。)

验证:编译 0 错;EditMode 273/273(269 + 新增 4)。
分阶段观察过失败面:只加转发时,2 条转发测试转绿、顺序测试仍红在
WasActiveOnDespawn 上;补顺序后全绿。
变异验证——把扫描范围改成 GetComponentsInChildren 后,边界测试恰好变红
(该测试在修复前是平凡通过的,需确认它真有约束力),还原后复验 273/273。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 16:03:57 +08:00
co-authored by Claude Opus 5
parent 5178b73637
commit 1e3c060c8d
5 changed files with 168 additions and 7 deletions
@@ -0,0 +1,119 @@
using NUnit.Framework;
using UnityEngine;
using BaseGames.Core.Pool;
namespace BaseGames.Tests.EditMode.Core
{
/// <summary>
/// 对象池必须真正通知 <see cref="IPoolable"/>。
///
/// IPoolable 的接口文档写着「由 PooledObject 子类或同 GameObject 上的其他 MonoBehaviour 实现,
/// 并在 PooledObject.OnSpawn/OnDespawn 中手动驱动」,但 PooledObject.OnSpawn/OnDespawn 是空的
/// public virtual、全库无任何子类,GlobalObjectPool 也从不查找 IPoolable 组件——
/// 于是这份契约从未生效。
///
/// 具体后果:EnemyBase(全库唯一的 IPoolable 实现者)死亡时 PerformDeath 会 ForceState(Dead)
/// 并禁用全部碰撞体,然后归池;除 EnemyRespawner 手动补调 OnSpawn 外,其余取用方
/// EnemySpawnerOnEvent / ChaoFengBoss 召唤 / RangedEnemy)拿到的复用敌人仍停在 Dead 态、
/// 碰撞体全关——一个不可交互也不会动的幽灵。
/// </summary>
public class PooledObjectForwardingTests
{
/// <summary>记录通知次数与「被通知时对象是否还活跃」的探针。</summary>
private sealed class SpyPoolable : MonoBehaviour, IPoolable
{
public int SpawnCalls;
public int DespawnCalls;
public bool WasActiveOnDespawn;
public void OnSpawn() => SpawnCalls++;
public void OnDespawn()
{
DespawnCalls++;
WasActiveOnDespawn = gameObject.activeSelf;
}
}
private GameObject _host;
[TearDown]
public void TearDown()
{
// 项目关闭了 Domain/Scene Reload:断言失败会跳过用例后续语句,
// 清理必须放在 TearDown,否则失败一次就往编辑器场景里漏一个对象。
if (_host != null) Object.DestroyImmediate(_host);
_host = null;
}
/// <summary>
/// 按生产路径建对象:两条创建路径(预热 EnqueueNew 与按需 Instantiate
/// 都在首次 Spawn 前调 Setup,所以 Setup 是可靠的解析时机。
/// </summary>
private (PooledObject po, SpyPoolable spy) MakePooled()
{
_host = new GameObject("pooled");
var po = _host.AddComponent<PooledObject>();
var spy = _host.AddComponent<SpyPoolable>();
po.Setup("TEST_Key", null);
return (po, spy);
}
[Test]
public void OnSpawn_NotifiesPoolableOnSameGameObject()
{
var (po, spy) = MakePooled();
po.OnSpawn();
Assert.AreEqual(1, spy.SpawnCalls,
"池取出对象时必须通知 IPoolable,否则复用的敌人带着上一条命的死亡状态出场");
}
[Test]
public void OnDespawn_NotifiesPoolableOnSameGameObject()
{
var (po, spy) = MakePooled();
po.OnDespawn();
Assert.AreEqual(1, spy.DespawnCalls,
"归池时必须通知 IPoolable,否则 EnemyBase.OnDespawn 里的清理(_nav.Stop)永远不执行");
}
[Test]
public void Notification_IsScopedToOwnGameObject_NotChildren()
{
// IPoolable 的接口文档限定为「同 GameObject 上的其他 MonoBehaviour」。
// 不向下扫子物体:嵌套的可池化对象会因此被两个 PooledObject 各通知一次。
var (po, _) = MakePooled();
var childGo = new GameObject("child");
childGo.transform.SetParent(_host.transform);
var childSpy = childGo.AddComponent<SpyPoolable>();
po.OnSpawn();
po.OnDespawn();
Assert.AreEqual(0, childSpy.SpawnCalls, "子物体上的 IPoolable 不在本组件职责内");
Assert.AreEqual(0, childSpy.DespawnCalls, "子物体上的 IPoolable 不在本组件职责内");
}
[Test]
public void Despawn_NotifiesBeforeDeactivating()
{
// 清理要在对象还活跃时跑(可用协程、可读组件状态),
// 且 PooledObject.ForceReturnToPool 本来就是「先 OnDespawn 再 SetActive(false)」。
// GlobalObjectPool.Despawn 顺序相反,两条归池路径给出的契约必须一致。
var (po, spy) = MakePooled();
var pool = new GameObject("pool").AddComponent<GlobalObjectPool>();
pool.Despawn("TEST_Key", po);
Assert.AreEqual(1, spy.DespawnCalls);
Assert.IsTrue(spy.WasActiveOnDespawn,
"OnDespawn 必须在 SetActive(false) 之前调用,否则清理跑在一个已停用的对象上");
Assert.IsFalse(po.gameObject.activeSelf, "通知之后仍然要停用对象");
Object.DestroyImmediate(pool.gameObject);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f2987c38d1c03d74db0a93f908048e3a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: