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; }