乐于分享
好东西不私藏

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

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

地图编辑器与关卡设计工具开发复盘

关卡编辑器是SLG游戏开发的”秘密武器”。好的编辑器让策划事半功倍,差的编辑器让所有人崩溃。今天聊聊我们项目地图编辑器的演进过程。

从”策划提需求,程序写代码”说起

项目初期,地图编辑是这样的流程:

  1. 策划画个草图,标注”这里放个障碍物,那里放个出生点”
  2. 程序照着草图写配置
  3. 运行游戏看效果
  4. 发现问题,策划再画一版

一个中等规模地图,从需求提出到上线测试,要折腾三四天。策划崩溃,程序也崩溃。

痛定思痛,我们决定做一套所见即所得的地图编辑器。

编辑器架构设计

整体架构

┌─────────────────────────────────────────────────────────┐│                      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 == nullreturn;// 绘制网格        DrawGrid(mapData);// 绘制Tiles        DrawTiles(mapData);// 绘制Entities        DrawEntities(mapData);// 处理输入        HandleInput(mapData, Event.current);// 强制重绘        HandleUtility.Repaint();    }voidDrawGrid(MapData mapData)    {        Handles.color = new Color(0.5f0.5f0.5f0.3f);// 绘制垂直线for (int x = 0; x <= mapData.Size.x; x++)        {            Vector3 start = new Vector3(x, 00);            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(00, 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.5f0.01f, y + 0.5f);                Vector3 size = new Vector3(0.95f0.02f0.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.3f0.7f0.3f),            TerrainType.Dirt => new Color(0.6f0.4f0.2f),            TerrainType.Water => new Color(0.2f0.4f0.8f0.6f),            TerrainType.Mountain => new Color(0.5f0.5f0.5f),            TerrainType.Forest => new Color(0.2f0.5f0.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 == nullreturn;// 显示结果        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。

总结

地图编辑器开发几点核心心得:

  1. WYSIWYG是基本要求,策划能实时看到效果比什么都重要
  2. 数据结构要干净,好的数据结构让序列化、验证都简单很多
  3. 版本控制是生命线,Undo/Redo + 快照回滚缺一不可
  4. 验证规则要完善,问题发现越早修复成本越低
  5. 性能不能忽视,编辑器卡顿比游戏卡顿更让人崩溃

好的工具让开发效率翻倍,差的工具让所有人痛苦。希望我们的经验对你有帮助。