2026/8/5 1:32:07

Unity地牢生成工具开发:模块化架构与算法实战

Unity地牢生成工具开发:模块化架构与算法实战 1. 项目概述为什么我们需要一个自己的地牢生成工具如果你和我一样是个喜欢捣鼓独立游戏开发的“手艺人”那么“地牢”这个概念一定不陌生。无论是经典的Roguelike还是魂系动作游戏一个设计精良、充满随机性的地牢往往是驱动玩家探索的核心动力。然而每次手动在Unity编辑器里摆放墙壁、门、宝箱不仅效率低下更扼杀了“随机生成”的灵魂。市面上的插件要么太贵要么功能臃肿要么生成的布局不符合你的独特玩法设计。这就是我动手搭建这个“可视化地牢生成工具”的初衷。它不是一个简单的脚本而是一个集成在Unity编辑器内的可视化工具窗口。你可以通过拖拽滑块、点击按钮实时预览不同算法生成的地牢布局并一键将生成结果转化为场景中真实的GameObject包括墙壁、地板、房间和走廊。整个过程无需编写任何额外的场景搭建代码真正实现了“所想即所得”。这个工具的核心价值在于它将程序化生成这个后端逻辑与关卡设计师的前端操作体验无缝结合让你能快速迭代地牢设计测试不同参数下的游戏体验。2. 核心设计思路模块化与数据驱动在动手写代码之前明确架构是关键。我不希望做出一个几百行代码揉在一起的“巨无霸”脚本那样后期维护和扩展将是噩梦。我的设计遵循两个原则模块化和数据驱动。2.1 模块化架构拆解整个工具可以清晰地分为四个核心模块各司其职生成算法模块这是工具的大脑。它负责核心的布局生成逻辑比如如何划分空间、如何创建房间、如何连接房间形成走廊。我选择了经典的“随机房间德劳内三角剖分最小生成树”算法组合。这个组合在保证房间分布合理性的同时能生成自然、不交叉的走廊网络是经过大量项目验证的可靠方案。数据模型模块这是工具的记忆。所有生成过程中的中间数据和最终结果都需要被结构化地保存。我定义了DungeonData类来存储整个地牢的网格信息、房间列表、走廊列表。Room类和Corridor类则分别描述单个房间位置、大小、类型和走廊连接的两个房间、路径点。这样生成算法只操作这些数据对象与Unity的GameObject完全解耦。可视化模块这是工具的眼睛和手。它负责两件事一是在编辑器的工具窗口里用Handles或GUI绘制出地牢的二维平面预览图二是根据DungeonData在场景中实例化对应的预制体Prefab生成实实在在的墙壁和地板。编辑器界面模块这是工具的操控面板。它是一个继承自EditorWindow的类提供滑块、输入框、按钮等UI控件让用户调整生成参数如地牢大小、房间数量、房间最小/最大尺寸并触发生成、清除、保存等操作。这种模块化的好处是显而易见的。比如哪天你觉得当前算法生成的地牢太“规整”想换成“洞穴”风格的波函数坍缩算法你只需要替换或新增一个算法模块其他部分几乎不用动。2.2 数据驱动的工作流数据驱动意味着整个工具的运行流程是参数输入 - 算法生成数据 - 数据驱动可视化。具体流程如下你在编辑器窗口调整参数点击“生成”按钮。编辑器窗口将参数传递给生成算法模块。生成算法模块运行产生一个纯粹的DungeonData对象里面只有数据没有任何Unity引擎相关的对象。可视化模块拿到这个DungeonData对象开始工作先在编辑器窗口里将其绘制成二维平面图供你预览如果你满意点击“生成到场景”它再根据数据去实例化对应的预制体。注意一定要严格区分“数据生成”和“场景实例化”这两个阶段。预览时只画线不创建任何GameObject这能保证操作是轻量且可逆的。确认无误后再进行真正的实例化避免场景被意外污染。3. 核心算法实现详解从随机房间到连通地牢算法是程序的灵魂。我采用的“随机房间德劳内三角剖分最小生成树”三步法平衡了随机性、合理性和性能。3.1 第一步在网格内随机撒点并生成房间首先我们需要一个二维布尔数组来表示整个地牢的网格true表示该格被占据墙或房间false表示空地。假设地牢大小为widthxheight。// 伪代码示例 bool[,] dungeonGrid new bool[width, height]; ListRoom rooms new ListRoom(); for (int i 0; i desiredRoomCount; i) { int retry 0; while (retry maxRetry) { // 随机生成房间位置和大小在最小/最大尺寸约束内 int roomWidth Random.Range(minRoomWidth, maxRoomWidth); int roomHeight Random.Range(minRoomHeight, maxRoomHeight); int posX Random.Range(1, width - roomWidth - 1); // 留出边界 int posY Random.Range(1, height - roomHeight - 1); Rect newRoom new Rect(posX, posY, roomWidth, roomHeight); // 关键检查新房间是否与已有房间重叠 bool overlap false; foreach (var existingRoom in rooms) { // 给房间之间增加至少1格的最小间距避免紧贴 if (newRoom.Overlaps(existingRoom.rect.Expanded(1))) { overlap true; break; } } if (!overlap) { // 不重叠则创建房间对象并在地图网格上标记占据区域 Room room new Room(newRoom); rooms.Add(room); MarkGrid(room, dungeonGrid); // 将该房间所占网格设为true break; } retry; } }实操心得这里的maxRetry最大重试次数设置非常重要。如果房间数量设得太多或房间尺寸太大可能永远找不到不重叠的位置导致无限循环。我一般设置为desiredRoomCount * 50。如果重试次数用尽仍未生成足够房间就接受当前数量或者提示用户调整参数。3.2 第二步使用德劳内三角剖分连接所有房间中心点现在我们有了一堆随机分布的房间。下一步是找到一种方式把它们连接起来。我们取每个房间的中心点作为连接点。直接连接所有点会形成全连通图这会导致走廊交叉、布局混乱。德劳内三角剖分能生成一个三角形网格其中任意三角形的外接圆内不包含其他点这能保证连接线不会过于密集和交叉是生成“自然”连接网络的优秀前置步骤。由于Unity没有内置的德劳内算法我们可以使用一个经过验证的第三方库比如MIConvexHull仅需其Delaunay部分或者实现一个相对简单的Bowyer-Watson算法。算法执行后我们会得到一个三角形列表每个三角形由三个房间中心点索引构成。// 伪代码假设我们得到了一个 Triangle 列表 ListTriangle delaunayTriangles DelaunayTriangulation(roomCenters);3.3 第三步应用最小生成树生成主走廊并酌情添加额外连接德劳内三角剖分给出了所有可能的“优质”连接边。但我们不需要全部我们只需要一个能连通所有房间的、且总长度最短的树状结构——这就是最小生成树。这里使用经典的普里姆算法或克鲁斯卡尔算法即可。// 伪代码使用普里姆算法生成MST边 ListEdge mstEdges PrimAlgorithm(roomCenters, delaunayTriangles);最小生成树能保证所有房间连通且走廊总长度最短非常高效。但这也可能导致地牢成为一条“单一路径”缺乏环路探索感会下降。因此我引入一个“额外连接比例”参数比如0.1。在生成MST后我们从德劳内边集合中随机选取一部分不在MST中的边加入最终的连接集合。这样就创造了一些环路增加了地牢的复杂度和探索分支。3.4 第四步根据连接边生成走廊路径现在我们有了一系列需要连接的房间对边。对于每一对房间A和B我们需要在网格上规划一条走廊。我采用简单的“L型”或“直线拐角”走廊生成算法计算两个房间中心点的坐标差。先随机决定是“先水平后垂直”还是“先垂直后水平”走。根据决定在网格上从起点向终点方向依次标记路径格子为“走廊”。在标记走廊时需要与之前的dungeonGrid进行交互。如果走廊路径穿过了已有房间的区域则直接“穿过”这代表了门的位置。如果路径与房间重叠在后续实例化时我们会在这里放置一个门预制体而不是墙。void CreateCorridorBetweenRooms(Room roomA, Room roomB, bool[,] grid) { Vector2Int pointA roomA.center; Vector2Int pointB roomB.center; // 随机选择拐角方向 if (Random.value 0.5f) { // 先走X轴再走Y轴 CreateHorizontalTunnel(grid, pointA.x, pointB.x, pointA.y); CreateVerticalTunnel(grid, pointA.y, pointB.y, pointB.x); } else { // 先走Y轴再走X轴 CreateVerticalTunnel(grid, pointA.y, pointB.y, pointA.x); CreateHorizontalTunnel(grid, pointA.x, pointB.x, pointB.y); } }至此dungeonGrid这个布尔数组就完整地记录了整个地牢的占据情况。rooms列表和corridors列表则记录了所有的逻辑实体。DungeonData对象封装了这些数据准备交给可视化模块。4. 编辑器工具窗口开发全流程有了强大的数据生成引擎我们需要一个友好的界面来操控它。这就是继承自EditorWindow的编辑器工具窗口。4.1 创建窗口与基础UI首先创建一个DungeonGeneratorWindow类。using UnityEditor; using UnityEngine; public class DungeonGeneratorWindow : EditorWindow { private DungeonGenerator generator; // 持有生成器实例 private DungeonData currentDungeonData; // 当前生成的数据 // 参数序列化以便窗口关闭后重新打开时记忆 [SerializeField] private int dungeonWidth 100; [SerializeField] private int dungeonHeight 100; [SerializeField] private int roomCount 10; [SerializeField] private int minRoomSize 4; [SerializeField] private int maxRoomSize 10; [SerializeField] private float extraConnectionRatio 0.1f; [MenuItem(Tools/Dungeon Generator)] // 在Unity菜单栏添加入口 static void Init() { var window GetWindowDungeonGeneratorWindow(); window.titleContent new GUIContent(地牢生成器); window.Show(); } void OnGUI() { EditorGUILayout.LabelField(地牢生成参数, EditorStyles.boldLabel); // 使用EditorGUILayout绘制参数控件 dungeonWidth EditorGUILayout.IntSlider(地牢宽度, dungeonWidth, 20, 500); dungeonHeight EditorGUILayout.IntSlider(地牢高度, dungeonHeight, 20, 500); roomCount EditorGUILayout.IntSlider(房间数量, roomCount, 2, 50); minRoomSize EditorGUILayout.IntField(房间最小尺寸, minRoomSize); maxRoomSize EditorGUILayout.IntField(房间最大尺寸, maxRoomSize); extraConnectionRatio EditorGUILayout.Slider(额外连接比例, extraConnectionRatio, 0f, 0.3f); EditorGUILayout.Space(10); GUILayout.BeginHorizontal(); if (GUILayout.Button(生成预览, GUILayout.Height(30))) { GeneratePreview(); } if (GUILayout.Button(清除预览, GUILayout.Height(30))) { ClearPreview(); } GUILayout.EndHorizontal(); if (GUILayout.Button(生成到场景, GUILayout.Height(40))) { GenerateInScene(); } // 如果当前有数据则触发场景视图的重绘以显示预览 if (currentDungeonData ! null) { SceneView.RepaintAll(); } } void GeneratePreview() { // 实例化生成器传入参数运行算法得到currentDungeonData generator new DungeonGenerator(dungeonWidth, dungeonHeight, roomCount, minRoomSize, maxRoomSize, extraConnectionRatio); currentDungeonData generator.Generate(); // 生成数据后需要重绘窗口和场景视图来显示预览 Repaint(); SceneView.RepaintAll(); } void ClearPreview() { currentDungeonData null; // 也需要清除场景视图中的绘制 SceneView.RepaintAll(); } void GenerateInScene() { if (currentDungeonData null) { EditorUtility.DisplayDialog(提示, 请先生成预览, 确定); return; } // 调用可视化模块根据currentDungeonData在场景中实例化预制体 Visualizer.InstantiateDungeonInScene(currentDungeonData); } }4.2 在场景视图Scene View中绘制预览仅在窗口里显示参数不够直观我们需要在场景视图的二维顶视角下实时绘制出地牢的预览图。这需要用到HandlesAPI和SceneView的回调。我们在DungeonGeneratorWindow类中添加OnSceneGUI的调度。由于EditorWindow本身没有OnSceneGUI我们需要通过SceneView.duringSceneGui委托来订阅。public class DungeonGeneratorWindow : EditorWindow { // ... 之前的变量和OnGUI代码 ... void OnEnable() { // 订阅场景GUI绘制事件 SceneView.duringSceneGui OnSceneGUI; } void OnDisable() { // 取消订阅防止内存泄漏 SceneView.duringSceneGui - OnSceneGUI; } void OnSceneGUI(SceneView sceneView) { if (currentDungeonData null) return; // 设置Handles的颜色和矩阵 Handles.color Color.white; // 通常我们希望在XZ平面地面预览所以做一个坐标变换 // 假设1个网格单位对应Unity世界空间的1米 Matrix4x4 originalMatrix Handles.matrix; Handles.matrix Matrix4x4.TRS(Vector3.zero, Quaternion.identity, new Vector3(1, 0, 1)); // 忽略Y轴在XZ平面绘制 // 1. 绘制房间用方框 foreach (var room in currentDungeonData.Rooms) { Vector3 center new Vector3(room.Rect.center.x, 0, room.Rect.center.y); Vector3 size new Vector3(room.Rect.width, 0, room.Rect.height); Handles.DrawWireCube(center, size); } // 2. 绘制走廊用线 Handles.color Color.gray; foreach (var corridor in currentDungeonData.Corridors) { // 简化绘制绘制走廊的中心线路径 for (int i 0; i corridor.PathPoints.Count - 1; i) { Vector3 start new Vector3(corridor.PathPoints[i].x, 0, corridor.PathPoints[i].y); Vector3 end new Vector3(corridor.PathPoints[i1].x, 0, corridor.PathPoints[i1].y); Handles.DrawLine(start, end, 2f); // 可以设置线宽 } } Handles.matrix originalMatrix; // 恢复矩阵 } }现在点击“生成预览”后你不仅能在工具窗口看到参数还能在Scene视图的顶视角下看到一个由白框房间和灰线走廊构成的清晰二维布局图。这种即时反馈对于调整参数、评估生成效果至关重要。5. 从数据到实体场景实例化与预制体系统预览满意后下一步就是将数据变成场景中真实的物体。我们需要一套预制体系统。5.1 预制体配置与映射创建一个DungeonVisualizer脚本不继承MonoBehaviour作为静态工具类或由窗口调用。同时创建一个ScriptableObject作为可视化配置资产比如DungeonTheme。// DungeonTheme.cs using UnityEngine; [CreateAssetMenu(fileName New Dungeon Theme, menuName Dungeon Generator/Theme)] public class DungeonTheme : ScriptableObject { public GameObject floorPrefab; // 地板预制体 public GameObject wallPrefab; // 墙壁预制体 public GameObject doorPrefab; // 门预制体 public GameObject corridorFloorPrefab; // 走廊地板可与房间地板不同 // 可以扩展宝箱、火炬、怪物生成点等 }DungeonVisualizer的工作流程如下读取currentDungeonData和指定的DungeonTheme。遍历dungeonGrid对于每个被占据的格子如果它是房间的一部分且是边缘格根据相邻四格判断则在其位置和朝向实例化wallPrefab。否则房间或走廊内部实例化floorPrefab或corridorFloorPrefab。遍历所有走廊路径特别处理与房间重叠的格子即门的位置在这些地方实例化doorPrefab并可能需要移除该位置已生成的墙。5.2 性能优化与组织直接实例化上千个GameObject可能会导致场景层级混乱和一帧卡顿。有两个优化技巧按区块实例化不要在一个for循环里连续实例化所有物体。可以使用EditorApplication.update分帧进行或者在协程中每帧实例化一定数量。在编辑器工具中分帧实例化能保持界面响应。父物体归类在实例化时将所有生成的地板、墙壁、门分别放在不同的父物体下如“Dungeon_Floors”、“Dungeon_Walls”、“Dungeon_Doors”。这样场景层级清晰也便于整体操作如隐藏、删除。public static class Visualizer { public static void InstantiateDungeonInScene(DungeonData data, DungeonTheme theme) { GameObject dungeonRoot new GameObject(Generated_Dungeon); GameObject floorsParent new GameObject(Floors); GameObject wallsParent new GameObject(Walls); GameObject doorsParent new GameObject(Doors); floorsParent.transform.SetParent(dungeonRoot.transform); wallsParent.transform.SetParent(dungeonRoot.transform); doorsParent.transform.SetParent(dungeonRoot.transform); // 分帧实例化示例简化版实际需用EditorCoroutine for (int x 0; x data.Width; x) { for (int y 0; y data.Height; y) { if (data.Grid[x, y]) { Vector3 worldPos new Vector3(x, 0, y); // 假设Y轴向上 // 判断格子类型并实例化到对应的父物体下 // ... // 每生成100个物体等待一帧 if ((x * data.Height y) % 100 0) { // EditorApplication.update 或 yield return null (在协程中) } } } } } }6. 常见问题、调试技巧与扩展思路在实际开发中你肯定会遇到各种预期之外的情况。这里分享几个我踩过的坑和解决方法。6.1 生成结果不稳定或异常问题每次生成的地牢差异巨大有时房间堆在一起有时走廊不连通。排查检查随机种子确保在生成开始时使用固定的随机种子如Random.InitState(seed)进行调试这样每次运行结果可复现便于定位问题。可视化调试中间步骤在算法关键步骤后将中间状态如房间位置、德劳内三角边、MST边也绘制到Scene视图。这能帮你直观看到是房间生成阶段就重叠了还是连通性算法出错了。边界检查确保房间生成时其Rect没有超出dungeonGrid的范围并且留出了墙壁的厚度通常房间边缘的格子是墙。解决在我的实现中最棘手的是房间重叠检测。最初的Rect.Overlaps检测没有加入间距导致房间可以紧贴走廊无法穿过。后来我给每个房间的Rect扩大了一个单位Expanded(1)再进行检测问题就解决了。6.2 编辑器窗口操作卡顿或无响应问题点击“生成预览”后编辑器卡住几秒甚至白屏。排查算法复杂度地牢尺寸width*height和房间数量roomCount过大会导致算法计算量激增尤其是德劳内三角剖分复杂度在O(n log n)到O(n²)之间。OnSceneGUI绘制过载如果在OnSceneGUI中无脑绘制所有网格比如每个格子画一个点当地牢尺寸为100x100时一帧要绘制10000个物体必然卡顿。解决参数限制在UI上给地牢尺寸和房间数量设置合理的上限比如500x50050个房间。优化绘制预览时只绘制房间边框和走廊中心线不绘制每个格子。这是最有效的优化。异步生成将耗时的生成算法放到后台线程但需要注意Unity API的调用限制。在编辑器环境下可以使用Task.Run配合EditorApplication.delayCall回到主线程更新数据。6.3 生成到场景后物体位置或旋转不对问题墙壁方向错误地板悬空或嵌入地面。排查坐标系统一确保算法中的网格坐标x, y与Unity世界坐标通常是XZ平面的映射关系正确。我习惯用(gridX, 0, gridY)作为世界坐标。预制体轴心点检查你使用的墙、地板预制体的轴心点Pivot是否在物体底部中心。如果轴心点在模型中心实例化后就需要做位置偏移。墙壁朝向根据网格相邻关系判断墙壁朝向时逻辑要严谨。是朝北的墙还是朝东的墙这决定了你需要给墙壁预制体施加Quaternion.Euler(0, 90, 0)还是Quaternion.identity的旋转。解决写一个简单的测试脚本在指定坐标实例化一个参考物体如小方块对比算法中该坐标的预期位置和场景中的实际位置就能快速发现坐标映射错误。6.4 工具的扩展方向这个基础工具框架有巨大的扩展潜力更多算法替换或增加新的地牢生成算法如“二元空间分割”、“波函数坍缩WFC”、“细胞自动机洞穴生成”。只需实现新的IDungeonGenerator接口并在UI上提供选择下拉框。主题系统增强让DungeonTheme支持更多预制体变体如不同材质的墙、不同类型的门并可以根据房间的“类型”普通、宝库、Boss房来分配不同的装饰物和怪物生成点。动态加载与种子系统将生成逻辑与场景实例化分离实现运行时动态生成地牢。配合种子系统让玩家可以用特定的种子代码重现同一个地牢。与Tilemap集成不生成3D物体而是将地牢数据转换为Unity 2D的Tilemap数据用于2D游戏开发。导出功能将生成的地牢布局数据导出为JSON或自定义二进制格式供其他工具或游戏服务器使用。开发这个工具的过程让我对Unity编辑器扩展、算法应用、以及数据驱动设计有了更深的理解。它不仅仅是一个地牢生成器更是一个如何将复杂算法封装成友好生产力工具的典型案例。当你看到调整一个滑块整个地牢布局随之实时变化时那种掌控感和创造力迸发的体验是单纯写游戏逻辑代码难以比拟的。希望这个详细的拆解和实战记录能帮你少走弯路更快地搭建起属于自己的关卡创作流水线。