diff --git a/Assets/Tests/EditMode/Core/PooledObjectForwardingTests.cs b/Assets/Tests/EditMode/Core/PooledObjectForwardingTests.cs
new file mode 100644
index 00000000..980eebcc
--- /dev/null
+++ b/Assets/Tests/EditMode/Core/PooledObjectForwardingTests.cs
@@ -0,0 +1,119 @@
+using NUnit.Framework;
+using UnityEngine;
+using BaseGames.Core.Pool;
+
+namespace BaseGames.Tests.EditMode.Core
+{
+ ///
+ /// 对象池必须真正通知 。
+ ///
+ /// 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 态、
+ /// 碰撞体全关——一个不可交互也不会动的幽灵。
+ ///
+ public class PooledObjectForwardingTests
+ {
+ /// 记录通知次数与「被通知时对象是否还活跃」的探针。
+ 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;
+ }
+
+ ///
+ /// 按生产路径建对象:两条创建路径(预热 EnqueueNew 与按需 Instantiate)
+ /// 都在首次 Spawn 前调 Setup,所以 Setup 是可靠的解析时机。
+ ///
+ private (PooledObject po, SpyPoolable spy) MakePooled()
+ {
+ _host = new GameObject("pooled");
+ var po = _host.AddComponent();
+ var spy = _host.AddComponent();
+ 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();
+
+ 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();
+
+ 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);
+ }
+ }
+}
diff --git a/Assets/Tests/EditMode/Core/PooledObjectForwardingTests.cs.meta b/Assets/Tests/EditMode/Core/PooledObjectForwardingTests.cs.meta
new file mode 100644
index 00000000..927c6c63
--- /dev/null
+++ b/Assets/Tests/EditMode/Core/PooledObjectForwardingTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f2987c38d1c03d74db0a93f908048e3a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Core/Pool/GlobalObjectPool.cs b/Assets/_Game/Scripts/Core/Pool/GlobalObjectPool.cs
index 88ae7731..9ab83001 100644
--- a/Assets/_Game/Scripts/Core/Pool/GlobalObjectPool.cs
+++ b/Assets/_Game/Scripts/Core/Pool/GlobalObjectPool.cs
@@ -154,8 +154,10 @@ namespace BaseGames.Core.Pool
aliveRef.Remove(po.AliveNode);
po.AliveNode = null;
}
- po.gameObject.SetActive(false);
+ // 先通知再停用:清理要跑在还活跃的对象上(可用协程、可读组件状态)。
+ // 与 PooledObject.ForceReturnToPool 的顺序一致——两条归池路径必须给出同一份契约。
po.OnDespawn();
+ po.gameObject.SetActive(false);
int maxCount = _maxCounts.GetValueOrDefault(key, 0);
int queueSize = _pools.TryGetValue(key, out var queue) ? queue.Count : 0;
diff --git a/Assets/_Game/Scripts/Core/Pool/PooledObject.cs b/Assets/_Game/Scripts/Core/Pool/PooledObject.cs
index 5d8792ae..7096164d 100644
--- a/Assets/_Game/Scripts/Core/Pool/PooledObject.cs
+++ b/Assets/_Game/Scripts/Core/Pool/PooledObject.cs
@@ -24,14 +24,37 @@ namespace BaseGames.Core.Pool
// 组件缓存(避免反复 GetComponent)
private readonly Dictionary _componentCache = new();
+ // 本物体上的 IPoolable 实现者。首次使用时解析一次并缓存——池化本就是为省开销,
+ // 不能每次 Spawn/Despawn 都遍历一遍组件。惰性解析而非依赖 Setup:
+ // 结果与解析时机无关,也就不存在"忘了调 Setup 就静默不通知"的失败模式。
+ private IPoolable[] _poolables;
+ private IPoolable[] Poolables => _poolables ??= GetComponents();
+
public void Setup(string key, GlobalObjectPool pool)
{
AddressKey = key;
_pool = pool;
}
- public virtual void OnSpawn() { }
- public virtual void OnDespawn(){ }
+ ///
+ /// 由池在取出对象后调用,转发给本物体上的所有 。
+ /// 子类覆盖时必须调 base,否则 IPoolable 实现者收不到通知。
+ ///
+ public virtual void OnSpawn()
+ {
+ var ps = Poolables;
+ for (int i = 0; i < ps.Length; i++) ps[i].OnSpawn();
+ }
+
+ ///
+ /// 由池在归还对象时调用(对象仍处于活跃状态),转发给本物体上的所有 。
+ /// 子类覆盖时必须调 base。
+ ///
+ public virtual void OnDespawn()
+ {
+ var ps = Poolables;
+ for (int i = 0; i < ps.Length; i++) ps[i].OnDespawn();
+ }
// ── 归还 API ──────────────────────────────────────────────────────
/// 立即归还到对象池。
@@ -67,8 +90,10 @@ namespace BaseGames.Core.Pool
///
/// 可选接口:若池化对象需要在 Spawn/Despawn 时执行额外逻辑,
- /// 由 PooledObject 子类或同 GameObject 上的其他 MonoBehaviour 实现,
- /// 并在 PooledObject.OnSpawn/OnDespawn 中手动驱动。
+ /// 由 PooledObject 子类或**同 GameObject 上**的其他 MonoBehaviour 实现。
+ /// / 会自动转发,
+ /// 调用方无需手动驱动。
+ /// 只扫本物体、不向下扫子物体:嵌套的可池化对象会因此被两个 PooledObject 各通知一次。
///
public interface IPoolable
{
diff --git a/Assets/_Game/Scripts/Enemies/EnemyRespawner.cs b/Assets/_Game/Scripts/Enemies/EnemyRespawner.cs
index 7bc90d72..defe8e25 100644
--- a/Assets/_Game/Scripts/Enemies/EnemyRespawner.cs
+++ b/Assets/_Game/Scripts/Enemies/EnemyRespawner.cs
@@ -127,12 +127,14 @@ namespace BaseGames.Enemies
private void SpawnEnemy()
{
GameObject go = null;
+ bool fromPool = false;
// 优先:对象池
if (!string.IsNullOrEmpty(_poolKey))
{
var pool = ServiceLocator.GetOrDefault();
go = pool?.Spawn(_poolKey, transform.position, transform.rotation);
+ fromPool = go != null;
}
// 兜底:直接实例化
@@ -152,8 +154,10 @@ namespace BaseGames.Enemies
}
_activeEnemy = enemy;
- // 确保对象池复用路径也能正确重置运行时状态
- _activeEnemy.OnSpawn();
+ // 池化路径的重置由 PooledObject.OnSpawn 转发 IPoolable 完成,这里不能再调一次:
+ // OnSpawn 末尾会 Spawned?.Invoke(),重复触发即重复执行出生能力。
+ // 兜底实例化路径没有池,需要在这里补一次。
+ if (!fromPool) _activeEnemy.OnSpawn();
_activeEnemy.OnDied += OnEnemyDied;
}