Files
zeling_v2/Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs
Joywayer 520f84999b 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.
2026-05-23 21:23:09 +08:00

453 lines
21 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 System.Collections.Generic;
using System.Linq;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.World.Map;
namespace BaseGames.World.Streaming
{
/// <summary>
/// 核心流式加载调度器。挂载于 Persistent 场景的 SYS_RoomStreamingManager 对象上。
/// <para>
/// 职责:
/// <list type="bullet">
/// <item>在 Persistent 场景初始化时从 MapDatabaseSO 构建 <see cref="WorldGraph"/></item>
/// <item>监听 <see cref="EVT_RoomEntered"/> 事件,重新计算 StreamingSet</item>
/// <item>后台异步预加载邻居房间(<see cref="RoomState.Dormant"/>),并发数由预算配置限制</item>
/// <item>执行内存预算检查,通过 LRU 策略卸载距离最远、最久未访问的 Dormant 房间</item>
/// <item>提供 ActivateRoom / DeactivateRoom 接口供 <see cref="TransitionDirector"/> 调用</item>
/// </list>
/// </para>
/// </summary>
[DefaultExecutionOrder(-800)]
public class RoomStreamingManager : MonoBehaviour, IRoomStreamingManager, ISceneLoadCoordinator
{
[Header("配置")]
[SerializeField] private MapDatabaseSO _mapDatabase;
[SerializeField] private StreamingBudgetConfigSO _budget;
[Header("事件频道 - 监听(输入)")]
[Tooltip("外部触发器或非流式加载路径在玩家到达新房间时发布此事件(携带 RoomId。\n" +
"流式管理器订阅此频道以支持冷启动和外部调试传送。\n" +
"正常流式过渡路径由 ActivateRoomCoroutine 直接更新 _currentRoomId不依赖此订阅。")]
[SerializeField] private StringEventChannelSO _onRoomEntered;
[Header("事件频道 - 发布")]
[Tooltip("某个房间加载并进入 Dormant 状态后发布(携带 RoomId。\n" +
"供 TransitionDirector 检查是否可执行 Seamless 切换。")]
[SerializeField] private StringEventChannelSO _onRoomPreloaded;
[Tooltip("房间激活完成Active后发布携带 RoomId。\n" +
"地图探索、任务系统等应订阅此事件以感知当前房间变更。\n" +
"注意:与 _onRoomEntered输入是不同频道避免流式管理器自身产生事件循环。")]
[SerializeField] private StringEventChannelSO _onRoomActivated;
[Header("格子单位(世界坐标)")]
[Tooltip("每个格子对应的 Unity 世界坐标单位数,与关卡设计网格对齐。")]
[SerializeField] private float _unitsPerGrid = 16f;
// ── 运行时状态 ─────────────────────────────────────────────────────────────
private WorldGraph _graph;
private Dictionary<string, RoomHandle> _handles = new();
private readonly Queue<string> _loadQueue = new();
/// <summary>O(1) Contains 镜像,与 _loadQueue 保持同步,避免 Queue.Contains 的 O(n) 开销。</summary>
private readonly HashSet<string> _queuedRoomIds = new();
/// <summary>当前正在运行 CoolingCoroutine 的房间,防止竞态下重复启动冷却协程。</summary>
private readonly HashSet<string> _roomsInCooling = new();
/// <summary>单次 BFS 预算:从当前房间到所有已加载房间的跳数缓存,供 LRU 排序使用。</summary>
private Dictionary<string, int> _hopCache = new();
private int _activeLoads;
private string _currentRoomId;
private readonly CompositeDisposable _subscriptions = new();
// ── IRoomStreamingManager ─────────────────────────────────────────────────
public string CurrentRoomId => _currentRoomId;
public void RegisterRoomController(RoomController controller)
{
if (controller == null) return;
if (_handles.TryGetValue(controller.RoomId, out var handle))
{
handle.RoomController = controller;
// Active 房间直接初始化相机(正常过渡流程,非后台预加载)
if (handle.State == RoomState.Active)
controller.SetupCamera();
}
else
{
// 首次注册(通常是 SceneLoader 直接加载的初始房间,绕过了流式路径)
controller.SetupCamera();
// 冷启动引导:若流式系统尚无当前房间,以此房间为起点开始预加载邻居。
// 此路径仅在游戏第一帧或从存档热载入时触发一次。
if (string.IsNullOrEmpty(_currentRoomId) && _graph != null)
{
_currentRoomId = controller.RoomId;
RecalculateStreamingSet(controller.RoomId);
Debug.Log($"[RoomStreamingManager] 冷启动:以 {controller.RoomId} 为基准启动流式预加载。");
}
}
}
/// <summary>
/// 立即将指定房间加入预加载队列。
/// 用于快速传送预热、玩家即将触碰的门口等主动预加载场景。
/// 若已加载或已在队列中则忽略。
/// </summary>
public void PreloadRoom(string roomId)
{
if (_handles.ContainsKey(roomId)) return;
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);
}
private void OnEnable()
{
_onRoomEntered?.Subscribe(OnRoomEntered).AddTo(_subscriptions);
}
private void OnDisable()
{
_subscriptions.Clear();
}
private void OnDestroy()
{
ServiceLocator.Unregister<IRoomStreamingManager>(this);
ServiceLocator.Unregister<ISceneLoadCoordinator>(this);
}
private void Update()
{
DrainLoadQueue();
}
// ── 房间进入事件 ───────────────────────────────────────────────────────────
private void OnRoomEntered(string roomId)
{
if (roomId == _currentRoomId) return;
_currentRoomId = roomId;
RecalculateStreamingSet(roomId);
}
// ── StreamingSet 计算 ──────────────────────────────────────────────────────
/// <summary>
/// 根据当前房间重新计算需要保持 Dormant 的邻居集合,
/// 并调度加载和卸载操作。单次 BFS 完成所有计算,结果缓存供 LRU 排序复用。
/// </summary>
private void RecalculateStreamingSet(string currentRoomId)
{
if (_graph == null) return;
int hops = _budget != null ? _budget.PreloadLookaheadHops : 2;
// 单次 BFS同时服务于 keepIds 构建、预加载候选、LRU 排序三个用途
_hopCache = _graph.GetAllHopDistances(currentRoomId);
// 从跳数缓存派生保留集(跳数 ≤ hops 的所有房间 + 当前房间)
var keepIds = new HashSet<string> { currentRoomId };
foreach (var kv in _hopCache)
{
if (kv.Value <= hops)
keepIds.Add(kv.Key);
}
// 不在保留集内的 Dormant 房间 → 开始冷却防重复_roomsInCooling 守卫)
foreach (var handle in _handles.Values)
{
if (!keepIds.Contains(handle.RoomId) &&
handle.State == RoomState.Dormant &&
!_roomsInCooling.Contains(handle.RoomId))
{
StartCoroutine(CoolingCoroutine(handle));
}
}
// 重建加载队列清除废弃条目keepIds 已缩小时不再需要的房间),
// 按跳数升序重新入队(近邻优先),确保有限并发槽优先服务最近的房间。
// 跳数 = 0 为当前房间本身,已在 Active 路径中处理,跳过以避免冷启动时重复加载。
_loadQueue.Clear();
_queuedRoomIds.Clear();
foreach (var kv in _hopCache.OrderBy(kv => kv.Value))
{
if (kv.Value == 0 || kv.Value > hops) continue;
if (!_handles.ContainsKey(kv.Key))
EnqueuePreload(kv.Key);
}
EnforceMemoryBudget();
}
// ── 预加载队列 ────────────────────────────────────────────────────────────
private void EnqueuePreload(string roomId)
{
if (_queuedRoomIds.Contains(roomId)) return; // O(1) 查重
if (_handles.ContainsKey(roomId)) return;
_loadQueue.Enqueue(roomId);
_queuedRoomIds.Add(roomId);
}
/// <summary>每帧尝试从队列中启动新的加载,受 MaxConcurrentLoads 限制。</summary>
private void DrainLoadQueue()
{
if (_budget == null) return;
while (_activeLoads < _budget.MaxConcurrentLoads && _loadQueue.Count > 0)
{
string roomId = _loadQueue.Dequeue();
_queuedRoomIds.Remove(roomId); // 同步镜像
if (_handles.ContainsKey(roomId)) continue; // 已被其他路径加载
var node = _graph?.GetNode(roomId);
if (node == null) continue;
StartCoroutine(PreloadRoomCoroutine(roomId));
}
}
private IEnumerator PreloadRoomCoroutine(string roomId)
{
_activeLoads++;
int perFrame = _budget != null ? _budget.LifecycleActivatePerFrame : 8;
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}
yield return handle.LoadAsync(roomId);
_activeLoads--;
if (handle.State == RoomState.Dormant)
{
_onRoomPreloaded?.Raise(roomId);
Debug.Log($"[RoomStreamingManager] 预加载完成:{roomId}");
}
else
{
// 加载失败,从字典移除
_handles.Remove(roomId);
}
}
// ── 内存预算 / LRU 卸载 ───────────────────────────────────────────────────
private void EnforceMemoryBudget()
{
if (_budget == null) return;
// 按距离降序 + LRU 时间升序排列所有 Dormant 房间。
// 排除已在冷却协程中的房间:它们已被调度为卸载,无需重复发起 UnloadRoomCoroutine。
var dormants = _handles.Values
.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();
// 第一道:数量超限,从列表头部(距离最远/最久未访问)开始卸载
int excess = Mathf.Max(0, dormants.Count - _budget.MaxDormantRooms);
for (int i = 0; i < excess; i++)
StartCoroutine(UnloadRoomCoroutine(dormants[i].RoomId));
// 第二道:内存超限,从数量截止处继续(避免与第一道循环重复调度同一批房间)
int totalKB = _handles.Values.Sum(h => h.EstimatedMemKB);
int budgetKB = _budget.MaxMemoryMB * 1024;
if (totalKB <= budgetKB) return;
for (int i = excess; i < dormants.Count; i++)
{
if (totalKB <= budgetKB) break;
var h = dormants[i];
StartCoroutine(UnloadRoomCoroutine(h.RoomId));
totalKB -= h.EstimatedMemKB;
}
}
// ── 冷却 / 卸载 ───────────────────────────────────────────────────────────
private IEnumerator CoolingCoroutine(RoomHandle handle)
{
// 防止同一房间同时运行多个 CoolingCoroutine
if (!_roomsInCooling.Add(handle.RoomId)) yield break;
try
{
float duration = _budget != null ? _budget.CoolingDuration : 6f;
yield return new WaitForSeconds(duration);
// 冷却结束后再次检查是否仍需保留
if (_currentRoomId != null)
{
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可再次被激活
handle.ResetToDormant();
yield break;
}
}
StartCoroutine(UnloadRoomCoroutine(handle.RoomId));
}
finally
{
_roomsInCooling.Remove(handle.RoomId);
}
}
private IEnumerator UnloadRoomCoroutine(string roomId)
{
if (!_handles.TryGetValue(roomId, out var handle)) yield break;
yield return handle.UnloadAsync();
_handles.Remove(roomId);
Debug.Log($"[RoomStreamingManager] 已卸载:{roomId}");
}
// ── TransitionDirector 调用接口 ────────────────────────────────────────────
/// <summary>
/// 查询目标房间是否已就绪,可执行无等待的切换。
/// Dormant 和 Cooling 均视为就绪Cooling 房间已完成休眠化,
/// 可立即重置为 Dormant 后激活(常见于玩家折返场景)。
/// </summary>
public bool IsRoomDormant(string roomId)
=> _handles.TryGetValue(roomId, out var h) &&
(h.State == RoomState.Dormant || h.State == RoomState.Cooling);
/// <summary>
/// 激活目标房间Dormant → Active并停用前一个房间。
/// 由 <see cref="TransitionDirector"/> 在过渡时调用。
/// </summary>
public IEnumerator ActivateRoomCoroutine(string targetRoomId, SpawnContext context)
{
if (!_handles.TryGetValue(targetRoomId, out var targetHandle))
{
Debug.LogError($"[RoomStreamingManager] 目标房间 {targetRoomId} 不在已加载集合中,无法激活。");
yield break;
}
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.Activating) &&
!_roomsInCooling.Contains(previousRoomId))
{
prevHandle.BeginCooling();
StartCoroutine(CoolingCoroutine(prevHandle));
}
// 重新计算新房间的 StreamingSet
RecalculateStreamingSet(targetRoomId);
// 通知外部系统(地图探索、任务系统等):新房间已激活
_onRoomActivated?.Raise(targetRoomId);
}
/// <summary>
/// 将目标房间立即加载并激活(用于 Room/Scene 类型的快速传送、复活等非预加载路径)。
/// 完成后经由 ActivateRoomCoroutine 发布 EVT_RoomActivated 并重新计算 StreamingSet。
/// </summary>
public IEnumerator LoadAndActivateRoomCoroutine(string roomId, SpawnContext context)
{
if (!_handles.ContainsKey(roomId))
{
int perFrame = _budget != null ? _budget.LifecycleActivatePerFrame : 8;
var handle = new RoomHandle(roomId, this, perFrame);
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);
}
// ── Gizmos ────────────────────────────────────────────────────────────────
#if UNITY_EDITOR
private void OnDrawGizmos()
{
if (!Application.isPlaying || _graph == null) return;
foreach (var kv in _handles)
{
var node = _graph.GetNode(kv.Key);
if (node == null) continue;
Gizmos.color = kv.Value.State switch
{
RoomState.Active => new Color(0f, 1f, 0.3f, 0.3f),
RoomState.Dormant => new Color(0.2f, 0.6f, 1f, 0.2f),
RoomState.Loading => new Color(1f, 1f, 0f, 0.25f),
RoomState.Cooling => new Color(1f, 0.5f, 0f, 0.2f),
_ => new Color(0.5f, 0.5f, 0.5f, 0.1f),
};
Gizmos.DrawCube(node.WorldBounds.center, node.WorldBounds.size);
}
}
#endif
}
}