diff --git a/Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs b/Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs
new file mode 100644
index 0000000..94a2662
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs
@@ -0,0 +1,35 @@
+using System.Collections;
+
+namespace BaseGames.Core
+{
+ ///
+ /// 场景加载协调器接口。
+ ///
+ /// 定义于 BaseGames.Core 以避免 对
+ /// BaseGames.World.Streaming 产生直接依赖。
+ ///
+ ///
+ /// 由流式加载系统(RoomStreamingManager)实现并在 Awake 中向
+ /// 注册。当注册存在时,
+ /// 将符合条件的场景加载请求委托给本接口,确保房间生命周期(Dormant / Active / Cooling)
+ /// 得到完整维护;否则退回到 SceneLoader 原生路径。
+ ///
+ ///
+ public interface ISceneLoadCoordinator
+ {
+ ///
+ /// 判断给定场景地址是否应由流式系统管理(而非 SceneLoader 直接加载)。
+ /// 约定:以 "Room_" 前缀开头的地址均属于流式系统管辖范围。
+ ///
+ bool OwnsScene(string sceneName);
+
+ ///
+ /// 以完整流式路径加载并激活指定房间
+ /// (Load → Dormant → Active,同时将前一个 Active 房间送入冷却队列)。
+ ///
+ /// Addressable key(等同于 RoomId,前缀 "Room_")。
+ /// 目标房间出生点 ID;null 表示使用默认出生点。
+ /// true = 复活流程,玩家应在最近存档点出生。
+ IEnumerator LoadAndActivateCoroutine(string sceneName, string entryTransitionId, bool isRespawn);
+ }
+}
diff --git a/Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs.meta b/Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs.meta
new file mode 100644
index 0000000..73da6a4
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: cddf13c179a032c4293e181de7e8470f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Core/ITransitionDirector.cs.meta b/Assets/_Game/Scripts/Core/ITransitionDirector.cs.meta
new file mode 100644
index 0000000..4049b7b
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/ITransitionDirector.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ba229944271875048b97b953793bf37e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Core/SceneService.cs b/Assets/_Game/Scripts/Core/SceneService.cs
index 60934b1..adff7c8 100644
--- a/Assets/_Game/Scripts/Core/SceneService.cs
+++ b/Assets/_Game/Scripts/Core/SceneService.cs
@@ -104,10 +104,23 @@ namespace BaseGames.Core
if (fadeDuration > 0f)
yield return new WaitForSeconds(fadeDuration);
- if (_sceneLoader != null)
+ // 流式模式优先:若流式协调器已注册且声明对本场景的所有权,委托给流式系统加载。
+ // 这确保复活 / 快速传送等使用 Room/Scene 类型的路径也能正确触发冷却和卸载生命周期,
+ // 避免前一房间在 RoomStreamingManager 中永远停留在 Active 状态。
+ var coordinator = ServiceLocator.GetOrDefault();
+ if (coordinator != null && coordinator.OwnsScene(request.SceneName))
+ {
+ yield return StartCoroutine(coordinator.LoadAndActivateCoroutine(
+ request.SceneName, request.EntryTransitionId, request.IsRespawn));
+ }
+ else if (_sceneLoader != null)
+ {
yield return StartCoroutine(_sceneLoader.LoadSceneCoroutine(request));
+ }
else
+ {
Debug.LogError("[SceneService] _sceneLoader 未赋值,场景加载中断。请在 Inspector 中绑定 SceneLoader 组件。");
+ }
// 通知:WorldStateRegistry 已就绪,场景物体应在此帧内从中读取存档状态并应用初始状态。
// 订阅者(WorldStateRegistrySaver、各场景 StateApplier 等)会在同一帧同步执行。
diff --git a/Assets/_Game/Scripts/Editor/Enemies/EnemyRespawnerEditor.cs b/Assets/_Game/Scripts/Editor/Enemies/EnemyRespawnerEditor.cs
new file mode 100644
index 0000000..9d7799a
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Enemies/EnemyRespawnerEditor.cs
@@ -0,0 +1,266 @@
+using UnityEditor;
+using UnityEngine;
+using BaseGames.Enemies;
+using BaseGames.World;
+
+namespace BaseGames.Editor
+{
+ ///
+ /// 自定义 Inspector。
+ ///
+ /// - 配置校验:生成来源为空、永久击杀 ID 已填但 WorldStateRegistry 未关联时显示警告。
+ /// - 运行时状态:PlayMode 中显示当前激活实例信息。
+ /// - 批量验证菜单:BaseGames / Enemies / Validate Respawners。
+ ///
+ ///
+ [CustomEditor(typeof(EnemyRespawner))]
+ public class EnemyRespawnerEditor : UnityEditor.Editor
+ {
+ // ── 序列化属性引用 ─────────────────────────────────────────────────────────
+ private SerializedProperty _prefabProp;
+ private SerializedProperty _poolKeyProp;
+ private SerializedProperty _respawnDelayProp;
+ private SerializedProperty _maxRespawnCountProp;
+ private SerializedProperty _persistentKillIdProp;
+ private SerializedProperty _worldStateProp;
+
+ // 运行时只读字段(反射获取私有字段)
+ private static readonly System.Reflection.FieldInfo s_ActiveEnemyField =
+ typeof(EnemyRespawner).GetField("_activeEnemy",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ private static readonly System.Reflection.FieldInfo s_RespawnCountField =
+ typeof(EnemyRespawner).GetField("_respawnCountThisVisit",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ private static readonly System.Reflection.FieldInfo s_RespawnCoroutineField =
+ typeof(EnemyRespawner).GetField("_respawnCoroutine",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ private void OnEnable()
+ {
+ _prefabProp = serializedObject.FindProperty("_enemyPrefab");
+ _poolKeyProp = serializedObject.FindProperty("_poolKey");
+ _respawnDelayProp = serializedObject.FindProperty("_respawnDelay");
+ _maxRespawnCountProp = serializedObject.FindProperty("_maxRespawnCount");
+ _persistentKillIdProp = serializedObject.FindProperty("_persistentKillId");
+ _worldStateProp = serializedObject.FindProperty("_worldState");
+ }
+
+ public override void OnInspectorGUI()
+ {
+ serializedObject.Update();
+
+ // 手动绘制字段,以便对 _maxRespawnCount 做条件性灰显
+ DrawPropertiesExcluding(serializedObject, "_maxRespawnCount");
+
+ bool hasDelay = _respawnDelayProp != null && _respawnDelayProp.floatValue > 0f;
+ using (new EditorGUI.DisabledScope(!hasDelay))
+ {
+ EditorGUILayout.PropertyField(_maxRespawnCountProp,
+ new GUIContent("复活上限 (Max Respawn Count)",
+ _maxRespawnCountProp?.tooltip ?? ""));
+ }
+ if (!hasDelay)
+ EditorGUILayout.HelpBox("Respawn Delay = 0,Max Respawn Count 无效(敌人不会在房间内复活)。",
+ MessageType.None);
+
+ serializedObject.ApplyModifiedProperties();
+
+ EditorGUILayout.Space(6f);
+ DrawValidationSection();
+
+ if (Application.isPlaying)
+ {
+ EditorGUILayout.Space(6f);
+ DrawRuntimeSection();
+ }
+ }
+
+ // ── 配置校验区块 ──────────────────────────────────────────────────────────
+
+ private void DrawValidationSection()
+ {
+ EditorGUILayout.LabelField("配置检查", EditorStyles.boldLabel);
+
+ bool hasPrefab = _prefabProp.objectReferenceValue != null;
+ bool hasPoolKey = !string.IsNullOrWhiteSpace(_poolKeyProp.stringValue);
+ bool hasPersistentId = !string.IsNullOrWhiteSpace(_persistentKillIdProp.stringValue);
+ bool hasWorldState = _worldStateProp.objectReferenceValue != null;
+
+ // ── 生成来源 ───────────────────────────────────────────────────────
+ if (!hasPrefab && !hasPoolKey)
+ {
+ EditorGUILayout.HelpBox(
+ "_enemyPrefab 与 _poolKey 均为空,运行时无法生成敌人。\n" +
+ "请至少填写其中一项。",
+ MessageType.Error);
+ }
+ else if (hasPrefab && !hasPoolKey)
+ {
+ EditorGUILayout.HelpBox(
+ "当前使用直接实例化(_enemyPrefab)。\n" +
+ "频繁进出房间时建议改用对象池(填写 _poolKey)以减少 GC 压力。",
+ MessageType.Info);
+ }
+ else
+ {
+ DrawOk("生成来源配置正常。");
+ }
+
+ // ── 永久击杀 ───────────────────────────────────────────────────────
+ if (hasPersistentId && !hasWorldState)
+ {
+ EditorGUILayout.HelpBox(
+ "_persistentKillId 已填写,但 _worldState 未关联。\n" +
+ "击杀记录将无法持久化,存档重载后敌人仍会复活。",
+ MessageType.Warning);
+
+ if (GUILayout.Button("自动查找场景内 WorldStateRegistry"))
+ {
+ var found = FindFirstObjectByType();
+ if (found != null)
+ {
+ serializedObject.Update();
+ _worldStateProp.objectReferenceValue = found;
+ serializedObject.ApplyModifiedProperties();
+ }
+ else
+ {
+ EditorUtility.DisplayDialog(
+ "未找到",
+ "当前场景中没有 WorldStateRegistry 组件。\n" +
+ "请先在场景内放置 WorldStateRegistry。",
+ "确定");
+ }
+ }
+ }
+ else if (!hasPersistentId)
+ {
+ DrawOk("普通刷新点(每次进房复活)。");
+ }
+ else
+ {
+ DrawOk($"永久击杀已关联 WorldStateRegistry。\nID:{_persistentKillIdProp.stringValue}");
+ }
+ }
+
+ // ── 运行时状态区块(PlayMode 专用)────────────────────────────────────────
+
+ private void DrawRuntimeSection()
+ {
+ EditorGUILayout.LabelField("运行时状态", EditorStyles.boldLabel);
+
+ var respawner = (EnemyRespawner)target;
+ var activeEnemy = s_ActiveEnemyField?.GetValue(respawner) as EnemyBase;
+ int respawnCount = s_RespawnCountField != null
+ ? (int)s_RespawnCountField.GetValue(respawner)
+ : 0;
+ bool waitingRespawn = s_RespawnCoroutineField?.GetValue(respawner) != null;
+
+ if (activeEnemy == null && !waitingRespawn)
+ {
+ EditorGUILayout.LabelField("当前实例", "无(房间休眠或尚未生成)");
+ }
+ else if (waitingRespawn)
+ {
+ var prevColor = GUI.color;
+ GUI.color = new Color(0.6f, 0.8f, 1f);
+ int maxCount = _maxRespawnCountProp?.intValue ?? 0;
+ string countStr = maxCount == 0
+ ? $"{respawnCount} 次(无上限)"
+ : $"{respawnCount} / {maxCount} 次";
+ EditorGUILayout.LabelField("状态", $"⏳ 等待复活中…(已复活 {countStr})");
+ GUI.color = prevColor;
+ }
+ else if (activeEnemy != null)
+ {
+ string statusStr = activeEnemy.IsAlive ? "存活" : "死亡动画中";
+ var prevColor = GUI.color;
+ GUI.color = activeEnemy.IsAlive
+ ? new Color(0.4f, 0.9f, 0.4f)
+ : new Color(1f, 0.55f, 0.3f);
+ EditorGUILayout.LabelField("当前实例", $"{activeEnemy.name} [{statusStr}]");
+ GUI.color = prevColor;
+
+ int maxCount = _maxRespawnCountProp?.intValue ?? 0;
+ if (_respawnDelayProp?.floatValue > 0f)
+ {
+ string countStr = maxCount == 0
+ ? $"{respawnCount} 次(无上限)"
+ : $"{respawnCount} / {maxCount} 次";
+ EditorGUILayout.LabelField("本次访问已复活", countStr);
+ }
+
+ if (GUILayout.Button("在 Hierarchy 中定位", EditorStyles.miniButton))
+ Selection.activeGameObject = activeEnemy.gameObject;
+ }
+
+ // 强制每帧刷新,确保状态即时更新
+ Repaint();
+ }
+
+ // ── 通用辅助 ──────────────────────────────────────────────────────────────
+
+ private static void DrawOk(string message)
+ {
+ var prevColor = GUI.color;
+ GUI.color = new Color(0.4f, 0.9f, 0.4f);
+ EditorGUILayout.LabelField($"✓ {message}", EditorStyles.wordWrappedMiniLabel);
+ GUI.color = prevColor;
+ }
+
+ // ── 批量验证菜单 ──────────────────────────────────────────────────────────
+
+ [MenuItem("BaseGames/Enemies/Validate Respawners")]
+ private static void ValidateAllRespawners()
+ {
+ var all = FindObjectsByType(
+ FindObjectsInactive.Include, FindObjectsSortMode.None);
+
+ if (all.Length == 0)
+ {
+ EditorUtility.DisplayDialog("验证结果", "当前场景中未找到任何 EnemyRespawner。", "确定");
+ return;
+ }
+
+ int errors = 0;
+ int warnings = 0;
+
+ foreach (var r in all)
+ {
+ var so = new SerializedObject(r);
+ var prefab = so.FindProperty("_enemyPrefab");
+ var poolKey = so.FindProperty("_poolKey");
+ var killId = so.FindProperty("_persistentKillId");
+ var worldState = so.FindProperty("_worldState");
+
+ bool hasPrefab = prefab?.objectReferenceValue != null;
+ bool hasPoolKey = !string.IsNullOrWhiteSpace(poolKey?.stringValue);
+ bool hasKillId = !string.IsNullOrWhiteSpace(killId?.stringValue);
+ bool hasWS = worldState?.objectReferenceValue != null;
+
+ string scene = r.gameObject.scene.name;
+ string path = $"{scene}/{r.gameObject.name}";
+
+ if (!hasPrefab && !hasPoolKey)
+ {
+ errors++;
+ Debug.LogError($"[EnemyRespawner] {path}:_enemyPrefab 与 _poolKey 均为空,无法生成敌人。", r.gameObject);
+ }
+
+ if (hasKillId && !hasWS)
+ {
+ warnings++;
+ Debug.LogWarning($"[EnemyRespawner] {path}:_persistentKillId=\"{killId.stringValue}\" 但 _worldState 未关联,击杀记录无法持久化。", r.gameObject);
+ }
+ }
+
+ string msg = errors == 0 && warnings == 0
+ ? $"全部 {all.Length} 个 EnemyRespawner 配置正常。"
+ : $"检查完成:{errors} 个错误,{warnings} 个警告(共 {all.Length} 个)。\n详细信息见 Console 日志。";
+
+ EditorUtility.DisplayDialog("验证结果", msg, "确定");
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Editor/Enemies/EnemyRespawnerEditor.cs.meta b/Assets/_Game/Scripts/Editor/Enemies/EnemyRespawnerEditor.cs.meta
new file mode 100644
index 0000000..8518532
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Enemies/EnemyRespawnerEditor.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 25ae5943339e21845a4f350aac520224
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs.meta b/Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs.meta
new file mode 100644
index 0000000..9c609ec
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 43629b8180950e742b8f92d89180c9ac
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Editor/Scene/SceneScaffoldTools.cs b/Assets/_Game/Scripts/Editor/Scene/SceneScaffoldTools.cs
index 29c9449..f100eae 100644
--- a/Assets/_Game/Scripts/Editor/Scene/SceneScaffoldTools.cs
+++ b/Assets/_Game/Scripts/Editor/Scene/SceneScaffoldTools.cs
@@ -388,8 +388,35 @@ namespace BaseGames.Editor
roomSO.ApplyModifiedPropertiesWithoutUndo();
}
+ // ── MapRoomDataSO 模板(幂等:同名资产已存在时跳过)──────────────
+ string roomIdHint = SceneManager.GetActiveScene().name;
+ if (string.IsNullOrEmpty(roomIdHint) || roomIdHint == "Untitled")
+ roomIdHint = "Room_Unknown";
+
+ string roomSoFolder = "Assets/_Game/Data/Map/Rooms";
+ string roomSoPath = $"{roomSoFolder}/{roomIdHint}.asset";
+
+ bool roomSoCreated = false;
+ if (!System.IO.File.Exists(Application.dataPath + "/../" + roomSoPath))
+ {
+ EnsureFolder(roomSoFolder);
+ var roomData = ScriptableObject.CreateInstance();
+ roomData.RoomId = roomIdHint;
+ roomData.RegionId = roomIdHint.Contains("_")
+ ? roomIdHint.Split('_')[1]
+ : "";
+ AssetDatabase.CreateAsset(roomData, roomSoPath);
+ AssetDatabase.SaveAssets();
+ AssetDatabase.Refresh();
+ roomSoCreated = true;
+ }
+
// ── Report ─────────────────────────────────────────────────────
report.Add("在 RoomController._roomId 填写唯一房间 ID(如 \"Room_Forest_01\")。");
+ if (roomSoCreated)
+ report.Add($"已自动创建 MapRoomDataSO 模板:{roomSoPath}。请填写 GridPosition / GridSize / Exits,并将其添加到 MapDatabaseSO.AllRooms 中。");
+ else
+ report.Add($"MapRoomDataSO 已存在({roomSoPath}),请确认其 RoomId / Exits 与场景保持同步。");
report.Add("绑定 LensConfig SO 后单击 Inspector 中「从可视区域更新限位区域」计算正确的 BoxCollider。");
report.Add("使用 Tile Palette 在 Ground Tilemap 上绘制地形,然后在 NavSurface Inspector 中点击 Bake。");
report.Add("[Transitions] 子节点下使用 BaseGames/Scene/Place/Room Transition 添加过渡点。");
@@ -427,9 +454,10 @@ namespace BaseGames.Editor
AssignReference(streamingMgr, "_budget", budgetConfig);
AssignAsset(streamingMgr, "_onRoomEntered", report, false, "EVT_RoomEntered");
AssignAsset(streamingMgr, "_onRoomPreloaded", report, false, "EVT_RoomPreloaded");
+ AssignAsset(streamingMgr, "_onRoomActivated", report, false, "EVT_RoomActivated");
// ── TransitionDirector 字段 ───────────────────────────────────────
- AssignReference(transitionDir, "_streamingManager", streamingMgr);
+ AssignReference(transitionDir, "_streamingManagerRef", streamingMgr);
AssignReference(transitionDir, "_mapDatabase", mapDbAsset);
AssignReference(transitionDir, "_budget", budgetConfig);
AssignAsset(transitionDir, "_onFadeOutRequest", report, false, "EVT_FadeOutRequest");
diff --git a/Assets/_Game/Scripts/Editor/World/RoomTransitionEditor.cs b/Assets/_Game/Scripts/Editor/World/RoomTransitionEditor.cs
new file mode 100644
index 0000000..908f68c
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/World/RoomTransitionEditor.cs
@@ -0,0 +1,175 @@
+using UnityEditor;
+using UnityEngine;
+using BaseGames.Core.Events;
+using BaseGames.World;
+using BaseGames.World.Map;
+
+namespace BaseGames.Editor
+{
+ ///
+ /// RoomTransition 自定义 Inspector。
+ ///
+ /// 在默认字段下方附加「MapDatabase 同步」区块:
+ /// 自动查找场景内的 ,定位所属房间在
+ /// 中对应出口的 ,若与组件
+ /// _transitionType 不一致则显示警告并提供一键同步按钮。
+ ///
+ ///
+ [CustomEditor(typeof(RoomTransition))]
+ public class RoomTransitionEditor : UnityEditor.Editor
+ {
+ // 每次 Inspector 打开后按需查找一次,避免每帧搜索。
+ private MapDatabaseSO _cachedMapDb;
+ private bool _mapDbSearched;
+
+ public override void OnInspectorGUI()
+ {
+ DrawDefaultInspector();
+
+ EditorGUILayout.Space(8f);
+ DrawSyncSection();
+ }
+
+ private void DrawSyncSection()
+ {
+ var targetAddrProp = serializedObject.FindProperty("_targetSceneAddress");
+ var typeProp = serializedObject.FindProperty("_transitionType");
+ if (targetAddrProp == null || typeProp == null) return;
+
+ string targetAddr = targetAddrProp.stringValue;
+ if (string.IsNullOrEmpty(targetAddr)) return;
+
+ // 在同一场景内找 RoomController(多场景 Additive 时仅匹配本对象所在场景)
+ string sourceRoomId = FindSourceRoomId(((RoomTransition)target).gameObject);
+ if (string.IsNullOrEmpty(sourceRoomId)) return;
+
+ var mapDb = GetMapDatabase();
+ if (mapDb == null) return;
+
+ var roomData = mapDb.GetRoom(sourceRoomId);
+ if (roomData?.Exits == null) return;
+
+ TransitionType? soType = null;
+ foreach (var exit in roomData.Exits)
+ {
+ if (exit.TargetRoomId == targetAddr)
+ {
+ soType = exit.PreferredTransitionType;
+ break;
+ }
+ }
+ if (!soType.HasValue) return;
+
+ var currentType = (TransitionType)typeProp.intValue;
+
+ EditorGUILayout.LabelField("MapDatabase 同步", EditorStyles.boldLabel);
+
+ if (currentType == soType.Value)
+ {
+ var prev = GUI.color;
+ GUI.color = new Color(0.4f, 0.9f, 0.4f);
+ EditorGUILayout.LabelField($" ✓ 与 MapDatabase 一致({soType.Value})");
+ GUI.color = prev;
+ }
+ else
+ {
+ EditorGUILayout.HelpBox(
+ $"MapDatabase 中出口 → {targetAddr} 的 PreferredTransitionType = {soType.Value}," +
+ $"但当前组件设置为 {currentType}。两者不一致可能导致关卡编辑时信息有误。",
+ MessageType.Warning);
+
+ if (GUILayout.Button($"从 MapDatabase 同步 → {soType.Value}"))
+ {
+ serializedObject.Update();
+ typeProp.intValue = (int)soType.Value;
+ serializedObject.ApplyModifiedProperties();
+ }
+ }
+ }
+
+ /// 在与 相同场景内找到 RoomController 并返回其 RoomId。
+ private static string FindSourceRoomId(GameObject go)
+ {
+ var scene = go.scene;
+ if (!scene.IsValid()) return null;
+
+ foreach (var root in scene.GetRootGameObjects())
+ {
+ var ctrl = root.GetComponentInChildren(true);
+ if (ctrl != null) return ctrl.RoomId;
+ }
+ return null;
+ }
+
+ private MapDatabaseSO GetMapDatabase()
+ {
+ if (_mapDbSearched) return _cachedMapDb;
+ _mapDbSearched = true;
+ var guids = AssetDatabase.FindAssets("t:MapDatabaseSO");
+ if (guids.Length > 0)
+ _cachedMapDb = AssetDatabase.LoadAssetAtPath(
+ AssetDatabase.GUIDToAssetPath(guids[0]));
+ return _cachedMapDb;
+ }
+
+ // ── 批量验证菜单项 ────────────────────────────────────────────────────────
+
+ [MenuItem("BaseGames/Scene/Validate Transition Types")]
+ private static void ValidateAllTransitionTypes()
+ {
+ var guids = AssetDatabase.FindAssets("t:MapDatabaseSO");
+ if (guids.Length == 0)
+ {
+ EditorUtility.DisplayDialog("验证失败", "未找到 MapDatabaseSO 资产,请先创建。", "确定");
+ return;
+ }
+
+ var mapDb = AssetDatabase.LoadAssetAtPath(
+ AssetDatabase.GUIDToAssetPath(guids[0]));
+
+ var transitions = Object.FindObjectsByType(
+ FindObjectsInactive.Include, FindObjectsSortMode.None);
+
+ int mismatch = 0;
+ int skipped = 0;
+
+ foreach (var t in transitions)
+ {
+ var so = new SerializedObject(t);
+ var targetProp = so.FindProperty("_targetSceneAddress");
+ var typeProp = so.FindProperty("_transitionType");
+ if (targetProp == null || typeProp == null) continue;
+
+ string sourceRoomId = FindSourceRoomId(t.gameObject);
+ if (string.IsNullOrEmpty(sourceRoomId)) { skipped++; continue; }
+
+ var roomData = mapDb.GetRoom(sourceRoomId);
+ if (roomData?.Exits == null) { skipped++; continue; }
+
+ string targetAddr = targetProp.stringValue;
+ TransitionType? soType = null;
+ foreach (var exit in roomData.Exits)
+ {
+ if (exit.TargetRoomId == targetAddr) { soType = exit.PreferredTransitionType; break; }
+ }
+ if (!soType.HasValue) { skipped++; continue; }
+
+ var currentType = (TransitionType)typeProp.intValue;
+ if (currentType != soType.Value)
+ {
+ mismatch++;
+ Debug.LogWarning(
+ $"[TransitionType 不一致] {t.gameObject.scene.name} / {t.gameObject.name}:" +
+ $"MapDatabase={soType.Value} 组件={currentType}",
+ t.gameObject);
+ }
+ }
+
+ string msg = mismatch == 0
+ ? $"全部 {transitions.Length} 个 RoomTransition 与 MapDatabase 一致(跳过 {skipped} 个)。"
+ : $"发现 {mismatch} 处类型不一致(跳过 {skipped} 个)。详细信息见 Console 日志。";
+
+ EditorUtility.DisplayDialog("验证结果", msg, "确定");
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Editor/World/RoomTransitionEditor.cs.meta b/Assets/_Game/Scripts/Editor/World/RoomTransitionEditor.cs.meta
new file mode 100644
index 0000000..1fc5efc
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/World/RoomTransitionEditor.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: bb4315fd38131af4a8a42ddfb7b82151
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Editor/World/StreamingGraphWindow.cs b/Assets/_Game/Scripts/Editor/World/StreamingGraphWindow.cs
new file mode 100644
index 0000000..f8e5e3c
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/World/StreamingGraphWindow.cs
@@ -0,0 +1,625 @@
+using System.Collections.Generic;
+using System.Linq;
+using UnityEditor;
+using UnityEditor.AddressableAssets;
+using UnityEngine;
+using BaseGames.World;
+using BaseGames.World.Map;
+using BaseGames.World.Streaming;
+using BaseGames.Core.Events;
+
+namespace BaseGames.Editor
+{
+ ///
+ /// 房间连通性可视化编辑器窗口。
+ ///
+ /// 功能:
+ ///
+ /// - 读取 绘制全图房间节点(以 GridPosition/GridSize 定位)
+ /// - 绘制出口连线,颜色按 区分
+ /// - 节点边框颜色 = Addressable 注册状态(绿色已注册 / 红色未注册)
+ /// - 运行时叠加:在 Play Mode 下按 着色节点填充
+ /// - 单击节点 → Ping SO;双击 → 打开对应场景
+ /// - 支持鼠标滚轮缩放(向光标缩放)和中键拖拽平移
+ ///
+ ///
+ /// 菜单:BaseGames/World/Streaming Graph
+ ///
+ public class StreamingGraphWindow : EditorWindow
+ {
+ // ── 常量 ───────────────────────────────────────────────────────────────────
+
+ private const float ToolbarH = 28f;
+ private const float StatusBarH = 22f;
+ private const float CellSizePx = 20f; // 每格默认像素数
+ private const float MinNodeW = 64f;
+ private const float MinNodeH = 28f;
+ private const float LegendRowH = 16f; // 图例行高
+
+ // 颜色:过渡类型(连线)
+ private static readonly Color ColorSeamless = new(0.2f, 0.9f, 0.9f);
+ private static readonly Color ColorAtmospheric = new(0.8f, 0.4f, 1.0f);
+ private static readonly Color ColorRoomType = new(0.8f, 0.8f, 0.8f);
+ private static readonly Color ColorSceneType = new(1.0f, 0.6f, 0.1f);
+
+ // 颜色:Addressable 注册状态(节点边框)
+ private static readonly Color ColorRegistered = new(0.2f, 0.9f, 0.2f);
+ private static readonly Color ColorUnregistered = new(0.9f, 0.2f, 0.2f);
+
+ // 颜色:运行时房间状态(节点填充)
+ private static readonly Color ColorActive = new(0.1f, 0.7f, 0.2f, 0.85f);
+ private static readonly Color ColorDormant = new(0.2f, 0.4f, 0.7f, 0.75f);
+ private static readonly Color ColorLoading = new(0.9f, 0.8f, 0.1f, 0.75f);
+ private static readonly Color ColorCooling = new(0.9f, 0.5f, 0.1f, 0.75f);
+ private static readonly Color ColorActivating = new(0.3f, 0.8f, 0.4f, 0.75f);
+ private static readonly Color ColorUnloading = new(0.7f, 0.2f, 0.2f, 0.75f);
+ private static readonly Color ColorDefaultFill = new(0.25f, 0.25f, 0.28f, 0.95f);
+ private static readonly Color ColorCanvasBg = new(0.15f, 0.15f, 0.17f, 1.0f);
+ private static readonly Color ColorBoss = new(1.0f, 0.3f, 0.3f);
+
+ // ── 运行时状态 ────────────────────────────────────────────────────────────
+
+ private MapDatabaseSO _mapDb;
+ private List _nodes = new();
+ private Dictionary _nodeIndex = new(); // O(1) roomId→node 索引
+ private IRoomStreamingManager _cachedMgr; // Play Mode 缓存,避免每帧扫描
+ private Vector2 _offset = new(20f, 20f);
+ private float _zoom = 1f;
+ private bool _isPanning;
+ private string _regionFilter = "";
+ private string[] _regions = System.Array.Empty();
+ private int _regionIndex; // 0 = 全部
+ private bool _showLegend = true;
+ private GUIStyle _labelStyle;
+
+ // ── 菜单 / 打开 ───────────────────────────────────────────────────────────
+
+ [MenuItem("BaseGames/World/Streaming Graph")]
+ public static void Open()
+ {
+ var win = GetWindow("Streaming Graph");
+ win.minSize = new Vector2(400f, 300f);
+ win.Refresh();
+ }
+
+ // ── 数据类 ────────────────────────────────────────────────────────────────
+
+ private class NodeData
+ {
+ public MapRoomDataSO So;
+ public string RoomId;
+ public bool IsRegistered;
+ public RoomState? RuntimeState; // null = 编辑模式或未在流式系统中
+ }
+
+ // ── Unity 回调 ────────────────────────────────────────────────────────────
+
+ private void OnEnable()
+ {
+ // Play Mode 进入/退出时自动刷新 Addressable + 运行时状态
+ EditorApplication.playModeStateChanged += OnPlayModeChanged;
+ Refresh();
+ }
+
+ private void OnDisable()
+ {
+ EditorApplication.playModeStateChanged -= OnPlayModeChanged;
+ }
+
+ private void OnPlayModeChanged(PlayModeStateChange change)
+ {
+ if (change == PlayModeStateChange.EnteredPlayMode ||
+ change == PlayModeStateChange.EnteredEditMode)
+ {
+ _cachedMgr = null;
+ Refresh();
+ }
+ }
+
+ private void OnGUI()
+ {
+ InitStyles();
+
+ DrawToolbar();
+
+ var canvasRect = new Rect(0f, ToolbarH, position.width, position.height - ToolbarH - StatusBarH);
+
+ if (_mapDb == null)
+ {
+ EditorGUI.DrawRect(canvasRect, ColorCanvasBg);
+ GUI.Label(
+ new Rect(canvasRect.center.x - 120f, canvasRect.center.y - 10f, 240f, 20f),
+ "未找到 MapDatabaseSO,请先创建并点击刷新。");
+ }
+ else
+ {
+ if (Application.isPlaying)
+ UpdateRuntimeStates();
+
+ DrawCanvas(canvasRect);
+ }
+
+ DrawStatusBar();
+
+ // 持续 Repaint,保持运行时状态颜色实时更新
+ if (Application.isPlaying)
+ Repaint();
+ }
+
+ // ── 工具栏 ────────────────────────────────────────────────────────────────
+
+ private void DrawToolbar()
+ {
+ using var _ = new EditorGUILayout.HorizontalScope(EditorStyles.toolbar,
+ GUILayout.Height(ToolbarH));
+
+ if (GUILayout.Button("刷新", EditorStyles.toolbarButton, GUILayout.Width(48)))
+ Refresh();
+
+ if (GUILayout.Button("居中", EditorStyles.toolbarButton, GUILayout.Width(48)))
+ CenterView();
+
+ GUILayout.Label("区域:", EditorStyles.miniLabel, GUILayout.Width(36));
+ int newIdx = EditorGUILayout.Popup(_regionIndex, _regions,
+ EditorStyles.toolbarPopup, GUILayout.Width(100));
+ if (newIdx != _regionIndex)
+ {
+ _regionIndex = newIdx;
+ _regionFilter = newIdx == 0 ? "" : _regions[newIdx];
+ BuildNodeCache();
+ }
+
+ GUILayout.FlexibleSpace();
+
+ // 缩放显示
+ GUILayout.Label($"缩放:{_zoom:F2}×", EditorStyles.miniLabel, GUILayout.Width(72));
+
+ _showLegend = GUILayout.Toggle(_showLegend, "图例", EditorStyles.toolbarButton,
+ GUILayout.Width(42));
+ }
+
+ // ── 画布 ──────────────────────────────────────────────────────────────────
+
+ private void DrawCanvas(Rect canvasRect)
+ {
+ // 背景
+ if (Event.current.type == EventType.Repaint)
+ EditorGUI.DrawRect(canvasRect, ColorCanvasBg);
+
+ HandleCanvasInput(canvasRect);
+
+ if (Event.current.type != EventType.Repaint)
+ return;
+
+ // 先绘制连线(在节点层下面)
+ Handles.BeginGUI();
+ DrawEdges(canvasRect);
+ Handles.EndGUI();
+
+ // 绘制节点填充 + 标签(GUI.BeginClip 裁剪至画布范围)
+ GUI.BeginClip(canvasRect);
+ DrawNodeFills(canvasRect);
+ GUI.EndClip();
+
+ // 绘制节点边框(Handles,使用绝对坐标)
+ Handles.BeginGUI();
+ DrawNodeBorders(canvasRect);
+ Handles.EndGUI();
+
+ if (_showLegend)
+ DrawLegend(canvasRect);
+ }
+
+ private void HandleCanvasInput(Rect canvasRect)
+ {
+ var e = Event.current;
+ if (!canvasRect.Contains(e.mousePosition))
+ {
+ if (_isPanning && e.type == EventType.MouseUp)
+ _isPanning = false;
+ return;
+ }
+
+ // 滚轮缩放(向光标点缩放)
+ if (e.type == EventType.ScrollWheel)
+ {
+ float prevZoom = _zoom;
+ _zoom = Mathf.Clamp(_zoom * (1f - e.delta.y * 0.05f), 0.15f, 5f);
+ var cursor = e.mousePosition - canvasRect.min;
+ _offset = cursor - (_zoom / prevZoom) * (cursor - _offset);
+ e.Use();
+ Repaint();
+ return;
+ }
+
+ // 中键按下 / 松开
+ if (e.type == EventType.MouseDown && e.button == 2) { _isPanning = true; e.Use(); }
+ if (e.type == EventType.MouseUp && e.button == 2) { _isPanning = false; e.Use(); }
+
+ // 中键拖拽平移
+ if (e.type == EventType.MouseDrag && _isPanning)
+ {
+ _offset += e.delta;
+ e.Use();
+ Repaint();
+ return;
+ }
+
+ // 左键点击节点
+ if (e.type == EventType.MouseDown && e.button == 0)
+ {
+ foreach (var node in _nodes)
+ {
+ var r = GetAbsNodeRect(node, canvasRect);
+ if (!r.Contains(e.mousePosition)) continue;
+
+ if (e.clickCount == 2)
+ OpenRoomScene(node.RoomId);
+ else
+ {
+ Selection.activeObject = node.So;
+ EditorGUIUtility.PingObject(node.So);
+ }
+ e.Use();
+ break;
+ }
+ }
+ }
+
+ // ── 绘制:连线 ────────────────────────────────────────────────────────────
+
+ private void DrawEdges(Rect canvasRect)
+ {
+ foreach (var node in _nodes)
+ {
+ if (node.So.Exits == null) continue;
+ foreach (var exit in node.So.Exits)
+ {
+ if (!_nodeIndex.TryGetValue(exit.TargetRoomId, out var targetNode)) continue;
+
+ // 从出口格子坐标到目标房间中心
+ var from = canvasRect.min + CanvasLocalPos(exit.ExitGridPos.x, exit.ExitGridPos.y);
+ var to = GetAbsNodeRect(targetNode, canvasRect).center;
+
+ Handles.color = GetTransitionColor(exit.PreferredTransitionType);
+ Handles.DrawLine(from, to);
+ DrawArrowHead(from, to, 6f);
+ }
+ }
+ }
+
+ private static void DrawArrowHead(Vector2 from, Vector2 to, float size)
+ {
+ var dir = (to - from).normalized;
+ if (dir == Vector2.zero) return;
+ var perp = new Vector2(-dir.y, dir.x);
+ var tip = to;
+ var base1 = tip - dir * size + perp * (size * 0.5f);
+ var base2 = tip - dir * size - perp * (size * 0.5f);
+ Handles.DrawLine(tip, base1);
+ Handles.DrawLine(tip, base2);
+ }
+
+ // ── 绘制:节点填充(BeginClip 内,画布局部坐标) ─────────────────────────
+
+ private void DrawNodeFills(Rect canvasRect)
+ {
+ foreach (var node in _nodes)
+ {
+ // 在 BeginClip 内:坐标相对于 canvasRect.min
+ var localRect = GetLocalNodeRect(node);
+ if (!new Rect(0, 0, canvasRect.width, canvasRect.height).Overlaps(localRect))
+ continue;
+
+ EditorGUI.DrawRect(localRect, GetStateFillColor(node.RuntimeState));
+
+ // Boss 房间底部红色条
+ if (node.So.IsBossRoom)
+ {
+ var markerRect = new Rect(localRect.x, localRect.yMax - 3f, localRect.width, 3f);
+ EditorGUI.DrawRect(markerRect, ColorBoss);
+ }
+
+ // 标签
+ var label = BuildLabel(node);
+ GUI.Label(localRect, label, _labelStyle);
+ }
+ }
+
+ // ── 绘制:节点边框(Handles,绝对坐标) ──────────────────────────────────
+
+ private void DrawNodeBorders(Rect canvasRect)
+ {
+ foreach (var node in _nodes)
+ {
+ var r = GetAbsNodeRect(node, canvasRect);
+ if (!canvasRect.Overlaps(r)) continue;
+
+ var color = node.IsRegistered ? ColorRegistered : ColorUnregistered;
+ DrawHandleRect(r, color);
+ }
+ }
+
+ private static void DrawHandleRect(Rect r, Color c)
+ {
+ Handles.color = c;
+ var tl = new Vector3(r.xMin, r.yMin);
+ var tr = new Vector3(r.xMax, r.yMin);
+ var br = new Vector3(r.xMax, r.yMax);
+ var bl = new Vector3(r.xMin, r.yMax);
+ Handles.DrawLine(tl, tr);
+ Handles.DrawLine(tr, br);
+ Handles.DrawLine(br, bl);
+ Handles.DrawLine(bl, tl);
+ }
+
+ // ── 图例 ─────────────────────────────────────────────────────────────────
+
+ private void DrawLegend(Rect canvasRect)
+ {
+ const float W = 170f;
+ const float H = 148f;
+ const float Px = 8f;
+ const float Py = 8f;
+
+ var legendRect = new Rect(canvasRect.xMax - W - Px, canvasRect.y + Py, W, H);
+ EditorGUI.DrawRect(legendRect, new Color(0.1f, 0.1f, 0.12f, 0.88f));
+
+ float y = legendRect.y + 6f;
+ LegendRow(ref y, legendRect.x + 6f, "连线颜色(过渡类型)", Color.white, isHeader: true);
+ LegendRow(ref y, legendRect.x + 6f, " Seamless", ColorSeamless);
+ LegendRow(ref y, legendRect.x + 6f, " AtmosphericFade", ColorAtmospheric);
+ LegendRow(ref y, legendRect.x + 6f, " Room", ColorRoomType);
+ LegendRow(ref y, legendRect.x + 6f, " Scene", ColorSceneType);
+ LegendRow(ref y, legendRect.x + 6f, "边框(Addressable)", Color.white, isHeader: true);
+ LegendRow(ref y, legendRect.x + 6f, " 已注册", ColorRegistered);
+ LegendRow(ref y, legendRect.x + 6f, " 未注册", ColorUnregistered);
+
+ Handles.BeginGUI();
+ DrawHandleRect(legendRect, new Color(0.4f, 0.4f, 0.45f));
+ Handles.EndGUI();
+ }
+
+ private static void LegendRow(ref float y, float x, string label, Color swatch,
+ bool isHeader = false)
+ {
+ if (!isHeader)
+ {
+ EditorGUI.DrawRect(new Rect(x, y + 3f, 12f, 10f), swatch);
+ x += 16f;
+ }
+ GUI.Label(new Rect(x, y, 160f, LegendRowH), label,
+ isHeader ? EditorStyles.boldLabel : EditorStyles.label);
+ y += LegendRowH;
+ }
+
+ // ── 状态栏 ────────────────────────────────────────────────────────────────
+
+ private void DrawStatusBar()
+ {
+ var statusRect = new Rect(0f, position.height - StatusBarH, position.width, StatusBarH);
+ EditorGUI.DrawRect(statusRect, new Color(0.12f, 0.12f, 0.14f));
+
+ int totalEdges = _nodes.Sum(n => n.So?.Exits?.Length ?? 0);
+ int unregistered = _nodes.Count(n => !n.IsRegistered);
+ string playing = Application.isPlaying ? " ● Play Mode" : "";
+
+ var rooms = _mapDb?.AllRooms;
+ int filteredCount = _nodes.Count;
+ int totalCount = rooms?.Length ?? 0;
+ string roomsStr = _regionFilter.Length > 0
+ ? $"{filteredCount} / {totalCount} 房间"
+ : $"{totalCount} 房间";
+
+ string text = $" {roomsStr} · {totalEdges} 出口 · {unregistered} 未注册{playing}";
+ GUI.Label(new Rect(0f, position.height - StatusBarH, position.width, StatusBarH), text);
+ }
+
+ // ── 坐标工具 ──────────────────────────────────────────────────────────────
+
+ /// 网格坐标 → 画布局部偏移(不含 canvasRect.min)。
+ private Vector2 CanvasLocalPos(float gridX, float gridY)
+ => _offset + new Vector2(gridX * CellSizePx * _zoom, gridY * CellSizePx * _zoom);
+
+ /// 房间节点的画布局部 Rect(相对于 canvasRect.min,供 BeginClip 内使用)。
+ private Rect GetLocalNodeRect(NodeData node)
+ {
+ var pos = CanvasLocalPos(node.So.GridPosition.x, node.So.GridPosition.y);
+ float w = Mathf.Max(node.So.GridSize.x * CellSizePx * _zoom, MinNodeW);
+ float h = Mathf.Max(node.So.GridSize.y * CellSizePx * _zoom, MinNodeH);
+ return new Rect(pos.x, pos.y, w, h);
+ }
+
+ /// 房间节点的绝对 Rect(含 canvasRect.min,供 Handles 使用)。
+ private Rect GetAbsNodeRect(NodeData node, Rect canvasRect)
+ {
+ var local = GetLocalNodeRect(node);
+ return new Rect(canvasRect.min + local.min, local.size);
+ }
+
+ // ── 数据刷新 ──────────────────────────────────────────────────────────────
+
+ private void Refresh()
+ {
+ _mapDb = null;
+ var guids = AssetDatabase.FindAssets("t:MapDatabaseSO");
+ if (guids.Length > 0)
+ _mapDb = AssetDatabase.LoadAssetAtPath(
+ AssetDatabase.GUIDToAssetPath(guids[0]));
+
+ BuildRegions();
+ BuildNodeCache();
+ CheckAddressableRegistration();
+ Repaint();
+ }
+
+ private void BuildRegions()
+ {
+ var set = new HashSet { "" };
+ if (_mapDb?.AllRooms != null)
+ foreach (var r in _mapDb.AllRooms)
+ if (r != null && !string.IsNullOrEmpty(r.RegionId))
+ set.Add(r.RegionId);
+
+ var list = new List(set);
+ list.Sort();
+ int allIdx = list.IndexOf("");
+ if (allIdx >= 0) list.RemoveAt(allIdx);
+ list.Insert(0, "全部");
+
+ _regions = list.ToArray();
+ // 第 0 项 = "全部"(对应空字符串 _regionFilter),后续是各 RegionId
+ if (_regionIndex >= _regions.Length) _regionIndex = 0;
+ }
+
+ private void BuildNodeCache()
+ {
+ _nodes.Clear();
+ _nodeIndex.Clear();
+ if (_mapDb?.AllRooms == null) return;
+
+ foreach (var room in _mapDb.AllRooms)
+ {
+ if (room == null) continue;
+ if (!string.IsNullOrEmpty(_regionFilter) && room.RegionId != _regionFilter)
+ continue;
+ var node = new NodeData { So = room, RoomId = room.RoomId };
+ _nodes.Add(node);
+ _nodeIndex[room.RoomId] = node;
+ }
+ }
+
+ private void CheckAddressableRegistration()
+ {
+ var settings = AddressableAssetSettingsDefaultObject.Settings;
+ var registered = new HashSet();
+
+ if (settings != null)
+ foreach (var group in settings.groups)
+ {
+ if (group == null) continue;
+ foreach (var entry in group.entries)
+ registered.Add(entry.address);
+ }
+
+ foreach (var node in _nodes)
+ node.IsRegistered = registered.Contains(node.RoomId);
+ }
+
+ private void UpdateRuntimeStates()
+ {
+ // 缓存 IRoomStreamingManager 引用,避免每帧扫描全场景对象
+ if (_cachedMgr == null)
+ _cachedMgr = Object.FindAnyObjectByType() as IRoomStreamingManager;
+
+ foreach (var node in _nodes)
+ node.RuntimeState = _cachedMgr?.GetRoomState(node.RoomId);
+ }
+
+ // ── 视图工具 ──────────────────────────────────────────────────────────────
+
+ private void CenterView()
+ {
+ if (_nodes.Count == 0) return;
+
+ int minX = int.MaxValue, minY = int.MaxValue;
+ int maxX = int.MinValue, maxY = int.MinValue;
+
+ foreach (var n in _nodes)
+ {
+ minX = Mathf.Min(minX, n.So.GridPosition.x);
+ minY = Mathf.Min(minY, n.So.GridPosition.y);
+ maxX = Mathf.Max(maxX, n.So.GridPosition.x + Mathf.Max(n.So.GridSize.x, 1));
+ maxY = Mathf.Max(maxY, n.So.GridPosition.y + Mathf.Max(n.So.GridSize.y, 1));
+ }
+
+ int totalGridW = maxX - minX;
+ int totalGridH = maxY - minY;
+
+ float canvasW = position.width;
+ float canvasH = position.height - ToolbarH - StatusBarH;
+
+ float zoomX = (canvasW * 0.8f) / Mathf.Max(totalGridW * CellSizePx, 1f);
+ float zoomY = (canvasH * 0.8f) / Mathf.Max(totalGridH * CellSizePx, 1f);
+ _zoom = Mathf.Clamp(Mathf.Min(zoomX, zoomY), 0.15f, 3f);
+
+ float scaledW = totalGridW * CellSizePx * _zoom;
+ float scaledH = totalGridH * CellSizePx * _zoom;
+ _offset = new Vector2(
+ (canvasW - scaledW) * 0.5f - minX * CellSizePx * _zoom,
+ (canvasH - scaledH) * 0.5f - minY * CellSizePx * _zoom);
+
+ Repaint();
+ }
+
+ // ── 辅助 ──────────────────────────────────────────────────────────────────
+
+ private void InitStyles()
+ {
+ if (_labelStyle != null) return;
+ _labelStyle = new GUIStyle(EditorStyles.label)
+ {
+ alignment = TextAnchor.MiddleCenter,
+ fontSize = 9,
+ wordWrap = true,
+ normal = { textColor = new Color(0.92f, 0.92f, 0.92f) }
+ };
+ }
+
+ private static Color GetStateFillColor(RoomState? state)
+ {
+ if (!state.HasValue) return ColorDefaultFill;
+ return state.Value switch
+ {
+ RoomState.Active => ColorActive,
+ RoomState.Activating => ColorActivating,
+ RoomState.Dormant => ColorDormant,
+ RoomState.Loading => ColorLoading,
+ RoomState.Cooling => ColorCooling,
+ RoomState.Unloading => ColorUnloading,
+ _ => ColorDefaultFill
+ };
+ }
+
+ private static Color GetTransitionColor(TransitionType t) => t switch
+ {
+ TransitionType.Seamless => ColorSeamless,
+ TransitionType.AtmosphericFade => ColorAtmospheric,
+ TransitionType.Scene => ColorSceneType,
+ _ => ColorRoomType
+ };
+
+ private static string BuildLabel(NodeData node)
+ {
+ var id = node.RoomId ?? "";
+ // 缩短显示:去掉 "Room_" 前缀
+ if (id.StartsWith("Room_")) id = id[5..];
+
+ string stateTag = node.RuntimeState switch
+ {
+ RoomState.Active => "\n● Active",
+ RoomState.Activating => "\n◑ Activating",
+ RoomState.Dormant => "\n○ Dormant",
+ RoomState.Loading => "\n⟳ Loading",
+ RoomState.Cooling => "\n◌ Cooling",
+ RoomState.Unloading => "\n✕ Unloading",
+ _ => ""
+ };
+ string bossTag = node.So.IsBossRoom ? " [B]" : "";
+ return id + bossTag + stateTag;
+ }
+
+ private static void OpenRoomScene(string roomId)
+ {
+ var guids = AssetDatabase.FindAssets($"t:SceneAsset {roomId}");
+ if (guids.Length == 0)
+ {
+ Debug.LogWarning($"[StreamingGraph] 未找到场景 {roomId}。");
+ return;
+ }
+ var path = AssetDatabase.GUIDToAssetPath(guids[0]);
+ if (UnityEditor.SceneManagement.EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
+ UnityEditor.SceneManagement.EditorSceneManager.OpenScene(path);
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Editor/World/StreamingGraphWindow.cs.meta b/Assets/_Game/Scripts/Editor/World/StreamingGraphWindow.cs.meta
new file mode 100644
index 0000000..9f6b57f
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/World/StreamingGraphWindow.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ff23fee892b09e6479e2470fbdcbb1b3
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Enemies/AI/BDTaskAttributes.cs.meta b/Assets/_Game/Scripts/Enemies/AI/BDTaskAttributes.cs.meta
new file mode 100644
index 0000000..2065c4f
--- /dev/null
+++ b/Assets/_Game/Scripts/Enemies/AI/BDTaskAttributes.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 6e52f911001ee0448971afc570e5e486
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs b/Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs
index a840617..5840b58 100644
--- a/Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs
+++ b/Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs
@@ -24,7 +24,7 @@ namespace BaseGames.Enemies.AI
public override void OnAwake()
{
_boss = GetComponent();
- _executor = GetComponentInChildren();
+ _executor = transform.GetComponentInChildren();
}
public override TaskStatus OnUpdate()
diff --git a/Assets/_Game/Scripts/Enemies/EnemyRespawner.cs b/Assets/_Game/Scripts/Enemies/EnemyRespawner.cs
new file mode 100644
index 0000000..7bc90d7
--- /dev/null
+++ b/Assets/_Game/Scripts/Enemies/EnemyRespawner.cs
@@ -0,0 +1,246 @@
+using System.Collections;
+using UnityEngine;
+using BaseGames.Core;
+using BaseGames.Core.Pool;
+using BaseGames.World;
+
+namespace BaseGames.Enemies
+{
+ ///
+ /// 场景内的敌人刷新点。实现 ,与房间流式系统联动:
+ ///
+ /// - 房间激活()时生成敌人实例。
+ /// - 房间休眠()时清理仍存活的实例,确保下次进入时刷出新实例。
+ /// - 可选 :敌人在房间内死亡后等待若干秒自动复活。
+ /// - 可选的 :击杀后写入 ,
+ /// 使敌人在存档重载后不再复活(适用于 Boss 或仅出现一次的守卫)。
+ ///
+ ///
+ /// 挂载方式:在房间场景中放置空 GameObject 作为刷新点,挂上本组件,
+ /// 并通过 或 指定要生成的敌人。
+ ///
+ ///
+ public class EnemyRespawner : MonoBehaviour, IRoomLifecycle
+ {
+ [Header("生成配置")]
+ [Tooltip("要生成的敌人预制体(直接引用)。当 _poolKey 非空时,预制体作为兜底备选。")]
+ [SerializeField] private GameObject _enemyPrefab;
+
+ [Tooltip("对象池键(对应 GlobalObjectPool 中已预热的 AddressKey 常量)。\n" +
+ "非空时优先通过 IObjectPoolService 取用对象;池服务不可用时回退至 _enemyPrefab。")]
+ [SerializeField] private string _poolKey;
+
+ [Header("房间内复活")]
+ [Tooltip("0(默认)= 敌人死亡后不在房间内复活,仅下次进入房间时重新生成。\n" +
+ "大于 0 = 死亡后等待 N 秒在原位复活,适用于需要持续巡逻的普通敌人。")]
+ [Min(0f)]
+ [SerializeField] private float _respawnDelay = 0f;
+
+ [Tooltip("每次进入房间时,敌人最多在房间内复活的次数。\n" +
+ "0 = 不限次数(仅在 _respawnDelay > 0 时有意义)。\n" +
+ "示例:设为 2 表示同一次房间访问中敌人最多死而复生 2 次,第 3 次死后不再刷新。")]
+ [Min(0)]
+ [SerializeField] private int _maxRespawnCount = 0;
+
+ [Header("永久击杀")]
+ [Tooltip("空 = 每次进入房间均复活。\n" +
+ "非空 = 击杀后将此 ID 写入 WorldStateRegistry.DestroyedObjectIds;存档后不再复活。\n" +
+ "适用于 Boss 或仅出现一次的关键敌人。建议格式:ENM_{EnemyId}_{RoomId}")]
+ [SerializeField] private string _persistentKillId;
+
+ [Tooltip("永久击杀时写入的世界状态注册表。仅在 _persistentKillId 非空时使用。")]
+ [SerializeField] private WorldStateRegistry _worldState;
+
+ // ── 运行时 ────────────────────────────────────────────────────────────────
+ private EnemyBase _activeEnemy;
+
+ /// 本次房间访问中敌人已在房间内复活的次数(不含初始生成)。
+ private int _respawnCountThisVisit;
+
+ private Coroutine _respawnCoroutine;
+
+ // ── IRoomLifecycle ────────────────────────────────────────────────────────
+
+ ///
+ /// 房间休眠时清理当前存活的敌人实例,并取消待执行的复活协程。
+ /// 已进入死亡流程( = false)的实例由
+ /// 自行处理,此处不干预。
+ ///
+ public void OnRoomDormant()
+ {
+ // 取消待执行的复活计时
+ if (_respawnCoroutine != null)
+ {
+ StopCoroutine(_respawnCoroutine);
+ _respawnCoroutine = null;
+ }
+
+ if (_activeEnemy != null)
+ {
+ _activeEnemy.OnDied -= OnEnemyDied;
+
+ if (_activeEnemy.IsAlive)
+ {
+ // 优先归还对象池;池服务不可用或无 PooledObject 时直接销毁
+ var po = _activeEnemy.GetComponent();
+ var pool = !string.IsNullOrEmpty(_poolKey)
+ ? ServiceLocator.GetOrDefault()
+ : null;
+
+ if (pool != null && po != null)
+ pool.Despawn(_poolKey, po);
+ else
+ Destroy(_activeEnemy.gameObject);
+ }
+ // IsAlive=false:EnemyBase.Die() 已通过动画回调调度销毁,自然完成即可
+
+ _activeEnemy = null;
+ }
+
+ _respawnCountThisVisit = 0;
+ }
+
+ ///
+ /// 房间激活时生成敌人。永久死亡的敌人不会再次生成。
+ ///
+ /// 出生上下文(含是否为复活流程等信息)。
+ public void OnRoomActivate(SpawnContext context)
+ {
+ // 永久击杀检查:存档中已标记为 Destroyed,不再生成
+ if (!string.IsNullOrEmpty(_persistentKillId)
+ && _worldState != null
+ && _worldState.IsDestroyed(_persistentKillId))
+ return;
+
+ // 重置本轮复活计数
+ _respawnCountThisVisit = 0;
+
+ // 快速折返时防止重复生成(上一轮实例尚存活)
+ if (_activeEnemy != null && _activeEnemy.IsAlive) return;
+
+ _activeEnemy = null;
+ SpawnEnemy();
+ }
+
+ // ── 内部 ──────────────────────────────────────────────────────────────────
+
+ private void SpawnEnemy()
+ {
+ GameObject go = null;
+
+ // 优先:对象池
+ if (!string.IsNullOrEmpty(_poolKey))
+ {
+ var pool = ServiceLocator.GetOrDefault();
+ go = pool?.Spawn(_poolKey, transform.position, transform.rotation);
+ }
+
+ // 兜底:直接实例化
+ if (go == null && _enemyPrefab != null)
+ go = Instantiate(_enemyPrefab, transform.position, transform.rotation);
+
+ if (go == null)
+ {
+ Debug.LogError($"[EnemyRespawner] {name}:无法生成敌人,请检查 _enemyPrefab 或 _poolKey 配置。", this);
+ return;
+ }
+
+ if (!go.TryGetComponent(out var enemy))
+ {
+ Debug.LogWarning($"[EnemyRespawner] {name}:生成的 GameObject 上未找到 EnemyBase 组件。", this);
+ return;
+ }
+
+ _activeEnemy = enemy;
+ // 确保对象池复用路径也能正确重置运行时状态
+ _activeEnemy.OnSpawn();
+ _activeEnemy.OnDied += OnEnemyDied;
+ }
+
+ private void OnEnemyDied()
+ {
+ // 永久击杀:写入 WorldStateRegistry(存档管道持久化至 SaveData.World.DestroyedObjectIds)
+ if (!string.IsNullOrEmpty(_persistentKillId) && _worldState != null)
+ _worldState.MarkDestroyed(_persistentKillId);
+
+ if (_activeEnemy != null)
+ {
+ _activeEnemy.OnDied -= OnEnemyDied;
+ _activeEnemy = null;
+ }
+
+ // 房间内延迟复活:_respawnDelay > 0 且未超过复活上限
+ bool canRespawn = _respawnDelay > 0f
+ && (_maxRespawnCount == 0 || _respawnCountThisVisit < _maxRespawnCount);
+
+ if (canRespawn)
+ _respawnCoroutine = StartCoroutine(RespawnAfterDelay());
+ }
+
+ private IEnumerator RespawnAfterDelay()
+ {
+ yield return new WaitForSeconds(_respawnDelay);
+
+ // 房间可能在等待期间进入休眠(OnRoomDormant 会 StopCoroutine,此处为双重保险)
+ if (!gameObject.activeInHierarchy) yield break;
+
+ // 再次检查永久击杀(等待期间存档状态可能已变化)
+ if (!string.IsNullOrEmpty(_persistentKillId)
+ && _worldState != null
+ && _worldState.IsDestroyed(_persistentKillId))
+ yield break;
+
+ _respawnCountThisVisit++;
+ _respawnCoroutine = null;
+ SpawnEnemy();
+ }
+
+ private void OnDestroy()
+ {
+ // 场景卸载时确保取消订阅,防止野指针回调
+ if (_activeEnemy != null)
+ _activeEnemy.OnDied -= OnEnemyDied;
+ }
+
+ // ── OnValidate ────────────────────────────────────────────────────────────
+#if UNITY_EDITOR
+ private void OnValidate()
+ {
+ // _persistentKillId 非空但 _worldState 未关联时,尝试自动查找场景内唯一实例
+ if (!string.IsNullOrEmpty(_persistentKillId) && _worldState == null)
+ _worldState = FindFirstObjectByType();
+ }
+#endif
+
+ // ── Editor Gizmos ─────────────────────────────────────────────────────────
+#if UNITY_EDITOR
+ private static readonly Color s_GizmoColorNormal = new(1f, 0.35f, 0.2f, 0.85f);
+ private static readonly Color s_GizmoColorPersistent = new(1f, 0.85f, 0.1f, 0.85f);
+
+ private void OnDrawGizmos()
+ {
+ // 未选中时:小球 + 低透明度,便于关卡编辑时总览所有刷新点
+ Color c = !string.IsNullOrEmpty(_persistentKillId)
+ ? s_GizmoColorPersistent
+ : s_GizmoColorNormal;
+ c.a = 0.3f;
+ Gizmos.color = c;
+ Gizmos.DrawWireSphere(transform.position, 0.25f);
+ }
+
+ private void OnDrawGizmosSelected()
+ {
+ bool isPersistent = !string.IsNullOrEmpty(_persistentKillId);
+ Gizmos.color = isPersistent ? s_GizmoColorPersistent : s_GizmoColorNormal;
+ Gizmos.DrawWireSphere(transform.position, 0.4f);
+
+ var labelStyle = new GUIStyle(UnityEditor.EditorStyles.miniLabel)
+ {
+ normal = { textColor = Gizmos.color }
+ };
+ string label = isPersistent ? $"{name} [永久]" : name;
+ UnityEditor.Handles.Label(transform.position + Vector3.up * 0.65f, label, labelStyle);
+ }
+#endif
+ }
+}
diff --git a/Assets/_Game/Scripts/Enemies/EnemyRespawner.cs.meta b/Assets/_Game/Scripts/Enemies/EnemyRespawner.cs.meta
new file mode 100644
index 0000000..b805612
--- /dev/null
+++ b/Assets/_Game/Scripts/Enemies/EnemyRespawner.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 3b8f1de7a8f37a84195a9b5c1df5170d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Enemies/Perception/EnemyThreatAssessor.cs.meta b/Assets/_Game/Scripts/Enemies/Perception/EnemyThreatAssessor.cs.meta
new file mode 100644
index 0000000..c6165ee
--- /dev/null
+++ b/Assets/_Game/Scripts/Enemies/Perception/EnemyThreatAssessor.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 03901716d747f924d9d7380e4d555559
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/World/IRoomLifecycle.cs.meta b/Assets/_Game/Scripts/World/IRoomLifecycle.cs.meta
new file mode 100644
index 0000000..819611f
--- /dev/null
+++ b/Assets/_Game/Scripts/World/IRoomLifecycle.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 643576a745fe0f84ba333c99d3ea302d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/World/IRoomStreamingManager.cs b/Assets/_Game/Scripts/World/IRoomStreamingManager.cs
index 937642c..94def06 100644
--- a/Assets/_Game/Scripts/World/IRoomStreamingManager.cs
+++ b/Assets/_Game/Scripts/World/IRoomStreamingManager.cs
@@ -45,5 +45,11 @@ namespace BaseGames.World
/// 用于非流式冷启动路径(如游戏初始化、快速传送落地)。
///
IEnumerator LoadAndActivateRoomCoroutine(string roomId, SpawnContext context);
+
+ ///
+ /// 获取指定房间的当前流式状态。
+ /// 若房间不在流式系统中(未加载或 ID 不存在),返回 。
+ ///
+ RoomState GetRoomState(string roomId);
}
}
diff --git a/Assets/_Game/Scripts/World/IRoomStreamingManager.cs.meta b/Assets/_Game/Scripts/World/IRoomStreamingManager.cs.meta
new file mode 100644
index 0000000..88ab6fa
--- /dev/null
+++ b/Assets/_Game/Scripts/World/IRoomStreamingManager.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d12962e2719b0374ea8257e78ee81a14
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/World/Map/MapRoomDataSO.cs b/Assets/_Game/Scripts/World/Map/MapRoomDataSO.cs
index 671312b..d733992 100644
--- a/Assets/_Game/Scripts/World/Map/MapRoomDataSO.cs
+++ b/Assets/_Game/Scripts/World/Map/MapRoomDataSO.cs
@@ -8,7 +8,7 @@ namespace BaseGames.World.Map
{
///
/// 单个房间的地图数据 SO(架构 15_MapShopModule §1.1)。
- /// 资产路径: Assets/ScriptableObjects/Map/Room_{RoomId}.asset
+ /// 资产路径: Assets/_Game/Data/Map/Rooms/Room_{RoomId}.asset
///
[CreateAssetMenu(menuName = "BaseGames/World/Map/RoomData")]
public class MapRoomDataSO : ScriptableObject
@@ -61,7 +61,7 @@ namespace BaseGames.World.Map
///
/// 全局地图数据库 SO(编辑器配置一次;架构 15_MapShopModule §1.1)。
- /// 资产路径: Assets/ScriptableObjects/Map/MapDatabase.asset
+ /// 资产路径: Assets/_Game/Data/Map/MapDatabase.asset
///
[CreateAssetMenu(menuName = "BaseGames/World/Map/MapDatabase")]
public class MapDatabaseSO : ScriptableObject
diff --git a/Assets/_Game/Scripts/World/RoomState.cs.meta b/Assets/_Game/Scripts/World/RoomState.cs.meta
new file mode 100644
index 0000000..9d96be7
--- /dev/null
+++ b/Assets/_Game/Scripts/World/RoomState.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 2b0c4aa720fd33a4394460868ec5c907
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/World/SpawnContext.cs.meta b/Assets/_Game/Scripts/World/SpawnContext.cs.meta
new file mode 100644
index 0000000..39afde2
--- /dev/null
+++ b/Assets/_Game/Scripts/World/SpawnContext.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 231e8065d039d4c45bc0e11e64998bb7
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/World/Streaming.meta b/Assets/_Game/Scripts/World/Streaming.meta
new file mode 100644
index 0000000..f437e9c
--- /dev/null
+++ b/Assets/_Game/Scripts/World/Streaming.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: da275f27c58bf9c40b3fa7b79e6e8b39
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/World/Streaming/BaseGames.World.Streaming.asmdef.meta b/Assets/_Game/Scripts/World/Streaming/BaseGames.World.Streaming.asmdef.meta
new file mode 100644
index 0000000..591d447
--- /dev/null
+++ b/Assets/_Game/Scripts/World/Streaming/BaseGames.World.Streaming.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 18b849fabd3ac314bbe36055acb3a4b8
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/World/Streaming/RoomHandle.cs b/Assets/_Game/Scripts/World/Streaming/RoomHandle.cs
index 007984f..6ab9c91 100644
--- a/Assets/_Game/Scripts/World/Streaming/RoomHandle.cs
+++ b/Assets/_Game/Scripts/World/Streaming/RoomHandle.cs
@@ -96,6 +96,13 @@ namespace BaseGames.World.Streaming
{
if (State == RoomState.Unloaded || State == RoomState.Unloading || !_sceneHandle.IsValid()) yield break;
+ // 若正处于加载中,等待加载完成后再卸载(Addressables 不支持对未完成的句柄直接卸载)
+ if (State == RoomState.Loading)
+ {
+ while (!_sceneHandle.IsDone)
+ yield return null;
+ }
+
// Active 状态先走 Deactivate,再设置 Unloading
bool needsDeactivate = State == RoomState.Active ||
State == RoomState.Activating ||
diff --git a/Assets/_Game/Scripts/World/Streaming/RoomHandle.cs.meta b/Assets/_Game/Scripts/World/Streaming/RoomHandle.cs.meta
new file mode 100644
index 0000000..21ec44b
--- /dev/null
+++ b/Assets/_Game/Scripts/World/Streaming/RoomHandle.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c41dde1869912a647a425338b185282f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs b/Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs
index 89e9835..31813ce 100644
--- a/Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs
+++ b/Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs
@@ -22,14 +22,16 @@ namespace BaseGames.World.Streaming
///
///
[DefaultExecutionOrder(-800)]
- public class RoomStreamingManager : MonoBehaviour, IRoomStreamingManager
+ public class RoomStreamingManager : MonoBehaviour, IRoomStreamingManager, ISceneLoadCoordinator
{
[Header("配置")]
[SerializeField] private MapDatabaseSO _mapDatabase;
[SerializeField] private StreamingBudgetConfigSO _budget;
- [Header("事件频道 - 监听")]
- [Tooltip("玩家进入新房间时发布(携带 RoomId 字符串)。")]
+ [Header("事件频道 - 监听(输入)")]
+ [Tooltip("外部触发器或非流式加载路径在玩家到达新房间时发布此事件(携带 RoomId)。\n" +
+ "流式管理器订阅此频道以支持冷启动和外部调试传送。\n" +
+ "正常流式过渡路径由 ActivateRoomCoroutine 直接更新 _currentRoomId,不依赖此订阅。")]
[SerializeField] private StringEventChannelSO _onRoomEntered;
[Header("事件频道 - 发布")]
@@ -37,6 +39,11 @@ namespace BaseGames.World.Streaming
"供 TransitionDirector 检查是否可执行 Seamless 切换。")]
[SerializeField] private StringEventChannelSO _onRoomPreloaded;
+ [Tooltip("房间激活完成(Active)后发布(携带 RoomId)。\n" +
+ "地图探索、任务系统等应订阅此事件以感知当前房间变更。\n" +
+ "注意:与 _onRoomEntered(输入)是不同频道,避免流式管理器自身产生事件循环。")]
+ [SerializeField] private StringEventChannelSO _onRoomActivated;
+
[Header("格子单位(世界坐标)")]
[Tooltip("每个格子对应的 Unity 世界坐标单位数,与关卡设计网格对齐。")]
[SerializeField] private float _unitsPerGrid = 16f;
@@ -73,8 +80,17 @@ namespace BaseGames.World.Streaming
}
else
{
- // 首次进入(非流式路径也可能触发),直接初始化相机
+ // 首次注册(通常是 SceneLoader 直接加载的初始房间,绕过了流式路径)
controller.SetupCamera();
+
+ // 冷启动引导:若流式系统尚无当前房间,以此房间为起点开始预加载邻居。
+ // 此路径仅在游戏第一帧或从存档热载入时触发一次。
+ if (string.IsNullOrEmpty(_currentRoomId) && _graph != null)
+ {
+ _currentRoomId = controller.RoomId;
+ RecalculateStreamingSet(controller.RoomId);
+ Debug.Log($"[RoomStreamingManager] 冷启动:以 {controller.RoomId} 为基准启动流式预加载。");
+ }
}
}
@@ -89,11 +105,32 @@ namespace BaseGames.World.Streaming
EnqueuePreload(roomId);
}
+ public RoomState GetRoomState(string roomId)
+ {
+ return _handles.TryGetValue(roomId, out var handle)
+ ? handle.State
+ : RoomState.Unloaded;
+ }
+
+ // ── ISceneLoadCoordinator ─────────────────────────────────────────────────
+
+ public bool OwnsScene(string sceneName)
+ => !string.IsNullOrEmpty(sceneName) &&
+ sceneName.StartsWith("Room_", System.StringComparison.Ordinal);
+
+ public IEnumerator LoadAndActivateCoroutine(
+ string sceneName, string entryTransitionId, bool isRespawn)
+ {
+ var ctx = new SpawnContext(entryTransitionId, isRespawn);
+ yield return LoadAndActivateRoomCoroutine(sceneName, ctx);
+ }
+
// ── 生命周期 ──────────────────────────────────────────────────────────────
private void Awake()
{
ServiceLocator.Register(this);
+ ServiceLocator.Register(this);
_graph = WorldGraph.Build(_mapDatabase, _unitsPerGrid);
}
@@ -110,6 +147,7 @@ namespace BaseGames.World.Streaming
private void OnDestroy()
{
ServiceLocator.Unregister(this);
+ ServiceLocator.Unregister(this);
}
private void Update()
@@ -160,10 +198,14 @@ namespace BaseGames.World.Streaming
}
}
- // 保留集内尚未加载的房间 → 加入预加载队列
- foreach (var kv in _hopCache)
+ // 重建加载队列:清除废弃条目(keepIds 已缩小时不再需要的房间),
+ // 按跳数升序重新入队(近邻优先),确保有限并发槽优先服务最近的房间。
+ // 跳数 = 0 为当前房间本身,已在 Active 路径中处理,跳过以避免冷启动时重复加载。
+ _loadQueue.Clear();
+ _queuedRoomIds.Clear();
+ foreach (var kv in _hopCache.OrderBy(kv => kv.Value))
{
- if (kv.Value > hops) continue;
+ if (kv.Value == 0 || kv.Value > hops) continue;
if (!_handles.ContainsKey(kv.Key))
EnqueuePreload(kv.Key);
}
@@ -206,6 +248,9 @@ namespace BaseGames.World.Streaming
var handle = new RoomHandle(roomId, this, perFrame);
// 从图节点读取内存估算(关卡设计师在 MapRoomDataSO 中填写)
handle.EstimatedMemKB = _graph.GetNode(roomId)?.EstimatedMemoryKB ?? 0;
+ if (handle.EstimatedMemKB == 0)
+ Debug.LogWarning($"[RoomStreamingManager] {roomId}: EstimatedMemoryKB 未设置(= 0),内存预算检查将跳过此房间。" +
+ "请在 MapRoomDataSO 中填写 Profiler 测量值。");
_handles[roomId] = handle;
// RoomId 与 Addressable key 相同(规范:Room_{Region}_{Id})
@@ -231,9 +276,10 @@ namespace BaseGames.World.Streaming
{
if (_budget == null) return;
- // 按距离降序 + LRU 时间升序排列所有 Dormant 房间
+ // 按距离降序 + LRU 时间升序排列所有 Dormant 房间。
+ // 排除已在冷却协程中的房间:它们已被调度为卸载,无需重复发起 UnloadRoomCoroutine。
var dormants = _handles.Values
- .Where(h => h.State == RoomState.Dormant)
+ .Where(h => h.State == RoomState.Dormant && !_roomsInCooling.Contains(h.RoomId))
.OrderByDescending(h => _hopCache.TryGetValue(h.RoomId, out int d) ? d : int.MaxValue)
.ThenBy(h => h.LastActiveTime)
.ToList();
@@ -272,10 +318,10 @@ namespace BaseGames.World.Streaming
// 冷却结束后再次检查是否仍需保留
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;
+ int hops = _budget != null ? _budget.PreloadLookaheadHops : 2;
+ // 直接复用 _hopCache(已在最近一次 RecalculateStreamingSet 中更新),避免重复 BFS
+ bool stillNeeded = handle.RoomId == _currentRoomId
+ || (_hopCache.TryGetValue(handle.RoomId, out int dist) && dist <= hops);
if (stillNeeded)
{
// 玩家已回到本房间附近:重置为 Dormant,可再次被激活
@@ -303,10 +349,13 @@ namespace BaseGames.World.Streaming
// ── TransitionDirector 调用接口 ────────────────────────────────────────────
///
- /// 查询目标房间是否已处于 Dormant 状态,可执行无等待的切换。
+ /// 查询目标房间是否已就绪,可执行无等待的切换。
+ /// Dormant 和 Cooling 均视为就绪:Cooling 房间已完成休眠化,
+ /// 可立即重置为 Dormant 后激活(常见于玩家折返场景)。
///
public bool IsRoomDormant(string roomId)
- => _handles.TryGetValue(roomId, out var h) && h.State == RoomState.Dormant;
+ => _handles.TryGetValue(roomId, out var h) &&
+ (h.State == RoomState.Dormant || h.State == RoomState.Cooling);
///
/// 激活目标房间(Dormant → Active)并停用前一个房间。
@@ -322,14 +371,21 @@ namespace BaseGames.World.Streaming
string previousRoomId = _currentRoomId;
+ // Cooling 房间已完成休眠化,可直接重置为 Dormant 后激活
+ // 场景:玩家 A→B 后立即 B→A(折返),A 处于 Cooling 状态
+ // CoolingCoroutine 超时后检查 _currentRoomId==A → stillNeeded=true → ResetToDormant()=无操作(已不在 Cooling),正常结束
+ if (targetHandle.State == RoomState.Cooling)
+ targetHandle.ResetToDormant();
+
// 激活新房间
yield return targetHandle.Activate(context);
_currentRoomId = targetRoomId;
// 旧房间进入冷却(不立即卸载;守卫防止 CoolingCoroutine 重复启动)
+ // 同时包含 Activating:玩家在激活过程中快速折返时也应正确触发冷却
if (!string.IsNullOrEmpty(previousRoomId) &&
_handles.TryGetValue(previousRoomId, out var prevHandle) &&
- prevHandle.State == RoomState.Active &&
+ (prevHandle.State == RoomState.Active || prevHandle.State == RoomState.Activating) &&
!_roomsInCooling.Contains(previousRoomId))
{
prevHandle.BeginCooling();
@@ -339,13 +395,13 @@ namespace BaseGames.World.Streaming
// 重新计算新房间的 StreamingSet
RecalculateStreamingSet(targetRoomId);
- // 通知:视同进入新房间(触发地图探索更新等)
- _onRoomEntered?.Raise(targetRoomId);
+ // 通知外部系统(地图探索、任务系统等):新房间已激活
+ _onRoomActivated?.Raise(targetRoomId);
}
///
- /// 将目标房间立即加载并激活(用于 Scene 类型的非流式路径首次进入某房间)。
- /// 完成后广播 EVT_RoomEntered 以触发 StreamingSet 重新计算。
+ /// 将目标房间立即加载并激活(用于 Room/Scene 类型的快速传送、复活等非预加载路径)。
+ /// 完成后经由 ActivateRoomCoroutine 发布 EVT_RoomActivated 并重新计算 StreamingSet。
///
public IEnumerator LoadAndActivateRoomCoroutine(string roomId, SpawnContext context)
{
@@ -353,9 +409,17 @@ namespace BaseGames.World.Streaming
{
int perFrame = _budget != null ? _budget.LifecycleActivatePerFrame : 8;
var handle = new RoomHandle(roomId, this, perFrame);
- handle.EstimatedMemKB = _graph.GetNode(roomId)?.EstimatedMemoryKB ?? 0;
+ handle.EstimatedMemKB = _graph?.GetNode(roomId)?.EstimatedMemoryKB ?? 0;
_handles[roomId] = handle;
yield return handle.LoadAsync(roomId);
+
+ if (handle.State != RoomState.Dormant)
+ {
+ // 加载失败:清理僵尸 handle,不继续激活(前一个房间保持 Active)
+ _handles.Remove(roomId);
+ Debug.LogError($"[RoomStreamingManager] LoadAndActivate:{roomId} 加载失败,取消激活。");
+ yield break;
+ }
}
yield return ActivateRoomCoroutine(roomId, context);
diff --git a/Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs.meta b/Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs.meta
new file mode 100644
index 0000000..59e7dc6
--- /dev/null
+++ b/Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0c17eea5df8bd684599801eb7dd7c41f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/World/Streaming/StreamingBudgetConfigSO.cs.meta b/Assets/_Game/Scripts/World/Streaming/StreamingBudgetConfigSO.cs.meta
new file mode 100644
index 0000000..88cb8ab
--- /dev/null
+++ b/Assets/_Game/Scripts/World/Streaming/StreamingBudgetConfigSO.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 65e349713a81a3846a654ea9f428ef99
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/World/Streaming/TransitionDirector.cs.meta b/Assets/_Game/Scripts/World/Streaming/TransitionDirector.cs.meta
new file mode 100644
index 0000000..47bf606
--- /dev/null
+++ b/Assets/_Game/Scripts/World/Streaming/TransitionDirector.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: a6db86de2f8c90548ba7e185b9cd5df6
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/World/Streaming/WorldGraph.cs b/Assets/_Game/Scripts/World/Streaming/WorldGraph.cs
index 5062b66..7ea0454 100644
--- a/Assets/_Game/Scripts/World/Streaming/WorldGraph.cs
+++ b/Assets/_Game/Scripts/World/Streaming/WorldGraph.cs
@@ -140,43 +140,6 @@ namespace BaseGames.World.Streaming
return node;
}
- /// 返回与指定房间直接相邻(1 跳)的所有邻居节点。
- public IEnumerable GetDirectNeighbors(string roomId)
- {
- if (!_nodes.TryGetValue(roomId, out var node)) yield break;
- foreach (var edge in node.Edges)
- yield return edge.To;
- }
-
- ///
- /// 通过 BFS 获取距离指定房间不超过 跳的所有房间节点集合。
- /// 结果不包含起始节点本身。
- ///
- public HashSet GetNeighborsWithinHops(string startRoomId, int maxHops)
- {
- var result = new HashSet();
- var visited = new HashSet { 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;
- }
-
///
/// 计算两个房间之间的最短跳数(BFS)。
/// 若不可达返回 int.MaxValue。
diff --git a/Assets/_Game/Scripts/World/Streaming/WorldGraph.cs.meta b/Assets/_Game/Scripts/World/Streaming/WorldGraph.cs.meta
new file mode 100644
index 0000000..a9d0c61
--- /dev/null
+++ b/Assets/_Game/Scripts/World/Streaming/WorldGraph.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8e6c4da6273ca654cbf30351d31202ed
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/zeling_v2.sln b/zeling_v2.sln
index 1b10da4..afc086a 100644
--- a/zeling_v2.sln
+++ b/zeling_v2.sln
@@ -93,6 +93,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PathBerserker2d.Upgrade", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lofelt.NiceVibrations.Demo", "Lofelt.NiceVibrations.Demo.csproj", "{14C347B2-CA5C-168F-A3AF-5EB9B2E38865}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.World.Streaming", "BaseGames.World.Streaming.csproj", "{8FA0AF4D-7EF6-D3CD-F2A1-9C291AA06F3C}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Opsive.Shared.Editor.Import", "Opsive.Shared.Editor.Import.csproj", "{98DF9B7C-303C-762E-38C1-C19D328BF217}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Tutorial", "BaseGames.Tutorial.csproj", "{0A1566C3-6032-C8A1-D015-8EF75B3F7099}"
@@ -309,6 +311,10 @@ Global
{14C347B2-CA5C-168F-A3AF-5EB9B2E38865}.Debug|Any CPU.Build.0 = Debug|Any CPU
{14C347B2-CA5C-168F-A3AF-5EB9B2E38865}.Release|Any CPU.ActiveCfg = Release|Any CPU
{14C347B2-CA5C-168F-A3AF-5EB9B2E38865}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8FA0AF4D-7EF6-D3CD-F2A1-9C291AA06F3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8FA0AF4D-7EF6-D3CD-F2A1-9C291AA06F3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8FA0AF4D-7EF6-D3CD-F2A1-9C291AA06F3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8FA0AF4D-7EF6-D3CD-F2A1-9C291AA06F3C}.Release|Any CPU.Build.0 = Release|Any CPU
{98DF9B7C-303C-762E-38C1-C19D328BF217}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{98DF9B7C-303C-762E-38C1-C19D328BF217}.Debug|Any CPU.Build.0 = Debug|Any CPU
{98DF9B7C-303C-762E-38C1-C19D328BF217}.Release|Any CPU.ActiveCfg = Release|Any CPU