Add enemy respawner and related components for room lifecycle management

- Implemented EnemyRespawner to manage enemy spawning and respawning within rooms.
- Added IRoomLifecycle interface for room activation and dormancy handling.
- Created supporting classes and metadata for enemy perception and threat assessment.
- Established streaming system components for room state management and transitions.
- Added necessary metadata files for new scripts to ensure proper integration with Unity.
This commit is contained in:
2026-05-23 21:23:09 +08:00
parent a1b4e629aa
commit 520f84999b
34 changed files with 1710 additions and 63 deletions

View File

@@ -22,14 +22,16 @@ namespace BaseGames.World.Streaming
/// </para>
/// </summary>
[DefaultExecutionOrder(-800)]
public class RoomStreamingManager : MonoBehaviour, IRoomStreamingManager
public class RoomStreamingManager : MonoBehaviour, IRoomStreamingManager, ISceneLoadCoordinator
{
[Header("配置")]
[SerializeField] private MapDatabaseSO _mapDatabase;
[SerializeField] private StreamingBudgetConfigSO _budget;
[Header("事件频道 - 监听")]
[Tooltip("玩家进入新房间时发布(携带 RoomId 字符串)。")]
[Header("事件频道 - 监听(输入)")]
[Tooltip("外部触发器或非流式加载路径在玩家到达新房间时发布此事件(携带 RoomId)。\n" +
"流式管理器订阅此频道以支持冷启动和外部调试传送。\n" +
"正常流式过渡路径由 ActivateRoomCoroutine 直接更新 _currentRoomId不依赖此订阅。")]
[SerializeField] private StringEventChannelSO _onRoomEntered;
[Header("事件频道 - 发布")]
@@ -37,6 +39,11 @@ namespace BaseGames.World.Streaming
"供 TransitionDirector 检查是否可执行 Seamless 切换。")]
[SerializeField] private StringEventChannelSO _onRoomPreloaded;
[Tooltip("房间激活完成Active后发布携带 RoomId。\n" +
"地图探索、任务系统等应订阅此事件以感知当前房间变更。\n" +
"注意:与 _onRoomEntered输入是不同频道避免流式管理器自身产生事件循环。")]
[SerializeField] private StringEventChannelSO _onRoomActivated;
[Header("格子单位(世界坐标)")]
[Tooltip("每个格子对应的 Unity 世界坐标单位数,与关卡设计网格对齐。")]
[SerializeField] private float _unitsPerGrid = 16f;
@@ -73,8 +80,17 @@ namespace BaseGames.World.Streaming
}
else
{
// 首次进入(非流式路径也可能触发),直接初始化相机
// 首次注册(通常是 SceneLoader 直接加载的初始房间,绕过了流式路径)
controller.SetupCamera();
// 冷启动引导:若流式系统尚无当前房间,以此房间为起点开始预加载邻居。
// 此路径仅在游戏第一帧或从存档热载入时触发一次。
if (string.IsNullOrEmpty(_currentRoomId) && _graph != null)
{
_currentRoomId = controller.RoomId;
RecalculateStreamingSet(controller.RoomId);
Debug.Log($"[RoomStreamingManager] 冷启动:以 {controller.RoomId} 为基准启动流式预加载。");
}
}
}
@@ -89,11 +105,32 @@ namespace BaseGames.World.Streaming
EnqueuePreload(roomId);
}
public RoomState GetRoomState(string roomId)
{
return _handles.TryGetValue(roomId, out var handle)
? handle.State
: RoomState.Unloaded;
}
// ── ISceneLoadCoordinator ─────────────────────────────────────────────────
public bool OwnsScene(string sceneName)
=> !string.IsNullOrEmpty(sceneName) &&
sceneName.StartsWith("Room_", System.StringComparison.Ordinal);
public IEnumerator LoadAndActivateCoroutine(
string sceneName, string entryTransitionId, bool isRespawn)
{
var ctx = new SpawnContext(entryTransitionId, isRespawn);
yield return LoadAndActivateRoomCoroutine(sceneName, ctx);
}
// ── 生命周期 ──────────────────────────────────────────────────────────────
private void Awake()
{
ServiceLocator.Register<IRoomStreamingManager>(this);
ServiceLocator.Register<ISceneLoadCoordinator>(this);
_graph = WorldGraph.Build(_mapDatabase, _unitsPerGrid);
}
@@ -110,6 +147,7 @@ namespace BaseGames.World.Streaming
private void OnDestroy()
{
ServiceLocator.Unregister<IRoomStreamingManager>(this);
ServiceLocator.Unregister<ISceneLoadCoordinator>(this);
}
private void Update()
@@ -160,10 +198,14 @@ namespace BaseGames.World.Streaming
}
}
// 保留集内尚未加载的房间 → 加入预加载队列
foreach (var kv in _hopCache)
// 重建加载队列清除废弃条目keepIds 已缩小时不再需要的房间),
// 按跳数升序重新入队(近邻优先),确保有限并发槽优先服务最近的房间。
// 跳数 = 0 为当前房间本身,已在 Active 路径中处理,跳过以避免冷启动时重复加载。
_loadQueue.Clear();
_queuedRoomIds.Clear();
foreach (var kv in _hopCache.OrderBy(kv => kv.Value))
{
if (kv.Value > hops) continue;
if (kv.Value == 0 || kv.Value > hops) continue;
if (!_handles.ContainsKey(kv.Key))
EnqueuePreload(kv.Key);
}
@@ -206,6 +248,9 @@ namespace BaseGames.World.Streaming
var handle = new RoomHandle(roomId, this, perFrame);
// 从图节点读取内存估算(关卡设计师在 MapRoomDataSO 中填写)
handle.EstimatedMemKB = _graph.GetNode(roomId)?.EstimatedMemoryKB ?? 0;
if (handle.EstimatedMemKB == 0)
Debug.LogWarning($"[RoomStreamingManager] {roomId}: EstimatedMemoryKB 未设置(= 0内存预算检查将跳过此房间。" +
"请在 MapRoomDataSO 中填写 Profiler 测量值。");
_handles[roomId] = handle;
// RoomId 与 Addressable key 相同规范Room_{Region}_{Id}
@@ -231,9 +276,10 @@ namespace BaseGames.World.Streaming
{
if (_budget == null) return;
// 按距离降序 + LRU 时间升序排列所有 Dormant 房间
// 按距离降序 + LRU 时间升序排列所有 Dormant 房间
// 排除已在冷却协程中的房间:它们已被调度为卸载,无需重复发起 UnloadRoomCoroutine。
var dormants = _handles.Values
.Where(h => h.State == RoomState.Dormant)
.Where(h => h.State == RoomState.Dormant && !_roomsInCooling.Contains(h.RoomId))
.OrderByDescending(h => _hopCache.TryGetValue(h.RoomId, out int d) ? d : int.MaxValue)
.ThenBy(h => h.LastActiveTime)
.ToList();
@@ -272,10 +318,10 @@ namespace BaseGames.World.Streaming
// 冷却结束后再次检查是否仍需保留
if (_currentRoomId != null)
{
int hops = _budget != null ? _budget.PreloadLookaheadHops : 2;
var neighbors = _graph.GetNeighborsWithinHops(_currentRoomId, hops);
bool stillNeeded = neighbors.Any(n => n.RoomId == handle.RoomId)
|| handle.RoomId == _currentRoomId;
int hops = _budget != null ? _budget.PreloadLookaheadHops : 2;
// 直接复用 _hopCache已在最近一次 RecalculateStreamingSet 中更新),避免重复 BFS
bool stillNeeded = handle.RoomId == _currentRoomId
|| (_hopCache.TryGetValue(handle.RoomId, out int dist) && dist <= hops);
if (stillNeeded)
{
// 玩家已回到本房间附近:重置为 Dormant可再次被激活
@@ -303,10 +349,13 @@ namespace BaseGames.World.Streaming
// ── TransitionDirector 调用接口 ────────────────────────────────────────────
/// <summary>
/// 查询目标房间是否已处于 Dormant 状态,可执行无等待的切换。
/// 查询目标房间是否已就绪,可执行无等待的切换。
/// Dormant 和 Cooling 均视为就绪Cooling 房间已完成休眠化,
/// 可立即重置为 Dormant 后激活(常见于玩家折返场景)。
/// </summary>
public bool IsRoomDormant(string roomId)
=> _handles.TryGetValue(roomId, out var h) && h.State == RoomState.Dormant;
=> _handles.TryGetValue(roomId, out var h) &&
(h.State == RoomState.Dormant || h.State == RoomState.Cooling);
/// <summary>
/// 激活目标房间Dormant → Active并停用前一个房间。
@@ -322,14 +371,21 @@ namespace BaseGames.World.Streaming
string previousRoomId = _currentRoomId;
// Cooling 房间已完成休眠化,可直接重置为 Dormant 后激活
// 场景:玩家 A→B 后立即 B→A折返A 处于 Cooling 状态
// CoolingCoroutine 超时后检查 _currentRoomId==A → stillNeeded=true → ResetToDormant()=无操作(已不在 Cooling正常结束
if (targetHandle.State == RoomState.Cooling)
targetHandle.ResetToDormant();
// 激活新房间
yield return targetHandle.Activate(context);
_currentRoomId = targetRoomId;
// 旧房间进入冷却(不立即卸载;守卫防止 CoolingCoroutine 重复启动)
// 同时包含 Activating玩家在激活过程中快速折返时也应正确触发冷却
if (!string.IsNullOrEmpty(previousRoomId) &&
_handles.TryGetValue(previousRoomId, out var prevHandle) &&
prevHandle.State == RoomState.Active &&
(prevHandle.State == RoomState.Active || prevHandle.State == RoomState.Activating) &&
!_roomsInCooling.Contains(previousRoomId))
{
prevHandle.BeginCooling();
@@ -339,13 +395,13 @@ namespace BaseGames.World.Streaming
// 重新计算新房间的 StreamingSet
RecalculateStreamingSet(targetRoomId);
// 通知:视同进入新房间(触发地图探索更新等)
_onRoomEntered?.Raise(targetRoomId);
// 通知外部系统(地图探索、任务系统等):新房间已激活
_onRoomActivated?.Raise(targetRoomId);
}
/// <summary>
/// 将目标房间立即加载并激活(用于 Scene 类型的非流式路径首次进入某房间)。
/// 完成后广播 EVT_RoomEntered 以触发 StreamingSet 重新计算
/// 将目标房间立即加载并激活(用于 Room/Scene 类型的快速传送、复活等非预加载路径)。
/// 完成后经由 ActivateRoomCoroutine 发布 EVT_RoomActivated 并重新计算 StreamingSet。
/// </summary>
public IEnumerator LoadAndActivateRoomCoroutine(string roomId, SpawnContext context)
{
@@ -353,9 +409,17 @@ namespace BaseGames.World.Streaming
{
int perFrame = _budget != null ? _budget.LifecycleActivatePerFrame : 8;
var handle = new RoomHandle(roomId, this, perFrame);
handle.EstimatedMemKB = _graph.GetNode(roomId)?.EstimatedMemoryKB ?? 0;
handle.EstimatedMemKB = _graph?.GetNode(roomId)?.EstimatedMemoryKB ?? 0;
_handles[roomId] = handle;
yield return handle.LoadAsync(roomId);
if (handle.State != RoomState.Dormant)
{
// 加载失败:清理僵尸 handle不继续激活前一个房间保持 Active
_handles.Remove(roomId);
Debug.LogError($"[RoomStreamingManager] LoadAndActivate{roomId} 加载失败,取消激活。");
yield break;
}
}
yield return ActivateRoomCoroutine(roomId, context);