- 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.
389 lines
17 KiB
C#
389 lines
17 KiB
C#
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
|
||
}
|
||
}
|