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.
This commit is contained in:
2026-05-23 19:10:29 +08:00
parent 81c326af53
commit a1b4e629aa
165 changed files with 7904 additions and 313 deletions

View File

@@ -0,0 +1,24 @@
namespace BaseGames.World
{
/// <summary>
/// 实现此接口的组件可感知房间的 Dormant / Active 生命周期切换。
/// <para>
/// <see cref="Streaming.RoomHandle"/> 在休眠或激活房间时,
/// 会调用场景内所有实现了此接口的组件,使其做出相应响应(关闭 AI、保存状态等
/// </para>
/// </summary>
public interface IRoomLifecycle
{
/// <summary>
/// 房间进入休眠时调用。实现方应关闭 AI、暂停动画、停止音效
/// 以避免 Dormant 房间消耗不必要的 CPU。
/// </summary>
void OnRoomDormant();
/// <summary>
/// 房间被激活时调用。实现方应恢复 AI、重置状态、播放入场效果等。
/// </summary>
/// <param name="context">出生上下文,含出生点 ID 和是否为复活流程。</param>
void OnRoomActivate(SpawnContext context);
}
}

View File

@@ -0,0 +1,49 @@
using System.Collections;
namespace BaseGames.World
{
/// <summary>
/// 房间流式加载管理器接口。
/// <para>
/// <see cref="RoomController"/> 在 Start() 中通过 ServiceLocator 查找此接口,
/// 若存在则将自身注册,由管理器控制相机初始化时机(避免 Dormant 房间抢占相机)。
/// 若不存在(非流式模式)则退回原有行为。
/// </para>
/// <para>
/// <see cref="Streaming.TransitionDirector"/> 通过此接口操作流式管理器,
/// 完全解耦于具体实现类,便于测试和替换。
/// </para>
/// </summary>
public interface IRoomStreamingManager
{
/// <summary>房间加载完成后RoomController.Start() 调用此方法将自身注册到流式管理器。</summary>
void RegisterRoomController(RoomController controller);
/// <summary>当前处于 Active 状态的房间 ID。</summary>
string CurrentRoomId { get; }
/// <summary>
/// 立即将指定房间加入预加载队列。
/// 若房间已加载或已在队列中则忽略。
/// 用于快速传送预热、玩家即将触碰的门口等主动预加载场景。
/// </summary>
void PreloadRoom(string roomId);
/// <summary>
/// 查询目标房间是否已处于 Dormant 状态,可立即执行无等待的激活。
/// </summary>
bool IsRoomDormant(string roomId);
/// <summary>
/// 激活目标房间Dormant → Active并将前一个房间置于冷却状态。
/// 目标房间必须已处于 Dormant 状态。由 <see cref="Streaming.TransitionDirector"/> 在过渡时调用。
/// </summary>
IEnumerator ActivateRoomCoroutine(string targetRoomId, SpawnContext context);
/// <summary>
/// 若房间尚未加载则先加载,完成后激活。
/// 用于非流式冷启动路径(如游戏初始化、快速传送落地)。
/// </summary>
IEnumerator LoadAndActivateRoomCoroutine(string roomId, SpawnContext context);
}
}

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.World.Map
{
@@ -32,6 +33,13 @@ namespace BaseGames.World.Map
public bool IsSavePoint;
public bool IsShop;
public Sprite MapIconOverride; // null = 按 isXxx 自动选择图标
[Header("流式加载")]
[Tooltip("此房间场景资产的预估内存KB。\n" +
"在 Profiler 中测量场景实际内存后填入,供流式管理器执行内存预算检查使用。\n" +
"建议在关卡内容基本定型后更新此值。0 = 未填写,将跳过内存预算检查。")]
[Min(0)]
public int EstimatedMemoryKB;
}
[Serializable]
@@ -40,6 +48,11 @@ namespace BaseGames.World.Map
public string TargetRoomId; // 连接的目标房间 ID
public Vector2Int ExitGridPos; // 出口在格子地图上的位置
public ExitDirection Direction; // 出口方向
[Tooltip("此出口触发的过渡类型。\n" +
"Seamless无缝切换同区域相邻房间首选\n" +
"AtmosphericFade短暂淡出 + 区域名提示(跨大区域边界首选)。")]
public TransitionType PreferredTransitionType;
}
public enum ExitDirection { Up, Down, Left, Right }
@@ -61,8 +74,11 @@ namespace BaseGames.World.Map
public MapRoomDataSO GetRoom(string roomId)
{
if (_index == null)
{
if (AllRooms == null) return null;
_index = AllRooms.Where(r => r != null)
.ToDictionary(r => r.RoomId);
}
_index.TryGetValue(roomId, out var r);
return r;
}

View File

@@ -6,11 +6,15 @@ namespace BaseGames.World
{
/// <summary>
/// 房间控制器。挂在每个房间场景的 [RoomRoot] 下。
/// Start 时切换摄像机到玩家当前所在的 CameraArea并提供出生点查询。
/// <para>
/// 在流式加载模式下(<see cref="IRoomStreamingManager"/> 已注册Start() 将自身注册到
/// 流式管理器,由管理器控制相机初始化时机,避免 Dormant 房间抢占相机。
/// 非流式模式(无管理器注册)则退回原有行为,在 Start() 中立即初始化相机。
/// </para>
/// 支持房间内存在多个 CameraArea 的情况:动态检测玩家位于哪个触发区域,
/// 无匹配时回退到场景内第一个 CameraArea。
/// </summary>
public class RoomController : MonoBehaviour
public class RoomController : MonoBehaviour, IRoomLifecycle
{
[SerializeField] private string _roomId;
[SerializeField] private PlayerSpawnPoint[] _spawnPoints;
@@ -23,6 +27,39 @@ namespace BaseGames.World
public string RoomId => _roomId;
private void Start()
{
// 流式模式:注册到管理器,由管理器控制相机初始化时机(避免 Dormant 房间抢占相机)
var streaming = ServiceLocator.GetOrDefault<IRoomStreamingManager>();
if (streaming != null)
{
streaming.RegisterRoomController(this);
return;
}
// 非流式模式(管理器未注册):立即初始化相机,保持原有行为
SetupCamera();
}
// ── IRoomLifecycle ────────────────────────────────────────────────────────
/// <summary>
/// 房间进入休眠时由 <see cref="Streaming.RoomHandle"/> 调用。
/// 相机无需额外操作,视觉隐藏由 RoomHandle 批量关闭 Renderer 完成。
/// </summary>
public void OnRoomDormant() { }
/// <summary>
/// 房间被激活时由 <see cref="Streaming.RoomHandle"/> 调用,重新初始化相机区域。
/// </summary>
public void OnRoomActivate(SpawnContext context)
{
SetupCamera();
}
// ── 相机初始化 ────────────────────────────────────────────────────────────
/// <summary>初始化相机区域。可由 Start() 或 OnRoomActivate() 调用。</summary>
public void SetupCamera()
{
// 显式覆盖优先:直接使用编辑器/工具指定的基线区域
CameraArea area = _cameraArea != null ? _cameraArea : FindAreaForPlayer();

View File

@@ -0,0 +1,29 @@
namespace BaseGames.World
{
/// <summary>
/// 房间在流式加载系统中的生命周期状态。
/// </summary>
public enum RoomState
{
/// <summary>未加载,资产不在内存中。</summary>
Unloaded,
/// <summary>正在后台异步加载中。</summary>
Loading,
/// <summary>已加载并初始化处于休眠状态渲染器、AI、物理均已关闭不消耗 CPU。</summary>
Dormant,
/// <summary>正在激活中(分帧启用 AI。</summary>
Activating,
/// <summary>完全激活,玩家当前所在的房间。</summary>
Active,
/// <summary>玩家已离开,等待冷却计时后转为 Dormant 或 Unloaded。</summary>
Cooling,
/// <summary>正在后台异步卸载中。</summary>
Unloading,
}
}

View File

@@ -25,9 +25,11 @@ namespace BaseGames.World
[SerializeField] private string _targetTransitionId; // 目标房间出生点 ID
[Header("过渡类型")]
[Tooltip("Room极短淡出无加载画面相邻房间边界专用。\n" +
"Scene完整淡出 + 加载画面,大区域/地图间切换专用。")]
[SerializeField] private TransitionType _transitionType = TransitionType.Room;
[Tooltip("Seamless无缝切换流式系统标准选项绝大多数房间门使用此类型。\n" +
"AtmosphericFade短暂淡出 + 区域名提示,适合跨大区域边界。\n" +
"Room极短淡出不走流式系统仅用于非流式独立场景切换。\n" +
"Scene完整淡出 + 加载画面,大区域/地图间传送专用。")]
[SerializeField] private TransitionType _transitionType = TransitionType.Seamless;
[Header("触发方式")]
[SerializeField] private bool _autoTrigger = true; // true = 玩家进入触发器自动触发

View File

@@ -0,0 +1,24 @@
namespace BaseGames.World
{
/// <summary>
/// 玩家在房间激活时的出生上下文。
/// 由 <see cref="Streaming.RoomHandle"/> 传递给 <see cref="RoomController"/>
/// 用于确定出生点位置和方向。
/// </summary>
public readonly struct SpawnContext
{
/// <summary>目标出生点 ID对应 <see cref="PlayerSpawnPoint.TransitionId"/>。null 时使用默认出生点。</summary>
public readonly string EntryTransitionId;
/// <summary>true = 死亡复活流程,不播放入场动画。</summary>
public readonly bool IsRespawn;
public SpawnContext(string entryTransitionId, bool isRespawn = false)
{
EntryTransitionId = entryTransitionId;
IsRespawn = isRespawn;
}
public static readonly SpawnContext Default = new SpawnContext(null, false);
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "BaseGames.World.Streaming",
"rootNamespace": "BaseGames.World.Streaming",
"references": [
"BaseGames.Core",
"BaseGames.Core.Events",
"BaseGames.World",
"BaseGames.World.Map",
"Unity.Addressables",
"Unity.ResourceManager"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,294 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.ResourceManagement.ResourceProviders;
using UnityEngine.SceneManagement;
namespace BaseGames.World.Streaming
{
/// <summary>
/// 单个房间的流式加载状态包装器。
/// <para>
/// 由 <see cref="RoomStreamingManager"/> 创建和管理,封装以下职责:
/// <list type="bullet">
/// <item>Addressables 加载 / 卸载句柄</item>
/// <item>Dormantize批量关闭 Renderer、暂停 IRoomLifecycleAI、音效等</item>
/// <item>Activate批量恢复渲染分帧唤醒 IRoomLifecycle</item>
/// <item>Deactivate玩家离开后关闭本房间内容</item>
/// </list>
/// </para>
/// </summary>
public class RoomHandle
{
// ── 公开属性 ──────────────────────────────────────────────────────────────
public string RoomId { get; }
public RoomState State { get; private set; } = RoomState.Unloaded;
public int EstimatedMemKB { get; set; }
/// <summary>上次处于 Active 状态的时间Time.time。用于 LRU 卸载优先级计算。</summary>
public float LastActiveTime { get; private set; }
/// <summary>加载完成后由 RoomController.Start() 通过 IRoomStreamingManager.RegisterRoomController 注入。</summary>
public RoomController RoomController { get; set; }
// ── 私有字段 ──────────────────────────────────────────────────────────────
private AsyncOperationHandle<SceneInstance> _sceneHandle;
// 缓存的场景组件集合Dormantize 后可快速 Activate
private Renderer[] _renderers;
private IRoomLifecycle[] _lifecycles;
private AudioSource[] _audioSources;
private Light[] _lights;
private ParticleSystem[] _particleSystems;
// 分帧激活的协程宿主(由 RoomStreamingManager 提供)
private MonoBehaviour _coroutineRunner;
private int _lifecycleActivatePerFrame;
// ── 构造 ──────────────────────────────────────────────────────────────────
public RoomHandle(string roomId, MonoBehaviour coroutineRunner, int lifecycleActivatePerFrame)
{
RoomId = roomId;
_coroutineRunner = coroutineRunner;
_lifecycleActivatePerFrame = lifecycleActivatePerFrame;
}
// ── 加载 / 卸载 ───────────────────────────────────────────────────────────
/// <summary>
/// 开始后台异步加载Additive
/// 加载完成后自动调用 <see cref="Dormantize"/> 将房间置于休眠状态。
/// </summary>
public IEnumerator LoadAsync(string addressableKey)
{
if (State != RoomState.Unloaded)
{
Debug.LogWarning($"[RoomHandle] {RoomId} 非 Unloaded 状态,忽略重复加载请求。");
yield break;
}
State = RoomState.Loading;
_sceneHandle = Addressables.LoadSceneAsync(addressableKey, LoadSceneMode.Additive);
while (!_sceneHandle.IsDone)
yield return null;
if (_sceneHandle.Status != AsyncOperationStatus.Succeeded)
{
Debug.LogError($"[RoomHandle] {RoomId} 加载失败:{addressableKey}");
State = RoomState.Unloaded;
yield break;
}
// 收集场景内组件引用,然后置于休眠
CollectSceneComponents();
Dormantize();
}
/// <summary>异步卸载本房间。完成后 State → Unloaded。</summary>
public IEnumerator UnloadAsync()
{
if (State == RoomState.Unloaded || State == RoomState.Unloading || !_sceneHandle.IsValid()) yield break;
// Active 状态先走 Deactivate再设置 Unloading
bool needsDeactivate = State == RoomState.Active ||
State == RoomState.Activating ||
State == RoomState.Cooling;
if (needsDeactivate)
Deactivate();
State = RoomState.Unloading;
var op = Addressables.UnloadSceneAsync(_sceneHandle);
yield return op;
_renderers = null;
_lifecycles = null;
_audioSources = null;
_lights = null;
_particleSystems = null;
RoomController = null;
State = RoomState.Unloaded;
}
// ── Dormantize ────────────────────────────────────────────────────────────
/// <summary>
/// 将已加载的房间置于休眠状态。
/// 关闭所有 Renderer 和 AudioSource通知所有 IRoomLifecycle 组件进入休眠。
/// 场景内 GameObject 保持激活(便于快速恢复),但不消耗渲染和 AI 开销。
/// </summary>
public void Dormantize()
{
// 允许从 Loading初次加载完成后、Active、Activating、Cooling 进入
if (State != RoomState.Loading &&
State != RoomState.Active &&
State != RoomState.Activating &&
State != RoomState.Cooling)
{
Debug.LogWarning($"[RoomHandle] {RoomId} 在 {State} 状态下调用 Dormantize忽略。");
return;
}
if (_renderers != null)
foreach (var r in _renderers) if (r != null) r.enabled = false;
if (_audioSources != null)
foreach (var a in _audioSources) if (a != null) { a.Stop(); a.enabled = false; }
if (_lights != null)
foreach (var l in _lights) if (l != null) l.enabled = false;
if (_particleSystems != null)
foreach (var ps in _particleSystems) if (ps != null) ps.Pause();
if (_lifecycles != null)
foreach (var l in _lifecycles) l?.OnRoomDormant();
State = RoomState.Dormant;
}
// ── Activate ──────────────────────────────────────────────────────────────
/// <summary>
/// 激活此房间Dormant → Active
/// 恢复 Renderer、AudioSource分帧唤醒 IRoomLifecycle并设置玩家出生点。
/// 目标房间必须已处于 <see cref="RoomState.Dormant"/> 状态。
/// </summary>
public IEnumerator Activate(SpawnContext context)
{
if (State != RoomState.Dormant)
{
Debug.LogError($"[RoomHandle] {RoomId} 不是 Dormant 状态(当前:{State}),无法激活。");
yield break;
}
State = RoomState.Activating;
// 先恢复渲染(让玩家立刻能看到新房间)
if (_renderers != null)
foreach (var r in _renderers) if (r != null) r.enabled = true;
if (_audioSources != null)
foreach (var a in _audioSources) if (a != null) a.enabled = true;
if (_lights != null)
foreach (var l in _lights) if (l != null) l.enabled = true;
if (_particleSystems != null)
foreach (var ps in _particleSystems) if (ps != null) ps.Play();
// 相机 + 出生点(由 RoomController 处理)
RoomController?.OnRoomActivate(context);
// 分帧激活 IRoomLifecycleAI、特效等
yield return _coroutineRunner.StartCoroutine(ActivateLifecyclesGradually(context));
State = RoomState.Active;
LastActiveTime = Time.time;
}
/// <summary>将 IRoomLifecycle 组件分帧激活,避免单帧 CPU 峰值。</summary>
private IEnumerator ActivateLifecyclesGradually(SpawnContext context)
{
if (_lifecycles == null) yield break;
int activatedThisFrame = 0;
foreach (var lc in _lifecycles)
{
if (lc == null) continue;
lc.OnRoomActivate(context);
activatedThisFrame++;
if (activatedThisFrame >= _lifecycleActivatePerFrame)
{
activatedThisFrame = 0;
yield return null;
}
}
}
// ── Deactivate ────────────────────────────────────────────────────────────
/// <summary>
/// 玩家离开后关闭此房间Active / Activating → Dormant
/// 与 <see cref="Dormantize"/> 相同,但语义上表示从 Active 退出。
/// </summary>
public void Deactivate()
{
if (State != RoomState.Active && State != RoomState.Activating && State != RoomState.Cooling)
{
Debug.LogWarning($"[RoomHandle] {RoomId} 在 {State} 状态下调用 Deactivate忽略。");
return;
}
Dormantize();
}
// ── 冷却 ──────────────────────────────────────────────────────────────────
/// <summary>
/// 标记为冷却中,同时休眠渲染和 AI 开销。
/// 玩家离开后不立即卸载,等待冷却计时结束后再卸载。
/// </summary>
public void BeginCooling()
{
if (State != RoomState.Active && State != RoomState.Activating) return;
// 先休眠渲染和 AI与 Dormantize 相同,但最终 State 设为 Cooling 而非 Dormant
Dormantize();
State = RoomState.Cooling;
}
/// <summary>
/// 冷却期间玩家返回,将状态从 Cooling 重置为 Dormant。
/// 渲染已在 BeginCooling 时关闭,无需重复操作。
/// </summary>
public void ResetToDormant()
{
if (State == RoomState.Cooling)
State = RoomState.Dormant;
}
// ── 内部工具 ──────────────────────────────────────────────────────────────
/// <summary>
/// 收集加载完成的场景中所有需要被管理的组件引用。
/// 在 LoadAsync 完成后、Dormantize 之前调用一次。
/// </summary>
private void CollectSceneComponents()
{
if (!_sceneHandle.IsValid()) return;
Scene scene = _sceneHandle.Result.Scene;
GameObject[] roots = scene.GetRootGameObjects();
var rendererList = new List<Renderer>();
var lifecycleList = new List<IRoomLifecycle>();
var audioSourceList = new List<AudioSource>();
var lightList = new List<Light>();
var particleList = new List<ParticleSystem>();
foreach (var root in roots)
{
rendererList.AddRange(root.GetComponentsInChildren<Renderer>(true));
lifecycleList.AddRange(root.GetComponentsInChildren<IRoomLifecycle>(true));
audioSourceList.AddRange(root.GetComponentsInChildren<AudioSource>(true));
lightList.AddRange(root.GetComponentsInChildren<Light>(true));
particleList.AddRange(root.GetComponentsInChildren<ParticleSystem>(true));
}
_renderers = rendererList.ToArray();
_lifecycles = lifecycleList.ToArray();
_audioSources = audioSourceList.ToArray();
_lights = lightList.ToArray();
_particleSystems = particleList.ToArray();
}
public override string ToString() => $"RoomHandle[{RoomId}, {State}]";
}
}

View File

@@ -0,0 +1,388 @@
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
}
}

View File

@@ -0,0 +1,71 @@
using UnityEngine;
namespace BaseGames.World.Streaming
{
/// <summary>
/// 流式加载系统的内存与性能预算配置。
/// 挂载于 Persistent 场景的 SYS_RoomStreamingManager 对象上,或通过 Inspector 注入。
/// 可为不同目标平台PC / 主机 / 移动端)准备多份配置资产。
/// 资产路径Assets/_Game/Data/Streaming/
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/World/Streaming/BudgetConfig")]
public class StreamingBudgetConfigSO : ScriptableObject
{
[Header("内存与容量")]
[Tooltip("同时保持 Dormant 状态的最大房间数量(不含当前 Active 房间)。\n" +
"超出此限制后LRU 策略将卸载距离最远、最久未访问的 Dormant 房间。")]
[Min(1)]
public int MaxDormantRooms = 6;
[Tooltip("房间资产内存上限MB。仅用于超出时触发 LRU 卸载的第二道保障检查。\n" +
"需在编辑器 Profiler 中测量各房间实际内存后填入估算值。")]
[Min(64)]
public int MaxMemoryMB = 300;
[Header("加载控制")]
[Tooltip("同时进行的 Addressables 加载操作数量上限。\n" +
"过高会导致 I/O 竞争和帧率波动,建议 1-2。")]
[Range(1, 4)]
public int MaxConcurrentLoads = 2;
[Tooltip("预加载的邻居跳数。\n" +
"1 = 仅预加载直接相邻出口的房间2 = 再加载邻居的邻居。\n" +
"值越高预加载越激进,内存压力越大。")]
[Range(1, 3)]
public int PreloadLookaheadHops = 2;
[Header("激活与冷却")]
[Tooltip("玩家离开房间后,延迟多少秒才将该房间转为 Dormant/Unloaded。\n" +
"防止来回反复时频繁卸载重载。推荐 5-10 s。")]
[Min(0f)]
public float CoolingDuration = 6f;
[Tooltip("房间 Activate 时每帧最多激活的 IRoomLifecycle 数量,分帧避免 CPU 峰值。")]
[Min(1)]
public int LifecycleActivatePerFrame = 8;
[Header("AtmosphericFade 演出")]
[Tooltip("AtmosphericFade 过渡的淡出时长(秒)。")]
[Range(0.1f, 1f)]
public float AtmosphericFadeOutDuration = 0.25f;
[Tooltip("AtmosphericFade 过渡的淡入时长(秒)。")]
[Range(0.1f, 1f)]
public float AtmosphericFadeInDuration = 0.3f;
[Tooltip("AtmosphericFade 过渡中,区域名称文本显示的最短持续时间(秒)。")]
[Range(0.5f, 3f)]
public float RegionNameDisplayDuration = 1.2f;
[Header("Seamless 过渡等待")]
[Tooltip("Seamless 过渡等待目标房间预加载完成的最长时间(秒)。\n" +
"超时后放弃本次切换,玩家可再次触发(届时房间通常已就绪)。")]
[Range(2f, 30f)]
public float SeamlessWaitTimeout = 10f;
}
}

View File

@@ -0,0 +1,236 @@
using System.Collections;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.World.Streaming
{
/// <summary>
/// 过渡导演。挂载于 Persistent 场景的 SYS_RoomStreamingManager 对象上(与 RoomStreamingManager 同级)。
/// <para>
/// 实现 <see cref="ITransitionDirector"/>,由 <see cref="SceneService"/> 在收到
/// <see cref="TransitionType.Seamless"/> 或 <see cref="TransitionType.AtmosphericFade"/> 请求时调用。
/// </para>
/// <para>
/// 三档过渡行为:
/// <list type="table">
/// <item><term>Seamless</term>
/// <description>无任何遮挡。目标房间已 Dormant → 同帧内激活,相机跟随,无等待感。</description></item>
/// <item><term>AtmosphericFade</term>
/// <description>短暂淡出 → 激活房间 → 显示区域名 → 淡入。适合跨大区域边界。</description></item>
/// <item><term>Scene / Room</term>
/// <description>不由本类处理,由 SceneService 走原有黑屏加载流程。</description></item>
/// </list>
/// </para>
/// </summary>
[DefaultExecutionOrder(-790)]
public class TransitionDirector : MonoBehaviour, ITransitionDirector
{
[Header("依赖")]
[SerializeField] private RoomStreamingManager _streamingManagerRef;
[Header("事件频道 - 发布")]
[SerializeField] private VoidEventChannelSO _onFadeOutRequest;
[SerializeField] private VoidEventChannelSO _onFadeInRequest;
[Tooltip("AtmosphericFade 过渡中,向 UI 发布区域名称字符串(可为空,则跳过显示)。")]
[SerializeField] private StringEventChannelSO _onRegionNameDisplay;
[Tooltip("AtmosphericFade 过渡完成后发布,供 SceneService 同步淡入时序使用。")]
[SerializeField] private VoidEventChannelSO _onSceneWorldStateRestored;
[Header("事件频道 - 监听(世界数据)")]
[Tooltip("MapManager 提供的地图数据库,用于读取区域名称。可为空,为空时跳过区域名显示。")]
[SerializeField] private BaseGames.World.Map.MapDatabaseSO _mapDatabase;
[Header("预算配置")]
[SerializeField] private StreamingBudgetConfigSO _budget;
private bool _isTransitioning;
/// <summary>通过接口访问流式管理器,与具体实现类完全解耦。</summary>
private IRoomStreamingManager _streaming;
// ── 生命周期 ──────────────────────────────────────────────────────────────
private void Awake()
{
_streaming = _streamingManagerRef;
ServiceLocator.Register<ITransitionDirector>(this);
}
private void OnDestroy()
{
ServiceLocator.Unregister<ITransitionDirector>(this);
}
// ── ITransitionDirector ───────────────────────────────────────────────────
public bool CanHandleSeamless(string targetSceneName)
=> _streaming != null && _streaming.IsRoomDormant(targetSceneName);
public void HandleTransition(SceneLoadRequest request)
{
if (_isTransitioning)
{
Debug.LogWarning($"[TransitionDirector] 过渡进行中,忽略新请求:{request.SceneName}");
return;
}
switch (request.TransitionType)
{
case TransitionType.Seamless:
// 房间已就绪:立即无缝切换;否则等待预加载完成后再切换(等待阶段不持有过渡锁)
if (_streaming.IsRoomDormant(request.SceneName))
StartCoroutine(SeamlessTransitionCoroutine(request));
else
StartCoroutine(WaitForRoomAndSeamlessCoroutine(request));
break;
case TransitionType.AtmosphericFade:
StartCoroutine(AtmosphericFadeCoroutine(request));
break;
default:
Debug.LogError($"[TransitionDirector] 不应处理的过渡类型:{request.TransitionType}");
break;
}
}
// ── Seamless 过渡 ─────────────────────────────────────────────────────────
/// <summary>
/// 无遮挡无等待的无缝切换。
/// 目标房间已 Dormant 且在内存中,本帧即可完成激活。
/// </summary>
private IEnumerator SeamlessTransitionCoroutine(SceneLoadRequest request)
{
_isTransitioning = true;
try
{
var context = new SpawnContext(request.EntryTransitionId, request.IsRespawn);
yield return _streaming.ActivateRoomCoroutine(request.SceneName, context);
_onSceneWorldStateRestored?.Raise();
yield return null;
}
finally
{
_isTransitioning = false;
}
}
/// <summary>
/// 目标房间尚未就绪时:主动触发预加载,在等待阶段不持有过渡锁(允许后续请求覆盖),
/// 房间就绪后再锁定并执行激活。若超时则静默放弃,玩家可再次触发。
/// </summary>
private IEnumerator WaitForRoomAndSeamlessCoroutine(SceneLoadRequest request)
{
// 等待阶段:不持有 _isTransitioning 锁,避免 10s 内所有门触发被丢弃
_streaming.PreloadRoom(request.SceneName);
float maxWait = _budget != null ? _budget.SeamlessWaitTimeout : 10f;
float elapsed = 0f;
while (!_streaming.IsRoomDormant(request.SceneName) && elapsed < maxWait)
{
elapsed += Time.deltaTime;
yield return null;
}
if (!_streaming.IsRoomDormant(request.SceneName))
{
Debug.LogWarning($"[TransitionDirector] 等待房间 {request.SceneName} 预加载超时({maxWait:F1}s放弃本次切换。");
yield break;
}
// 激活阶段:此时才持有锁(等待期间若另一次切换已发起则放弃本次)
if (_isTransitioning) yield break;
_isTransitioning = true;
try
{
var context = new SpawnContext(request.EntryTransitionId, request.IsRespawn);
yield return _streaming.ActivateRoomCoroutine(request.SceneName, context);
_onSceneWorldStateRestored?.Raise();
yield return null;
}
finally
{
_isTransitioning = false;
}
}
// ── AtmosphericFade 过渡 ──────────────────────────────────────────────────
/// <summary>
/// 短暂淡出 → 等待目标房间预加载(黑屏期间利用淡出时间预热)→ 激活 → 显示区域名称 → 淡入。
/// 若房间在黑屏期间仍未就绪(超时),则取消切换并淡入还原画面。
/// </summary>
private IEnumerator AtmosphericFadeCoroutine(SceneLoadRequest request)
{
_isTransitioning = true;
try
{
float fadeOut = _budget != null ? _budget.AtmosphericFadeOutDuration : 0.25f;
float fadeIn = _budget != null ? _budget.AtmosphericFadeInDuration : 0.3f;
float nameHold = _budget != null ? _budget.RegionNameDisplayDuration : 1.2f;
float maxWait = _budget != null ? _budget.SeamlessWaitTimeout : 10f;
// 1. 触发预加载(若尚未开始),同步发起淡出——黑屏期间完成加载
_streaming.PreloadRoom(request.SceneName);
_onFadeOutRequest?.Raise();
yield return new WaitForSeconds(fadeOut);
// 2. 等待目标房间进入 Dormant黑屏中等待淡出时间已消耗部分等待窗口
float elapsed = 0f;
while (!_streaming.IsRoomDormant(request.SceneName) && elapsed < maxWait)
{
elapsed += Time.deltaTime;
yield return null;
}
if (!_streaming.IsRoomDormant(request.SceneName))
{
Debug.LogWarning($"[TransitionDirector] AtmosphericFade等待房间 {request.SceneName} 超时({maxWait:F1}s取消切换并还原画面。");
// 黑屏中取消:淡入还原,玩家停在原房间
_onFadeInRequest?.Raise();
yield return new WaitForSeconds(fadeIn);
yield break;
}
// 3. 激活目标房间(已 Dormant立即完成
var context = new SpawnContext(request.EntryTransitionId, request.IsRespawn);
yield return _streaming.ActivateRoomCoroutine(request.SceneName, context);
// 4. 通知状态恢复
_onSceneWorldStateRestored?.Raise();
yield return null;
// 5. 显示区域名称
string regionName = GetRegionName(request.SceneName);
if (!string.IsNullOrEmpty(regionName))
{
_onRegionNameDisplay?.Raise(regionName);
yield return new WaitForSeconds(nameHold);
}
// 6. 淡入
_onFadeInRequest?.Raise();
yield return new WaitForSeconds(fadeIn);
}
finally
{
_isTransitioning = false;
}
}
// ── 工具方法 ──────────────────────────────────────────────────────────────
/// <summary>从 MapDatabase 获取目标房间所属区域的显示名称。</summary>
private string GetRegionName(string roomId)
{
if (_mapDatabase == null) return null;
var roomData = _mapDatabase.GetRoom(roomId);
return roomData?.RegionId; // 可扩展为 RegionDisplayName
}
}
}

View File

@@ -0,0 +1,241 @@
using System.Collections.Generic;
using UnityEngine;
using BaseGames.World.Map;
namespace BaseGames.World.Streaming
{
/// <summary>
/// 单个房间在运行时连通图中的节点。
/// 由 <see cref="WorldGraph"/> 从 <see cref="MapDatabaseSO"/> 构建。
/// </summary>
public class RoomNode
{
public string RoomId;
public string ZoneId;
/// <summary>
/// 房间在世界坐标中的范围(格子坐标乘以 <see cref="WorldGraph.UnitsPerGrid"/>)。
/// 用于相机边界计算和出口坐标换算。
/// </summary>
public Rect WorldBounds;
/// <summary>
/// 此房间资产的预估内存KB。由关卡设计师在 MapRoomDataSO 中填写,
/// 供 <see cref="RoomStreamingManager"/> 执行内存预算检查使用。
/// </summary>
public int EstimatedMemoryKB;
/// <summary>与此房间相连的所有出口(有向边)。</summary>
public List<RoomEdge> Edges = new();
}
/// <summary>
/// 两个房间之间的有向连接(出口)。
/// </summary>
public class RoomEdge
{
public RoomNode From;
public RoomNode To;
/// <summary>出口方向,用于预测加载优先级(与玩家移动方向对比)。</summary>
public ExitDirection Direction;
/// <summary>出口在世界坐标中的近似位置,用于预测加载的距离计算。</summary>
public Vector2 ExitWorldPos;
/// <summary>过渡关联的 <see cref="TransitionType"/>,由关卡设计师在 RoomTransition 组件中配置。</summary>
public Core.Events.TransitionType PreferredTransitionType;
}
/// <summary>
/// 运行时房间连通图。
/// <para>
/// 在 Persistent 场景初始化阶段由 <see cref="RoomStreamingManager"/> 从
/// <see cref="MapDatabaseSO"/> 构建,之后只读,线程安全(无写操作)。
/// </para>
/// 提供邻居查询、BFS 跳数计算、预加载候选集合生成。
/// </summary>
public class WorldGraph
{
/// <summary>每个格子单元对应的 Unity 世界坐标单位数。应与关卡设计网格对齐。</summary>
public readonly float UnitsPerGrid;
private readonly Dictionary<string, RoomNode> _nodes = new();
public WorldGraph(float unitsPerGrid = 16f)
{
UnitsPerGrid = unitsPerGrid;
}
// ── 构建 ──────────────────────────────────────────────────────────────────
/// <summary>
/// 从 <see cref="MapDatabaseSO"/> 构建运行时图。
/// 调用一次,之后图为只读。
/// </summary>
public static WorldGraph Build(MapDatabaseSO database, float unitsPerGrid = 16f)
{
var graph = new WorldGraph(unitsPerGrid);
if (database == null || database.AllRooms == null)
{
Debug.LogError("[WorldGraph] MapDatabaseSO 为空,无法构建世界图。");
return graph;
}
// 第一遍:创建所有节点
foreach (var roomData in database.AllRooms)
{
if (roomData == null) continue;
var node = new RoomNode
{
RoomId = roomData.RoomId,
ZoneId = roomData.RegionId,
WorldBounds = new Rect(
roomData.GridPosition.x * unitsPerGrid,
roomData.GridPosition.y * unitsPerGrid,
roomData.GridSize.x * unitsPerGrid,
roomData.GridSize.y * unitsPerGrid),
EstimatedMemoryKB = roomData.EstimatedMemoryKB,
};
graph._nodes[roomData.RoomId] = node;
}
// 第二遍:建立有向边(双向,各方向独立记录便于方向性预测)
foreach (var roomData in database.AllRooms)
{
if (roomData?.Exits == null) continue;
if (!graph._nodes.TryGetValue(roomData.RoomId, out var fromNode)) continue;
foreach (var exit in roomData.Exits)
{
if (string.IsNullOrEmpty(exit.TargetRoomId)) continue;
if (!graph._nodes.TryGetValue(exit.TargetRoomId, out var toNode)) continue;
var edge = new RoomEdge
{
From = fromNode,
To = toNode,
Direction = exit.Direction,
ExitWorldPos = new Vector2(
exit.ExitGridPos.x * unitsPerGrid,
exit.ExitGridPos.y * unitsPerGrid),
PreferredTransitionType = exit.PreferredTransitionType,
};
fromNode.Edges.Add(edge);
}
}
Debug.Log($"[WorldGraph] 构建完成:{graph._nodes.Count} 个房间节点。");
return graph;
}
// ── 查询 ──────────────────────────────────────────────────────────────────
/// <summary>获取节点。若不存在返回 null。</summary>
public RoomNode GetNode(string roomId)
{
_nodes.TryGetValue(roomId, out var node);
return node;
}
/// <summary>返回与指定房间直接相邻1 跳)的所有邻居节点。</summary>
public IEnumerable<RoomNode> GetDirectNeighbors(string roomId)
{
if (!_nodes.TryGetValue(roomId, out var node)) yield break;
foreach (var edge in node.Edges)
yield return edge.To;
}
/// <summary>
/// 通过 BFS 获取距离指定房间不超过 <paramref name="maxHops"/> 跳的所有房间节点集合。
/// 结果不包含起始节点本身。
/// </summary>
public HashSet<RoomNode> GetNeighborsWithinHops(string startRoomId, int maxHops)
{
var result = new HashSet<RoomNode>();
var visited = new HashSet<string> { startRoomId };
var queue = new Queue<(string roomId, int depth)>();
queue.Enqueue((startRoomId, 0));
while (queue.Count > 0)
{
var (current, depth) = queue.Dequeue();
if (!_nodes.TryGetValue(current, out var node)) continue;
foreach (var edge in node.Edges)
{
if (visited.Contains(edge.To.RoomId)) continue;
visited.Add(edge.To.RoomId);
result.Add(edge.To);
if (depth + 1 < maxHops)
queue.Enqueue((edge.To.RoomId, depth + 1));
}
}
return result;
}
/// <summary>
/// 计算两个房间之间的最短跳数BFS
/// 若不可达返回 int.MaxValue。
/// </summary>
public int GetHopDistance(string fromRoomId, string toRoomId)
{
if (fromRoomId == toRoomId) return 0;
var visited = new HashSet<string> { fromRoomId };
var queue = new Queue<(string roomId, int depth)>();
queue.Enqueue((fromRoomId, 0));
while (queue.Count > 0)
{
var (current, depth) = queue.Dequeue();
if (!_nodes.TryGetValue(current, out var node)) continue;
foreach (var edge in node.Edges)
{
if (edge.To.RoomId == toRoomId) return depth + 1;
if (visited.Contains(edge.To.RoomId)) continue;
visited.Add(edge.To.RoomId);
queue.Enqueue((edge.To.RoomId, depth + 1));
}
}
return int.MaxValue;
}
/// <summary>图中总节点数,供调试使用。</summary>
public int NodeCount => _nodes.Count;
/// <summary>
/// 单次 BFS 计算从 <paramref name="startRoomId"/> 到所有可达节点的最短跳数。
/// 返回字典供调用方缓存,避免每次查询重复运行 BFS。
/// </summary>
public Dictionary<string, int> GetAllHopDistances(string startRoomId)
{
var result = new Dictionary<string, int>();
if (!_nodes.ContainsKey(startRoomId)) return result;
result[startRoomId] = 0;
var queue = new Queue<(string roomId, int depth)>();
queue.Enqueue((startRoomId, 0));
while (queue.Count > 0)
{
var (current, depth) = queue.Dequeue();
if (!_nodes.TryGetValue(current, out var node)) continue;
foreach (var edge in node.Edges)
{
if (result.ContainsKey(edge.To.RoomId)) continue;
result[edge.To.RoomId] = depth + 1;
queue.Enqueue((edge.To.RoomId, depth + 1));
}
}
return result;
}
}
}