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 { /// /// 核心流式加载调度器。挂载于 Persistent 场景的 SYS_RoomStreamingManager 对象上。 /// /// 职责: /// /// 在 Persistent 场景初始化时从 MapDatabaseSO 构建 /// 监听 事件,重新计算 StreamingSet /// 后台异步预加载邻居房间(),并发数由预算配置限制 /// 执行内存预算检查,通过 LRU 策略卸载距离最远、最久未访问的 Dormant 房间 /// 提供 ActivateRoom / DeactivateRoom 接口供 调用 /// /// /// [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 _handles = new(); private readonly Queue _loadQueue = new(); /// O(1) Contains 镜像,与 _loadQueue 保持同步,避免 Queue.Contains 的 O(n) 开销。 private readonly HashSet _queuedRoomIds = new(); /// 当前正在运行 CoolingCoroutine 的房间,防止竞态下重复启动冷却协程。 private readonly HashSet _roomsInCooling = new(); /// 单次 BFS 预算:从当前房间到所有已加载房间的跳数缓存,供 LRU 排序使用。 private Dictionary _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} 为基准启动流式预加载。"); } } } /// /// 立即将指定房间加入预加载队列。 /// 用于快速传送预热、玩家即将触碰的门口等主动预加载场景。 /// 若已加载或已在队列中则忽略。 /// 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(this); ServiceLocator.Register(this); _graph = WorldGraph.Build(_mapDatabase, _unitsPerGrid); } private void OnEnable() { _onRoomEntered?.Subscribe(OnRoomEntered).AddTo(_subscriptions); } private void OnDisable() { _subscriptions.Clear(); } private void OnDestroy() { ServiceLocator.Unregister(this); ServiceLocator.Unregister(this); } private void Update() { DrainLoadQueue(); } // ── 房间进入事件 ─────────────────────────────────────────────────────────── private void OnRoomEntered(string roomId) { if (roomId == _currentRoomId) return; _currentRoomId = roomId; RecalculateStreamingSet(roomId); } // ── StreamingSet 计算 ────────────────────────────────────────────────────── /// /// 根据当前房间重新计算需要保持 Dormant 的邻居集合, /// 并调度加载和卸载操作。单次 BFS 完成所有计算,结果缓存供 LRU 排序复用。 /// 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 { 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); } /// 每帧尝试从队列中启动新的加载,受 MaxConcurrentLoads 限制。 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 调用接口 ──────────────────────────────────────────── /// /// 查询目标房间是否已就绪,可执行无等待的切换。 /// Dormant 和 Cooling 均视为就绪:Cooling 房间已完成休眠化, /// 可立即重置为 Dormant 后激活(常见于玩家折返场景)。 /// public bool IsRoomDormant(string roomId) => _handles.TryGetValue(roomId, out var h) && (h.State == RoomState.Dormant || h.State == RoomState.Cooling); /// /// 激活目标房间(Dormant → Active)并停用前一个房间。 /// 由 在过渡时调用。 /// 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); } /// /// 将目标房间立即加载并激活(用于 Room/Scene 类型的快速传送、复活等非预加载路径)。 /// 完成后经由 ActivateRoomCoroutine 发布 EVT_RoomActivated 并重新计算 StreamingSet。 /// 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 } }