资讯动态

Godot 4实现杀戮尖塔式地图生成器:从数据结构到交互渲染

发布时间:2026/10/5 9:04:41 来源:尧图企业网站定制
玩过《杀戮尖塔》的朋友看到那张从底层一路铺到 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(VE)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结果后面想换战斗系统整个地图代码几乎是重写。按数据结构、生成算法、渲染交互、玩法接入四层来组织才是这张地图能活很长时间的结构基础。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑