
我最早接触杀戮尖塔的“分层地图”时就好奇这张每次开局都不一样的路线图到底是怎么生成的。后来自己在Godot里做卡牌Roguelike用GDScript一步步实现了仿杀戮尖塔的地图生成逻辑跑起来的那一瞬间很有成就感。这篇文章我会从数据结构设计、节点布局、连线生成、连通性校验到渲染交互完整记录我的实现思路和踩过的坑。对于正在用Godot做肉鸽、卡牌或刷宝类游戏的人或者单纯想练习GDScript的开发者这是一份可以直接参考的实战记录。1. 地图生成的整体思路与数据结构设计1.1 先拆解杀戮尖塔地图的三层本质杀戮尖塔的地图看似复杂本质上是三样东西的组合分层、节点、连线。整个地图被分成若干横向层最下方一个起点最上方一个Boss中间是若干层可选节点。玩家只能从当前层上升到下一层不能回头也不能跨层跳跃。每一层的节点数量不固定一般在3到6个之间浮动节点类型包含普通战斗、精英、商店、休息、事件等。连线连接相邻两层的节点方向永远向上一个下层节点通常可以连接到上层1到3个节点一个上层节点同时也接收来自下层节点的多条入边。理解了这一点就明白地图生成不是“迷宫生成”而是“图生成”并且是一张有向无环图。它不需要考虑同层节点之间的关系所有逻辑都集中在相邻层的连接上。这样设计的好处是局部性强每一层只跟上下两层打交道生成算法可以很简洁也方便后续做节点高亮、路径预览和移动限制。另一个关键认知是地图生成要同时保证“随机性”和“可解性”。随机性体现在每层节点数量、节点类型、连接关系的变化上可解性体现在从起点出发的任意路径最终都能到达Boss不会出现选了某个节点后走进死胡同。杀戮尖塔能让人反复刷而不生气正是因为这两点平衡得好。后面我会详细说明如何用保底连接和校验机制来保证这一点。1.2 用GDScript表示地图的核心数据结构我一开始想过用字典存节点但试下来还是类更顺手。Godot的GDScript支持内部类用类来表示MapNode和MapData代码可读性和扩展性都好得多。下面是我在实际项目中用到的核心结构# 地图节点内部类 class MapNode: enum NodeType { START, NORMAL, ELITE, SHOP, REST, EVENT, BOSS } var layer: int # 层索引0为起点层 var index: int # 当前层内的索引 var position: Vector2 # 坐标用于布局和渲染 var type: int # 节点类型参考 NodeType var out_connections: Array [] # 连接到下一层的节点列表 var in_connections: Array [] # 来自上一层的节点列表 var data: Dictionary {} # 扩展字段比如怪物ID、商店商品列表 func _init(l: int, i: int, pos: Vector2, t: int NodeType.NORMAL): layer l index i position pos type t # 整张地图 class MapData: var layers: Array [] # Array of Array[MapNode]layers[0]是起点层为什么不直接用数组存坐标和连接关系因为MapNode里既要有逻辑数据layer/index/type又要有渲染数据position还要有图关系out/in连接。如果全塞进字典访问起来都是字符串key写起来啰嗦不说类型也没保障。用类之后node.out_connections一眼就能看出意思。我特意保存了in_connections。有些新手只保存出边觉得反正玩家只能往上走。但后面做反向BFS校验、做“当前节点有哪些前驱节点”的界面展示甚至做路线回放时入边都很有用。既然生成连接时已经知道边的信息不如都存下来用空间换方便。1.3 为什么用GDScript而不是C#或CGodot支持GDScript、C#、C等语言但做这种一次性生成、数据量又极小的地图GDScript完全足够而且开发效率最高。地图生成本质上是逻辑计算涉及大量数组遍历GDScript的运行速度虽然比不上C#但一个地图最多几十个节点性能瓶颈根本不在语言而在渲染和后续的战斗系统。GDScript还有一个好处它和Godot编辑器集成得非常好改了代码按一下F5就能跑不需要额外的项目配置。流程是生成地图、立即在场景里看到结果、调整参数、再跑。用这种“热迭代”方式调随机参数非常舒服。你不需要像写传统游戏框架那样搞一堆接口直接写类、写函数然后挂到节点上就能工作。对于原型验证GDScript是最省事的选择。2. 分层的布局算法让节点均匀又有变化2.1 参数配置与每层节点数量的随机策略地图总层数我习惯设置为7对应杀戮尖塔里“6层1个Boss”的常见结构。中间层节点数设置为3到6浮动起点层和Boss层固定为1。如果游戏有特殊事件层比如隐藏商店层或特殊Boss前哨站可以把这个常量调大这里先保持简单。const LAYER_COUNT : 7 const MIN_NODES : 3 const MAX_NODES : 6 const VIEWPORT_WIDTH : 1280 const VIEWPORT_HEIGHT : 720在生成各层节点数时我会让相邻层的节点数不要差太大。比如上一层是6个节点下一层直接变2个视觉上会出现“宽度骤变”路线连接也会变得很局促。一个简单的做法是限制下一层节点数在上一层的正负2以内。第一次实现时我没加这个限制结果有时出现一层4个、下一层8个的奇葩地形连接线乱得没法看。后来加了约束地图明显舒服了。2.2 计算节点坐标的省心方法有了层数y坐标可以直接用线性分布。杀戮尖塔地图虽然是斜的但我们先做水平的后续加旋转即可。x坐标我采用“等分区间随机偏移”的方式把视口宽度除以节点数加一得到每个节点的中心位置然后在中心附近加一点随机偏移。func _create_layer(layer_index: int, node_count: int) - Array: var nodes: Array [] var spacing: float VIEWPORT_WIDTH / float(node_count 1) var max_offset: float spacing * 0.3 var y: float VIEWPORT_HEIGHT - 80.0 - layer_index * ((VIEWPORT_HEIGHT - 40.0 - 80.0) / float(LAYER_COUNT - 1)) for i in range(node_count): var x: float spacing * (i 1) _rng.randf_range(-max_offset, max_offset) var node : MapNode.new(layer_index, i, Vector2(x, y)) nodes.append(node) return nodes这里有两个容易踩的坑。第一个是偏移量固定写死比如randf_range(-20, 20)。当节点数为6时spacing约182偏移20没什么问题但当节点数变成8时spacing约142偏移20会让两个相邻节点拉近到102像素视觉上还是可以但如果节点数到10spacing约116偏移20就有点挤了。所以一定要让偏移量跟spacing挂钩我用的是spacing的30%作为最大偏移。第二个坑是起点层和Boss层只有一个节点如果也用这个公式spacing会非常大x会落在中间加偏移后也没问题。但为了让起点和Boss居中我通常在创建时对这两层单独处理直接设置x为VIEWPORT_WIDTH/2y按公式计算。这样生成出来的地图下方有一个圆心点上方也有一个圆心点清晰直观。2.3 节点类型的分布规则节点类型不能随便乱分否则可能会全都是普通战斗玩起来很无聊。我用的比例是普通战斗50%事件15%精英10%商店10%休息10%宝箱或特殊事件5%。但纯随机分会出现“这一局没有精英”、“那一局商店特别多”的情况。为了平衡我采用两阶段策略。第一阶段把所有中间层的节点按照比例随机分配类型。第二阶段检查当前地图中是否存在商店节点和精英节点如果不存在就强制把某几个普通战斗节点替换成缺少的类型。这样既保留了随机性又保证了每张地图的基础体验。Boss层单独设置起点层也可以单独设置。这个逻辑放在连接生成之后做不影响连接关系。func _assign_node_types(map: MapData) - void: for l in range(1, map.layers.size() - 1): for node in map.layers[l]: # 根据权重随机分配类型 var roll: float _rng.randf() if roll 0.5: node.type MapNode.NodeType.NORMAL elif roll 0.65: node.type MapNode.NodeType.EVENT elif roll 0.75: node.type MapNode.NodeType.ELITE elif roll 0.85: node.type MapNode.NodeType.SHOP elif roll 0.95: node.type MapNode.NodeType.REST else: node.type MapNode.NodeType.EVENT # 保证至少一个商店和一个精英 _ensure_at_least_one(map, MapNode.NodeType.SHOP) _ensure_at_least_one(map, MapNode.NodeType.ELITE)_ensure_at_least_one的实现很简单遍历所有中间层节点如果找到目标类型就返回否则随机挑一个普通战斗节点改为目标类型。这里我建议不要改精英或商店节点因为它们本身稀缺改了会破坏体验。3. 连线生成随机地图的关键点3.1 起点到第一层保证开局自由地图的起点是唯一的我希望玩家开局能自由选择第一层的任意节点所以起点会连接到第一层的所有节点。这样玩家第一层选择不受限制后面每一层的可达范围则取决于当前节点连接了哪些上层节点。func _connect_start_to_first_layer(map: MapData) - void: var start_node: MapNode map.layers[0][0] var first_layer: Array map.layers[1] for next_node in first_layer: _connect(start_node, next_node)如果你希望开局也有限制比如起点只连接第一层其中2个节点数学上也是可行的。但那样玩家还没开始玩就要被“窄门”卡一下挫败感会比较强。至少在我的项目里开局全连接体验更好。3.2 中间层连接的“保底 随机”两阶段法中间层的连接是整个地图的核心。我的做法分成三个小阶段先保底下层入边再保底上层出边最后随机加料。因为只做随机加料容易造成孤立节点很可能下层某个节点没有任何上层节点指向它玩家永远到不了那里。保底阶段一遍历下一层的每个节点在上一层找一个x坐标最接近的节点建立连接。这样做能同时保证“下层每个节点都有入边”和“连线不会太离谱”。因为x坐标最接近意味着两条线不会突然从左拉到右视觉上比较顺眼。保底阶段二遍历上一层的每个节点如果某个节点还没有任何出边就为它连到下一层x坐标最接近的节点保证玩家选了它之后还能继续向上走。加料阶段对上层每个节点以一定概率额外连接下一层的其他节点让路线丰富起来。但我会限制最大出度为3。如果让一个节点连接下一层所有节点那选择就失去了意义地图也会变成一团乱麻。func _connect_layers(up_layer: Array, down_layer: Array) - void: # 阶段一保证down_layer每个节点都有入边 for down_node in down_layer: var best_up: MapNode _nearest_x(up_layer, down_node.position.x) _connect(best_up, down_node) # 阶段二保证up_layer每个节点都有出边 for up_node in up_layer: if up_node.out_connections.is_empty(): var best_down: MapNode _nearest_x(down_layer, up_node.position.x) _connect(up_node, best_down) # 阶段三随机加料概率和数量自己调 for up_node in up_layer: var max_extra: int 3 - up_node.out_connections.size() if max_extra 0: continue for i in range(max_extra): if _rng.randf() 0.3: var candidate: MapNode down_layer[_rng.randi() % down_layer.size()] if not up_node.out_connections.has(candidate): _connect(up_node, candidate)_nearest_x就是遍历数组返回position.x最接近target_x的节点。这个函数在整个算法里会频繁调用性能不是问题但可以写得简洁些。我测试过不同的加料概率。概率太小时地图基本就是有序的楼梯式爬塔玩家很快能摸清规律概率太大时路径选择太多丧失“路线规划”的乐趣。0.3到0.4之间比较合适每个人审美不同你可以把这个值做成可配置项方便反复调。3.3 最后一层连到Boss杜绝死局Boss层只有唯一节点因此倒数第二层的所有节点都必须连接到Boss否则玩家在倒数第二层选了某个节点后就会出现“这层没有通向Boss的路线”的尴尬局面。这里不允许随机直接用全连接func _connect_second_last_to_boss(map: MapData) - void: var second_last_layer: Array map.layers[map.layers.size() - 2] var boss_node: MapNode map.layers[map.layers.size() - 1][0] for node in second_last_layer: _connect(node, boss_node)其实这里也可以做点花样比如让部分节点连向一个“精英守卫”再连Boss。但在最简版本里确保全连是底线。3.4 如何避免连线交叉成蜘蛛网地图美观度很大程度取决于连线交叉情况。我用的“x最近优先”已经能减少交叉因为相邻层的节点x坐标都是从左到右排列的这样连接关系基本上保持单调。但加料阶段的随机连接可能会引入一些交叉。交叉有两种处理方式。一是接受它因为杀戮尖塔原版地图同样有交叉线只要不严重玩家是可以接受的。二是做后处理去交叉遍历所有连线对检查它们是否相交如果相交则交换目标节点。这个算法实现起来有一点复杂而且可能引入新的不可达问题所以我建议原版先不要做等地图功能和玩法稳定后再考虑优化。另一个更实用的减少交叉的方法是调整每层的节点顺序。生成节点时虽然x坐标加了随机偏移但整体顺序保持从小到大。连接时优先选择顺序相近的目标节点交叉就会控制在局部范围内。这也是为什么我反复强调“最近优先”。4. 连通性校验保证地图一定能通关4.1 为什么保底了还要校验理论上只要保证每层每个节点至少有一条入边和一条出边从起点就能访问所有节点任意节点也都能最终到达Boss。但代码是人在写保底逻辑很容易出漏。比如忘掉把Boss层单独处理或者某个层循环的索引写错了又或者随机加料覆盖了出边判断。为了不把这些bug留给测试我在生成完地图后立刻做一遍BFS校验不通过就直接重新生成或打印警告。这也是“随机地图生成”的最佳实践不要尝试在生成算法里包治百病先生成再校验有问题就重开。反正地图生成是毫秒级操作玩家根本感觉不到。哪怕100次里有一次失败重roll一次也完全没问题。4.2 用BFS检查可达性我从起点出发沿着出边遍历所有节点如果能访问到所有节点说明不存在“选了一个节点后后续路线断开”的情况。再从Boss出发反向遍历入边如果能访问到所有节点说明从所有节点都能到达Boss。两个条件同时满足地图就是合法的。func is_map_valid(map: MapData) - bool: # 从起点正向BFS var visited : {} var stack : [map.layers[0][0]] while stack.size() 0: var node: MapNode stack.pop_back() if visited.has(node): continue visited[node] true for child in node.out_connections: stack.append(child) for layer in map.layers: for node in layer: if not visited.has(node): push_warning(不可达节点: %d/%d % [node.layer, node.index]) return false # 从Boss反向BFS var boss_nodes: Array map.layers[map.layers.size() - 1] var visited_rev : {} var stack_rev: Array boss_nodes.duplicate() while stack_rev.size() 0: var node: MapNode stack_rev.pop_back() if visited_rev.has(node): continue visited_rev[node] true for parent in node.in_connections: stack_rev.append(parent) for layer in map.layers: for node in layer: if not visited_rev.has(node): push_warning(无法到达Boss: %d/%d % [node.layer, node.index]) return false return true这段代码里我用的是栈而不是队列所以实际上是深度优先遍历。对于这种小图栈和队列效果一样。如果你想学习搜索算法可以把pop_back改成pop_front就变成广度优先了但对于这个需求来说无所谓。4.3 随机种子让地图可复现的关键做游戏最怕什么最怕玩家在某个存档地图上踩到Bug你却没法复现。如果只依赖全局随机函数那么地图每次生成都不一样复现Bug只能靠运气。所以我强烈建议所有随机都通过一个独立的RandomNumberGenerator对象来生成并保存种子值。var _rng : RandomNumberGenerator.new() func generate_map(seed_value: int) - MapData: _rng.seed seed_value # ... 后面所有随机都用 _rng.randf() / _rng.randi_range()这样你只需要在控制台输入generate_map(20250101)就能得到一张固定的地图。对于做“每日挑战”、排行榜、录像回放功能来说这是必需品。如果不这样后续你写战斗AI或自动化测试时会因为每个操作消耗随机种子不同而陷入混乱。我相信很多老哥都有过这种痛苦经历提前避免绝对不亏。5. 地图渲染与交互让数据变成画面5.1 用Node2D的_draw直接画图Godot 4里自绘很简单我创建了一个MapRenderer节点继承Node2D传入MapData后在_draw()中画线和节点。这样不需要任何美术资源原型阶段就能快速看到效果。extends Node2D var map: MapData func _draw() - void: if map null: return # 画连线 for layer in map.layers: for node in layer: for child in node.out_connections: draw_line(node.position, child.position, Color(0.6, 0.6, 0.6, 0.8), 2.0) # 画节点 for layer in map.layers: for node in layer: var color : _get_color_for_type(node.type) draw_circle(node.position, 18, color) draw_arc(node.position, 18, 0, TAU, 32, Color(0.1, 0.1, 0.1), 2.0)这里注意TAU在Godot中代表360度的弧度值相当于2*PI。在Godot 3里draw_line有抗锯齿参数Godot 4里去掉了如果你用的是3.x需要把draw_line的最后一个参数删掉或用draw_line(from, to, color, width, true)兼容。我这段代码基于Godot 4.x运行环境不同时请自行调整。5.2 鼠标选中与路径高亮交互的逻辑很简单当鼠标左键点击时遍历所有节点找到距离点击位置最近的节点。如果距离小于固定半径就认为它被点击了。之后维护一个current_node变量如果点击的节点是current_node的子节点就更新当前节点否则忽略点击。这是最严格的“只能前进”交互。如果游戏允许玩家回看但实际规则不允许后退那么点击不在可达列表里的节点时可以给出一个震动或提示音。高亮部分我通过记录current_node和它的子节点在_draw()中额外绘制func _draw() - void: # ... 上面的基础绘制 if current_node ! null: # 高亮当前节点 draw_circle(current_node.position, 22, Color(1, 1, 1, 0.9)) # 高亮可达节点 for child in current_node.out_connections: draw_arc(child.position, 24, 0, TAU, 32, Color(1, 1, 0.2), 4.0)如果你用的是Godot的Control体系可能会想用TextureButton放在CanvasLayer里但那套方案在地图滚动/缩放时比较麻烦。用Node2D自绘后续只要把整个MapRenderer作为Camera2D的子节点就可以轻松实现镜头的平移缩放。5.3 地图数据与战斗系统解耦这一点非常重要。不要把战斗场景路径、事件脚本直接塞在MapNode里。正确做法是MapNode只存“类型”和“层数”外部通过类型去查配置表。比如NORMAL对应普通战斗场景ELITE对应精英战斗场景SHOP对应商店场景。节点类型可以由一个枚举表示enum NodeType { START, NORMAL, ELITE, SHOP, REST, EVENT, BOSS }这样做的好处是地图生成器是纯逻辑不依赖场景树方便单独测试。你甚至可以写一个命令行工具只生成地图数据并保存为JSON。而战斗系统拿到一个MapNode后根据node.type加载对应场景再读取node.data中的参数比如敌人ID列表来初始化战斗。我一开始图省事直接在MapNode里加了scene_path字段结果每次改动节点类型都要手工改场景路径还要处理场景不存在的问题。后来改成类型映射整个世界清净了。6. 常见问题与避坑指南6.1 为什么节点总是重叠节点重叠大多是因为偏移量过大或者节点数过多。解决方法是把最大偏移量绑定到spacing上。还有一个小坑当你使用全局随机函数randf_range时如果其他地方消耗了随机数节点布局可能时好时坏改用独立的RandomNumberGenerator后地图生成更加可控。为了直观我给一个表格参考不同节点数下的建议最大偏移节点数spacing约值30%偏移约值332096425676521364618254814242如果你用的是1280宽度节点数超过8地图就会变得很紧密建议考虑动态调整节点数量上限。我设置的MAX_NODES6一般不会触碰拥挤问题。6.2 地图上有些节点永远点不到点不到就说明这个节点没有入边或者入边来自一个同样不可达的节点。先检查保底逻辑。最常见的问题是跑保底阶段时只遍历了上层节点给出边却没有遍历下层节点补入边。顺序不能反先保证下层入边再补上层出边。另外也要检查起点层连接是否真的覆盖了第一层全部节点。如果你的生成流程跑完仍然出现不可达节点可以加一个调试函数把每一层每个节点的入边数量打印出来。通常一眼就能看出哪个节点入边为0。我在开发时就是这样排查的。6.3 地图路线太直或者太乱路线太直是因为“就近连接”太强随机加料概率太低。路线太乱则是因为加料概率太高或者一个节点出边太多。我的建议是最大出度设成3加料概率在0.3到0.4之间调。如果你想做出分支感强一些的地图可以适当增加加料概率但一定要控制在0.6以下否则玩家会眼花。另外相邻层节点数差异大会让路线难看。我前面提过限制下一层节点数与当前层节点数之差不超过2这个策略非常有效。尤其当上一层节点数为3下一层直接变成6时连接线会呈放射状虽然不算错但视觉冲击太大。限制后地图会更平稳。6.4 使用Godot 4的_draw时参数总是报错不少从Godot 3迁移过来的老哥会遇到这个问题。Godot 4中draw_circle和draw_arc的参数变化不大主要是颜色和宽度顺序变了另外TAU常量取代了2*PI。如果你看到的教程还是draw_line(from, to, color, width, antialiased)那基本是Godot 3的写法。我的建议是明确自己的引擎版本对应查找文档如果只想做原型用我上面的代码即可。6.5 地图种子不固定无法复现这个前面已经强调过再提一次绝对不要用全局randi()生成地图。因为全局随机种子会被其他系统消耗比如战斗暴击、掉落、事件文本。正确做法是给地图生成器单独建一个RandomNumberGenerator并在开始生成前设置seed。这样无论游戏其他系统消耗多少随机数地图都只由那个种子决定。尤其是做每日挑战时这个技巧能让你省很多时间。7. 实战中的细节与扩展方向7.1 一个最小可运行的地图生成器骨架下面我把整个流程串起来这是我在项目中使用的生成器核心代码。它不依赖任何外部场景可以直接放在一个普通脚本里调用。extends RefCounted const LAYER_COUNT : 7 const MIN_NODES : 3 const MAX_NODES : 6 const VIEWPORT_WIDTH : 1280 const VIEWPORT_HEIGHT : 720 var _rng : RandomNumberGenerator.new() func generate(seed_value: int) - MapData: _rng.seed seed_value var map : MapData.new() # 创建层 for l in range(LAYER_COUNT): var count: int 1 if l 0 and l LAYER_COUNT - 1: count _rng.randi_range(MIN_NODES, MAX_NODES) map.layers.append(_create_layer(l, count)) # 设置起点和Boss类型 map.layers[0][0].type MapNode.NodeType.START map.layers[LAYER_COUNT - 1][0].type MapNode.NodeType.BOSS # 生成连接 _connect_start_to_first_layer(map) for l in range(1, LAYER_COUNT - 1): _connect_layers(map.layers[l], map.layers[l 1]) _connect_second_last_to_boss(map) # 分配中间层节点类型 _assign_node_types(map) # 校验 if not is_map_valid(map): push_warning(地图不合法重新生成) return generate(seed_value 1) return map这里的递归重新生成只是为了演示实际可以用循环避免递归深度问题。不过种子1后重新生成对于这种小逻辑来说也没问题。7.2 地图数据序列化与每日挑战当你做好了种子系统序列化就变得很简单。只需要保存一个种子值之后用同样的生成函数重新生成地图即可。但要注意如果后续版本修改了生成算法老存档地图可能无法完全复现。如果想要更稳定的存档可以把每个节点的类型、层、索引和连接关系序列化成JSON显式保存。这样即使生成算法改变老存档也能玩。我通常的做法是两种都做在线模式下保存种子用于快速生成关键时刻保存完整地图数据用于回放。其实对于单机肉鸽保存种子就够了。只要你不大幅改动算法玩家重新加载存档后看到的还是同一张地图。7.3 后续可以扩展的玩法方向地图生成器做完后可以加的功能很多。比如地图缩放和平移可以让玩家在狭小屏幕上看到全图节点图标替换用美术资源把圆点变成宝剑、金币、篝火图标怪物配置根据节点难度动态提升甚至地图隐藏事件比如玩家走到某个特定节点触发随机传送。我个人最想做的其实是“地图倾向性”系统。比如某个角色偏向精英路线就可以增加本局地图中精英节点的比例或者在生成完成后优先让起点到Boss的路径经过更多精英节点。这需要给每个节点计算“是否在主路径”的标记然后调整类型。这个扩展可以很好地提升策略深度也是我后续计划的方向之一。走完这套开发流程后我最深刻的体会是随机地图生成看似高大上本质上是一套“有限范围内的随机排列组合”。与其追求花哨的算法不如先把数据结构理清楚把保底逻辑写扎实再加随机参数调出自己想要的手感。最后再分享一个实用小技巧所有随机参数层数、节点数范围、加料概率、偏移系数尽量都定义成常量或可配置字段不要散落在代码深处。你每次调整都会省下大量时间也更容易找到最适合自己游戏的“手感”。希望这篇记录能帮你少走一些弯路期待看到你生成出属于自己的那张杀戮尖塔地图。