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:
24
Assets/_Game/Scripts/World/IRoomLifecycle.cs
Normal file
24
Assets/_Game/Scripts/World/IRoomLifecycle.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
49
Assets/_Game/Scripts/World/IRoomStreamingManager.cs
Normal file
49
Assets/_Game/Scripts/World/IRoomStreamingManager.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
29
Assets/_Game/Scripts/World/RoomState.cs
Normal file
29
Assets/_Game/Scripts/World/RoomState.cs
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 = 玩家进入触发器自动触发
|
||||
|
||||
24
Assets/_Game/Scripts/World/SpawnContext.cs
Normal file
24
Assets/_Game/Scripts/World/SpawnContext.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
294
Assets/_Game/Scripts/World/Streaming/RoomHandle.cs
Normal file
294
Assets/_Game/Scripts/World/Streaming/RoomHandle.cs
Normal 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、暂停 IRoomLifecycle(AI、音效等)</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);
|
||||
|
||||
// 分帧激活 IRoomLifecycle(AI、特效等)
|
||||
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}]";
|
||||
}
|
||||
}
|
||||
388
Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs
Normal file
388
Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
236
Assets/_Game/Scripts/World/Streaming/TransitionDirector.cs
Normal file
236
Assets/_Game/Scripts/World/Streaming/TransitionDirector.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
241
Assets/_Game/Scripts/World/Streaming/WorldGraph.cs
Normal file
241
Assets/_Game/Scripts/World/Streaming/WorldGraph.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user