☰
Godot实现杀戮尖塔式地图生成:分层DAG与随机规则详解
2026/10/5 4:58:24 网站建设 项目流程

最近在 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 的好处在于:

  1. 天然保证无环:连线只从 i 层指向 i+1 层,从数学上就不存在回路。
  2. 边界清晰:每层节点横向排布,层间纵向推进,视觉层次分明。
  3. 规则可控:通过限制“下一层节点的索引范围”就能控制路径的疏密程度,进而控制难度曲线。

我做这个模块的时候,原定方案是直接用图数据结构手写所有边,写完之后发现完全没必要——因为规则的约束足够强,你只需要在“当前层每个节点都连向下一层几个候选节点”这个逻辑里做文章,就能得到整体合理、局部有随机感的地图。

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连到i+5,就会产生高度倾斜的斜线,多条这样的线交叉起来画面没法看;如果只允许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 生成器主体代码

生成器的整体流程分五步:

  1. 创建随机数生成器,设置种子。
  2. 按层循环生成所有房间,记录坐标和类型。
  3. 逐层连接节点,生成边。
  4. 后处理:补全孤立节点、强制 Boss 前营火。
  5. 返回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 data

3.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 到 i+2这个区间实测交叉最少

还有几个隐藏参数值得调:同一目标最大入边数。我建议默认 2,超过 2 会导致某些节点变成交通枢纽,其他节点门可罗雀,视觉不平衡。

4.3 手把手复现一遍完整流程

如果你从零开始,我的建议流程是:

  1. 新建 Godot 4.x 项目。
  2. 创建map_data.gd,粘贴上面三个类。
  3. 创建map_generator.gd,粘贴生成器类。
  4. 创建map_view.gd,继承Node2D,实现_draw()和_unhandled_input()。
  5. 在场景根节点挂上MapView,在_ready()里调用var data = MapGenerator.generate(),然后把data赋值给视图。
  6. 运行,查看地图,点击节点测试路径推进。
  7. 把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 到 i+2这个区间我已经测过交叉概率很低,但如果你把范围改成i-3 到 i+3,那交叉概率会急剧上升。要记住:连线交叉不是靠画布优化解决的,而是从生成规则上就避免的。

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 序列化,玩家退出重进后地图可以完整还原。这是纯数据类设计带来的最大红利。

在踩过几次坑之后,我个人现在做任何地图生成功能,都会先花半小时确认数据结构是否纯净、随机过程是否完全可控、路径约束是否能在生成期保证,而不是画到屏幕上再修。地图交互的复杂度其实不在代码量,而在数据结构设计的“后知后觉”——等你发现层与层之间连线乱成一团,再想回头改数据结构就真的晚了。这套模块目前的代码量也就三百行左右,但因为它只关心结构不关心具体战斗内容,我后面的项目拿过来改改参数就能直接复用,性价比非常高。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询