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.
This commit is contained in:
35
Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs
Normal file
35
Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System.Collections;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 场景加载协调器接口。
|
||||
/// <para>
|
||||
/// 定义于 <c>BaseGames.Core</c> 以避免 <see cref="SceneService"/> 对
|
||||
/// <c>BaseGames.World.Streaming</c> 产生直接依赖。
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 由流式加载系统(RoomStreamingManager)实现并在 Awake 中向
|
||||
/// <see cref="ServiceLocator"/> 注册。当注册存在时,<see cref="SceneService"/>
|
||||
/// 将符合条件的场景加载请求委托给本接口,确保房间生命周期(Dormant / Active / Cooling)
|
||||
/// 得到完整维护;否则退回到 SceneLoader 原生路径。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface ISceneLoadCoordinator
|
||||
{
|
||||
/// <summary>
|
||||
/// 判断给定场景地址是否应由流式系统管理(而非 SceneLoader 直接加载)。
|
||||
/// <para>约定:以 <c>"Room_"</c> 前缀开头的地址均属于流式系统管辖范围。</para>
|
||||
/// </summary>
|
||||
bool OwnsScene(string sceneName);
|
||||
|
||||
/// <summary>
|
||||
/// 以完整流式路径加载并激活指定房间
|
||||
/// (Load → Dormant → Active,同时将前一个 Active 房间送入冷却队列)。
|
||||
/// </summary>
|
||||
/// <param name="sceneName">Addressable key(等同于 RoomId,前缀 "Room_")。</param>
|
||||
/// <param name="entryTransitionId">目标房间出生点 ID;null 表示使用默认出生点。</param>
|
||||
/// <param name="isRespawn">true = 复活流程,玩家应在最近存档点出生。</param>
|
||||
IEnumerator LoadAndActivateCoroutine(string sceneName, string entryTransitionId, bool isRespawn);
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs.meta
Normal file
11
Assets/_Game/Scripts/Core/ISceneLoadCoordinator.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cddf13c179a032c4293e181de7e8470f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
11
Assets/_Game/Scripts/Core/ITransitionDirector.cs.meta
Normal file
11
Assets/_Game/Scripts/Core/ITransitionDirector.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ba229944271875048b97b953793bf37e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<ISceneLoadCoordinator>();
|
||||
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 等)会在同一帧同步执行。
|
||||
|
||||
266
Assets/_Game/Scripts/Editor/Enemies/EnemyRespawnerEditor.cs
Normal file
266
Assets/_Game/Scripts/Editor/Enemies/EnemyRespawnerEditor.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using BaseGames.Enemies;
|
||||
using BaseGames.World;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="EnemyRespawner"/> 自定义 Inspector。
|
||||
/// <list type="bullet">
|
||||
/// <item>配置校验:生成来源为空、永久击杀 ID 已填但 WorldStateRegistry 未关联时显示警告。</item>
|
||||
/// <item>运行时状态:PlayMode 中显示当前激活实例信息。</item>
|
||||
/// <item>批量验证菜单:BaseGames / Enemies / Validate Respawners。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[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<WorldStateRegistry>();
|
||||
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<EnemyRespawner>(
|
||||
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, "确定");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 25ae5943339e21845a4f350aac520224
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
11
Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 43629b8180950e742b8f92d89180c9ac
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<MapRoomDataSO>();
|
||||
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");
|
||||
|
||||
175
Assets/_Game/Scripts/Editor/World/RoomTransitionEditor.cs
Normal file
175
Assets/_Game/Scripts/Editor/World/RoomTransitionEditor.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.World;
|
||||
using BaseGames.World.Map;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// RoomTransition 自定义 Inspector。
|
||||
/// <para>
|
||||
/// 在默认字段下方附加「MapDatabase 同步」区块:
|
||||
/// 自动查找场景内的 <see cref="RoomController"/>,定位所属房间在 <see cref="MapDatabaseSO"/>
|
||||
/// 中对应出口的 <see cref="RoomExitData.PreferredTransitionType"/>,若与组件
|
||||
/// <c>_transitionType</c> 不一致则显示警告并提供一键同步按钮。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>在与 <paramref name="go"/> 相同场景内找到 RoomController 并返回其 RoomId。</summary>
|
||||
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<RoomController>(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<MapDatabaseSO>(
|
||||
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<MapDatabaseSO>(
|
||||
AssetDatabase.GUIDToAssetPath(guids[0]));
|
||||
|
||||
var transitions = Object.FindObjectsByType<RoomTransition>(
|
||||
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, "确定");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bb4315fd38131af4a8a42ddfb7b82151
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
625
Assets/_Game/Scripts/Editor/World/StreamingGraphWindow.cs
Normal file
625
Assets/_Game/Scripts/Editor/World/StreamingGraphWindow.cs
Normal file
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 房间连通性可视化编辑器窗口。
|
||||
/// <para>
|
||||
/// 功能:
|
||||
/// <list type="bullet">
|
||||
/// <item>读取 <see cref="MapDatabaseSO"/> 绘制全图房间节点(以 GridPosition/GridSize 定位)</item>
|
||||
/// <item>绘制出口连线,颜色按 <see cref="TransitionType"/> 区分</item>
|
||||
/// <item>节点边框颜色 = Addressable 注册状态(绿色已注册 / 红色未注册)</item>
|
||||
/// <item>运行时叠加:在 Play Mode 下按 <see cref="RoomState"/> 着色节点填充</item>
|
||||
/// <item>单击节点 → Ping SO;双击 → 打开对应场景</item>
|
||||
/// <item>支持鼠标滚轮缩放(向光标缩放)和中键拖拽平移</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>菜单:<c>BaseGames/World/Streaming Graph</c></para>
|
||||
/// </summary>
|
||||
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<NodeData> _nodes = new();
|
||||
private Dictionary<string, NodeData> _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<string>();
|
||||
private int _regionIndex; // 0 = 全部
|
||||
private bool _showLegend = true;
|
||||
private GUIStyle _labelStyle;
|
||||
|
||||
// ── 菜单 / 打开 ───────────────────────────────────────────────────────────
|
||||
|
||||
[MenuItem("BaseGames/World/Streaming Graph")]
|
||||
public static void Open()
|
||||
{
|
||||
var win = GetWindow<StreamingGraphWindow>("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);
|
||||
}
|
||||
|
||||
// ── 坐标工具 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>网格坐标 → 画布局部偏移(不含 canvasRect.min)。</summary>
|
||||
private Vector2 CanvasLocalPos(float gridX, float gridY)
|
||||
=> _offset + new Vector2(gridX * CellSizePx * _zoom, gridY * CellSizePx * _zoom);
|
||||
|
||||
/// <summary>房间节点的画布局部 Rect(相对于 canvasRect.min,供 BeginClip 内使用)。</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>房间节点的绝对 Rect(含 canvasRect.min,供 Handles 使用)。</summary>
|
||||
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<MapDatabaseSO>(
|
||||
AssetDatabase.GUIDToAssetPath(guids[0]));
|
||||
|
||||
BuildRegions();
|
||||
BuildNodeCache();
|
||||
CheckAddressableRegistration();
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private void BuildRegions()
|
||||
{
|
||||
var set = new HashSet<string> { "" };
|
||||
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<string>(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<string>();
|
||||
|
||||
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<RoomStreamingManager>() 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ff23fee892b09e6479e2470fbdcbb1b3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BDTaskAttributes.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BDTaskAttributes.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e52f911001ee0448971afc570e5e486
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -24,7 +24,7 @@ namespace BaseGames.Enemies.AI
|
||||
public override void OnAwake()
|
||||
{
|
||||
_boss = GetComponent<BossBase>();
|
||||
_executor = GetComponentInChildren<BossSkillExecutor>();
|
||||
_executor = transform.GetComponentInChildren<BossSkillExecutor>();
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
|
||||
246
Assets/_Game/Scripts/Enemies/EnemyRespawner.cs
Normal file
246
Assets/_Game/Scripts/Enemies/EnemyRespawner.cs
Normal file
@@ -0,0 +1,246 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Pool;
|
||||
using BaseGames.World;
|
||||
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// 场景内的敌人刷新点。实现 <see cref="IRoomLifecycle"/>,与房间流式系统联动:
|
||||
/// <list type="bullet">
|
||||
/// <item>房间激活(<see cref="OnRoomActivate"/>)时生成敌人实例。</item>
|
||||
/// <item>房间休眠(<see cref="OnRoomDormant"/>)时清理仍存活的实例,确保下次进入时刷出新实例。</item>
|
||||
/// <item>可选 <see cref="_respawnDelay"/>:敌人在房间内死亡后等待若干秒自动复活。</item>
|
||||
/// <item>可选的 <see cref="_persistentKillId"/>:击杀后写入 <see cref="WorldStateRegistry"/>,
|
||||
/// 使敌人在存档重载后不再复活(适用于 Boss 或仅出现一次的守卫)。</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// 挂载方式:在房间场景中放置空 GameObject 作为刷新点,挂上本组件,
|
||||
/// 并通过 <see cref="_enemyPrefab"/> 或 <see cref="_poolKey"/> 指定要生成的敌人。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>本次房间访问中敌人已在房间内复活的次数(不含初始生成)。</summary>
|
||||
private int _respawnCountThisVisit;
|
||||
|
||||
private Coroutine _respawnCoroutine;
|
||||
|
||||
// ── IRoomLifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 房间休眠时清理当前存活的敌人实例,并取消待执行的复活协程。
|
||||
/// 已进入死亡流程(<see cref="EnemyBase.IsAlive"/> = false)的实例由
|
||||
/// <see cref="EnemyBase.Die"/> 自行处理,此处不干预。
|
||||
/// </summary>
|
||||
public void OnRoomDormant()
|
||||
{
|
||||
// 取消待执行的复活计时
|
||||
if (_respawnCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_respawnCoroutine);
|
||||
_respawnCoroutine = null;
|
||||
}
|
||||
|
||||
if (_activeEnemy != null)
|
||||
{
|
||||
_activeEnemy.OnDied -= OnEnemyDied;
|
||||
|
||||
if (_activeEnemy.IsAlive)
|
||||
{
|
||||
// 优先归还对象池;池服务不可用或无 PooledObject 时直接销毁
|
||||
var po = _activeEnemy.GetComponent<PooledObject>();
|
||||
var pool = !string.IsNullOrEmpty(_poolKey)
|
||||
? ServiceLocator.GetOrDefault<IObjectPoolService>()
|
||||
: null;
|
||||
|
||||
if (pool != null && po != null)
|
||||
pool.Despawn(_poolKey, po);
|
||||
else
|
||||
Destroy(_activeEnemy.gameObject);
|
||||
}
|
||||
// IsAlive=false:EnemyBase.Die() 已通过动画回调调度销毁,自然完成即可
|
||||
|
||||
_activeEnemy = null;
|
||||
}
|
||||
|
||||
_respawnCountThisVisit = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 房间激活时生成敌人。永久死亡的敌人不会再次生成。
|
||||
/// </summary>
|
||||
/// <param name="context">出生上下文(含是否为复活流程等信息)。</param>
|
||||
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<IObjectPoolService>();
|
||||
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<EnemyBase>(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<WorldStateRegistry>();
|
||||
}
|
||||
#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
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Enemies/EnemyRespawner.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/EnemyRespawner.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3b8f1de7a8f37a84195a9b5c1df5170d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 03901716d747f924d9d7380e4d555559
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
11
Assets/_Game/Scripts/World/IRoomLifecycle.cs.meta
Normal file
11
Assets/_Game/Scripts/World/IRoomLifecycle.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 643576a745fe0f84ba333c99d3ea302d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -45,5 +45,11 @@ namespace BaseGames.World
|
||||
/// 用于非流式冷启动路径(如游戏初始化、快速传送落地)。
|
||||
/// </summary>
|
||||
IEnumerator LoadAndActivateRoomCoroutine(string roomId, SpawnContext context);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定房间的当前流式状态。
|
||||
/// 若房间不在流式系统中(未加载或 ID 不存在),返回 <see cref="RoomState.Unloaded"/>。
|
||||
/// </summary>
|
||||
RoomState GetRoomState(string roomId);
|
||||
}
|
||||
}
|
||||
|
||||
11
Assets/_Game/Scripts/World/IRoomStreamingManager.cs.meta
Normal file
11
Assets/_Game/Scripts/World/IRoomStreamingManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d12962e2719b0374ea8257e78ee81a14
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -8,7 +8,7 @@ namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个房间的地图数据 SO(架构 15_MapShopModule §1.1)。
|
||||
/// 资产路径: Assets/ScriptableObjects/Map/Room_{RoomId}.asset
|
||||
/// 资产路径: Assets/_Game/Data/Map/Rooms/Room_{RoomId}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/World/Map/RoomData")]
|
||||
public class MapRoomDataSO : ScriptableObject
|
||||
@@ -61,7 +61,7 @@ namespace BaseGames.World.Map
|
||||
|
||||
/// <summary>
|
||||
/// 全局地图数据库 SO(编辑器配置一次;架构 15_MapShopModule §1.1)。
|
||||
/// 资产路径: Assets/ScriptableObjects/Map/MapDatabase.asset
|
||||
/// 资产路径: Assets/_Game/Data/Map/MapDatabase.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/World/Map/MapDatabase")]
|
||||
public class MapDatabaseSO : ScriptableObject
|
||||
|
||||
11
Assets/_Game/Scripts/World/RoomState.cs.meta
Normal file
11
Assets/_Game/Scripts/World/RoomState.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2b0c4aa720fd33a4394460868ec5c907
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
11
Assets/_Game/Scripts/World/SpawnContext.cs.meta
Normal file
11
Assets/_Game/Scripts/World/SpawnContext.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 231e8065d039d4c45bc0e11e64998bb7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Scripts/World/Streaming.meta
Normal file
8
Assets/_Game/Scripts/World/Streaming.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: da275f27c58bf9c40b3fa7b79e6e8b39
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 18b849fabd3ac314bbe36055acb3a4b8
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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 ||
|
||||
|
||||
11
Assets/_Game/Scripts/World/Streaming/RoomHandle.cs.meta
Normal file
11
Assets/_Game/Scripts/World/Streaming/RoomHandle.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c41dde1869912a647a425338b185282f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -22,14 +22,16 @@ namespace BaseGames.World.Streaming
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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<IRoomStreamingManager>(this);
|
||||
ServiceLocator.Register<ISceneLoadCoordinator>(this);
|
||||
_graph = WorldGraph.Build(_mapDatabase, _unitsPerGrid);
|
||||
}
|
||||
|
||||
@@ -110,6 +147,7 @@ namespace BaseGames.World.Streaming
|
||||
private void OnDestroy()
|
||||
{
|
||||
ServiceLocator.Unregister<IRoomStreamingManager>(this);
|
||||
ServiceLocator.Unregister<ISceneLoadCoordinator>(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();
|
||||
@@ -273,9 +319,9 @@ 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;
|
||||
// 直接复用 _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 调用接口 ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 查询目标房间是否已处于 Dormant 状态,可执行无等待的切换。
|
||||
/// 查询目标房间是否已就绪,可执行无等待的切换。
|
||||
/// Dormant 和 Cooling 均视为就绪:Cooling 房间已完成休眠化,
|
||||
/// 可立即重置为 Dormant 后激活(常见于玩家折返场景)。
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 激活目标房间(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将目标房间立即加载并激活(用于 Scene 类型的非流式路径首次进入某房间)。
|
||||
/// 完成后广播 EVT_RoomEntered 以触发 StreamingSet 重新计算。
|
||||
/// 将目标房间立即加载并激活(用于 Room/Scene 类型的快速传送、复活等非预加载路径)。
|
||||
/// 完成后经由 ActivateRoomCoroutine 发布 EVT_RoomActivated 并重新计算 StreamingSet。
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0c17eea5df8bd684599801eb7dd7c41f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 65e349713a81a3846a654ea9f428ef99
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a6db86de2f8c90548ba7e185b9cd5df6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -140,43 +140,6 @@ namespace BaseGames.World.Streaming
|
||||
return node;
|
||||
}
|
||||
|
||||
/// <summary>返回与指定房间直接相邻(1 跳)的所有邻居节点。</summary>
|
||||
public IEnumerable<RoomNode> GetDirectNeighbors(string roomId)
|
||||
{
|
||||
if (!_nodes.TryGetValue(roomId, out var node)) yield break;
|
||||
foreach (var edge in node.Edges)
|
||||
yield return edge.To;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 BFS 获取距离指定房间不超过 <paramref name="maxHops"/> 跳的所有房间节点集合。
|
||||
/// 结果不包含起始节点本身。
|
||||
/// </summary>
|
||||
public HashSet<RoomNode> GetNeighborsWithinHops(string startRoomId, int maxHops)
|
||||
{
|
||||
var result = new HashSet<RoomNode>();
|
||||
var visited = new HashSet<string> { startRoomId };
|
||||
var queue = new Queue<(string roomId, int depth)>();
|
||||
queue.Enqueue((startRoomId, 0));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (current, depth) = queue.Dequeue();
|
||||
if (!_nodes.TryGetValue(current, out var node)) continue;
|
||||
|
||||
foreach (var edge in node.Edges)
|
||||
{
|
||||
if (visited.Contains(edge.To.RoomId)) continue;
|
||||
visited.Add(edge.To.RoomId);
|
||||
result.Add(edge.To);
|
||||
if (depth + 1 < maxHops)
|
||||
queue.Enqueue((edge.To.RoomId, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算两个房间之间的最短跳数(BFS)。
|
||||
/// 若不可达返回 int.MaxValue。
|
||||
|
||||
11
Assets/_Game/Scripts/World/Streaming/WorldGraph.cs.meta
Normal file
11
Assets/_Game/Scripts/World/Streaming/WorldGraph.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8e6c4da6273ca654cbf30351d31202ed
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user