From 520f84999bc855eb0b54dd38cd29bf19730cf31a Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 23 May 2026 21:23:09 +0800 Subject: [PATCH] Add enemy respawner and related components for room lifecycle management - Implemented EnemyRespawner to manage enemy spawning and respawning within rooms. - Added IRoomLifecycle interface for room activation and dormancy handling. - Created supporting classes and metadata for enemy perception and threat assessment. - Established streaming system components for room state management and transitions. - Added necessary metadata files for new scripts to ensure proper integration with Unity. --- .../Scripts/Core/ISceneLoadCoordinator.cs | 35 + .../Core/ISceneLoadCoordinator.cs.meta | 11 + .../Scripts/Core/ITransitionDirector.cs.meta | 11 + Assets/_Game/Scripts/Core/SceneService.cs | 15 +- .../Editor/Enemies/EnemyRespawnerEditor.cs | 266 ++++++++ .../Enemies/EnemyRespawnerEditor.cs.meta | 11 + .../Editor/Modules/StreamingModule.cs.meta | 11 + .../Editor/Scene/SceneScaffoldTools.cs | 30 +- .../Editor/World/RoomTransitionEditor.cs | 175 +++++ .../Editor/World/RoomTransitionEditor.cs.meta | 11 + .../Editor/World/StreamingGraphWindow.cs | 625 ++++++++++++++++++ .../Editor/World/StreamingGraphWindow.cs.meta | 11 + .../Enemies/AI/BDTaskAttributes.cs.meta | 11 + .../Scripts/Enemies/AI/BD_CanUseBossSkill.cs | 2 +- .../_Game/Scripts/Enemies/EnemyRespawner.cs | 246 +++++++ .../Scripts/Enemies/EnemyRespawner.cs.meta | 11 + .../Perception/EnemyThreatAssessor.cs.meta | 11 + .../Scripts/World/IRoomLifecycle.cs.meta | 11 + .../Scripts/World/IRoomStreamingManager.cs | 6 + .../World/IRoomStreamingManager.cs.meta | 11 + .../_Game/Scripts/World/Map/MapRoomDataSO.cs | 4 +- Assets/_Game/Scripts/World/RoomState.cs.meta | 11 + .../_Game/Scripts/World/SpawnContext.cs.meta | 11 + Assets/_Game/Scripts/World/Streaming.meta | 8 + .../BaseGames.World.Streaming.asmdef.meta | 7 + .../Scripts/World/Streaming/RoomHandle.cs | 7 + .../World/Streaming/RoomHandle.cs.meta | 11 + .../World/Streaming/RoomStreamingManager.cs | 106 ++- .../Streaming/RoomStreamingManager.cs.meta | 11 + .../Streaming/StreamingBudgetConfigSO.cs.meta | 11 + .../Streaming/TransitionDirector.cs.meta | 11 + .../Scripts/World/Streaming/WorldGraph.cs | 37 -- .../World/Streaming/WorldGraph.cs.meta | 11 + zeling_v2.sln | 6 + 34 files changed, 1710 insertions(+), 63 deletions(-) create mode 100644 Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs create mode 100644 Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs.meta create mode 100644 Assets/_Game/Scripts/Core/ITransitionDirector.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Enemies/EnemyRespawnerEditor.cs create mode 100644 Assets/_Game/Scripts/Editor/Enemies/EnemyRespawnerEditor.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/World/RoomTransitionEditor.cs create mode 100644 Assets/_Game/Scripts/Editor/World/RoomTransitionEditor.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/World/StreamingGraphWindow.cs create mode 100644 Assets/_Game/Scripts/Editor/World/StreamingGraphWindow.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AI/BDTaskAttributes.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/EnemyRespawner.cs create mode 100644 Assets/_Game/Scripts/Enemies/EnemyRespawner.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Perception/EnemyThreatAssessor.cs.meta create mode 100644 Assets/_Game/Scripts/World/IRoomLifecycle.cs.meta create mode 100644 Assets/_Game/Scripts/World/IRoomStreamingManager.cs.meta create mode 100644 Assets/_Game/Scripts/World/RoomState.cs.meta create mode 100644 Assets/_Game/Scripts/World/SpawnContext.cs.meta create mode 100644 Assets/_Game/Scripts/World/Streaming.meta create mode 100644 Assets/_Game/Scripts/World/Streaming/BaseGames.World.Streaming.asmdef.meta create mode 100644 Assets/_Game/Scripts/World/Streaming/RoomHandle.cs.meta create mode 100644 Assets/_Game/Scripts/World/Streaming/RoomStreamingManager.cs.meta create mode 100644 Assets/_Game/Scripts/World/Streaming/StreamingBudgetConfigSO.cs.meta create mode 100644 Assets/_Game/Scripts/World/Streaming/TransitionDirector.cs.meta create mode 100644 Assets/_Game/Scripts/World/Streaming/WorldGraph.cs.meta 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