玩过《杀戮尖塔》的朋友,看到那张从底层一路铺到 Boss 脚下的节点路线图,多半会有两个感觉:第一是路径选择很自由,你想绕开精英怪总能找到缝隙;第二是每局都不一样,但整体又保持同一种节奏感。真轮到自己用 Godot 动手做一张“杀戮尖塔式地图”时,很多人才发现情况没有想象中那么简单——随机摆节点、随手连几条线,结果要么整张图乱成一团蜘蛛网,要么走到一半发现没有下一层节点可选。这篇文章我会用 GDScript 从数据结构开始,一步步把地图生成、连线、连通性检查、渲染和点选交互完整做出来。代码以 Godot 4 为主,初学阶段能看懂,想做机制参考的也能直接拿走改。
1. 先确定地图的数据形态:节点、层与邻接关系
1.1 杀戮尖塔地图到底由什么构成
杀戮尖塔的地图看上去是个流动的路线网,本质却非常朴素:一张有向无环图,也就是从底部到顶部单向流动的节点和边的组合。玩家从最底层的出生点出发,每往上走一层,就要在几个候选节点里挑一个继续推进,直到最顶层的 Boss 节点结束。
这里的几个关键点是:
- 按“层”划分,不是按连续坐标。地图被横向切开成十几层,每一层里的节点处在相近的垂直高度上。
- 每个节点只有通向上方若干节点的边,不会反向连接,更不会横向连接同一层的节点。
- 路线不能断。最坏情况下至少能有一条从出生点走到 Boss 的完整路径。
- 节点有类型区分,比如战斗、精英、事件、休息、宝箱、商店、Boss。
把这个模型固定下来之后,后续所有代码都围绕“层”和“边”展开,不会跑偏。
1.2 用 GDScript 定义 Node 和 Graph
我习惯把所有逻辑放在一张地图的总控场景里,用两个内部类来承载数据。先看节点定义:
class MapNode: var id: int = 0 var row: int = 0 var col: int = 0 var pos: Vector2 = Vector2.ZERO var node_type: String = "combat" var prev_ids: Array[int] = [] var next_ids: Array[int] = [] var visited: bool = falseid是整个地图里的唯一编号,用于快速索引。row是节点所在层,col是节点在当前层里的列位置。col 不直接等于像素坐标,它只是排在左边还是右边的逻辑位置。pos是最终计算出的屏幕坐标,渲染和点击判断都用它。prev_ids和next_ids分别记录“谁通向自己”和“自己通向谁”,这是图的邻接表结构。
地图容器则放在生成器脚本里:
extends Node2D const MAP_ROWS := 15 const MIN_NODES_PER_ROW := 1 const MAX_NODES_PER_ROW := 4 const VERTICAL_GAP := 260.0 const HORIZONTAL_SPACING := 220.0 const EDGE_SPAN := 1 var nodes: Array[MapNode] = [] var graph_rows: Array[Array] = [] var current_id: int = 0 var rng := RandomNumberGenerator.new()为什么用graph_rows而不是把所有节点一股脑塞进nodes就完事?因为连线、渲染和可达性检查都需要频繁按层访问,每层单独存一个数组,代码写起来会清爽很多。
2. 分层生成节点:随机但不失控
2.1 每层节点数量的波动范围
生成节点的第一件事,是确定每一层要放多少个节点。直接纯随机取 1 到 4 的问题在于,地图可能连续多层都只有 1 个节点,玩家没有选择空间;也可能某层突然塞了 4 个节点但上一层的节点无法合理扩散出来。处理办法是让节点数量围绕一个目标区间浮动。
我的实现思路是:出生层和 Boss 层固定只有 1 个节点,中间层在 1 到 4 之间取随机数,但会增加一个权重判断,让层数越靠近顶层,出现 3 到 4 个节点的概率稍微收敛一点,避免顶层路线过于拥堵。
func _build_rows() -> void: for r in range(MAP_ROWS): var count: int = 1 if r == 0 or r == MAP_ROWS - 1: count = 1 else: count = rng.randi_range(MIN_NODES_PER_ROW, MAX_NODES_PER_ROW) var row_nodes: Array[MapNode] = [] for c in range(count): var node := MapNode.new() node.id = nodes.size() node.row = r node.col = c node.node_type = _pick_node_type(r) var x := _compute_x(r, c, count) var y := _row_y(r) node.pos = Vector2(x, y) row_nodes.append(node) nodes.append(node) graph_rows.append(row_nodes)这里还有一个容易忽略的细节:nodes的 id 是按生成顺序递增的,所以之后做 BFS 遍历时可以用visited标记来复用同一个数组,不用担心数组越界。
2.2 坐标计算:让同一层节点横向居中排布
坐标计算中最大的坑是把col直接乘上一个固定间距,结果节点不居中。比如一层有 1 个节点,一层有 4 个节点,前者偏向左下角,后者横跨整个屏幕,视觉重心会歪。
我用的居中公式很直接:先算出整层所有节点占的总宽度,然后让第一个节点的 x 从负半个总宽开始,这样整层节点的几何中心就归一到了原点。
func _compute_x(row: int, col: int, count: int) -> float: var total_width := float(count - 1) * HORIZONTAL_SPACING return -total_width / 2.0 + float(col) * HORIZONTAL_SPACING垂直方向则是从下往上逐层递减。我把画布底部作为出生层的位置,所以 y 坐标用BASE_Y - row * VERTICAL_GAP计算。这样第 0 层在最下面,第 14 层在最上面,符合玩家“向上爬塔”的直觉。
2.3 节点类型怎么分配
节点类型完全随机会让地图失去规划感。比如商店在前期出现得太多,休息点又集中在后期,玩家会明显感觉不平衡。
我参考杀戮尖塔的节奏感,做了个很简单的分层概率:地图前 60% 以战斗和事件为主,休息和宝箱少量出现;后 40% 提高精英怪概率。这样前期玩家在积累资源,后期则要面对更严峻的路线取舍。
func _pick_node_type(row: int) -> String: if row == 0: return "spawn" if row == MAP_ROWS - 1: return "boss" var roll := rng.randf() var late := float(row) / float(MAP_ROWS - 1) > 0.6 if late and roll < 0.25: return "elite" if roll < 0.45: return "combat" elif roll < 0.6: return "event" elif roll < 0.72: return "rest" elif roll < 0.86: return "treasure" return "shop"这个比例不是标准答案,但它提供一个很重要的思维:程序生成里的“随机”,不是每个值权重相同,而是你要主动用规则去控制概率分布,让它服务于游戏节奏。
3. 邻层连线:边数控制、就近匹配与防交叉
3.1 最基本的连线规则
节点生成完后,最核心的工作是给相邻两层连边。杀戮尖塔地图的边有三个特点:不会跳过太远的列、节点不会连接到自己下层所有节点、同一层内部没有边。我通常把两层的邻接跨度限制在EDGE_SPAN = 1,也就是当前节点只能连接到下一层里col - 1、col、col + 1这三个候选节点之一。
之所以限制跨度,一是为了视觉上不出现跨层长线,二是在玩法上维持“岔路感”。如果跨度太大,玩家从某一节点出发可以直接跳到三层之外,路线的规划感就被削弱了。
func _nearby_candidates(node: MapNode, next_row: Array) -> Array: var result: Array[MapNode] = [] for i in range(next_row.size()): if abs(node.col - i) <= EDGE_SPAN: result.append(next_row[i]) result.sort_custom(func(a: MapNode, b: MapNode) -> bool: return abs(node.col - a.col) < abs(node.col - b.col) ) return result排序的目的是让连线优先选择“最近”的下一层节点,这样路线不会故意绕弯,也更贴近杀戮尖塔中路径通常朝附近蔓延的观感。
主连线函数则遍历当前层的每个节点,随机决定连 1 到 3 条边:
func _connect_rows() -> void: for r in range(MAP_ROWS - 1): var cur_row: Array = graph_rows[r] var next_row: Array = graph_rows[r + 1] for node in cur_row: var candidates := _nearby_candidates(node, next_row) if candidates.is_empty(): continue var edge_count := rng.randi_range(1, mini(3, candidates.size())) for i in range(edge_count): var target: MapNode = candidates[i] _add_edge(node, target) _force_next_row_has_prev(next_row, cur_row)mini(3, candidates.size())是为了避免候选节点不够时数组越界。
3.2 强制补边:保证下一层每个节点都接得住
只看上面那段循环,会产生一个明显问题:如果某层的节点在随机连线时运气不好,某些下一层节点可能一个 inbound 边都拿不到,变成“死节点”,玩家永远到不了那里。
所以我必须在每层连完之后跑一个强制补边流程:
func _force_next_row_has_prev(next_row: Array, cur_row: Array) -> void: for target in next_row: if not target.prev_ids.is_empty(): continue var best: MapNode = _nearest_node_in_row(cur_row, target.col) if best != null: _add_edge(best, target)_nearest_node_in_row的实现比较直白:遍历当前层的节点,按列距离最近者优先。如果EDGE_SPAN的限制导致所有候选源都不满足跨度条件,就退而求其次找一个最近的源节点,先保证连通性。
这一步最大的价值在于:把“尽量不乱连线”和“保证不出现死节点”拆成两个阶段。先按美观原则随机连,再按连通性兜底补边,逻辑比在一个循环里同时做两件事要清晰得多。
3.3 防交叉问题的现实处理办法
很多做地图生成的人会在“边不能交叉”这个要求上死磕。这里分享一个实战判断:如果两层节点数量差距过大,纯几何上的完全防交叉会让连线规则变得异常复杂,而且生成的地图可能因为约束太强而失去随机性。
工程上更合理的组合拳是:第一,把EDGE_SPAN限制为 1,从源头减少交叉可能;第二,渲染时把直线改成曲线,让即便有轻微重叠也变得不那么突兀;第三,如果确实要做严格防交叉,可以用“顺序匹配”的思路——两层节点按左右排序后,只允许边从某个源节点连到排序后位置不相悖的目标节点,但这样实现起来维护成本高,不做成核心功能的话,收益并不明显。
我个人建议:先跑通随机连线,观察 5 到 10 局生成结果,如果交叉率很低,就没有必要引入额外复杂度。
4. 连通性体检:从出生层到 Boss 不能断
4.1 BFS 为什么够用
有了边之后,不能直接认为地图就合法,必须验证“从出生节点出发能不能到达所有节点”。最坏情况是某条断链导致 Boss 根本无法抵达,这种地图一放进去就是线上事故。
用 BFS(广度优先搜索)是最稳妥的方案,因为地图规模很小,每层最多 4 个节点,总共也就 50 个左右节点。BFS 的时间复杂度是 O(V+E),V 是节点数,E 是边数,在这种小规模数据上几乎是瞬间完成。更重要的一点是:BFS 不仅能判断“是否可达”,还能通过队列弹出顺序记录出整条路径,后续做玩家路线回显、调试可视化都很方便。
4.2 代码实现与自动修补
func _validate_and_repair() -> bool: for n in nodes: n.visited = false var queue: Array[MapNode] = [] var start: MapNode = graph_rows[0][0] start.visited = true queue.append(start) while not queue.is_empty(): var cur: MapNode = queue.pop_front() for nid in cur.next_ids: var neighbor := _get_node(nid) if not neighbor.visited: neighbor.visited = true queue.append(neighbor) var unreachable: Array[MapNode] = [] for n in nodes: if not n.visited: unreachable.append(n) if unreachable.size() > 0: push_warning("检测到 %d 个不可达节点,开始修复" % unreachable.size()) var repaired := false for n in unreachable: if n.row == 0: continue var any_prev_row := graph_rows[n.row - 1] var source := _nearest_node_in_row(any_prev_row, n.col) if source != null: _add_edge(source, n) repaired = true return not (unreachable.size() > 0 and not repaired)思路很朴素:从第 0 层的出生点开始,把所有能通过next_ids走到的节点都标记为 visited;遍历完成后,哪些节点没被标记,哪些节点就有问题。修复方式也非常直接——从它的上一层随便找一个离它最近的节点,强行补一条边。
这里有一个值得展开的细节:为什么修复方式比“删除不可达节点”更好?原因在于节点类型是分配好的,删掉一个节点会直接破坏这一层的路线密度,甚至可能让下一层节点彻底失去来源;而补边只是改动邻接关系,对层级结构和节点类型的影响最小。
4.3 一个容易踩的坑:只检查了上一层,没检查下一层
我做第一版时只检查了“是否有节点无法从出生点到达”,却漏掉了另一种断裂:某个当前节点没有任何next_ids,它虽然自身可以被玩家走到,但走到它之后就成了死胡同。如果这是层内唯一节点,玩家整局游戏直接卡死。
所以连通性检查需要拆成两个维度:一是从出生点向下的可达性,二是每个非 Boss 层的普通节点是否至少有一条向下的出口。后者实现起来更简单,遍历一层检查next_ids.is_empty()即可,发现问题后往下一层补边。
我把这两个验证合并到了生成流程的最后,每次生成地图都会自动跑一遍,有任何问题打印警告。这样调试效率会比“看上去挺好,玩到一半才发现断链”高很多。
5. 渲染与点选交互:把数据结构变成可玩界面
5.1 用 _draw 画出路线和节点
到这一步,地图已经是一张逻辑完整的有向无环图了。接下来要把它变成玩家能看到的界面。
Godot 里自定义绘制最简单的方案是用_draw()方法画圆和画线。节点画成圆形,边画成线条,这样不需要任何额外美术资源就能把地图骨架搭出来。
const NODE_COLORS := { "spawn": Color(0.4, 0.7, 1.0), "boss": Color(0.9, 0.2, 0.2), "combat": Color(0.85, 0.8, 0.3), "elite": Color(0.9, 0.5, 0.1), "event": Color(0.3, 0.5, 0.8), "rest": Color(0.3, 0.8, 0.4), "treasure": Color(0.95, 0.8, 0.2), "shop": Color(0.6, 0.4, 0.9) } func _draw() -> void: # 第一轮:先画所有边 for node in nodes: for nid in node.next_ids: var next := _get_node(nid) draw_line(node.pos, next.pos, Color(0.45, 0.45, 0.5), 6.0, true) # 第二轮:再画所有节点 for node in nodes: var color: Color = NODE_COLORS[node.node_type] draw_circle(node.pos, 24.0, color) draw_arc(node.pos, 30.0, 0.0, TAU, 32, Color(0.1, 0.1, 0.15), 2.0, true)为什么分两轮画,而不是每个节点把它的边和圆一起画出来?因为边大概率会和后面节点的圆发生重叠。如果先画圆再画边,线条会从圆形图标上方穿过,观感很乱。先全部画边,再统一画圆,节点就能盖住线条末端,视觉上干净得多。
5.2 当前路线的高亮显示
高亮当前可走的边是地图交互里最体现“玩法感”的部分。玩家点选一个节点后,从当前节点通向下一层的边应该变色,让玩家一眼知道下一步能去哪里。
这个逻辑放到_draw()里实现很简单:
var current: MapNode = _get_node(current_id) if current != null: for nid in current.next_ids: var next := _get_node(nid) draw_line(current.pos, next.pos, Color(1.0, 0.9, 0.3), 8.0, true)同时,为了不遮挡节点,高亮也放在画节点之前执行,顺序仍然是“普通边、高亮边、节点圆”。
5.3 点击检测与移动
点击检测我放在_unhandled_input里处理,因为地图不需要拦截所有鼠标事件,做成未处理事件可以让 UI 层优先响应按钮等控件。
核心逻辑有三步:
- 把鼠标位置转换到地图节点的本地坐标。
- 遍历当前节点的所有
next_ids,判断鼠标是否落在某个目标节点半径范围内。 - 如果命中,更新
current_id,触发重绘。
func _unhandled_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: var local_pos := to_local(event.position) var current := _get_node(current_id) for nid in current.next_ids: var next := _get_node(nid) if local_pos.distance_to(next.pos) < 30.0: current_id = nid queue_redraw() break这套交互最核心的一点是“移动只能沿边走”。玩家不能直接把鼠标点到任意节点上,必须先到达某个有通往该目的地边的节点。这正是杀戮尖塔路线选择的本质——自由是被限定在已生成路线内部的自由。
6. 种子、调参和后续扩展
6.1 让每局地图可复现
程序生成地图的过程中,随机源非常多:每层节点数量、节点类型、边数量、修复补边……如果这些随机调用没有统一管理,调试某个具体地图会非常困难。我用了RandomNumberGenerator的实例,而不是全局randi()。
func generate_with_seed(seed_value: int) -> void: rng = RandomNumberGenerator.new() rng.seed = seed_value nodes.clear() graph_rows.clear() _build_rows() _connect_rows() _repair_orphan_nodes() _validate_and_repair() queue_redraw()这样做有一个很实用的附带效果:种子本身就是房间号的字符串表达。玩家在论坛里复制一串种子,就能完全复现同一张地图,这对地图生成器类的功能来说是最有力的传播方式。
我也建议在开发阶段用print打印出种子值。很多时候你翻到一个奇怪的地图,如果不知道种子,几乎不可能复现排查;有了种子,问题就好查多了。
6.2 参数调优:如何让地图更“杀戮尖塔”
原始参数我用的是 15 层、每层 1 到 4 个节点。但这个数值对你自己的项目未必合适,需要考虑地图在屏幕上的尺寸和玩家单局时长。
- 如果地图高度太矮,玩家几乎没有决策空间,路线选择感很弱。
- 如果地图太高,节点密度过大,生成路线容易视觉上拥挤,玩家也难以快速规划。
- 如果每层节点数量始终在 3 到 4 个,玩家会感到压力太大,因为选择太多时反而不知道往哪走。
我通常的做法是把节点类型分布和地图层数绑定。比如层数超过 18 时,可以把中途某几层固定设置为休息或宝箱,给玩家喘息机会。地图不是难度越高越好,而是要在“决策压力”和“可读性”之间找平衡。
6.3 从路线图到完整关卡功能
地图生成器做完后,后续扩展空间非常大:
- 给每张地图记录一个层级进度,玩家到达某个节点时,把对应战斗场景的实例化数据绑定到节点上。
- 加入“已访问节点”标记,在地图界面里用半透明或打钩样式表现。
- 把
next_ids和prev_ids用于 AI 寻路,实现“预览下一层”“显示最短路径”等辅助功能。 - 在
MapNode里增加数据字段保存事件 ID、奖励池 ID,让节点类型不只是一种枚举字符串,而是真正的关卡配置入口。
我最推荐的扩展方向是给节点增加“依赖条件”。比如某些精英节点要求玩家在前几层修复过特定事件后才能解锁,或者某些商店节点只有在特定天数才会开启。底盘已经有邻接表和节点 id 了,加搜索逻辑只是时间问题,不需要推翻地图生成器的结构。
最后分享一个从实际项目里踩出来的经验:不要把地图生成器和具体的战斗场景强耦合。生成器只负责给出层、节点、边、类型这几个“数学模型”,至于节点点击后进入什么战斗、要加载什么资源,那是上层逻辑的事。把这两层拆开之后,后续无论是接回合制战斗、接剧情事件,还是接地形小游戏,都只需要写一个小适配层。我见过太多人把战斗实例直接塞进MapNode,结果后面想换战斗系统,整个地图代码几乎是重写。按数据结构、生成算法、渲染交互、玩法接入四层来组织,才是这张地图能活很长时间的结构基础。