
最近在 Godot 里用 GDScript 做了一套仿《杀戮尖塔》的地图生成模块整个拆出来之后发现这套东西远没有想象中那么玄乎——核心就是一张“分层的有向无环图”再加一套控制节点间距和连线范围的随机规则。做完之后很多做 Roguelike 的朋友来问我细节今天就完整梳理一遍从算法思路到 GDScript 代码实现再到编辑器里的调试技巧一次性说清楚。这套内容适合两类人看一是想在 Godot 里做卡牌构筑或 Roguelike 项目的开发者二是已经在做但卡在地图节点布局、连线约束、路径可达性这块的同学。整体代码不依赖第三方插件纯 GDScript 实现Godot 4.x 可以直接跑。1. 先搞懂杀戮尖塔地图的本质是什么1.1 地图的结构拆解杀戮尖塔的地图从玩家视角看是一张由节点和连线组成的路线图玩家从底部起点出发每前进一层就得在有限的几个节点里选一个走下去一路走到顶层的 Boss 节点。从数据结构的视角看这本质上是一张分层 DAG层Layer地图纵向分成固定层数每层包含若干节点玩家每走一步就切换一层。节点Node每个节点代表一种房间类型比如战斗、精英、事件、营火、商店、宝藏、Boss。连线Edge连线只能从当前层指向下一层不能回头不能跨层不能横向连接。这个结构天然保证了游戏流程的线性推进同时又因为每层有多个选择、每个节点只连向下一层的一部分节点所以玩家能感知到“策略性选择”而不是“一条路走到黑”。层数、节点数、连接数量三个参数直接决定了地图的复杂度。我做的时候把层数定为 15 层第 0 层是起点第 1 到 13 层是中间层第 14 层是 Boss 层。中间层每层节点数在 4 到 6 之间浮动越到后面越宽这样后期选择空间大前期节奏紧凑。1.2 为什么选分层 DAG 而不是其他方案可能有人会问既然 Roguelike 地图要随机直接用 Unity 那种网格地图或者完全随机连线不就行了这里有个关键点杀戮尖塔式地图的体验核心是“有限选择”。如果用完全随机连线节点之间可能出现大量交叉玩家视觉上很难追踪路径如果用网格地图每个格子四方向连通那玩家的移动自由度太高反而失去了“每次只选一条路”的决策感。分层 DAG 的好处在于天然保证无环连线只从 i 层指向 i1 层从数学上就不存在回路。边界清晰每层节点横向排布层间纵向推进视觉层次分明。规则可控通过限制“下一层节点的索引范围”就能控制路径的疏密程度进而控制难度曲线。我做这个模块的时候原定方案是直接用图数据结构手写所有边写完之后发现完全没必要——因为规则的约束足够强你只需要在“当前层每个节点都连向下一层几个候选节点”这个逻辑里做文章就能得到整体合理、局部有随机感的地图。2. 地图生成核心算法设计2.1 坐标布局与间距约束地图的布局我用的是“逐层生成、整体抖动”的方式。所有节点的坐标由两个值决定横向索引和纵向层号。最初版本我直接在固定位置放节点生成出来发现每层的节点都整整齐齐对齐成几列特别死板。后来改成给每个节点叠加一个横向随机偏移量但偏移量不能乱加——如果偏移过大两层节点会错位太远连线会非常倾斜甚至交叉视觉上乱成一团。我用偏移量的尺度跟节点间距挂钩。假设每层的节点基础间距是 160 像素那横向偏移范围设置成 ±60 像素这样就保证即使两个节点各自向相反方向偏移它们也不会重叠而且连线还是相对垂直的。具体坐标计算我写在生成器里每一层先算出基础横坐标再让每个节点在这个基础上抖动一次。纵向上每层间隔 220 像素这个距离够大可以放得下节点描述信息和连线。const LAYER_GAP : 220.0 const NODE_SPACING : 160.0 const JITTER_X : 60.0 func _calculate_position(layer: int, index: int, count: int, rng: RandomNumberGenerator) - Vector2: var total_width : (count - 1) * NODE_SPACING var start_x : -total_width * 0.5 var base_x : start_x index * NODE_SPACING var jitter : rng.randf_range(-JITTER_X, JITTER_X) return Vector2(base_x jitter, layer * LAYER_GAP)2.2 层内节点数量与覆盖规则每层节点数量不是固定的我会根据层号做一个曲线。第 1 层只有 1 个节点起点下方的第一战第 2 到第 4 层逐步增加到 4 个第 5 到第 12 层维持在 4 到 6 个第 13 层是 Boss 前最后一层我倾向于 5 到 6 个给玩家足够的备战节点。这个变化过程在代码里就是一段简单的参数配置func _node_count_for_layer(layer: int, rng: RandomNumberGenerator) - int: if layer 0: return 1 if layer LAYER_COUNT - 1: return 1 if layer 2: return 3 if layer 4: return 4 return rng.randi_range(4, 6)关键点在于所有节点都必须至少有一条入边和一条出边。如果某个节点没有入边玩家永远不会走到它那它就是个摆设如果没有出边玩家走到它之后就卡住了。为了保证这一点我加了后处理函数专门扫描孤立节点并强制补边。2.3 连线范围与交叉控制连线是这张地图最核心的部分。我采用的规则是当前层第 i 个节点最多连向下一层的 3 个节点索引范围限制在i - 1到i 2之间。这个索引范围限制很关键。如果允许i连到i5就会产生高度倾斜的斜线多条这样的线交叉起来画面没法看如果只允许i连到i那路径选择就完全线性毫无策略感。i - 1到i 2这个范围我实测下来比较合适既有足够的路径分支又不会出现边交叉。实际代码中我维护了一个“下一层节点可用列表”每连一条边就从列表里移除这个目标确保同一个目标不会累积太多源节点避免出现“下一层某个节点被上面所有节点连接其他节点无人问津”的不均衡情况。func _connect_layers(from_layer: Array, to_layer: Array, rng: RandomNumberGenerator) - void: var to_indices : range(to_layer.size()) for from_index in from_layer.size(): var current : from_layer[from_index] var start : max(0, from_index - 1) var end : min(to_layer.size() - 1, from_index 2) var candidates: Array[int] [] for idx in to_indices: if idx start and idx end: candidates.append(idx) candidates.shuffle() var edge_count : rng.randi_range(1, min(3, candidates.size())) var selected : candidates.slice(0, edge_count) for target_index in selected: _add_edge(current, to_layer[target_index])这段逻辑里我用了to_indices数组来保证每个目标最多被连一次——选过的索引就从to_indices里移除。当然这只是第一版限制如果后面想提高地图密度可以把这个限制放宽到“最多被连两次”。2.4 房间类型分配策略节点类型分配不是完全随机而是带权重的分层策略。战斗和事件是主力类型占比最高营火、商店、宝箱等比战斗稀缺保证玩家不能永远苟在安全区。我的基础权重表如下房间类型基础权重限制条件战斗45无事件25无精英12第 1、2 层不出现营火10Boss 层前一层至少出现 1 个商店10每张地图最多出现 2 个宝箱8每张地图最多出现 2 个第 1 层不出现精英很好理解——玩家刚开局牌组薄弱不适合一上来就打精英。Boss 前一层强制至少一个营火是为了让玩家有最后一个喘息的机会调整血量和卡牌这是从杀戮尖塔原版设计里学来的实际体验下来确实重要。enum RoomType { MONSTER, ELITE, EVENT, REST, SHOP, TREASURE, BOSS, START } func _assign_room_type(layer: int, generated_rooms: Dictionary, rng: RandomNumberGenerator) - RoomType: if layer 0: return RoomType.START if layer LAYER_COUNT - 1: return RoomType.BOSS while true: var roll : rng.randf_range(0.0, 100.0) if roll 45.0: return RoomType.MONSTER elif roll 70.0: return RoomType.EVENT elif roll 82.0: if layer 3: return RoomType.ELITE elif roll 92.0: if generated_rooms.get(RoomType.SHOP, 0) 2: return RoomType.SHOP elif roll 98.0: return RoomType.REST else: if generated_rooms.get(RoomType.TREASURE, 0) 2: return RoomType.TREASURE生成了之后还要做一次后处理统计 Boss 层前一层有没有营火如果没有就随机挑一个节点把它改成营火。3. GDScript 在 Godot 里的完整实现3.1 数据类的设计我先定义了三个核心类MapData、MapRoom、MapEdge。这三个类是这套地图系统的数据层不依赖任何引擎节点方便单元测试和存档序列化。class MapRoom: var id: int var layer: int var position: Vector2 var room_type: RoomType var edges_to: Array[int] [] # 目标节点的 id # 状态用于交互 var is_reachable : false var is_visited : false class MapEdge: var from_id: int var to_id: int var points: PackedVector2Array class MapData: var rooms: Dictionary {} # id - MapRoom var edges: Array[MapEdge] [] var seed: int 0 var start_room_id: int 0 var boss_room_id: int 0用 Dictionary 存房间比数组好处是查找方便特别是后处理阶段要频繁按 id 找房间。edges_to存在房间里同时边也单独存了一份带坐标点的MapEdge这样绘制地图时直接遍历edges数组就行不用重新计算连线。3.2 生成器主体代码生成器的整体流程分五步创建随机数生成器设置种子。按层循环生成所有房间记录坐标和类型。逐层连接节点生成边。后处理补全孤立节点、强制 Boss 前营火。返回MapData。我直接贴生成器类的主要代码。这个类我建议独立成一个脚本map_generator.gd不要挂在场景节点上方便以后做自动测试和热重载。class_name MapGenerator extends RefCounted const LAYER_COUNT : 15 static func generate(seed_value: int -1) - MapData: var rng : RandomNumberGenerator.new() if seed_value 0: rng.randomize() else: rng.seed seed_value var data : MapData.new() data.seed rng.seed var next_id : 0 # 1. 生成房间 for layer in LAYER_COUNT: var count : _node_count_for_layer(layer, rng) var layer_rooms: Array[MapRoom] [] for i in count: var room : MapRoom.new() room.id next_id room.layer layer room.position _calculate_position(layer, i, count, rng) room.room_type _assign_room_type_with_post_check(layer, data, rng, Vector2.ZERO) data.rooms[room.id] room layer_rooms.append(room) if layer 0: data.start_room_id room.id if layer LAYER_COUNT - 1: data.boss_room_id room.id next_id 1 # 2. 生成连线这里需要重新分组上面已经有 layer_rooms 数组这里简化 var layers: Array[Array] [] for i in LAYER_COUNT: layers.append([]) for room in data.rooms.values(): layers[room.layer].append(room) for i in range(LAYER_COUNT - 1): _connect_layers(layers[i], layers[i 1], rng) # 3. 后处理 _ensure_all_rooms_connected(data, rng) _ensure_rest_before_boss(data, rng) return data3.3 地图绘制与状态刷新地图生成只是第一步让它在屏幕上像样地显示出来才是体验的关键。我单独做了一个MapView节点继承Node2D持有MapData引用负责在_draw()里遍历MapData.edges画直线或曲线。遍历MapData.rooms画节点圆圈和房间图标。根据is_reachable、is_visited、room_type切换颜色。连线我用的是二次贝塞尔而不是直线。直线虽然简单但多条路径交叉时特别乱贝塞尔曲线自带弧度视觉上更柔和也不容易被误判成交叉。func _draw() - void: for edge in data.edges: var from_room: MapRoom data.rooms[edge.from_id] var to_room: MapRoom data.rooms[edge.to_id] var mid : (from_room.position to_room.position) * 0.5 mid.x 20 draw_curve(from_room.position, mid, to_room.position, Color(0.4, 0.4, 0.4), 2.0) for room in data.rooms.values(): draw_circle(room.position, ROOM_RADIUS, _color_for_room(room))这里的_color_for_room函数把房间状态映射成颜色可到达的节点高亮暖色、已访问的变灰、不可达的用暗色。玩家视角一目了然。3.4 鼠标交互与路径推进地图节点自然的交互方式就是点击。我给每个房间提供_to_screen_position和_on_click两个方法在_unhandled_input里做鼠标拾取func _unhandled_input(event: InputEvent) - void: if event is InputEventMouseButton and event.pressed and event.button_index MOUSE_BUTTON_LEFT: var world_pos : get_global_mouse_position() for room in data.rooms.values(): if room.is_reachable and room.position.distance_to(world_pos) ROOM_RADIUS: _move_to_room(room) return_move_to_room做的事情是把当前房间标记为is_visited找到下一层与它连接的房间把它们的is_reachable置为 true其他全部置为 false然后queue_redraw()。这一步就是整个“地图推进”的核心逻辑。func _move_to_room(room: MapRoom) - void: for r in data.rooms.values(): r.is_reachable false room.is_visited true for edge in data.edges: if edge.from_id room.id: var next_room: MapRoom data.rooms[edge.to_id] next_room.is_reachable true current_room_id room.id queue_redraw()4. 实操过程与调优实录4.1 用 tool 脚本在编辑器里实时预览这一节可能是我最想分享的部分。最初我做地图生成都是运行游戏之后才能看到结果调参数特别痛苦——改一个节点间距就得重新跑一遍游戏浪费时间还没效率。后来我给生成器加了一个tool支持。做法是在MapView节点上加tool声明然后监听export参数的变化一旦某个参数变了就自动重新生成地图并刷新预览。tool extends Node2D export var map_seed: int 0: set(v): map_seed v if Engine.is_editor_hint(): regenerate() export var auto_preview: bool false: set(v): auto_preview v if Engine.is_editor_hint() and v: regenerate()加了tool之后我在编辑器的 Inspector 面板里直接拖动 seed 数值地图会实时刷新节点位置、连线、房间颜色肉眼可见地变化。这个改动直接让我的调参效率提高了三倍不止。强烈建议所有做地图生成的朋友在编辑器阶段就引入tool不要等到运行时再调。4.2 可玩性调优参数分享做完基础功能后我花了不少时间调参数这里直接分享一组我实测下来比较适合 15 层地图的参数配置参数推荐值说明层数15太少没有推进感太多流程拖沓中间层节点数4-6少于 4 选择太少多于 6 地图太拥挤横向基础间距160 px跟节点半径 25 px 搭配视觉间距舒适层间垂直间距220 px能放下节点描述文本和房间图标横向偏移抖动±60 px增加自然感不会破坏连线的垂直走向最大出边数3超过 3 条路径分支过散策略区分度下降索引连接范围i-1 到 i2这个区间实测交叉最少还有几个隐藏参数值得调同一目标最大入边数。我建议默认 2超过 2 会导致某些节点变成交通枢纽其他节点门可罗雀视觉不平衡。4.3 手把手复现一遍完整流程如果你从零开始我的建议流程是新建 Godot 4.x 项目。创建map_data.gd粘贴上面三个类。创建map_generator.gd粘贴生成器类。创建map_view.gd继承Node2D实现_draw()和_unhandled_input()。在场景根节点挂上MapView在_ready()里调用var data MapGenerator.generate()然后把data赋值给视图。运行查看地图点击节点测试路径推进。把map_view改成tool开始调参。每一步依赖关系都列出来了按顺序做完大概需要二十分钟到半小时。做完之后你可以得到一个自带点击推进逻辑、可随机种子复现、带房间类型分配的完整地图模块。5. 常见问题与排查技巧实录5.1 生成后出现孤立节点和断头路这是我自己踩过最深的坑。第一版生成器生成完我发现有将近四分之一的节点没有被任何边连接变成了视觉上的“幽灵房间”。原因在于我最初生成边的时候只关注了“从源节点向外连”没有验证“目标节点有没有被连入”。解决方案就是后处理的时候做一次“入边覆盖检查”遍历所有非起点节点如果它的edges_to为空或者入边数量为 0就在上一层随机找一个节点补一条边。由于边是单向的这里注意只能是“上一层 - 本层”。另一个陷阱是“断头路”即某个节点有入边但没有出边。除了 Boss 节点其他任何节点都不允许这样。后处理函数要同时检查这两种情况缺一不可。static func _ensure_all_rooms_connected(data: MapData, rng: RandomNumberGenerator) - void: var rooms: Array data.rooms.values() var layers : _build_layers(rooms) for layer in range(LAYER_COUNT - 1): for room in layers[layer]: if room.edges_to.is_empty(): var to_layer: Array layers[layer 1] var target : to_layer[rng.randi_range(0, to_layer.size() - 1)] _add_edge(data, room, target) for layer in range(1, LAYER_COUNT): for room in layers[layer]: var has_incoming : false for edge in data.edges: if edge.to_id room.id: has_incoming true break if not has_incoming: var from_layer: Array layers[layer - 1] var source : from_layer[rng.randi_range(0, from_layer.size() - 1)] _add_edge(data, source, room)5.2 节点重叠与连线交叉严重这问题几乎都是横向抖动范围太大导致的。抖动 ±60 px 配 160 px 间距没问题但如果你把NODE_SPACING改成 80还保持 ±60 的抖动那相邻节点距离可能压缩到只剩 20 像素视觉上就挤成一团。排查思路很简单先关闭抖动看布局是否正常再逐步加大抖动范围。如果关闭抖动后布局仍有问题那就不是抖动的问题而是每层节点数量和间距没匹配上。连线交叉的另一个来源是索引连接范围。i-1 到 i2这个区间我已经测过交叉概率很低但如果你把范围改成i-3 到 i3那交叉概率会急剧上升。要记住连线交叉不是靠画布优化解决的而是从生成规则上就避免的。5.3 随机种子不可复现GDScript 的随机数函数有两套全局的randi()/randf()和RandomNumberGenerator实例方法。如果用全局函数你没法指定种子用RandomNumberGenerator实例每次都需要显式设置seed属性而且要保证生成过程中所有随机数都来自这一个实例。我的生成器里统一使用rng实例不使用任何全局随机函数。生成完地图后我把data.seed保存下来这样任何一个玩家都能通过输入种子重放同一张地图对 Roguelike 游戏做每日挑战功能特别有用。还有一个小细节RandomNumberGenerator.seed的类型是int但如果你不主动设置默认情况下它的 seed 是固定的如果生成器只创建一次实例并重复使用第二次生成时会延续上一次的随机状态。所以每次生成新地图我都新建一个RandomNumberGenerator不复用旧实例。5.4 地图显示位置偏移与缩放适配最后一个是 UI 层面的问题。地图中心点在场景原点(0, 0)但不同手机的屏幕宽高比不一样直接显示可能偏左或偏右。我在MapView里做了一步“适配居中”func _on_resized() - void: if not data: return var map_center : Vector2.ZERO for room in data.rooms.values(): map_center room.position map_center / data.rooms.size() offset -map_center同时让场景根节点的Camera2D或者 CanvasLayer 的定位以地图中心为锚点。这样不管屏幕比例如何地图始终居中显示不会出现一半地图在屏幕外的情况。6. 从地图到完整项目的几条扩展思路地图模块做完之后后续要接内容就是水到渠成的事。我这里分享几个我实际在走的扩展方向。扩展一节点内容填充。MapRoom里可以挂room_data字段存战斗配置ID、事件脚本ID、商店商品表等。这样地图生成器只负责结构具体内容由玩法层决定模块之间解耦。扩展二动态事件效果。比如某些事件节点触发后会改变地图——增加一个营火、移除一条路径、把某个战斗节点变成精英节点。实现上就是在MapView里提供add_room()、remove_room()、reconnect_room()几个方法操作MapData之后queue_redraw()。扩展三存档序列化。因为MapData是纯数据类没有引擎节点依赖我直接用 JSON 序列化玩家退出重进后地图可以完整还原。这是纯数据类设计带来的最大红利。在踩过几次坑之后我个人现在做任何地图生成功能都会先花半小时确认数据结构是否纯净、随机过程是否完全可控、路径约束是否能在生成期保证而不是画到屏幕上再修。地图交互的复杂度其实不在代码量而在数据结构设计的“后知后觉”——等你发现层与层之间连线乱成一团再想回头改数据结构就真的晚了。这套模块目前的代码量也就三百行左右但因为它只关心结构不关心具体战斗内容我后面的项目拿过来改改参数就能直接复用性价比非常高。