游戏开发项目复盘之地图编辑器与关卡设计工具开发复盘
地图编辑器与关卡设计工具开发复盘

关卡编辑器是SLG游戏开发的”秘密武器”。好的编辑器让策划事半功倍,差的编辑器让所有人崩溃。今天聊聊我们项目地图编辑器的演进过程。
从”策划提需求,程序写代码”说起
项目初期,地图编辑是这样的流程:
-
策划画个草图,标注”这里放个障碍物,那里放个出生点” -
程序照着草图写配置 -
运行游戏看效果 -
发现问题,策划再画一版
一个中等规模地图,从需求提出到上线测试,要折腾三四天。策划崩溃,程序也崩溃。
痛定思痛,我们决定做一套所见即所得的地图编辑器。
编辑器架构设计
整体架构
┌─────────────────────────────────────────────────────────┐│ Unity Editor │├─────────────────────────────────────────────────────────┤│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ ││ │ 工具栏 │ │ 场景视图 │ │ 属性面板 │ │ 资源库 │ ││ │ Toolbar │ │ Scene │ │Inspector │ │ Browser │ ││ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │├─────────────────────────────────────────────────────────┤│ ┌─────────────────────────────────────────────────────┐││ │ Core Engine │││ │ ┌───────────┐ ┌───────────┐ ┌───────────────────┐ │││ │ │ TileSystem│ │ UnitSpawn │ │ ValidationEngine │ │││ │ └───────────┘ └───────────┘ └───────────────────┘ │││ └─────────────────────────────────────────────────────┘│├─────────────────────────────────────────────────────────┤│ ┌─────────────────────────────────────────────────────┐││ │ Data Layer │││ │ ┌───────────┐ ┌───────────┐ ┌───────────────────┐ │││ │ │ Serializer│ │ Version │ │ Undo/RedoManager │ │││ │ │ │ │ Manager │ │ │ │││ │ └───────────┘ └───────────┘ └───────────────────┘ │││ └─────────────────────────────────────────────────────┘│└─────────────────────────────────────────────────────────┘
核心数据结构
// 地图数据 - 核心配置类[System.Serializable]publicclassMapData{publicstring MapId;publicstring MapName;public Vector2Int Size;// Tile层:地形信息public TileData[,] Tiles;// Entity层:单位、装饰物、触发器等public List<MapEntity> Entities;// 元数据public MapMetadata Metadata;// 版本控制publicint Version;publicstring LastModifiedBy;public DateTime LastModifiedTime;}// 单个Tile[System.Serializable]publicclassTileData{publicint X, Y;public TerrainType Terrain; // 地形类型:草地、河流、山地publicint MoveCost; // 移动消耗publicbool IsPassable; // 是否可通行publicint DefenseBonus; // 防御加成public List<BuffEffect> TileBuffs; // 地块附加效果}// 地图实体(单位、装饰物、触发器等)[System.Serializable]publicclassMapEntity{publicstring EntityId;public EntityType Type; // Unit, Decoration, Trigger, SpawnPointpublic Vector3 Position;public Vector3 Rotation;public Vector3 Scale;// 类型特定数据(JSON存储,运行时反序列化)publicstring ConfigData; // 显示相关publicstring PrefabPath;public Sprite Icon;}
WYSIWYG编辑器实现
场景视图渲染
编辑器最难的部分是”所见即所得”。我们用Custom Editor实现:
#if UNITY_EDITOR[CustomEditor(typeof(MapData))]publicclassMapDataEditor : Editor{private MapEditorTool currentTool = MapEditorTool.Select;privatebool isMouseDown;private Vector2Int lastTileCoord;voidOnEnable() {// 订阅SceneView回调 SceneView.duringSceneGui += OnSceneGUI; Tools.hidden = true; // 隐藏Unity默认工具 }voidOnDisable() { SceneView.duringSceneGui -= OnSceneGUI; Tools.hidden = false; }voidOnSceneGUI(SceneView view) {var mapData = target as MapData;if (mapData == null) return;// 绘制网格 DrawGrid(mapData);// 绘制Tiles DrawTiles(mapData);// 绘制Entities DrawEntities(mapData);// 处理输入 HandleInput(mapData, Event.current);// 强制重绘 HandleUtility.Repaint(); }voidDrawGrid(MapData mapData) { Handles.color = new Color(0.5f, 0.5f, 0.5f, 0.3f);// 绘制垂直线for (int x = 0; x <= mapData.Size.x; x++) { Vector3 start = new Vector3(x, 0, 0); Vector3 end = new Vector3(x, 0, mapData.Size.y); Handles.DrawLine(start, end); }// 绘制水平线for (int y = 0; y <= mapData.Size.y; y++) { Vector3 start = new Vector3(0, 0, y); Vector3 end = new Vector3(mapData.Size.x, 0, y); Handles.DrawLine(start, end); } }voidDrawTiles(MapData mapData) {for (int x = 0; x < mapData.Size.x; x++) {for (int y = 0; y < mapData.Size.y; y++) {var tile = mapData.Tiles[x, y];// 绘制地块颜色 Color tileColor = GetTerrainColor(tile.Terrain);// 不可通行地块加深if (!tile.IsPassable) { tileColor *= 0.7f; }// 选中地块高亮if (x == lastTileCoord.x && y == lastTileCoord.y) { tileColor = Color.yellow; }// 绘制半透明方块 Vector3 center = new Vector3(x + 0.5f, 0.01f, y + 0.5f); Vector3 size = new Vector3(0.95f, 0.02f, 0.95f); Handles.color = tileColor; Handles.DrawSolidCube(center, size);// 绘制坐标(远处显示)if (x % 5 == 0 && y % 5 == 0) { Handles.Label(center + Vector3.up * 0.5f, $"({x},{y})", EditorStyles.miniLabel); } } } }voidHandleInput(MapData mapData, Event e) {// 获取鼠标射线 Ray ray = HandleUtility.GUIPointToWorldRay(e.mousePosition);if (!Physics.Raycast(ray, out RaycastHit hit, 1000f, LayerMask.GetMask("Ground"))) {return; } Vector2Int tileCoord = new Vector2Int( Mathf.FloorToInt(hit.point.x), Mathf.FloorToInt(hit.point.z));// 验证坐标范围if (tileCoord.x < 0 || tileCoord.x >= mapData.Size.x || tileCoord.y < 0 || tileCoord.y >= mapData.Size.y) {return; }switch (e.type) {case EventType.MouseDown: isMouseDown = true; lastTileCoord = tileCoord; OnToolAction(mapData, tileCoord, e.shift);break;case EventType.MouseDrag:if (isMouseDown) {// 拖拽时连续应用(笔刷模式)if (tileCoord != lastTileCoord) { OnToolAction(mapData, tileCoord, e.shift); lastTileCoord = tileCoord; } }break;case EventType.MouseUp: isMouseDown = false;break; } }voidOnToolAction(MapData mapData, Vector2Int coord, bool isErase) {switch (currentTool) {case MapEditorTool.Brush: ApplyBrush(mapData, coord, isErase);break;case MapEditorTool.Fill: FloodFill(mapData, coord, isErase);break;case MapEditorTool.Entity: PlaceEntity(mapData, coord);break; } EditorUtility.SetDirty(mapData); }Color GetTerrainColor(TerrainType terrain) {return terrain switch { TerrainType.Grass => new Color(0.3f, 0.7f, 0.3f), TerrainType.Dirt => new Color(0.6f, 0.4f, 0.2f), TerrainType.Water => new Color(0.2f, 0.4f, 0.8f, 0.6f), TerrainType.Mountain => new Color(0.5f, 0.5f, 0.5f), TerrainType.Forest => new Color(0.2f, 0.5f, 0.2f), _ => Color.white }; }}publicenum MapEditorTool{ Select, Brush, // 笔刷:绘制单个地块 Fill, // 填充:区域填充 Entity, // 实体:放置单位/装饰物 Eraser, // 橡皮擦 Pathfinder // 寻路测试}#endif
属性面板编辑器
#if UNITY_EDITOR[CustomPropertyDrawer(typeof(TileData))]publicclassTileDataDrawer : PropertyDrawer{publicoverridevoidOnGUI(Rect position, SerializedProperty property, GUIContent label) { EditorGUI.BeginProperty(position, label, property);// 标题 EditorGUI.LabelField(position, label); position.y += EditorGUIUtility.singleLineHeight; EditorGUI.indentLevel++;// 地形类型var terrainProp = property.FindPropertyRelative("Terrain"); EditorGUI.PropertyField(position, terrainProp); position.y += EditorGUIUtility.singleLineHeight;// 可通行性var passableProp = property.FindPropertyRelative("IsPassable"); EditorGUI.PropertyField(position, passableProp); position.y += EditorGUIUtility.singleLineHeight;// 移动消耗(仅当不可通行时禁用) EditorGUI.BeginDisabledGroup(!passableProp.boolValue);var moveCostProp = property.FindPropertyRelative("MoveCost"); EditorGUI.PropertyField(position, moveCostProp); EditorGUI.EndDisabledGroup(); EditorGUI.indentLevel--; EditorGUI.EndProperty(); }publicoverridefloatGetPropertyHeight(SerializedProperty property, GUIContent label) {return EditorGUIUtility.singleLineHeight * 5; }}#endif
数据序列化与版本管理
JSON序列化方案
publicclassMapSerializer{privatestaticreadonly JsonSerializerSettings Settings = new JsonSerializerSettings { Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Ignore, TypeNameHandling = TypeNameHandling.Auto, ReferenceLoopHandling = ReferenceLoopHandling.Ignore };// 保存地图publicvoidSaveMap(MapData mapData, string path) {// 更新元数据 mapData.Metadata.LastModifiedTime = DateTime.Now; mapData.Metadata.LastModifiedBy = Environment.UserName; mapData.Version++;// 序列化为JSONstring json = JsonConvert.SerializeObject(mapData, Settings);// 写入文件 File.WriteAllText(path, json);// 同时保存二进制版本(加载更快) SaveBinaryVersion(mapData, path + ".bin"); Debug.Log($"[MapSerializer] 地图已保存: {mapData.MapName} v{mapData.Version}"); }// 加载地图public MapData LoadMap(string path) {if (File.Exists(path + ".bin")) {// 优先加载二进制版本return LoadBinaryVersion(path + ".bin"); }// 回退到JSONstring json = File.ReadAllText(path);return JsonConvert.DeserializeObject<MapData>(json, Settings); }// 二进制序列化(高效)voidSaveBinaryVersion(MapData mapData, string path) {usingvar stream = File.Create(path);usingvar writer = new BinaryWriter(stream);// 版本头 writer.Write("MAPF"); // Map File writer.Write(1); // Format version// 基础信息 writer.Write(mapData.MapId); writer.Write(mapData.MapName); writer.Write(mapData.Size.x); writer.Write(mapData.Size.y); writer.Write(mapData.Version);// Tiles(压缩存储) SaveTilesBinary(writer, mapData.Tiles);// Entities writer.Write(mapData.Entities.Count);foreach (var entity in mapData.Entities) { writer.Write(entity.EntityId); writer.Write((int)entity.Type); writer.Write(entity.Position.x); writer.Write(entity.Position.y); writer.Write(entity.Position.z); writer.Write(entity.ConfigData ?? ""); } }voidSaveTilesBinary(BinaryWriter writer, TileData[,] tiles) {int width = tiles.GetLength(0);int height = tiles.GetLength(1); writer.Write(width); writer.Write(height);// 批量写入,减少IOfor (int x = 0; x < width; x++) {for (int y = 0; y < height; y++) {var tile = tiles[x, y]; writer.Write((byte)tile.Terrain); writer.Write(tile.IsPassable); writer.Write((byte)tile.MoveCost); writer.Write((short)tile.DefenseBonus); } } }}
版本控制集成
publicclassVersionManager{privatestring mapPath;private List<MapVersion> versions = new();publicvoidCheckout(string mapId) { mapPath = GetMapPath(mapId);// 加载版本历史 LoadVersionHistory(); }// 保存快照publicvoidSaveSnapshot(string description) {var snapshot = new MapVersion { Version = GetCurrentVersion() + 1, Timestamp = DateTime.Now, Description = description, Author = Environment.UserName, MapDataJson = GetCurrentMapJson() }; versions.Add(snapshot); SaveVersionHistory(); }// 回滚到指定版本publicvoidRollback(int version) {var targetVersion = versions.FirstOrDefault(v => v.Version == version);if (targetVersion == null) { Debug.LogError($"[VersionManager] 版本 {version} 不存在");return; }// 备份当前版本 SaveSnapshot("回滚前自动备份");// 恢复目标版本var mapData = JsonConvert.DeserializeObject<MapData>(targetVersion.MapDataJson);new MapSerializer().SaveMap(mapData, mapPath); Debug.Log($"[VersionManager] 已回滚到版本 {version}"); }// 查看差异publicvoidShowDiff(int versionA, int versionB) {var vA = versions.FirstOrDefault(v => v.Version == versionA);var vB = versions.FirstOrDefault(v => v.Version == versionB);if (vA == null || vB == null) { Debug.LogError("[VersionManager] 版本不存在");return; }var diff = JsonDiffPatch.Diff(vA.MapDataJson, vB.MapDataJson); Debug.Log($"[VersionManager] 差异:\n{diff}"); }}
关卡验证机制
验证规则引擎
publicclassLevelValidator{private List<IValidationRule> rules;publicLevelValidator() { rules = new List<IValidationRule> {new PassableCheckRule(), // 地图可通行性new SpawnPointRule(), // 出生点配置new GoalReachableRule(), // 目标可达性new ResourceBalanceRule(), // 资源平衡new PerformanceCheckRule() // 性能预估 }; }public ValidationResult Validate(MapData mapData) {var result = new ValidationResult();foreach (var rule in rules) {var ruleResult = rule.Validate(mapData); result.Merge(ruleResult); }return result; }}// 示例规则:出生点验证publicclassSpawnPointRule : IValidationRule{publicstring RuleName => "出生点验证";public ValidationResult Validate(MapData mapData) {var result = new ValidationResult();var spawnPoints = mapData.Entities .Where(e => e.Type == EntityType.SpawnPoint) .ToList();// 检查是否有出生点if (spawnPoints.Count == 0) { result.AddError("地图必须至少有一个出生点");return result; }// 检查出生点是否可通行foreach (var spawn in spawnPoints) {var tile = GetTileAt(mapData, spawn.Position);if (tile != null && !tile.IsPassable) { result.AddError($"出生点 {spawn.EntityId} 位于不可通行地块"); } }// 检查出生点间距for (int i = 0; i < spawnPoints.Count; i++) {for (int j = i + 1; j < spawnPoints.Count; j++) {float distance = Vector3.Distance( spawnPoints[i].Position, spawnPoints[j].Position);if (distance < 10f) { result.AddWarning($"出生点 {spawnPoints[i].EntityId} 和 {spawnPoints[j].EntityId} " +$"距离过近 ({distance:F1})"); } } }return result; }}// 示例规则:可达性检查publicclassGoalReachableRule : IValidationRule{publicstring RuleName => "目标可达性验证";public ValidationResult Validate(MapData mapData) {var result = new ValidationResult();var goals = mapData.Entities .Where(e => e.Type == EntityType.Goal) .ToList();var spawns = mapData.Entities .Where(e => e.Type == EntityType.SpawnPoint) .ToList();if (goals.Count == 0 || spawns.Count == 0) {return result; // 其他规则会处理 }// 使用BFS检查每个出生点到每个目标是否可达var pathfinder = new AStarPathfinder(mapData);foreach (var spawn in spawns) {foreach (var goal in goals) {var path = pathfinder.FindPath( WorldToTile(spawn.Position), WorldToTile(goal.Position));if (path == null || path.Count == 0) { result.AddError($"从 {spawn.EntityId} 到 {goal.EntityId} 不可达,地图存在孤立区域"); } } }return result; }}
编辑器内实时验证
publicclassValidationPanel{private ValidationResult lastResult;privatebool autoValidate = true;publicvoidDraw(MapData mapData) { EditorGUILayout.Space();// 自动验证开关 autoValidate = EditorGUILayout.Toggle("自动验证", autoValidate);if (autoValidate) {// 实时验证 lastResult = new LevelValidator().Validate(mapData); }if (lastResult == null) return;// 显示结果 EditorGUILayout.Space(); EditorGUILayout.LabelField("验证结果", EditorStyles.boldLabel);// 错误(红色)if (lastResult.Errors.Count > 0) { EditorGUILayout.HelpBox($"发现 {lastResult.Errors.Count} 个错误", MessageType.Error);foreach (var error in lastResult.Errors) { EditorGUILayout.LabelField("❌ " + error); } }// 警告(黄色)if (lastResult.Warnings.Count > 0) { EditorGUILayout.HelpBox($"发现 {lastResult.Warnings.Count} 个警告", MessageType.Warning);foreach (var warning in lastResult.Warnings) { EditorGUILayout.LabelField("⚠️ " + warning); } }// 提示(蓝色)if (lastResult.Infos.Count > 0) {foreach (var info in lastResult.Infos) { EditorGUILayout.LabelField("ℹ️ " + info); } }// 全部通过if (lastResult.IsValid) { EditorGUILayout.HelpBox("验证通过!", MessageType.Info); } }}
踩过的坑
坑1:序列化版本不兼容
游戏上线后更新了数据结构,但旧地图加载全崩了。后来强制所有序列化加版本号字段,并实现向后兼容。
坑2:编辑器没做Undo
策划误操作把地图改坏了,结果无法恢复。从那以后所有操作都支持Undo/Redo。
坑3:性能测试拖到太晚
编辑器做得漂亮,但地图大了加载卡顿。还好上线前发现了,不然就社死了。
坑4:验证规则不完整
漏掉了”地图内不能有死胡同”这条规则,导致某些关卡无法通关。规则需要策划和程序一起review。
总结
地图编辑器开发几点核心心得:
-
WYSIWYG是基本要求,策划能实时看到效果比什么都重要 -
数据结构要干净,好的数据结构让序列化、验证都简单很多 -
版本控制是生命线,Undo/Redo + 快照回滚缺一不可 -
验证规则要完善,问题发现越早修复成本越低 -
性能不能忽视,编辑器卡顿比游戏卡顿更让人崩溃
好的工具让开发效率翻倍,差的工具让所有人痛苦。希望我们的经验对你有帮助。
夜雨聆风