Files
zeling_v2/Assets/_Game/Scripts/Enemies/EnemyRespawner.cs
T
joywayerandClaude Opus 5 1e3c060c8d 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>
2026-07-31 16:03:57 +08:00

251 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Pool;
using BaseGames.World;
namespace BaseGames.Enemies
{
/// <summary>
/// 场景内的敌人刷新点。实现 <see cref="IRoomLifecycle"/>,与房间流式系统联动:
/// <list type="bullet">
/// <item>房间激活(<see cref="OnRoomActivate"/>)时生成敌人实例。</item>
/// <item>房间休眠(<see cref="OnRoomDormant"/>)时清理仍存活的实例,确保下次进入时刷出新实例。</item>
/// <item>可选 <see cref="_respawnDelay"/>:敌人在房间内死亡后等待若干秒自动复活。</item>
/// <item>可选的 <see cref="_persistentKillId"/>:击杀后写入 <see cref="WorldStateRegistry"/>
/// 使敌人在存档重载后不再复活(适用于 Boss 或仅出现一次的守卫)。</item>
/// </list>
/// <para>
/// 挂载方式:在房间场景中放置空 GameObject 作为刷新点,挂上本组件,
/// 并通过 <see cref="_enemyPrefab"/> 或 <see cref="_poolKey"/> 指定要生成的敌人。
/// </para>
/// </summary>
public class EnemyRespawner : MonoBehaviour, IRoomLifecycle
{
[Header("生成配置")]
[Tooltip("要生成的敌人预制体(直接引用)。当 _poolKey 非空时,预制体作为兜底备选。")]
[SerializeField] private GameObject _enemyPrefab;
[Tooltip("对象池键(对应 GlobalObjectPool 中已预热的 AddressKey 常量)。\n" +
"非空时优先通过 IObjectPoolService 取用对象;池服务不可用时回退至 _enemyPrefab。")]
[SerializeField] private string _poolKey;
[Header("房间内复活")]
[Tooltip("0(默认)= 敌人死亡后不在房间内复活,仅下次进入房间时重新生成。\n" +
"大于 0 = 死亡后等待 N 秒在原位复活,适用于需要持续巡逻的普通敌人。")]
[Min(0f)]
[SerializeField] private float _respawnDelay = 0f;
[Tooltip("每次进入房间时,敌人最多在房间内复活的次数。\n" +
"0 = 不限次数(仅在 _respawnDelay > 0 时有意义)。\n" +
"示例:设为 2 表示同一次房间访问中敌人最多死而复生 2 次,第 3 次死后不再刷新。")]
[Min(0)]
[SerializeField] private int _maxRespawnCount = 0;
[Header("永久击杀")]
[Tooltip("空 = 每次进入房间均复活。\n" +
"非空 = 击杀后将此 ID 写入 WorldStateRegistry.DestroyedObjectIds;存档后不再复活。\n" +
"适用于 Boss 或仅出现一次的关键敌人。建议格式:ENM_{EnemyId}_{RoomId}")]
[SerializeField] private string _persistentKillId;
[Tooltip("永久击杀时写入的世界状态注册表。仅在 _persistentKillId 非空时使用。")]
[SerializeField] private WorldStateRegistry _worldState;
// ── 运行时 ────────────────────────────────────────────────────────────────
private EnemyBase _activeEnemy;
/// <summary>本次房间访问中敌人已在房间内复活的次数(不含初始生成)。</summary>
private int _respawnCountThisVisit;
private Coroutine _respawnCoroutine;
// ── IRoomLifecycle ────────────────────────────────────────────────────────
/// <summary>
/// 房间休眠时清理当前存活的敌人实例,并取消待执行的复活协程。
/// 已进入死亡流程(<see cref="EnemyBase.IsAlive"/> = false)的实例由
/// <see cref="EnemyBase.Die"/> 自行处理,此处不干预。
/// </summary>
public void OnRoomDormant()
{
// 取消待执行的复活计时
if (_respawnCoroutine != null)
{
StopCoroutine(_respawnCoroutine);
_respawnCoroutine = null;
}
if (_activeEnemy != null)
{
_activeEnemy.OnDied -= OnEnemyDied;
if (_activeEnemy.IsAlive)
{
// 优先归还对象池;池服务不可用或无 PooledObject 时直接销毁
var po = _activeEnemy.GetComponent<PooledObject>();
var pool = !string.IsNullOrEmpty(_poolKey)
? ServiceLocator.GetOrDefault<IObjectPoolService>()
: null;
if (pool != null && po != null)
pool.Despawn(_poolKey, po);
else
Destroy(_activeEnemy.gameObject);
}
// IsAlive=falseEnemyBase.Die() 已通过动画回调调度销毁,自然完成即可
_activeEnemy = null;
}
_respawnCountThisVisit = 0;
}
/// <summary>
/// 房间激活时生成敌人。永久死亡的敌人不会再次生成。
/// </summary>
/// <param name="context">出生上下文(含是否为复活流程等信息)。</param>
public void OnRoomActivate(SpawnContext context)
{
// 永久击杀检查:存档中已标记为 Destroyed,不再生成
if (!string.IsNullOrEmpty(_persistentKillId)
&& _worldState != null
&& _worldState.IsDestroyed(_persistentKillId))
return;
// 重置本轮复活计数
_respawnCountThisVisit = 0;
// 快速折返时防止重复生成(上一轮实例尚存活)
if (_activeEnemy != null && _activeEnemy.IsAlive) return;
_activeEnemy = null;
SpawnEnemy();
}
// ── 内部 ──────────────────────────────────────────────────────────────────
private void SpawnEnemy()
{
GameObject go = null;
bool fromPool = false;
// 优先:对象池
if (!string.IsNullOrEmpty(_poolKey))
{
var pool = ServiceLocator.GetOrDefault<IObjectPoolService>();
go = pool?.Spawn(_poolKey, transform.position, transform.rotation);
fromPool = go != null;
}
// 兜底:直接实例化
if (go == null && _enemyPrefab != null)
go = Instantiate(_enemyPrefab, transform.position, transform.rotation);
if (go == null)
{
Debug.LogError($"[EnemyRespawner] {name}:无法生成敌人,请检查 _enemyPrefab 或 _poolKey 配置。", this);
return;
}
if (!go.TryGetComponent<EnemyBase>(out var enemy))
{
Debug.LogWarning($"[EnemyRespawner] {name}:生成的 GameObject 上未找到 EnemyBase 组件。", this);
return;
}
_activeEnemy = enemy;
// 池化路径的重置由 PooledObject.OnSpawn 转发 IPoolable 完成,这里不能再调一次:
// OnSpawn 末尾会 Spawned?.Invoke(),重复触发即重复执行出生能力。
// 兜底实例化路径没有池,需要在这里补一次。
if (!fromPool) _activeEnemy.OnSpawn();
_activeEnemy.OnDied += OnEnemyDied;
}
private void OnEnemyDied()
{
// 永久击杀:写入 WorldStateRegistry(存档管道持久化至 SaveData.World.DestroyedObjectIds
if (!string.IsNullOrEmpty(_persistentKillId) && _worldState != null)
_worldState.MarkDestroyed(_persistentKillId);
if (_activeEnemy != null)
{
_activeEnemy.OnDied -= OnEnemyDied;
_activeEnemy = null;
}
// 房间内延迟复活:_respawnDelay > 0 且未超过复活上限
bool canRespawn = _respawnDelay > 0f
&& (_maxRespawnCount == 0 || _respawnCountThisVisit < _maxRespawnCount);
if (canRespawn)
_respawnCoroutine = StartCoroutine(RespawnAfterDelay());
}
private IEnumerator RespawnAfterDelay()
{
yield return new WaitForSeconds(_respawnDelay);
// 房间可能在等待期间进入休眠(OnRoomDormant 会 StopCoroutine,此处为双重保险)
if (!gameObject.activeInHierarchy) yield break;
// 再次检查永久击杀(等待期间存档状态可能已变化)
if (!string.IsNullOrEmpty(_persistentKillId)
&& _worldState != null
&& _worldState.IsDestroyed(_persistentKillId))
yield break;
_respawnCountThisVisit++;
_respawnCoroutine = null;
SpawnEnemy();
}
private void OnDestroy()
{
// 场景卸载时确保取消订阅,防止野指针回调
if (_activeEnemy != null)
_activeEnemy.OnDied -= OnEnemyDied;
}
// ── OnValidate ────────────────────────────────────────────────────────────
#if UNITY_EDITOR
private void OnValidate()
{
// _persistentKillId 非空但 _worldState 未关联时,尝试自动查找场景内唯一实例
if (!string.IsNullOrEmpty(_persistentKillId) && _worldState == null)
_worldState = FindFirstObjectByType<WorldStateRegistry>();
}
#endif
// ── Editor Gizmos ─────────────────────────────────────────────────────────
#if UNITY_EDITOR
private static readonly Color s_GizmoColorNormal = new(1f, 0.35f, 0.2f, 0.85f);
private static readonly Color s_GizmoColorPersistent = new(1f, 0.85f, 0.1f, 0.85f);
private void OnDrawGizmos()
{
// 未选中时:小球 + 低透明度,便于关卡编辑时总览所有刷新点
Color c = !string.IsNullOrEmpty(_persistentKillId)
? s_GizmoColorPersistent
: s_GizmoColorNormal;
c.a = 0.3f;
Gizmos.color = c;
Gizmos.DrawWireSphere(transform.position, 0.25f);
}
private void OnDrawGizmosSelected()
{
bool isPersistent = !string.IsNullOrEmpty(_persistentKillId);
Gizmos.color = isPersistent ? s_GizmoColorPersistent : s_GizmoColorNormal;
Gizmos.DrawWireSphere(transform.position, 0.4f);
var labelStyle = new GUIStyle(UnityEditor.EditorStyles.miniLabel)
{
normal = { textColor = Gizmos.color }
};
string label = isPersistent ? $"{name} [永久]" : name;
UnityEditor.Handles.Label(transform.position + Vector3.up * 0.65f, label, labelStyle);
}
#endif
}
}