1. 项目概述为什么我们需要一个自己的关卡编辑器如果你在Unity里做过稍微复杂一点的游戏尤其是平台跳跃、解谜或者RPG这类关卡驱动的项目肯定有过这样的体验策划拿着Excel表格或者手绘草图来找你说“这里加个箱子那里放个敌人这个机关需要和那个门联动”。然后你就得一遍遍地进Unity编辑器拖Prefab、调位置、写脚本、挂组件、设参数。改一次需求就得重复一遍这个流程效率低不说还容易出错版本管理里一堆场景文件的修改记录看得人头大。这就是为什么很多有经验的团队到了一定阶段都会选择自己动手搞一个“关卡编辑器”。它本质上是一个运行在Unity编辑器环境下的定制化工具把那些重复、繁琐的关卡搭建工作抽象成可视化的操作和配置文件。听起来很高大上但其实核心思想很简单把策划能决定的、经常变的东西从代码里剥离出来变成数据。市面上的商业关卡编辑器功能强大但往往不够贴合自己项目的特殊需求比如你那套独特的机关逻辑或者策划想要的那种“格子自由摆放”混合的编辑模式。自己从零构建虽然前期有开发成本但换来的是极致的灵活性和后期爆炸式提升的生产力。策划甚至可以在一定程度上独立完成关卡的配置和迭代而你作为程序则可以更专注于核心玩法和性能优化。今天我就以一个实战过的横版动作游戏项目为例带你走一遍从零构建一个轻量、高效、可扩展的关卡编辑器的全过程。我们会聚焦于几个核心痛点如何设计编辑器界面、如何管理关卡数据、如何实现可视化编辑以及如何将编辑结果无缝应用到运行时游戏。目标是让你看完之后能立刻着手为自己的项目打造一个称手的“关卡生产线”。2. 核心架构设计数据驱动与职责分离在动手写代码之前先想清楚架构。一个健壮的关卡编辑器其核心是“数据驱动”和“职责分离”。2.1 数据层ScriptableObject 与 JSON 的抉择关卡数据用什么存这是第一个要回答的问题。Unity里常见的选择有ScriptableObject和纯文本格式如JSON、XML。ScriptableObject的优势在于它与Unity编辑器深度集成可以方便地引用其他Unity资产如Prefab、Material、AudioClip并且能利用Unity自带的序列化功能在Inspector里直接编辑复杂数据结构。对于主要在编辑器内使用、且严重依赖Unity资产引用的数据它是首选。JSON/XML的优势在于它的纯粹和跨平台。它是纯文本人类可读版本管理友好diff清晰不依赖Unity引擎也可以被解析和修改。适合需要在线配置、热更新或者希望策划能用外部工具如Excel导出来生成数据的场景。在我的项目中我采用了混合策略核心定义数据用ScriptableObject比如“敌人类型定义”、“道具类型定义”、“机关模板定义”。这些数据相对稳定且需要引用Prefab等资源。关卡实例数据用JSON每个关卡的具体布局比如在坐标(10, 5)处放置一个“ID为1的敌人”在(20, 3)处放置一个“ID为5的宝箱”。这些数据变化频繁用JSON便于策划手动微调和版本对比。// 示例敌人类型定义 (ScriptableObject) [CreateAssetMenu(fileName NewEnemyType, menuName 关卡编辑器/敌人类型)] public class EnemyTypeSO : ScriptableObject { public string enemyID; // 唯一标识如 Goblin_Melee public string displayName; // 显示名如 “近战哥布林” public GameObject prefab; // 对应的Prefab public int baseHealth; public float moveSpeed; // ... 其他属性 } // 示例单个关卡数据 (将序列化为JSON) [System.Serializable] public class LevelData { public string levelID; public string levelName; public Vector2 playerStartPosition; public ListPlacedEnemyData enemies new ListPlacedEnemyData(); public ListPlacedPropData props new ListPlacedPropData(); // ... 其他实体列表 } [System.Serializable] public class PlacedEnemyData { public string enemyTypeID; // 引用 EnemyTypeSO 的 enemyID public Vector2 position; public Quaternion rotation; // 可能有的关卡特有覆盖属性如“此关卡此敌人血量加倍” public int overriddenHealth; }这样设计后EnemyTypeSO负责“是什么”LevelData里的PlacedEnemyData只负责“在哪里”和“个别特殊情况”。职责清晰修改敌人基础属性不会影响成百上千个关卡文件。2.2 编辑器层EditorWindow 与自定义 Inspector编辑器界面是我们的主战场。Unity提供了EditorWindow类来创建独立的工具窗口以及通过CustomEditor来扩展已有组件在Inspector中的显示。我的建议是主控面板用EditorWindow创建一个像“关卡编辑器”这样的总控窗口在这里可以新建、加载、保存关卡切换编辑模式如地形模式、物件摆放模式、事件编辑模式。细节编辑用CustomEditor当你在场景中选中一个由关卡编辑器放置的“关卡实体”如一个敌人、一个宝箱时在Inspector中显示其关联的关卡数据并允许直接编辑。这比在EditorWindow里找一个列表再编辑要直观得多。// 示例主编辑器窗口框架 public class LevelEditorWindow : EditorWindow { private LevelData currentLevelData; private string currentLevelFilePath; [MenuItem(Tools/关卡编辑器)] public static void ShowWindow() { GetWindowLevelEditorWindow(关卡编辑器); } private void OnGUI() { // 1. 工具栏新建、打开、保存 DrawToolbar(); // 2. 如果有关卡加载显示编辑区域 if (currentLevelData ! null) { DrawLevelInfo(); DrawPalette(); // 笔刷面板显示可放置的敌人、道具等 DrawEditModeToggles(); // 切换编辑模式 } } private void DrawToolbar() { GUILayout.BeginHorizontal(EditorStyles.toolbar); if (GUILayout.Button(新建, EditorStyles.toolbarButton)) { // 创建新的LevelData实例并提示保存路径 } if (GUILayout.Button(打开..., EditorStyles.toolbarButton)) { // 弹窗选择JSON文件并反序列化为LevelData } if (GUILayout.Button(保存, EditorStyles.toolbarButton) currentLevelData ! null) { // 将currentLevelData序列化为JSON保存到currentLevelFilePath } GUILayout.EndHorizontal(); } }2.3 运行时层数据加载与实体生成编辑好的关卡数据JSON最终要在游戏运行时被加载并实例化为场景中的真实GameObject。这里的关键是建立ID到实际Prefab的映射关系。我们有一个EnemyTypeSO里面包含了enemyID和prefab。在运行时我们需要一个管理器如LevelManager来加载JSON然后根据JSON中的enemyTypeID去一个集中的“资源库”里找到对应的Prefab进行实例化。这个“资源库”可以在游戏启动时自动收集所有EnemyTypeSO、PropTypeSO等定义文件构建一个Dictionarystring, GameObject的映射。public class LevelManager : MonoBehaviour { public static LevelManager Instance; private Dictionarystring, GameObject enemyPrefabMap new Dictionarystring, GameObject(); private void Awake() { Instance this; BuildPrefabMaps(); } private void BuildPrefabMaps() { // 加载所有EnemyTypeSO var allEnemyTypes Resources.LoadAllEnemyTypeSO(EnemyTypes); foreach (var type in allEnemyTypes) { if (!enemyPrefabMap.ContainsKey(type.enemyID)) { enemyPrefabMap.Add(type.enemyID, type.prefab); } else { Debug.LogError($重复的敌人ID: {type.enemyID}); } } // 同样方式加载道具、机关等类型... } public void LoadLevel(string levelJsonPath) { string jsonText File.ReadAllText(levelJsonPath); LevelData levelData JsonUtility.FromJsonLevelData(jsonText); // 生成玩家起始点 InstantiatePlayer(levelData.playerStartPosition); // 生成敌人 foreach (var enemyData in levelData.enemies) { if (enemyPrefabMap.TryGetValue(enemyData.enemyTypeID, out GameObject prefab)) { GameObject enemyObj Instantiate(prefab, enemyData.position, enemyData.rotation); // 可选将enemyData.overriddenHealth等覆盖属性应用到刚生成的敌人实例上 EnemyController enemyCtrl enemyObj.GetComponentEnemyController(); if (enemyCtrl ! null) { int healthToUse enemyData.overriddenHealth 0 ? enemyData.overriddenHealth : enemyPrefabMap[enemyData.enemyTypeID].GetComponentEnemyController().baseHealth; enemyCtrl.SetHealth(healthToUse); } } else { Debug.LogError($未找到敌人PrefabID: {enemyData.enemyTypeID}); } } // 生成道具、机关等... } }注意这里使用Resources.LoadAll是为了示例简单。对于大型项目更推荐使用Addressables可寻址资源系统或AssetBundle进行资源管理以实现更好的依赖管理和内存控制。Resources文件夹内的所有资源在打包时会被全部打入包内可能导致初始包体过大。3. 核心功能实现可视化编辑与场景交互有了架构我们来实现编辑器最“炫酷”的部分——在Scene视图里直接点点点就能创建和编辑关卡。3.1 在Scene视图绘制Gizmo与HandleUnity的Handles和Gizmos类是我们的画笔。我们可以在EditorWindow的OnSceneGUI方法中或者在带有[CustomEditor]特性的编辑器类中使用它们。目标当在“关卡编辑器”窗口中选择“放置敌人”模式并在笔刷面板选中“哥布林”时鼠标在Scene视图移动会有一个半透明的哥布林模型跟随预览点击鼠标左键就在点击位置生成一个敌人数据条目。// 在LevelEditorWindow中 private void OnEnable() { // 订阅Scene视图GUI事件 SceneView.duringSceneGui OnSceneGUI; } private void OnDisable() { SceneView.duringSceneGui - OnSceneGUI; } private string selectedEnemyBrushID null; // 当前选中的笔刷ID private GameObject brushPreviewInstance; // 预览用的GameObject实例 private void OnSceneGUI(SceneView sceneView) { // 如果当前不是“放置物件”模式或者没选笔刷则返回 if (currentEditMode ! EditMode.PlaceObject || string.IsNullOrEmpty(selectedEnemyBrushID)) return; // 获取当前鼠标在Scene视图中的世界坐标仅考虑地面层假设y0 Event e Event.current; Ray ray HandleUtility.GUIPointToWorldRay(e.mousePosition); Plane groundPlane new Plane(Vector3.up, Vector3.zero); if (groundPlane.Raycast(ray, out float enter)) { Vector3 worldPoint ray.GetPoint(enter); // 更新预览物体的位置 if (brushPreviewInstance null) { // 根据selectedEnemyBrushID加载预览用Prefab可以是一个简化版模型 GameObject previewPrefab GetPreviewPrefab(selectedEnemyBrushID); if (previewPrefab ! null) { brushPreviewInstance Instantiate(previewPrefab); brushPreviewInstance.hideFlags HideFlags.HideAndDontSave; // 不保存到场景 // 可以设置一个半透明的材质 } } if (brushPreviewInstance ! null) { brushPreviewInstance.transform.position worldPoint; } // 处理鼠标点击事件 if (e.type EventType.MouseDown e.button 0) { // 左键点击在currentLevelData中添加一个敌人数据 AddEnemyToLevelData(selectedEnemyBrushID, worldPoint); e.Use(); // 标记事件已处理防止其他操作 } } // 强制Scene视图重绘让预览物体一直显示 HandleUtility.Repaint(); } private void AddEnemyToLevelData(string enemyID, Vector3 position) { if (currentLevelData null) { EditorUtility.DisplayDialog(错误, 请先新建或打开一个关卡, 确定); return; } PlacedEnemyData newEnemy new PlacedEnemyData { enemyTypeID enemyID, position position, rotation Quaternion.identity }; currentLevelData.enemies.Add(newEnemy); // 标记数据已修改需要保存 EditorUtility.SetDirty(this); }这个流程实现了基本的“笔刷-预览-放置”功能。你可以用类似逻辑实现“选择/移动/删除”物件模式。3.2 实现网格对齐与吸附功能对于平台跳跃或战棋类游戏让物件对齐到网格是刚需。这可以在计算worldPoint之后加入一个网格对齐的运算。public float gridSize 1.0f; // 网格大小可在编辑器窗口设置 private Vector3 SnapToGrid(Vector3 rawPosition) { float snappedX Mathf.Round(rawPosition.x / gridSize) * gridSize; float snappedY Mathf.Round(rawPosition.y / gridSize) * gridSize; // 如果是2D游戏 float snappedZ Mathf.Round(rawPosition.z / gridSize) * gridSize; return new Vector3(snappedX, snappedY, snappedZ); }然后在worldPoint ray.GetPoint(enter);之后调用worldPoint SnapToGrid(worldPoint);即可。更进一步可以实现“顶点吸附”Snap to Vertex让物件吸附到其他已存在物件的顶点上这需要用到HandleUtility.ClosestPointToPolyLine等方法稍微复杂但非常实用。3.3 自定义Inspector编辑已放置的实体当我们在Scene视图中选中一个由关卡编辑器生成的“敌人实体”运行时生成但可能我们希望在编辑模式下也能微调其数据我们希望它的Inspector显示的是PlacedEnemyData而不是它身上的各种组件。我们可以创建一个LevelEntityProxy组件挂在这个实体上它持有一个对LevelData中具体数据条目的引用或索引。然后为这个LevelEntityProxy编写CustomEditor。// 挂在运行时生成的关卡实体上 public class LevelEntityProxy : MonoBehaviour { public LevelData linkedLevelData; // 指向所属的LevelData资产或通过管理器获取 public int enemyDataIndex; // 在linkedLevelData.enemies列表中的索引 // 或者直接存储一个GUID用于在数据中查找 } [CustomEditor(typeof(LevelEntityProxy))] public class LevelEntityProxyEditor : Editor { public override void OnInspectorGUI() { LevelEntityProxy proxy (LevelEntityProxy)target; if (proxy.linkedLevelData null || proxy.enemyDataIndex 0 || proxy.enemyDataIndex proxy.linkedLevelData.enemies.Count) { EditorGUILayout.HelpBox(数据链接丢失或索引无效。, MessageType.Error); return; } PlacedEnemyData enemyData proxy.linkedLevelData.enemies[proxy.enemyDataIndex]; // 显示并允许编辑enemyData的部分字段 EditorGUI.BeginChangeCheck(); enemyData.position EditorGUILayout.Vector3Field(位置, enemyData.position); enemyData.rotation Quaternion.Euler(EditorGUILayout.Vector3Field(旋转, enemyData.rotation.eulerAngles)); enemyData.overriddenHealth EditorGUILayout.IntField(覆盖生命值, enemyData.overriddenHealth); if (EditorGUI.EndChangeCheck()) { // 数据已修改更新场景中物体的实际位置 proxy.transform.position enemyData.position; proxy.transform.rotation enemyData.rotation; // 标记LevelData和场景为脏需要保存 EditorUtility.SetDirty(proxy.linkedLevelData); EditorUtility.SetDirty(proxy.gameObject); } } }这样策划在Scene视图里移动这个敌人GameObject或者在Inspector里修改数值实际上都是在修改背后的LevelData实现了所见即所得的编辑体验。4. 高级功能与扩展性设计一个基础的编辑器能用了但要让策划爱不释手还需要一些“甜点”功能。4.1 批量操作与数据验证策划可能会说“我把这一片的敌人都选中整体向右移动两格”或者“把这些宝箱的类型全部从‘木箱’换成‘铁箱’”。批量选择可以在OnSceneGUI中实现一个框选工具。记录鼠标拖拽的起始和结束点形成一个矩形区域然后遍历当前关卡的所有实体代理LevelEntityProxy判断其屏幕坐标是否在区域内。批量操作选中一批实体后在编辑器窗口提供一个操作面板可以统一设置偏移量、统一替换类型等。操作的本质是遍历选中实体对应的PlacedEnemyData列表进行批量修改。数据验证在保存关卡前自动运行一些检查。比如是否有两个物件重叠在了同一个网格是否有敌人被放到了地图边界外是否有必填的字段如出生点缺失 可以写一个ValidateLevelData方法返回一个错误列表在点击保存时提示给用户。4.2 分层与图层管理像Photoshop一样关卡元素可以分图层管理。比如“背景装饰层”、“可交互物件层”、“地形碰撞层”、“敌人层”。这样策划可以单独显示/隐藏某一层方便编辑。在数据结构上可以在LevelData里为不同类型的实体敌人、道具、地形块分别设立列表或者为每个PlacedObjectData增加一个layer字段。在编辑器绘制时根据当前可见的图层过滤要绘制的实体。4.3 关卡事件与逻辑编辑这是让关卡“活”起来的关键。比如“当玩家踩上压力板A打开门B击败所有敌人C出现宝箱D”。我们可以设计一个简单的可视化事件图系统或者采用更直接的规则配置方式。规则配置示例[System.Serializable] public class LevelEventRule { public string ruleName; public ListEventCondition conditions; // 条件列表如“敌人全部死亡”、“道具被拾取” public ListEventAction actions; // 动作列表如“生成物件”、“播放动画”、“触发下一个事件” } // 在LevelData中增加 public ListLevelEventRule eventRules new ListLevelEventRule();在编辑器里可以设计一个列表来添加/删除规则每个规则可以弹出窗口配置条件和动作。条件Condition和动作Action可以设计成一系列可插拔的类通过反射或手动注册的方式供编辑器选择这是典型的“命令模式”应用。运行时由一个LevelEventManager在每帧检查所有规则的条件是否满足一旦满足则执行对应的动作。4.4 与版本控制系统如Git的友好协作由于关卡数据是JSON文本文件它天生对Git友好。但是如果策划在编辑器里移动了一个物件Unity的Scene文件如果有关联可能也会变而Scene文件是二进制合并冲突是噩梦。最佳实践完全分离。关卡编辑器不直接修改任何Scene文件。它只生成和修改JSON数据文件。游戏运行时从一个固定的、空的“关卡容器”Scene开始由LevelManager动态加载JSON并实例化所有内容。这样版本库里只有可读的JSON文件在变化冲突容易解决策划也可以放心大胆地分支和合并。5. 性能优化与调试技巧编辑器工具本身的性能也很重要尤其是当关卡里有成千上万个物件时。5.1 编辑器绘制性能在OnSceneGUI里避免做昂贵的计算。比如预览物体不要每帧都Instantiate和Destroy。在激活笔刷时创建一次隐藏或切换笔刷时再销毁。网格绘制如果绘制整个关卡网格当网格很大时用Handles.DrawLine画成千上万条线会卡顿。可以考虑只绘制视口范围内的网格或者降低远处网格的绘制密度。物件图标用Handles.Label或Gizmos.DrawIcon绘制大量图标时注意图标的尺寸和裁剪。可以为距离摄像机过远的物件使用更简单的绘制方式如一个点。5.2 数据序列化与反序列化性能如果单个关卡文件非常大数MB频繁的JSON序列化/反序列化尤其是在自动保存时可能会造成卡顿。增量保存不一定每次修改都保存整个文件。可以记录脏数据区间只序列化并写入文件的一部分但这需要更复杂的文件结构。使用更快的库Unity自带的JsonUtility速度很快但功能有限不支持字典、多态。如果数据模型复杂可以考虑Newtonsoft.Json需导入或Unity.Serialization较新但要注意它们在大量数据下的性能。二进制格式最终发布时可以考虑将JSON转换成更紧凑、加载更快的二进制格式如MessagePack编辑器阶段依然用JSON方便编辑。5.3 常见问题与调试问题在编辑器里放置的物件运行时位置不对。排查首先检查坐标空间。编辑器里OnSceneGUI获取的世界坐标和运行时Instantiate使用的坐标是否在同一个空间比如是否都考虑了场景中某个“关卡根节点”的变换。确保你的LevelData中存储的position是世界坐标或者相对于一个固定的原点。技巧在LevelManager加载关卡时在每一个实例化的物件位置画一个Debug图形如Debug.DrawRay直观对比和编辑器里看到的位置是否一致。问题笔刷预览物体残留在场景中。排查确保在关闭编辑器窗口OnDisable、切换笔刷、或关闭模式时正确销毁了brushPreviewInstance。使用HideFlags.HideAndDontSave创建的物体不会被自动清理。技巧在OnDisable方法中加入if (brushPreviewInstance ! null) DestroyImmediate(brushPreviewInstance);。问题选中实体代理Proxy后Inspector不显示自定义界面。排查检查LevelEntityProxyEditor类是否放在了“Editor”文件夹下所有自定义编辑器代码都必须放在名为“Editor”的文件夹中否则不会被Unity加载。排查检查LevelEntityProxy组件上的数据链接是否有效。可能是linkedLevelData字段没有在编辑模式下正确赋值。可以考虑在LevelEntityProxy的OnValidate方法中尝试自动查找关联的LevelData。问题撤销Undo操作无效。排查在编辑器代码中修改任何可能导致场景或资产变化的数据时需要使用Undo.RecordObject来注册撤销点。// 在修改LevelData之前 Undo.RecordObject(levelDataAsset, Add Enemy); levelDataAsset.enemies.Add(newEnemy); // 修改后标记脏 EditorUtility.SetDirty(levelDataAsset);对于在Scene视图中的移动操作Unity的HandlesAPI如Handles.PositionHandle通常内置了撤销支持。构建一个完整的关卡编辑器是一个系统工程不可能一蹴而就。我的建议是采用迭代开发先做出最核心的“放箱子”功能让策划能用起来然后根据他们的反馈加入最急需的功能如网格、批量复制、事件触发器。这样边用边改最终打磨出来的工具才会真正贴合项目需求成为团队生产力的倍增器。记住工具的价值不在于功能多炫酷而在于它节省了多少重复劳动的时间。
Unity关卡编辑器开发实战:数据驱动与可视化编辑全流程
1. 项目概述为什么我们需要一个自己的关卡编辑器如果你在Unity里做过稍微复杂一点的游戏尤其是平台跳跃、解谜或者RPG这类关卡驱动的项目肯定有过这样的体验策划拿着Excel表格或者手绘草图来找你说“这里加个箱子那里放个敌人这个机关需要和那个门联动”。然后你就得一遍遍地进Unity编辑器拖Prefab、调位置、写脚本、挂组件、设参数。改一次需求就得重复一遍这个流程效率低不说还容易出错版本管理里一堆场景文件的修改记录看得人头大。这就是为什么很多有经验的团队到了一定阶段都会选择自己动手搞一个“关卡编辑器”。它本质上是一个运行在Unity编辑器环境下的定制化工具把那些重复、繁琐的关卡搭建工作抽象成可视化的操作和配置文件。听起来很高大上但其实核心思想很简单把策划能决定的、经常变的东西从代码里剥离出来变成数据。市面上的商业关卡编辑器功能强大但往往不够贴合自己项目的特殊需求比如你那套独特的机关逻辑或者策划想要的那种“格子自由摆放”混合的编辑模式。自己从零构建虽然前期有开发成本但换来的是极致的灵活性和后期爆炸式提升的生产力。策划甚至可以在一定程度上独立完成关卡的配置和迭代而你作为程序则可以更专注于核心玩法和性能优化。今天我就以一个实战过的横版动作游戏项目为例带你走一遍从零构建一个轻量、高效、可扩展的关卡编辑器的全过程。我们会聚焦于几个核心痛点如何设计编辑器界面、如何管理关卡数据、如何实现可视化编辑以及如何将编辑结果无缝应用到运行时游戏。目标是让你看完之后能立刻着手为自己的项目打造一个称手的“关卡生产线”。2. 核心架构设计数据驱动与职责分离在动手写代码之前先想清楚架构。一个健壮的关卡编辑器其核心是“数据驱动”和“职责分离”。2.1 数据层ScriptableObject 与 JSON 的抉择关卡数据用什么存这是第一个要回答的问题。Unity里常见的选择有ScriptableObject和纯文本格式如JSON、XML。ScriptableObject的优势在于它与Unity编辑器深度集成可以方便地引用其他Unity资产如Prefab、Material、AudioClip并且能利用Unity自带的序列化功能在Inspector里直接编辑复杂数据结构。对于主要在编辑器内使用、且严重依赖Unity资产引用的数据它是首选。JSON/XML的优势在于它的纯粹和跨平台。它是纯文本人类可读版本管理友好diff清晰不依赖Unity引擎也可以被解析和修改。适合需要在线配置、热更新或者希望策划能用外部工具如Excel导出来生成数据的场景。在我的项目中我采用了混合策略核心定义数据用ScriptableObject比如“敌人类型定义”、“道具类型定义”、“机关模板定义”。这些数据相对稳定且需要引用Prefab等资源。关卡实例数据用JSON每个关卡的具体布局比如在坐标(10, 5)处放置一个“ID为1的敌人”在(20, 3)处放置一个“ID为5的宝箱”。这些数据变化频繁用JSON便于策划手动微调和版本对比。// 示例敌人类型定义 (ScriptableObject) [CreateAssetMenu(fileName NewEnemyType, menuName 关卡编辑器/敌人类型)] public class EnemyTypeSO : ScriptableObject { public string enemyID; // 唯一标识如 Goblin_Melee public string displayName; // 显示名如 “近战哥布林” public GameObject prefab; // 对应的Prefab public int baseHealth; public float moveSpeed; // ... 其他属性 } // 示例单个关卡数据 (将序列化为JSON) [System.Serializable] public class LevelData { public string levelID; public string levelName; public Vector2 playerStartPosition; public ListPlacedEnemyData enemies new ListPlacedEnemyData(); public ListPlacedPropData props new ListPlacedPropData(); // ... 其他实体列表 } [System.Serializable] public class PlacedEnemyData { public string enemyTypeID; // 引用 EnemyTypeSO 的 enemyID public Vector2 position; public Quaternion rotation; // 可能有的关卡特有覆盖属性如“此关卡此敌人血量加倍” public int overriddenHealth; }这样设计后EnemyTypeSO负责“是什么”LevelData里的PlacedEnemyData只负责“在哪里”和“个别特殊情况”。职责清晰修改敌人基础属性不会影响成百上千个关卡文件。2.2 编辑器层EditorWindow 与自定义 Inspector编辑器界面是我们的主战场。Unity提供了EditorWindow类来创建独立的工具窗口以及通过CustomEditor来扩展已有组件在Inspector中的显示。我的建议是主控面板用EditorWindow创建一个像“关卡编辑器”这样的总控窗口在这里可以新建、加载、保存关卡切换编辑模式如地形模式、物件摆放模式、事件编辑模式。细节编辑用CustomEditor当你在场景中选中一个由关卡编辑器放置的“关卡实体”如一个敌人、一个宝箱时在Inspector中显示其关联的关卡数据并允许直接编辑。这比在EditorWindow里找一个列表再编辑要直观得多。// 示例主编辑器窗口框架 public class LevelEditorWindow : EditorWindow { private LevelData currentLevelData; private string currentLevelFilePath; [MenuItem(Tools/关卡编辑器)] public static void ShowWindow() { GetWindowLevelEditorWindow(关卡编辑器); } private void OnGUI() { // 1. 工具栏新建、打开、保存 DrawToolbar(); // 2. 如果有关卡加载显示编辑区域 if (currentLevelData ! null) { DrawLevelInfo(); DrawPalette(); // 笔刷面板显示可放置的敌人、道具等 DrawEditModeToggles(); // 切换编辑模式 } } private void DrawToolbar() { GUILayout.BeginHorizontal(EditorStyles.toolbar); if (GUILayout.Button(新建, EditorStyles.toolbarButton)) { // 创建新的LevelData实例并提示保存路径 } if (GUILayout.Button(打开..., EditorStyles.toolbarButton)) { // 弹窗选择JSON文件并反序列化为LevelData } if (GUILayout.Button(保存, EditorStyles.toolbarButton) currentLevelData ! null) { // 将currentLevelData序列化为JSON保存到currentLevelFilePath } GUILayout.EndHorizontal(); } }2.3 运行时层数据加载与实体生成编辑好的关卡数据JSON最终要在游戏运行时被加载并实例化为场景中的真实GameObject。这里的关键是建立ID到实际Prefab的映射关系。我们有一个EnemyTypeSO里面包含了enemyID和prefab。在运行时我们需要一个管理器如LevelManager来加载JSON然后根据JSON中的enemyTypeID去一个集中的“资源库”里找到对应的Prefab进行实例化。这个“资源库”可以在游戏启动时自动收集所有EnemyTypeSO、PropTypeSO等定义文件构建一个Dictionarystring, GameObject的映射。public class LevelManager : MonoBehaviour { public static LevelManager Instance; private Dictionarystring, GameObject enemyPrefabMap new Dictionarystring, GameObject(); private void Awake() { Instance this; BuildPrefabMaps(); } private void BuildPrefabMaps() { // 加载所有EnemyTypeSO var allEnemyTypes Resources.LoadAllEnemyTypeSO(EnemyTypes); foreach (var type in allEnemyTypes) { if (!enemyPrefabMap.ContainsKey(type.enemyID)) { enemyPrefabMap.Add(type.enemyID, type.prefab); } else { Debug.LogError($重复的敌人ID: {type.enemyID}); } } // 同样方式加载道具、机关等类型... } public void LoadLevel(string levelJsonPath) { string jsonText File.ReadAllText(levelJsonPath); LevelData levelData JsonUtility.FromJsonLevelData(jsonText); // 生成玩家起始点 InstantiatePlayer(levelData.playerStartPosition); // 生成敌人 foreach (var enemyData in levelData.enemies) { if (enemyPrefabMap.TryGetValue(enemyData.enemyTypeID, out GameObject prefab)) { GameObject enemyObj Instantiate(prefab, enemyData.position, enemyData.rotation); // 可选将enemyData.overriddenHealth等覆盖属性应用到刚生成的敌人实例上 EnemyController enemyCtrl enemyObj.GetComponentEnemyController(); if (enemyCtrl ! null) { int healthToUse enemyData.overriddenHealth 0 ? enemyData.overriddenHealth : enemyPrefabMap[enemyData.enemyTypeID].GetComponentEnemyController().baseHealth; enemyCtrl.SetHealth(healthToUse); } } else { Debug.LogError($未找到敌人PrefabID: {enemyData.enemyTypeID}); } } // 生成道具、机关等... } }注意这里使用Resources.LoadAll是为了示例简单。对于大型项目更推荐使用Addressables可寻址资源系统或AssetBundle进行资源管理以实现更好的依赖管理和内存控制。Resources文件夹内的所有资源在打包时会被全部打入包内可能导致初始包体过大。3. 核心功能实现可视化编辑与场景交互有了架构我们来实现编辑器最“炫酷”的部分——在Scene视图里直接点点点就能创建和编辑关卡。3.1 在Scene视图绘制Gizmo与HandleUnity的Handles和Gizmos类是我们的画笔。我们可以在EditorWindow的OnSceneGUI方法中或者在带有[CustomEditor]特性的编辑器类中使用它们。目标当在“关卡编辑器”窗口中选择“放置敌人”模式并在笔刷面板选中“哥布林”时鼠标在Scene视图移动会有一个半透明的哥布林模型跟随预览点击鼠标左键就在点击位置生成一个敌人数据条目。// 在LevelEditorWindow中 private void OnEnable() { // 订阅Scene视图GUI事件 SceneView.duringSceneGui OnSceneGUI; } private void OnDisable() { SceneView.duringSceneGui - OnSceneGUI; } private string selectedEnemyBrushID null; // 当前选中的笔刷ID private GameObject brushPreviewInstance; // 预览用的GameObject实例 private void OnSceneGUI(SceneView sceneView) { // 如果当前不是“放置物件”模式或者没选笔刷则返回 if (currentEditMode ! EditMode.PlaceObject || string.IsNullOrEmpty(selectedEnemyBrushID)) return; // 获取当前鼠标在Scene视图中的世界坐标仅考虑地面层假设y0 Event e Event.current; Ray ray HandleUtility.GUIPointToWorldRay(e.mousePosition); Plane groundPlane new Plane(Vector3.up, Vector3.zero); if (groundPlane.Raycast(ray, out float enter)) { Vector3 worldPoint ray.GetPoint(enter); // 更新预览物体的位置 if (brushPreviewInstance null) { // 根据selectedEnemyBrushID加载预览用Prefab可以是一个简化版模型 GameObject previewPrefab GetPreviewPrefab(selectedEnemyBrushID); if (previewPrefab ! null) { brushPreviewInstance Instantiate(previewPrefab); brushPreviewInstance.hideFlags HideFlags.HideAndDontSave; // 不保存到场景 // 可以设置一个半透明的材质 } } if (brushPreviewInstance ! null) { brushPreviewInstance.transform.position worldPoint; } // 处理鼠标点击事件 if (e.type EventType.MouseDown e.button 0) { // 左键点击在currentLevelData中添加一个敌人数据 AddEnemyToLevelData(selectedEnemyBrushID, worldPoint); e.Use(); // 标记事件已处理防止其他操作 } } // 强制Scene视图重绘让预览物体一直显示 HandleUtility.Repaint(); } private void AddEnemyToLevelData(string enemyID, Vector3 position) { if (currentLevelData null) { EditorUtility.DisplayDialog(错误, 请先新建或打开一个关卡, 确定); return; } PlacedEnemyData newEnemy new PlacedEnemyData { enemyTypeID enemyID, position position, rotation Quaternion.identity }; currentLevelData.enemies.Add(newEnemy); // 标记数据已修改需要保存 EditorUtility.SetDirty(this); }这个流程实现了基本的“笔刷-预览-放置”功能。你可以用类似逻辑实现“选择/移动/删除”物件模式。3.2 实现网格对齐与吸附功能对于平台跳跃或战棋类游戏让物件对齐到网格是刚需。这可以在计算worldPoint之后加入一个网格对齐的运算。public float gridSize 1.0f; // 网格大小可在编辑器窗口设置 private Vector3 SnapToGrid(Vector3 rawPosition) { float snappedX Mathf.Round(rawPosition.x / gridSize) * gridSize; float snappedY Mathf.Round(rawPosition.y / gridSize) * gridSize; // 如果是2D游戏 float snappedZ Mathf.Round(rawPosition.z / gridSize) * gridSize; return new Vector3(snappedX, snappedY, snappedZ); }然后在worldPoint ray.GetPoint(enter);之后调用worldPoint SnapToGrid(worldPoint);即可。更进一步可以实现“顶点吸附”Snap to Vertex让物件吸附到其他已存在物件的顶点上这需要用到HandleUtility.ClosestPointToPolyLine等方法稍微复杂但非常实用。3.3 自定义Inspector编辑已放置的实体当我们在Scene视图中选中一个由关卡编辑器生成的“敌人实体”运行时生成但可能我们希望在编辑模式下也能微调其数据我们希望它的Inspector显示的是PlacedEnemyData而不是它身上的各种组件。我们可以创建一个LevelEntityProxy组件挂在这个实体上它持有一个对LevelData中具体数据条目的引用或索引。然后为这个LevelEntityProxy编写CustomEditor。// 挂在运行时生成的关卡实体上 public class LevelEntityProxy : MonoBehaviour { public LevelData linkedLevelData; // 指向所属的LevelData资产或通过管理器获取 public int enemyDataIndex; // 在linkedLevelData.enemies列表中的索引 // 或者直接存储一个GUID用于在数据中查找 } [CustomEditor(typeof(LevelEntityProxy))] public class LevelEntityProxyEditor : Editor { public override void OnInspectorGUI() { LevelEntityProxy proxy (LevelEntityProxy)target; if (proxy.linkedLevelData null || proxy.enemyDataIndex 0 || proxy.enemyDataIndex proxy.linkedLevelData.enemies.Count) { EditorGUILayout.HelpBox(数据链接丢失或索引无效。, MessageType.Error); return; } PlacedEnemyData enemyData proxy.linkedLevelData.enemies[proxy.enemyDataIndex]; // 显示并允许编辑enemyData的部分字段 EditorGUI.BeginChangeCheck(); enemyData.position EditorGUILayout.Vector3Field(位置, enemyData.position); enemyData.rotation Quaternion.Euler(EditorGUILayout.Vector3Field(旋转, enemyData.rotation.eulerAngles)); enemyData.overriddenHealth EditorGUILayout.IntField(覆盖生命值, enemyData.overriddenHealth); if (EditorGUI.EndChangeCheck()) { // 数据已修改更新场景中物体的实际位置 proxy.transform.position enemyData.position; proxy.transform.rotation enemyData.rotation; // 标记LevelData和场景为脏需要保存 EditorUtility.SetDirty(proxy.linkedLevelData); EditorUtility.SetDirty(proxy.gameObject); } } }这样策划在Scene视图里移动这个敌人GameObject或者在Inspector里修改数值实际上都是在修改背后的LevelData实现了所见即所得的编辑体验。4. 高级功能与扩展性设计一个基础的编辑器能用了但要让策划爱不释手还需要一些“甜点”功能。4.1 批量操作与数据验证策划可能会说“我把这一片的敌人都选中整体向右移动两格”或者“把这些宝箱的类型全部从‘木箱’换成‘铁箱’”。批量选择可以在OnSceneGUI中实现一个框选工具。记录鼠标拖拽的起始和结束点形成一个矩形区域然后遍历当前关卡的所有实体代理LevelEntityProxy判断其屏幕坐标是否在区域内。批量操作选中一批实体后在编辑器窗口提供一个操作面板可以统一设置偏移量、统一替换类型等。操作的本质是遍历选中实体对应的PlacedEnemyData列表进行批量修改。数据验证在保存关卡前自动运行一些检查。比如是否有两个物件重叠在了同一个网格是否有敌人被放到了地图边界外是否有必填的字段如出生点缺失 可以写一个ValidateLevelData方法返回一个错误列表在点击保存时提示给用户。4.2 分层与图层管理像Photoshop一样关卡元素可以分图层管理。比如“背景装饰层”、“可交互物件层”、“地形碰撞层”、“敌人层”。这样策划可以单独显示/隐藏某一层方便编辑。在数据结构上可以在LevelData里为不同类型的实体敌人、道具、地形块分别设立列表或者为每个PlacedObjectData增加一个layer字段。在编辑器绘制时根据当前可见的图层过滤要绘制的实体。4.3 关卡事件与逻辑编辑这是让关卡“活”起来的关键。比如“当玩家踩上压力板A打开门B击败所有敌人C出现宝箱D”。我们可以设计一个简单的可视化事件图系统或者采用更直接的规则配置方式。规则配置示例[System.Serializable] public class LevelEventRule { public string ruleName; public ListEventCondition conditions; // 条件列表如“敌人全部死亡”、“道具被拾取” public ListEventAction actions; // 动作列表如“生成物件”、“播放动画”、“触发下一个事件” } // 在LevelData中增加 public ListLevelEventRule eventRules new ListLevelEventRule();在编辑器里可以设计一个列表来添加/删除规则每个规则可以弹出窗口配置条件和动作。条件Condition和动作Action可以设计成一系列可插拔的类通过反射或手动注册的方式供编辑器选择这是典型的“命令模式”应用。运行时由一个LevelEventManager在每帧检查所有规则的条件是否满足一旦满足则执行对应的动作。4.4 与版本控制系统如Git的友好协作由于关卡数据是JSON文本文件它天生对Git友好。但是如果策划在编辑器里移动了一个物件Unity的Scene文件如果有关联可能也会变而Scene文件是二进制合并冲突是噩梦。最佳实践完全分离。关卡编辑器不直接修改任何Scene文件。它只生成和修改JSON数据文件。游戏运行时从一个固定的、空的“关卡容器”Scene开始由LevelManager动态加载JSON并实例化所有内容。这样版本库里只有可读的JSON文件在变化冲突容易解决策划也可以放心大胆地分支和合并。5. 性能优化与调试技巧编辑器工具本身的性能也很重要尤其是当关卡里有成千上万个物件时。5.1 编辑器绘制性能在OnSceneGUI里避免做昂贵的计算。比如预览物体不要每帧都Instantiate和Destroy。在激活笔刷时创建一次隐藏或切换笔刷时再销毁。网格绘制如果绘制整个关卡网格当网格很大时用Handles.DrawLine画成千上万条线会卡顿。可以考虑只绘制视口范围内的网格或者降低远处网格的绘制密度。物件图标用Handles.Label或Gizmos.DrawIcon绘制大量图标时注意图标的尺寸和裁剪。可以为距离摄像机过远的物件使用更简单的绘制方式如一个点。5.2 数据序列化与反序列化性能如果单个关卡文件非常大数MB频繁的JSON序列化/反序列化尤其是在自动保存时可能会造成卡顿。增量保存不一定每次修改都保存整个文件。可以记录脏数据区间只序列化并写入文件的一部分但这需要更复杂的文件结构。使用更快的库Unity自带的JsonUtility速度很快但功能有限不支持字典、多态。如果数据模型复杂可以考虑Newtonsoft.Json需导入或Unity.Serialization较新但要注意它们在大量数据下的性能。二进制格式最终发布时可以考虑将JSON转换成更紧凑、加载更快的二进制格式如MessagePack编辑器阶段依然用JSON方便编辑。5.3 常见问题与调试问题在编辑器里放置的物件运行时位置不对。排查首先检查坐标空间。编辑器里OnSceneGUI获取的世界坐标和运行时Instantiate使用的坐标是否在同一个空间比如是否都考虑了场景中某个“关卡根节点”的变换。确保你的LevelData中存储的position是世界坐标或者相对于一个固定的原点。技巧在LevelManager加载关卡时在每一个实例化的物件位置画一个Debug图形如Debug.DrawRay直观对比和编辑器里看到的位置是否一致。问题笔刷预览物体残留在场景中。排查确保在关闭编辑器窗口OnDisable、切换笔刷、或关闭模式时正确销毁了brushPreviewInstance。使用HideFlags.HideAndDontSave创建的物体不会被自动清理。技巧在OnDisable方法中加入if (brushPreviewInstance ! null) DestroyImmediate(brushPreviewInstance);。问题选中实体代理Proxy后Inspector不显示自定义界面。排查检查LevelEntityProxyEditor类是否放在了“Editor”文件夹下所有自定义编辑器代码都必须放在名为“Editor”的文件夹中否则不会被Unity加载。排查检查LevelEntityProxy组件上的数据链接是否有效。可能是linkedLevelData字段没有在编辑模式下正确赋值。可以考虑在LevelEntityProxy的OnValidate方法中尝试自动查找关联的LevelData。问题撤销Undo操作无效。排查在编辑器代码中修改任何可能导致场景或资产变化的数据时需要使用Undo.RecordObject来注册撤销点。// 在修改LevelData之前 Undo.RecordObject(levelDataAsset, Add Enemy); levelDataAsset.enemies.Add(newEnemy); // 修改后标记脏 EditorUtility.SetDirty(levelDataAsset);对于在Scene视图中的移动操作Unity的HandlesAPI如Handles.PositionHandle通常内置了撤销支持。构建一个完整的关卡编辑器是一个系统工程不可能一蹴而就。我的建议是采用迭代开发先做出最核心的“放箱子”功能让策划能用起来然后根据他们的反馈加入最急需的功能如网格、批量复制、事件触发器。这样边用边改最终打磨出来的工具才会真正贴合项目需求成为团队生产力的倍增器。记住工具的价值不在于功能多炫酷而在于它节省了多少重复劳动的时间。