1. 项目概述为什么我们需要一个专属的关卡编辑器如果你正在开发一款地牢RPG游戏无论是回合制、动作类还是策略向关卡设计都是核心中的核心。传统的地牢关卡往往由策划在Excel里画格子程序再根据坐标硬编码到游戏里。这种工作流效率低、迭代慢一个简单的房间布局调整都可能需要程序重新编译打包策划和程序之间来回扯皮是家常便饭。几年前Unity推出了GraphView这套基于UI Toolkit的可视化节点编辑框架。它最出名的应用就是Shader Graph和Visual Scripting。但很多人不知道的是GraphView的潜力远不止于此它完全可以被我们“征用”过来打造一个完全贴合自己项目需求的专属可视化编辑器。想象一下策划同学可以在Unity编辑器里像搭积木一样通过拖拽节点来定义房间、连接通道、摆放怪物和宝箱所有数据实时可预览、可调整并且能一键导出为游戏可读的配置文件。这不仅能将策划从繁琐的表格中解放出来更能实现真正的“所见即所得”开发大幅提升团队协作效率和内容产出的质量。这个项目就是带你从零开始利用Unity的GraphView一步步构建一个专属于你地牢RPG的关卡编辑器。我们会聚焦于最核心的“房间-通道”连接逻辑并附上我踩过的所有坑和避坑指南确保你不仅能做出来还能做得稳定、高效。2. 核心思路与架构设计在动手写代码之前我们必须想清楚这个编辑器要管什么以及数据如何流动。一个地牢关卡抽象来看核心元素无非是“房间”和“通道”。2.1 数据模型定义房间与通道首先我们需要定义底层的数据结构。这部分不依赖任何UI是纯粹的数据类。房间节点数据 (RoomNodeData) 它代表地牢中的一个独立空间。我们需要记录它的唯一ID、在编辑器中的位置、房间类型例如起始房间、普通战斗房、精英房、宝箱房、Boss房、预设的资源路径以及它所连接的所有通道的ID列表。[System.Serializable] public class RoomNodeData { public string Guid; // 全局唯一标识用于节点间关联 public Vector2 Position; // 在GraphView画布上的位置 public RoomType Type; public string PrefabAssetPath; // 关联的预制体路径 public Liststring ConnectedEdgeGuids new Liststring(); // 连接到此房间的通道ID列表 } public enum RoomType { Start, NormalCombat, Elite, Treasure, Boss, // ... 可根据项目扩展 }通道边数据 (EdgeData) 它代表连接两个房间的路径。需要记录通道自身的ID以及它连接的两个房间节点的ID。[System.Serializable] public class EdgeData { public string Guid; public string InputNodeGuid; // 输入端房间ID public string OutputNodeGuid; // 输出端房间ID // 可以扩展通道类型单向门、双向门、密道、长度、事件等 }关卡数据容器 (LevelGraphData) 这是整个关卡数据的根容器包含所有房间和通道的数据并负责序列化保存为JSON或ScriptableObject。[System.Serializable] public class LevelGraphData { public ListRoomNodeData Rooms new ListRoomNodeData(); public ListEdgeData Edges new ListEdgeData(); }注意这里使用string类型的Guid而非直接引用对象是为了序列化的方便。在内存中运行时我们可以建立Dictionarystring, RoomNodeData来快速查找但在保存到磁盘时避免循环引用和Unity对象序列化的问题。2.2 编辑器架构GraphView的三驾马车GraphView的核心是三个类的协作GraphView、Node和Edge。GraphView 这是整个节点的画布容器。我们的主编辑器窗口将继承自EditorWindow并在其中创建一个GraphView实例作为可视化区域。它负责处理节点的拖拽、框选、缩放等视图操作以及管理所有节点和边的生命周期。Node 对应我们数据模型中的RoomNodeData。我们需要创建一个自定义的RoomNode类继承自Node。在这个类里我们定义节点的外观标题、颜色、输入输出端口并将它与一个RoomNodeData实例绑定。当节点在界面上被移动时需要同步更新其绑定的数据中的Position。Edge 对应数据模型中的EdgeData。当用户在两个RoomNode的端口之间拖出一条连接线时GraphView会自动创建Edge实例。我们需要监听边的创建和删除事件并相应地创建或删除底层的EdgeData同时更新关联房间数据中的ConnectedEdgeGuids列表。数据流设计 我们的目标是实现UI与数据的双向绑定。用户操作UI增删改节点/边 → 触发事件 → 更新内存中的LevelGraphData→ 序列化保存到资产文件。反之加载资产文件 → 反序列化为LevelGraphData→ 根据数据在GraphView中重建所有RoomNode和Edge。3. 实战步骤一搭建编辑器窗口与GraphView画布首先我们在Editor文件夹下创建核心脚本。3.1 创建主编辑器窗口创建一个继承自EditorWindow的类比如DungeonLevelGraphWindow。using UnityEditor; using UnityEngine; using UnityEngine.UIElements; public class DungeonLevelGraphWindow : EditorWindow { private DungeonGraphView _graphView; private LevelGraphData _currentLevelData; [MenuItem(Tools/地牢关卡编辑器)] public static void OpenWindow() { var window GetWindowDungeonLevelGraphWindow(); window.titleContent new GUIContent(地牢关卡编辑器); window.Show(); } private void OnEnable() { ConstructGraphView(); GenerateToolbar(); } private void OnDisable() { // 重要移除GraphView防止内存泄漏 rootVisualElement.Remove(_graphView); } private void ConstructGraphView() { _graphView new DungeonGraphView { name Dungeon Graph View }; // 让GraphView撑满整个窗口 _graphView.StretchToParentSize(); rootVisualElement.Add(_graphView); } private void GenerateToolbar() { var toolbar new Toolbar(); // 新建按钮 var createNewButton new Button(() RequestDataOperation(false)) { text 新建 }; // 加载按钮 var loadButton new Button(() RequestDataOperation(true)) { text 加载 }; // 保存按钮 var saveButton new Button(() SaveLevelData()) { text 保存 }; toolbar.Add(createNewButton); toolbar.Add(loadButton); toolbar.Add(saveButton); rootVisualElement.Add(toolbar); } private void RequestDataOperation(bool load) { // 这里实现弹出文件对话框选择保存路径或加载文件 // 简化示例使用EditorUtility保存/加载JSON string path EditorUtility.SaveFilePanelInProject(保存关卡, NewLevel, json, 请选择保存路径); if (!string.IsNullOrEmpty(path)) { // 序列化 _currentLevelData 到 JSON 并保存 } // 加载逻辑类似 } private void SaveLevelData() { if (_graphView ! null) { // 通知GraphView收集所有节点和边的数据填充到 _currentLevelData _currentLevelData _graphView.CollectGraphData(); // 然后调用序列化保存方法 RequestDataOperation(false); } } }3.2 实现自定义的DungeonGraphView这是核心类继承自GraphView。using System.Collections.Generic; using UnityEditor; using UnityEngine; using UnityEngine.UIElements; using UnityEditor.Experimental.GraphView; public class DungeonGraphView : GraphView { public DungeonGraphView() { // 1. 添加网格背景 this.AddGridBackground(); // 2. 启用基本的视图操作 this.AddManipulator(new ContentZoomer()); // 缩放 this.AddManipulator(new ContentDragger()); // 拖动画布 this.AddManipulator(new SelectionDragger()); // 拖拽选中项 this.AddManipulator(new RectangleSelector()); // 矩形框选 // 3. 设置节点可创建的类型后续会用到 nodeCreationRequest context { SearchWindow.Open(new SearchWindowContext(context.screenMousePosition), CreateNodeSearchProvider()); }; } // 关键定义哪些端口可以连接 public override ListPort GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter) { var compatiblePorts new ListPort(); ports.ForEach(port { // 基础规则不允许连接到自身且输入只能连输出输出只能连输入 if (startPort ! port startPort.node ! port.node startPort.direction ! port.direction) { // 这里可以添加更复杂的规则比如根据端口类型房间类型判断是否可连接 compatiblePorts.Add(port); } }); return compatiblePorts; } // 创建节点搜索窗口的数据提供者简化版 private DungeonNodeSearchProvider CreateNodeSearchProvider() { var provider ScriptableObject.CreateInstanceDungeonNodeSearchProvider(); provider.Initialize(this); return provider; } // 从当前视图收集数据生成LevelGraphData public LevelGraphData CollectGraphData() { var levelData new LevelGraphData(); // 遍历所有节点需要转换为RoomNode foreach (var node in nodes) { if (node is RoomNode roomNode) { levelData.Rooms.Add(roomNode.GetData()); } } // 遍历所有边 foreach (var edge in edges) { if (edge.input?.node is RoomNode inputNode edge.output?.node is RoomNode outputNode) { var edgeData new EdgeData { Guid edge.viewDataKey, // GraphView会为Edge自动生成viewDataKey InputNodeGuid inputNode.Guid, OutputNodeGuid outputNode.Guid }; levelData.Edges.Add(edgeData); } } return levelData; } // 根据LevelGraphData加载并重建视图 public void LoadGraphData(LevelGraphData levelData) { // 清空当前视图 DeleteElements(graphElements.ToList()); var nodeGuidMap new Dictionarystring, RoomNode(); // 1. 创建所有房间节点 foreach (var roomData in levelData.Rooms) { var roomNode RoomNode.CreateNode(roomData); AddElement(roomNode); nodeGuidMap[roomData.Guid] roomNode; } // 2. 创建所有通道边 foreach (var edgeData in levelData.Edges) { if (nodeGuidMap.TryGetValue(edgeData.InputNodeGuid, out var inputNode) nodeGuidMap.TryGetValue(edgeData.OutputNodeGuid, out var outputNode)) { // 找到输入节点的输入端口输出节点的输出端口 // 注意一个房间节点可能有多个端口这里需要根据业务逻辑确定连接规则 // 简化假设每个房间只有一个输入端口和一个输出端口 Port inputPort inputNode.inputContainer.QPort(); Port outputPort outputNode.outputContainer.QPort(); if (inputPort ! null outputPort ! null) { var edge inputPort.ConnectTo(outputPort); AddElement(edge); } } } } }实操心得GetCompatiblePorts方法是控制连接逻辑的闸门。在地牢编辑器中我们可能希望“起始房间”只有输出端口“Boss房间”只有输入端口普通房间可以既有输入也有输出。你可以在这里根据端口userData可以存储房间类型等信息来实现复杂的连接规则这是保证数据逻辑正确的关键。4. 实战步骤二实现自定义房间节点 (RoomNode)节点是编辑器的灵魂它负责将数据可视化并处理用户交互。using System; using UnityEditor.Experimental.GraphView; using UnityEngine; using UnityEngine.UIElements; public class RoomNode : Node { public string Guid { get; private set; } private RoomNodeData _data; private TextField _roomNameField; private EnumField _roomTypeField; // 工厂方法用于创建新节点或从数据加载节点 public static RoomNode CreateNode(RoomNodeData data null) { var node new RoomNode(data); return node; } private RoomNode(RoomNodeData data) { if (data null) { // 创建新节点 _data new RoomNodeData { Guid System.Guid.NewGuid().ToString(), Position Vector2.zero, Type RoomType.NormalCombat }; } else { // 从数据加载节点 _data data; } this.Guid _data.Guid; // 设置节点在画布上的位置 SetPosition(new Rect(_data.Position, Vector2.zero)); // 构建节点UI CreateNodeUI(); // 监听节点位置变化同步到数据 this.RegisterCallbackGeometryChangedEvent(OnGeometryChanged); } private void CreateNodeUI() { // 1. 标题 title 房间节点; // 2. 输入端口左侧 var inputPort InstantiatePort(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi, typeof(bool)); inputPort.portName 入口; inputContainer.Add(inputPort); // 3. 输出端口右侧 var outputPort InstantiatePort(Orientation.Horizontal, Direction.Output, Port.Capacity.Multi, typeof(bool)); outputPort.portName 出口; outputContainer.Add(outputPort); // 4. 主内容区域可编辑字段 var contentContainer new VisualElement(); // 房间名称 _roomNameField new TextField(名称); _roomNameField.value $房间_{Guid.Substring(0, 5)}; _roomNameField.RegisterValueChangedCallback(evt _data.CustomName evt.newValue); contentContainer.Add(_roomNameField); // 房间类型下拉菜单 _roomTypeField new EnumField(类型, _data.Type); _roomTypeField.RegisterValueChangedCallback(evt { _data.Type (RoomType)evt.newValue; UpdateNodeColor(); // 根据类型更新节点颜色 }); contentContainer.Add(_roomTypeField); // 扩展可以添加预制体选择字段、怪物等级滑块等 var prefabField new ObjectField(房间预制体); prefabField.objectType typeof(GameObject); prefabField.RegisterValueChangedCallback(evt { if (evt.newValue ! null) _data.PrefabAssetPath AssetDatabase.GetAssetPath(evt.newValue); }); contentContainer.Add(prefabField); mainContainer.Add(contentContainer); // 初始颜色 UpdateNodeColor(); } private void UpdateNodeColor() { // 根据房间类型设置标题背景色增强可视化 Color color Color.gray; switch (_data.Type) { case RoomType.Start: color Color.green; break; case RoomType.Boss: color Color.red; break; case RoomType.Treasure: color Color.yellow; break; case RoomType.Elite: color Color.magenta; break; case RoomType.NormalCombat: color Color.white; break; } titleContainer.style.backgroundColor new StyleColor(color); } private void OnGeometryChanged(GeometryChangedEvent evt) { // 当节点被拖拽移动后更新数据中的位置 _data.Position GetPosition().position; } public RoomNodeData GetData() { // 确保数据是最新的 _data.Position GetPosition().position; return _data; } }避坑指南一节点位置同步。SetPosition只在初始化或代码设置时调用。用户拖拽节点时位置变化是通过GeometryChangedEvent回调捕获的。务必在这里将最新的Rect位置同步回你的数据模型否则保存后再加载节点会跑回原点。5. 实战步骤三实现节点搜索与创建窗口为了让用户能方便地添加新节点我们需要一个搜索窗口。这需要创建一个ScriptableObject作为搜索提供者。using System.Collections.Generic; using UnityEditor; using UnityEditor.Experimental.GraphView; using UnityEngine; public class DungeonNodeSearchProvider : ScriptableObject, ISearchWindowProvider { private DungeonGraphView _graphView; public void Initialize(DungeonGraphView graphView) { _graphView graphView; } public ListSearchTreeEntry CreateSearchTree(SearchWindowContext context) { var entries new ListSearchTreeEntry(); entries.Add(new SearchTreeGroupEntry(new GUIContent(创建节点), 0)); // 添加各种房间类型选项 entries.Add(new SearchTreeEntry(new GUIContent(起始房间)) { level 1, userData RoomType.Start }); entries.Add(new SearchTreeEntry(new GUIContent(普通战斗房间)) { level 1, userData RoomType.NormalCombat }); entries.Add(new SearchTreeEntry(new GUIContent(精英房间)) { level 1, userData RoomType.Elite }); entries.Add(new SearchTreeEntry(new GUIContent(宝箱房间)) { level 1, userData RoomType.Treasure }); entries.Add(new SearchTreeEntry(new GUIContent(Boss房间)) { level 1, userData RoomType.Boss }); return entries; } public bool OnSelectEntry(SearchTreeEntry SearchTreeEntry, SearchWindowContext context) { // 获取鼠标在GraphView中的本地坐标 var worldMousePosition context.screenMousePosition; var localMousePosition _graphView.contentViewContainer.WorldToLocal(worldMousePosition); // 根据选择的类型创建节点数据 var roomData new RoomNodeData { Guid System.Guid.NewGuid().ToString(), Position localMousePosition, Type (RoomType)SearchTreeEntry.userData }; // 在GraphView中创建节点 var roomNode RoomNode.CreateNode(roomData); _graphView.AddElement(roomNode); return true; } }现在在GraphView空白处右键就能弹出搜索菜单创建不同类型的房间节点了。6. 实战步骤四数据序列化与持久化编辑器产生的数据最终需要保存到项目中。有两种主流选择JSON文本文件或Unity的ScriptableObject。这里以JSON为例因为它更通用也便于版本管理。在DungeonLevelGraphWindow中完善RequestDataOperation和SaveLevelData方法private void RequestDataOperation(bool load) { string path ; if (load) { path EditorUtility.OpenFilePanel(加载关卡文件, Assets/, json); if (!string.IsNullOrEmpty(path)) { LoadLevelData(path); } } else { path EditorUtility.SaveFilePanelInProject(保存关卡, NewLevel, json, 请选择保存路径); if (!string.IsNullOrEmpty(path)) { SaveLevelDataToPath(path); } } } private void SaveLevelDataToPath(string path) { if (_graphView null) return; _currentLevelData _graphView.CollectGraphData(); string jsonData JsonUtility.ToJson(_currentLevelData, true); // 美观格式打印 System.IO.File.WriteAllText(path, jsonData); AssetDatabase.Refresh(); // 刷新AssetDatabase让Unity识别新文件 Debug.Log($关卡数据已保存至: {path}); } private void LoadLevelData(string fullPath) { if (!System.IO.File.Exists(fullPath)) { EditorUtility.DisplayDialog(错误, 文件不存在, 确定); return; } string jsonData System.IO.File.ReadAllText(fullPath); _currentLevelData JsonUtility.FromJsonLevelGraphData(jsonData); if (_currentLevelData ! null _graphView ! null) { _graphView.LoadGraphData(_currentLevelData); } }避坑指南二路径处理与AssetDatabase。EditorUtility.SaveFilePanelInProject返回的是相对于项目的路径如Assets/Levels/MyLevel.json而System.IO.File需要绝对路径。在保存后调用AssetDatabase.Refresh()至关重要否则Unity编辑器无法立即看到或加载这个新创建的.json文件。加载时如果使用OpenFilePanel它返回的是绝对路径直接用于读取即可。7. 实战步骤五游戏运行时加载与解析编辑器工具最终是为游戏运行时服务的。我们需要在游戏代码中能够读取并解析保存的JSON文件将其转换为游戏场景中实际的房间实例。首先在游戏运行时代码中非Editor文件夹定义相同的数据结构RoomNodeData,EdgeData,LevelGraphData。注意移除Serializable属性中Editor-only的部分。然后创建一个关卡管理器using System.Collections.Generic; using UnityEngine; public class DungeonLevelManager : MonoBehaviour { public TextAsset levelJsonFile; // 在Inspector中拖入保存的.json文件 private LevelGraphData _loadedLevelData; private Dictionarystring, GameObject _spawnedRooms new Dictionarystring, GameObject(); void Start() { if (levelJsonFile ! null) { LoadAndGenerateDungeon(); } } void LoadAndGenerateDungeon() { _loadedLevelData JsonUtility.FromJsonLevelGraphData(levelJsonFile.text); if (_loadedLevelData null) { Debug.LogError(Failed to parse level data!); return; } // 1. 实例化所有房间 foreach (var roomData in _loadedLevelData.Rooms) { GameObject roomPrefab Resources.LoadGameObject(roomData.PrefabAssetPath); if (roomPrefab null) { Debug.LogWarning($Prefab not found at path: {roomData.PrefabAssetPath}. Using fallback.); roomPrefab GetFallbackPrefab(roomData.Type); } if (roomPrefab ! null) { // 这里可以根据roomData.Position进行布局或者使用其他生成算法 // 简化先按数据中的位置可视为逻辑坐标生成在平面上 Vector3 spawnPos new Vector3(roomData.Position.x, 0, roomData.Position.y) * 20f; // 放大间距 GameObject roomInstance Instantiate(roomPrefab, spawnPos, Quaternion.identity, this.transform); roomInstance.name $Room_{roomData.Guid}; _spawnedRooms[roomData.Guid] roomInstance; // 可以在这里将roomData注入到房间的控制器脚本中 var roomController roomInstance.GetComponentDungeonRoomController(); if (roomController ! null) { roomController.Initialize(roomData); } } } // 2. 处理通道连接例如生成门、设置传送点、激活路径 foreach (var edgeData in _loadedLevelData.Edges) { if (_spawnedRooms.TryGetValue(edgeData.InputNodeGuid, out GameObject inputRoom) _spawnedRooms.TryGetValue(edgeData.OutputNodeGuid, out GameObject outputRoom)) { // 在这里实现房间之间的连接逻辑 // 例如在两个房间之间生成一条走廊预制体 // 或者在每个房间的脚本上记录相邻房间的引用 LinkRooms(inputRoom, outputRoom, edgeData); } } } private GameObject GetFallbackPrefab(RoomType type) { // 返回一个默认的预制体确保游戏不会因为资源缺失而崩溃 string defaultPath DungeonRooms/DefaultRoom; // 或者根据类型返回不同的默认预制体 return Resources.LoadGameObject(defaultPath); } private void LinkRooms(GameObject roomA, GameObject roomB, EdgeData edgeData) { // 实现具体的连接逻辑 Debug.Log($Linked room {roomA.name} to {roomB.name}); // 例如roomA.GetComponentRoomDoor().SetConnectedRoom(roomB); } }实操心得在运行时加载时roomData.Position通常不作为最终的世界坐标而是作为逻辑布局的参考。实际的地牢生成算法可能更复杂比如基于网格、基于波函数坍缩等。这里的关键是将编辑器中的数据房间类型、连接关系准确地传递到运行时具体的空间布局和实例化逻辑可以由另一套算法驱动。8. 节点避坑与性能优化指南在实际开发中仅仅实现功能是不够的稳定性和性能同样重要。以下是我在多个项目中使用GraphView时总结的“血泪教训”。8.1 避坑一内存泄漏与事件注销GraphView和UIElements大量使用事件回调。如果你在节点RoomNode的构造函数或CreateNodeUI方法中注册了事件如RegisterValueChangedCallback必须在节点被删除时注销这些事件。否则节点对象将无法被垃圾回收导致内存泄漏。解决方案在RoomNode中实现IDisposable接口或在节点被从GraphView移除时可以监听GraphView的elementsRemoved事件手动注销所有事件。public class RoomNode : Node, IDisposable { private ListAction _unregisterActions new ListAction(); private void CreateNodeUI() { // ... 之前的UI创建代码 ... var callback new EventCallbackChangeEventstring(OnNameChanged); _roomNameField.RegisterCallback(callback); // 记录注销动作 _unregisterActions.Add(() _roomNameField.UnregisterCallback(callback)); } private void OnNameChanged(ChangeEventstring evt) { /* ... */ } public void Dispose() { foreach (var action in _unregisterActions) { action?.Invoke(); } _unregisterActions.Clear(); } } // 在DungeonGraphView中删除元素时调用Dispose public override void DeleteElements(IEnumerableGraphElement elements) { foreach (var elem in elements) { if (elem is RoomNode roomNode) { roomNode.Dispose(); } } base.DeleteElements(elements); }8.2 避坑二序列化ID的稳定性我们使用Guid作为节点和边的唯一标识。在编辑器中Guid在节点创建时生成。必须确保在保存、加载的整个周期内同一个节点对应的Guid保持不变。如果因为bug导致重新加载后Guid变化所有的连接关系都会错乱。检查点保存时确保CollectGraphData方法是从节点的GetData()获取数据而GetData()返回的是节点内部维护的、随着UI操作实时更新的_data对象。加载时RoomNode的构造函数必须使用传入的RoomNodeData中的Guid而不是生成新的。Edge的viewDataKey在创建时自动生成我们在保存时将其作为EdgeData.Guid。加载重建边时也应尝试恢复这个Key以保持一致性虽然GraphView内部主要靠端口连接关系但稳定的ID对调试和扩展有益。8.3 避坑三大规模节点的性能当地图变得非常庞大数百个节点时直接操作可能会卡顿。优化策略简化节点UI 每个RoomNode的UI尽量简单。避免在节点内嵌套复杂的、带有大量样式和逻辑的VisualElement。如果需要在节点上显示复杂信息考虑使用Tooltip或双击弹出一个详情编辑窗口。延迟加载/虚拟化 GraphView本身不支持UI虚拟化。如果节点数量极多可能需要自己实现分区域加载或者考虑将超大地图拆分成多个子图文件。操作合并 对于“一键生成迷宫”、“自动布局”等可能产生大量节点增删的操作将其封装为一个原子操作。在执行前graphView.AddElement和graphView.RemoveElement是相对耗时的。可以尝试在内存中构建好所有节点和边的数据模型然后一次性调用graphView.AddElements如果支持或先清除再全部添加减少中间状态的UI刷新。使用USS样式表 将节点的样式定义在USS文件中而不是在C#代码里用style属性硬编码。这能让样式计算更高效也便于统一管理。8.4 避坑四撤销/重做 (Undo/Redo) 支持编辑器没有撤销功能是反人类的。Unity Editor提供了Undo类来支持。集成Undo 在DungeonGraphView中对于任何会修改数据的操作创建节点、删除节点、移动节点、创建连接、删除连接都需要用Undo.RecordObject如果目标是ScriptableObject或Undo.RegisterCompleteObjectUndo包裹起来并为操作起一个名字。// 在创建节点的方法中 public void CreateRoomNodeAtPosition(Vector2 position, RoomType type) { var roomData new RoomNodeData { /* ... */ }; var roomNode RoomNode.CreateNode(roomData); // 记录Undo操作 Undo.RegisterCompleteObjectUndo(this, Create Room Node); // 或者如果你的数据容器是一个ScriptableObject // Undo.RecordObject(_levelDataContainer, Create Room Node); AddElement(roomNode); // 将数据添加到你的容器 // _levelDataContainer.Rooms.Add(roomData); // 标记容器为脏以便Unity保存 // EditorUtility.SetDirty(_levelDataContainer); }对于移动节点可以监听nodeGeometryChanged事件但需要防抖避免每帧都记录Undo。实现完整的Undo/Redo需要细致的设计通常将数据模型LevelGraphData放在一个ScriptableObject中所有操作都通过修改这个对象并通知GraphView更新视图来实现。这样Undo系统只需要操作这个ScriptableObject即可。9. 扩展思路让编辑器更强大基础功能完成后可以考虑以下扩展让你的关卡编辑器产生质的飞跃迷你地图与导航 在编辑器窗口一角添加一个缩略图显示整个关卡的全貌并可以快速定位和导航到特定区域。自动布局算法 实现力导向、树状、网格等自动布局算法一键整理杂乱无章的节点让关卡结构一目了然。子图与模块化 支持将一组频繁使用的房间组合如“标准战斗套件”、“解谜模板”保存为子图方便重复使用和批量放置。数据验证与规则检查 编写验证脚本检查关卡数据是否合法。例如“是否只有一个起始房间”、“所有房间是否都可达”、“是否存在孤岛房间”、“Boss房间的入口数量是否超标”。提供一键检查并高亮显示问题节点。与Tilemap集成 将节点数据直接映射到Unity的Tilemap上实现关卡逻辑与场景美术的快速关联。版本控制友好 将数据保存为纯文本JSON或YAML并设计清晰的数据结构便于使用Git等工具进行版本管理和协作。打造一个专属的关卡编辑器初期投入看似增加了工作量但对于一个需要大量关卡内容的RPG项目而言它带来的长期收益是巨大的。它标准化了工作流程降低了沟通成本并赋予了策划更大的创作自由度和迭代速度。希望这篇基于GraphView的实战指南能帮你顺利搭建起属于自己项目的强大关卡编辑工具。