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:
2026-05-23 21:23:09 +08:00
parent a1b4e629aa
commit 520f84999b
34 changed files with 1710 additions and 63 deletions

View 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 = 0Max 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, "确定");
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 25ae5943339e21845a4f350aac520224
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 43629b8180950e742b8f92d89180c9ac
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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");

View 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, "确定");
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bb4315fd38131af4a8a42ddfb7b82151
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ff23fee892b09e6479e2470fbdcbb1b3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: