Files
zeling_v2/Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs
Joywayer a1b4e629aa feat: Implement Room Streaming System
- Add RoomStreamingManager to manage room loading and unloading based on player proximity.
- Create StreamingBudgetConfigSO for memory and performance budgeting of the streaming system.
- Introduce TransitionDirector to handle seamless and atmospheric fade transitions between rooms.
- Develop WorldGraph to represent room connectivity and facilitate neighbor queries and distance calculations.
- Implement RoomNode and RoomEdge classes to structure room data and connections.
2026-05-23 19:10:29 +08:00

389 lines
17 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
{
[Header("配置")]
[SerializeField] private MapDatabaseSO _mapDatabase;
[SerializeField] private StreamingBudgetConfigSO _budget;
[Header("事件频道 - 监听")]
[Tooltip("玩家进入新房间时发布(携带 RoomId 字符串)。")]
[SerializeField] private StringEventChannelSO _onRoomEntered;
[Header("事件频道 - 发布")]
[Tooltip("某个房间加载并进入 Dormant 状态后发布(携带 RoomId。\n" +
"供 TransitionDirector 检查是否可执行 Seamless 切换。")]
[SerializeField] private StringEventChannelSO _onRoomPreloaded;
[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
{
// 首次进入(非流式路径也可能触发),直接初始化相机
controller.SetupCamera();
}
}
/// <summary>
/// 立即将指定房间加入预加载队列。
/// 用于快速传送预热、玩家即将触碰的门口等主动预加载场景。
/// 若已加载或已在队列中则忽略。
/// </summary>
public void PreloadRoom(string roomId)
{
if (_handles.ContainsKey(roomId)) return;
EnqueuePreload(roomId);
}
// ── 生命周期 ──────────────────────────────────────────────────────────────
private void Awake()
{
ServiceLocator.Register<IRoomStreamingManager>(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);
}
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));
}
}
// 保留集内尚未加载的房间 → 加入预加载队列
foreach (var kv in _hopCache)
{
if (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;
_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 房间
var dormants = _handles.Values
.Where(h => h.State == RoomState.Dormant)
.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;
var neighbors = _graph.GetNeighborsWithinHops(_currentRoomId, hops);
bool stillNeeded = neighbors.Any(n => n.RoomId == handle.RoomId)
|| handle.RoomId == _currentRoomId;
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 状态,可执行无等待的切换。
/// </summary>
public bool IsRoomDormant(string roomId)
=> _handles.TryGetValue(roomId, out var h) && h.State == RoomState.Dormant;
/// <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;
// 激活新房间
yield return targetHandle.Activate(context);
_currentRoomId = targetRoomId;
// 旧房间进入冷却(不立即卸载;守卫防止 CoolingCoroutine 重复启动)
if (!string.IsNullOrEmpty(previousRoomId) &&
_handles.TryGetValue(previousRoomId, out var prevHandle) &&
prevHandle.State == RoomState.Active &&
!_roomsInCooling.Contains(previousRoomId))
{
prevHandle.BeginCooling();
StartCoroutine(CoolingCoroutine(prevHandle));
}
// 重新计算新房间的 StreamingSet
RecalculateStreamingSet(targetRoomId);
// 通知:视同进入新房间(触发地图探索更新等)
_onRoomEntered?.Raise(targetRoomId);
}
/// <summary>
/// 将目标房间立即加载并激活(用于 Scene 类型的非流式路径首次进入某房间)。
/// 完成后广播 EVT_RoomEntered 以触发 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);
}
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
}
}