跳转至

第十二章:程序化地图生成

本章导言

在前几章中,我们的游戏地图都是通过编辑器手动绘制完成的。这种方式虽然直观,但在开发大规模或高重玩性游戏时显得力不从心。如果我们希望每次游戏运行时都呈现出不同的关卡结构,给玩家带来“下一次会有什么不同”的新鲜感,就需要借助程序化地图生成(Procedural Map Generation)技术。

本章将带你进入地图自动生成的世界,学会如何使用代码替代人工,生成结构合理、可导航、有趣味性的2D地图。我们将以《Battle Tank》项目为例,通过分步骤实现地图尺寸设定、安全区划定、道路生成、障碍物布局、敌方单位与巡逻点配置,打造一套完整的自动化地图生成系统。

通过学习本章内容,你将掌握程序化内容生成(PCG)的核心思想,并具备用Godot构建“每次都是新关卡”的能力。

1 什么是地图程序化生成

地图程序化生成是指使用算法自动生成游戏地图或场景结构,而非完全依赖人工编辑。这一技术属于更广泛的程序化内容生成(Procedural Content Generation,简称 PCG)范畴,是现代游戏开发中提高效率与重玩性的重要手段之一。

1.1 程序化生成的优势

程序化生成在以下方面具有显著优势:

  • 提升重玩性:每次游玩生成不同地图,增强探索与变化感
  • 节省开发成本:无需手工构建大量关卡,减少设计与测试负担
  • 支持无限世界:可动态扩展地图大小或复杂度
  • 增强挑战性:随机性带来不可预测性,适合策略与反应类玩法

不过,程序化生成也存在一些挑战。其一是可控性弱,若无设计约束,容易生成无趣或难以通关的结构。其二是调试困难。每次运行结果不同,排查问题需额外设计可重复性方案。其三是设计难度高,需设计合理规则,让生成内容既丰富又不混乱。

因此,程序化生成往往要在“随机”与“结构”之间取得平衡,既要有变化,也不能杂乱。

1.2 常见实现方法

程序化地图生成有多种常用技术,根据游戏类型和需求而不同:

方法 简要说明 常见应用
随机点与路径生成 在区域中随机生成起点、路径点,通过寻路算法连线 迷宫、塔防路径
细胞自动机(Cellular Automata) 用规则演化网格,形成自然的洞穴、地形结构 地牢、洞穴
分割空间(BSP) 递归划分区域,用于房间布局与走廊连接 Roguelike、地牢类
柏林噪声(Perlin Noise) 模拟连续自然地形变化,用于山脉、沙漠等 开放世界、地图生成器
WFC算法(Wave Function Collapse) 基于样本进行约束生成,保留局部样式 解谜、像素图生成

在接下来的章节中,我们将采用随机点和寻路算法结合的方式来生成地图路径,再配合区域填充与图层控制,实现一个轻量但结构明确的自动地图系统。实现一个具有可重玩性的战斗地图,为游戏注入无限变化的可能性。

2 TileMapLayer类的使用

在前面的章节中,我们已经学习过 TileMap 与 TileSet 的基础知识,并通过编辑器手动绘制过地图。本章将不再使用手动方式,而是完全依靠代码来生成地图内容。这意味着我们需要更加深入地掌握 Godot 中地图的编程接口,尤其是 TileMapLayer 类的使用。

2.1 游戏关卡地图结构回顾

在游戏项目中,我们使用编辑器手动绘制了以下几个 TileMapLayer 图层,分别负责不同内容的绘制:

  • Grass 图层:地图底层,填充为可通行的草地,所有地形内容都在此图层之上叠加。
  • Road 图层:在草地之上绘制的多条路径,供敌人巡逻和移动使用。
  • Border 图层:在地图边缘绘制树木等障碍,形成封闭边界防止玩家越界。
  • Items 图层:用于放置无法通过的石块,形成障碍或掩体。
  • Tree 图层:绘制装饰性树木,不影响导航,仅用于美化场景。

这些图层都共享一个 TileSet 资源文件,用于定义所有图块资源及其属性,如图块编号、图集坐标、碰撞信息和导航区域等。

2.2 TileMapLayer 的常用方法

在编辑器中,我们可以点击图块、选择图层并在场景视图中绘制地图;而在在自动生成地图时,我们会使用代码来绘制地图,这一切都由 TileMapLayer节点的方法来实现。如下是常用函数说明:

  • set_cell()函数:在地图的某个网格坐标上绘制指定图块。这相当于在编辑器中点击一个图块并手动绘制。

  • clear()函数:清空图层中所有已绘制的图块,相当于使用“橡皮工具”或点击“清除图层”。

  • get_used_cells()函数:返回当前图层中所有已绘制图块的位置(以网格坐标表示)。常用于后续判断哪些区域已经被占用。

  • get_used_cells_by_id()函数:获取地图中使用了某一特定图块的所有格子。可用于筛选所有草地区块、道路区块等。

  • get_surrounding_cells()函数:获取某个格子周围的8个邻接格子,是检测可用位置、生成物体聚集的重要工具。

  • map_to_local()函数:将网格坐标转换为像素坐标。在实际场景中放置物体(如敌人或玩家)时使用。

  • local_to_map()函数:将像素坐标转换为网格坐标,常用于获取出生点、控制点等对象在地图上的对应位置。

  • set_cells_terrain_connect()函数:批量绘制一组地图格子,并根据 TileSet 的 Terrain 设置自动进行图块连接。这相当于在编辑器中使用自动地形工具绘制道路。

这些函数是我们构建地图内容的基本接口,掌握它们的使用方式是实现可控的自动地图生成的关键。

3 地图生成整体流程

为了让地图生成过程稳定可控,我们将其分解为多个清晰的步骤,执行顺序也遵循了从“基础铺设”到“内容填充”、再到“行为关联”的逻辑演进:

  • 步骤1:初始化地图参数与关键坐标点。首先需要确定地图的尺寸(宽高)、敌人和玩家的出生位置,以及用于控制道路生成区域的边界点。具体思路是通过编辑器定义这些关键坐标点,将它们放置在场景树中。再通过代码来获取这些位置的像素坐标,再转换为地图网格坐标。这些坐标是整个地图生成算法的基础,后续生成道路、设定安全区、布置炮塔等步骤都依赖于这些初始点位。

  • 步骤2:填充草地图层。之后需要为整张地图设置一个统一的初始地形,用草地作为默认可通行区域。具体思路是使用双层 for 循环,遍历所有地图网格,在 Grass 图层上设置相应草地图块。草地图层是所有其它图层的“底图”,其它图层(如道路、边界、障碍)都是在其基础上叠加。如果不先铺设草地,地图会呈现空白状态,并影响后续视觉和导航。

  • 步骤3:设置敌我出生点的安全区域。然后需要生成敌我出生点的安全区域,确保敌人和玩家的出生位置周围不会生成障碍物或被其他单位占用。具体思路是以出生点为中心,获取周围一圈格子(包括二层邻近区域),将这些格子加入“安全区”列表,用于后续障碍物布置时排除。如果出生点周围被障碍物封死,可能导致玩家无法移动或敌人卡死,严重影响游戏流程。因此需要优先锁定这片区域为“禁布置区”。

  • 步骤4:设置地图边界。接下来需要在地图四周布置一圈不可穿越的障碍物,形成物理边界,防止玩家越界。具体思路是在 Border图层上叠加一圈树木图块,同时这些图块设置为不可通行。这一步是“安全封闭地图空间”的关键。若无边界限制,玩家可能走出地图区域造成逻辑错误或画面穿帮。

  • 步骤5:设置道路图层。动态生成路径是程序化地图的关键特征之一。相比于固定路径,随机生成路径能增加多样性与重玩性,同时也为敌人AI提供了新的巡逻路线。具体思路分两步,第一步是先随机生成若干个道路途径点,第二步是将这些途径点连接起来形成道路。在实现过程中我们会编写自定义类来生成随机途径点坐标,并使用内置的AStarGrid2D类来逐步连接这坐标,最终形成一条完整路径数组。第三步是在 Road 图层上用自动地形的方式绘制出来。

  • 步骤6:统计地图上的空闲格子。在随机布置障碍物和装饰物时,需要找出地图上仍未被道路或安全区占用的格子。具体思路是从草地图层中提取所有格子,再剔除道路占用的格子和安全区域中的格子,得到剩余可用的网格。在随机布置障碍和装饰物时,我们必须保证它们不会与已有内容(如道路或出生点)冲突,因此要预先筛选出“合法可用”的格子。

  • 步骤7:布置障碍物与装饰物。接下来需要在地图中布置障碍物和装饰物,以丰富地图结构与视觉表现,同时通过障碍物对玩家行动构成限制。具体思路是从空闲格子中随机选择坐标来生成石块(障碍)和树木(装饰),为了让这些物体更加自然,可以使用聚集式布置,即从一个中心格子开始向周围扩散填充。障碍物能提升战斗策略性,让敌我单位的移动变得更具挑战;装饰物则使场景更自然,不至于过于空旷或单调。

  • 步骤8:设置敌方单位的巡逻点。敌方单位的巡逻点是为敌方单位提供一组巡逻路线中的航点,使其行为更具可变性与动态感。具体思路是从道路图层中随机抽取若干格子作为巡逻点,并将其转为像素坐标供敌人引用。程序化地图中的巡逻点应随地图变化而变化,不能写死。如果每次地图结构不同而巡逻点不变,会导致敌人无法正常行走,甚至卡死。

  • 步骤9:设置炮塔布置点。还需要在地图中合理放置敌方炮塔,形成防守区域并制造挑战。具体思路是从道路周围的邻近格子中随机抽取位置作为候选炮塔点,确保不与安全区或障碍冲突,且远离玩家出生点一定距离。炮塔的布局决定了玩家移动的压力区,合理布置可引导玩家绕行、规避,形成战术性选择。必须避免将炮塔布在玩家一出生就会被攻击的位置,否则体验极差。

通过以上几个步骤,地图从一张空白草地逐渐演变为具备路径结构、封闭边界、障碍分布、战斗单位行为等功能的完整战斗关卡。这些步骤之间环环相扣、缺一不可,也体现了程序化地图生成在逻辑控制和空间规划上的挑战与魅力。

4 地图基础构建

地图生成的第一阶段是打好“基础地形”,包括设定尺寸与关键坐标点、填充草地图层、划定安全区域和绘制边界。这些操作为后续路径生成、敌人分布、装饰填充等系统提供了结构基础。

4.1 场景准备工作

在之前的章节中,我们通过手动绘图完成了地图制作。现在,我们将切换到自动生成方式。为此,我们先将之前的 map.tscn 场景文件复制一份,并重命名为 map_pcg.tscn。打开该场景后,删除 TileMapLayer 中所有手动绘制的图块,只保留节点树结构和关联的 map.tres 图块资源文件。此时地图的基本结构如下,地图内容是空白一片的,我们会用代码来填充地图。 alt text

接下来,在场景根节点挂载脚本,并定义常用图层节点与配置参数:

extends Node2D

@onready var road: TileMapLayer = $Road
@onready var grass: TileMapLayer = $Grass
@onready var border: TileMapLayer = $Border
@onready var items: TileMapLayer = $Items
@onready var tree: TileMapLayer = $Tree
@export var map_size = Vector2i(45,22)    # 定义了地图的尺寸
@export var points : Node2D
const grass_id :int = 0
const nav_grass_atlas = Vector2i(0,0)
代码解释:

  • map_size变量使用整数向量,表示地图宽度有45个网格,高度有22个网格大小。
  • points的用途是收纳,它的子节点会用于存储敌人和玩家的出生点。
  • grass_id是草地图块的资源编号,nav_grass_atlas是图集中的坐标。当你把鼠标放到底部操作区的TileMap的草地图块上时,可以看到资源编号和图集坐标的信息显示。

4.1 初始化地图关键坐标点

程序化地图中,需要通过代码获取场景中几个关键坐标点的信息。这些坐标包括:

  • 敌人出生点(EnemyStart)
  • 玩家出生点(PlayerStart) 另外在地图中道路的生成区域需要处于地图内部的一个矩形区域,需要定义矩形区域的左上角和右下角的坐标点。

  • 道路生成区域左上角坐标(TopLeft)

  • 道路生成区域右下角坐标(BottomRight)

func set_map_parameter():
    enemy_start_pos = points.get_node("EnemyStart").global_position
    enemy_start =  grass.local_to_map(enemy_start_pos)
    player_start_pos = points.get_node("PlayerStart").global_position
    player_start =  grass.local_to_map(player_start_pos)
    path_top_left =  grass.local_to_map(points.get_node("TopLeft").global_position)
    path_bottom_right =  grass.local_to_map(points.get_node("BottomRight").global_position)
代码解释:

  • set_map_parameter函数负责获取关键坐标点的信息
  • 通过get_node函数拿到敌人出生点的像素坐标
  • 通过local_to_map函数转换成地图网格坐标
  • 类似的也可以得到其它三个点的地图坐标。

4.2 填充草地图层

草地图层是地图的基础地形,我们将在所有格子上绘制草地,作为默认的可通行区域:

func setup_grass():
    grass.clear()
    for cell_x in range(map_size.x):
        for cell_y in range(map_size.y):
            grass.set_cell(Vector2i(cell_x,cell_y), grass_id, nav_grass_atlas)
代码解释:

  • setup_grass函数负责填充草地图层
  • grass.clear()函数会清空所有的网格内容,也就是删除可能还存在的人工绘制的地图内容。
  • 使用双重循环,遍历地图的每一个网格,设置每一个网格的内容为草地。这样就完成了草地图的填充。

set_cell是一个重要的内置函数,用于定义地图中网格的填充内容,它有三个参数,下面来详细说明。

  • 第一个参数是指在地图中的哪个坐标位置进行填充,坐标是指地图网格中的的整数坐标,而非屏幕上的像素坐标。你可以试着点击Grass节点,然后在主窗口中移动鼠标,可以在主窗口左下角观察到这个坐标。

alt text

  • 第二个参数是指TileSet中的图片资源编号,也就是source_id。你可以点击打开Grass节点的TileSet资源,然后鼠标移动到底部TileMap窗口中,将鼠标移动到不同的图片网格资源上,会显示三排信息,第一排显示的source:0,就是我们使用的资源编号,如果你点选到树木,source_id就会不一样。你会注意到草地、道路和沙漠这些资源的source_id是相同的,它们用的是同一个图片。

alt text

  • 第三个参数是填充的图片资源坐标。在上面显示的三排信息中,第二排信息就是网格在资源图片中所在的坐标。如果你将鼠标移动到左上角的草地上,坐标应该就是(0,0),如果移动到右边一格的道路上,这个坐标就变成了(1,0)。这个坐标更具体的明确了使用哪一块网格来填充地图。

在上面的代码中,grass_id就是指草地所在图片资源编号,nav_grass_atlas就是指的左上角那块草地的网格坐标。

4.3 设置安全区域

玩家与敌人的出生点如果周围有障碍物或炮塔,可能会导致卡住、无法移动等严重问题。因此需要在这些位置周围划出“安全区域”:

func setup_safe_area():
    var enemy_start_surround = grass.get_surrounding_cells(enemy_start)
    enemy_safe_area.append(enemy_start)
    enemy_safe_area.append_array(enemy_start_surround)
    for cell in enemy_start_surround:
        enemy_safe_area.append_array(grass.get_surrounding_cells(cell))
    var player_start_surround = grass.get_surrounding_cells(player_start)
    player_start_surround.append(player_start)
    player_start_surround.append_array(player_start_surround)
    for cell in player_start_surround:
        player_safe_area.append_array(grass.get_surrounding_cells(cell))
代码解释:

  • setup_safe_area函数负责定义一块安全区域
  • get_surrounding_cells是一个内置函数,用于获取输入网格的周围的四块网格。再向外一圈扩展,找到它们的邻居网格,然后把这些邻居网格添加到enemy_safe_area数组中,这样就完成了敌方出生点周围安全区域的设置。
  • 类似的,玩家出生点周围的安全区域也进行同样的设置。

4.4 设置地图边界

为了防止玩家走出地图,我们需要在地图外围用树木图块构建一圈边界。因为树木的背景是透明的,所以我们会先用另一种不可导航的草地图块来填充背景,再用树木来覆盖即可。

const border_tree_id :int = 5
const border_tree_atlas = Vector2i(0,0)
const nonav_grass_atlas = Vector2i(0,1)

func setup_border():
    var border_top_left = Vector2i.ZERO - Vector2i.ONE
    var border_bottom_right = map_size
    for cell_x in range(border_top_left.x, border_bottom_right.x+1):
        grass.set_cell(Vector2i(cell_x,border_top_left.y), grass_id, nonav_grass_atlas)
        grass.set_cell(Vector2i(cell_x,border_bottom_right.y), grass_id, nonav_grass_atlas)
        border.set_cell(Vector2i(cell_x,border_top_left.y), border_tree_id, border_tree_atlas)
        border.set_cell(Vector2i(cell_x,border_bottom_right.y), border_tree_id, border_tree_atlas)
    for cell_y in range(border_top_left.y, border_bottom_right.y+1):
        grass.set_cell(Vector2i(border_top_left.x,cell_y), grass_id, nonav_grass_atlas)
        grass.set_cell(Vector2i(border_bottom_right.x,cell_y), grass_id, nonav_grass_atlas)
        border.set_cell(Vector2i(border_top_left.x,cell_y), border_tree_id, border_tree_atlas)
        border.set_cell(Vector2i(border_bottom_right.x,cell_y), border_tree_id, border_tree_atlas)
代码解释:

  • border_tree_id是树木图块的编号,border_tree_atlas是树木图块在资源图片中的坐标,nonav_grass_atlas是不可导航的草地图块在资源图片中的坐标。
  • border_top_leftborder_bottom_right是边界的左上角和右下角坐标。边界格子范围比地图尺寸略大一圈,所以边界的左上角坐标是(-1,-1),右下角坐标就是map_size的大小。
  • 绘制边界时先绘制横向的上下两条边界,再绘制纵向的左右两条边界。
  • grass.set_cell函数用于填充草地网格,此处使用的草地图块是不可导航的草地。
  • border.set_cell函数用于填充树木网格。这样就完成了地图边界的设置。

这样,我们就构建了一张基础完善、结构规范的地图地形,为下一步路径生成与内容布置打下了坚实基础。

5 道路路径的生成

完成地图的基本构建后,接下来我们要实现的,是从敌人出生点到玩家出生点之间的路径规划与绘制。一条合适的路径不仅能为敌人单位提供导航依据,还决定了游戏过程中敌人如何接近玩家、从哪些方向发起进攻。

在这一节中,我们将使用自定义类(PathGenerator)来组织逻辑,通过 AStarGrid2D 寻路系统来构建可行走的地图路径,并最终绘制出一条自动连接的道路网格。

道路的生成可以分为三个小任务:

  • 随机选择若干途径点:这些点分布在敌人出生点与玩家出生点之间的区域,作为路径的中间参考点。
  • 将这些点串联为一条路径:从第一个途径点开始,采用“最近邻策略”,依次连接最近的下一个点,最终形成从敌人起点到玩家终点的通路。在网络地图上,这个过程就是在寻找最短路径。
  • 将路径绘制到地图上:使用在Road图层上调用自动地形工具绘制对应的图块。

这个过程的逻辑较为复杂,因此我们将其封装进一个专用的类中,以提升代码的可维护性与可重用性。

5.1 PathGenerator 类的实现

如果我们把所有路径点的生成、排序、连接逻辑都写在主地图脚本中,不仅代码杂乱,也难以调试与扩展。因此我们使用一个自定义类 PathGenerator:

  • 将路径逻辑独立封装:职责清晰,不与场景逻辑耦合;
  • 参数配置集中化:如地图范围、起止点、途径点数量等;
  • 支持复用与测试:可用于未来多个关卡、不同地图中。

我们可以在 scripts 文件夹下新建一个脚本文件 path_generator.gd,内容如下:

extends RefCounted
class_name PathGenerator

var _map_top_left: Vector2i
var _map_bottom_right: Vector2i
var _start_pos: Vector2i
var _stop_pos: Vector2i
var _points_num: int
var _path_points: Array[Vector2i]
var _random_points: Array[Vector2i]
代码解释:首先定义若干变量,包括地图的上下左右边界,途径点的数量,途径点的坐标,随机途径点的坐标等。

func setup(map_top_left, map_bottom_right, start_pos, stop_pos, points_num) -> void:
    _map_top_left = map_top_left
    _map_bottom_right = map_bottom_right
    _start_pos = start_pos
    _stop_pos = stop_pos
    _points_num = points_num
代码解释:setup函数会基于传入的参数来设置类中的变量。

func find_nearest_point(start:Vector2i, points:Array[Vector2i]):
    var dist_array: Array
    for point in points:
        dist_array.append([point, start.distance_squared_to(point)])
    dist_array.sort_custom(sort_ascending)
    return dist_array[0][0]

func sort_ascending(a, b):
    if a[1] < b[1]:
        return true
    return false
代码解释:

  • find_nearest_point函数负责找到离起点 start 最近的途径点。它会计算每个途径点到起始点的距离,然后基于这些距离进行自定义排序。排序完成后返回最近的途径点。
  • distance_squared_to 函数用于计算两个向量之间的欧几里得距离的平方(比直接计算 distance更快,无需开方)。
  • sort_ascending函数是一个自定义的比较函数,数组中每个元素是一个 [point, distance] 二元组,排序规则是根据distance 升序排列,使最近的点排在最前面。它会放到sort_custom中使用。

func generate_random_points():
    for i in range(_points_num):
        var rand_x = randi_range(_map_top_left.x, _map_bottom_right.x)
        var rand_y = randi_range(_map_top_left.y, _map_bottom_right.y)
        _random_points.append(Vector2i(rand_x, rand_y))
    var nearest_start = _start_pos

    for i in range(_points_num):
        var nearest_point = find_nearest_point(nearest_start,_random_points)
        var mid_points = [Vector2i(nearest_start.x, nearest_point.y),
                        Vector2i(nearest_point.x, nearest_start.y)]
        var mid_point = mid_points.pick_random()
        _path_points.append(mid_point)
        _path_points.append(nearest_point)
        nearest_start = nearest_point
        _random_points.erase(nearest_point) 
    return _path_points
代码解释:

  • generate_random_points函数用于生成一组有序的路径点数组。
  • 在第一个for循环中生成随机途径点。具体是调用randi_range函数生成随机整数,生成的时候会考虑区域的左上角和右下角两个点,将它们作为随机生成的边界值。生成的途径点的坐标会存在变量_random_points中。
  • 在第二个for循环中将途径点进行排序处理。从起始点开始,调用find_nearest_point函数去找最近的途径点。将它保存在变量nearest_point中。
  • 如果直接将地图中两个点进行道路连接,它们会是一条斜线,游戏中我们需要的道路是纵横方向的,所以需要定义一个中间拐点,这样三个点连接的路径就像是一条纵横方向的道路了。
  • 按照任务二的要求,把这些处理后的途径点排序后,保存在数组变量_path_points中。

这个函数就将原本“混乱的随机点”组织成了一条结构合理、便于绘制、适合寻路的路径骨架。但是注意,现在这个变量中保存的只是若干排序后的途径点的坐标。它们之间并不是紧紧邻接在一起的。所以还需要一个帮手来连接这些途径点。这个帮手就是AStarGrid2D

5.2 AStarGrid2D节点使用

Godot 引擎提供了一个非常方便的路径搜索工具:AStarGrid2D 类,它基于 \(A*\) 算法,专为网格地图设计。与普通的 AStar2D 类不同,它内建网格逻辑,不需要手动添加节点,只要设置区域和参数即可使用。

AStarGrid2D 的主要特点包括:

  • 自动避障:你可以配置哪些网格可通过,哪些不可通行;
  • 最短路径搜索:系统根据启发式代价计算从起点到终点的高效路径;
  • 节点使用简便:无需提前构建路径网,只需提供格子范围即可。

回到map_pcg.gd代码中,我们来增加代码如下:

var astargrid = AStarGrid2D.new()
var path_genertor = PathGenerator.new()

func setup_astargrid():
    astargrid.region = grass.get_used_rect()
    astargrid.cell_size = cell_size
    astargrid.offset = cell_size/2
    astargrid.diagonal_mode = astargrid.DIAGONAL_MODE_NEVER
    astargrid.update()
代码解释:

  • 使用AStarGrid2D类创建了一个名为astargrid的对象
  • 使用PahtGenerator类创建了一个名为path_genertor的对象
  • setup_astargrid函数负责设置AStarGrid2D的配置参数,以便在网格环境中进行路径查找。
  • astargrid.region = grass.get_used_rect 定义了寻路算法的可用区域。确保寻路网格只在草地区域内进行路径查找。
  • astargrid.cell_size = cell_size 设置了寻路算法网格单元格的大小。
  • astargrid.offset = cell_size / 2 设置网格单元格的偏移量。使网格的中心对齐于实际的地形。
  • astargrid.diagonal_mode = astargrid.DIAGONAL_MODE_NEVER 设置寻路算法是否允许对角线移动。DIAGONAL_MODE_NEVER表示禁止对角线移动,寻路过程中只允许水平和垂直方向的移动。
  • astargrid.update() 表示更新寻路算法网格的状态。

func setup_pathgenerator():
    path_genertor.setup(path_top_left,
                        path_bottom_right, 
                        enemy_start,
                        player_start, 
                        path_point_count)
代码解释:setup_pathgenerator函数负责将path_genertor对象进行设置,让它的内部变量得到正确的参数值。

func get_random_path():
    var path_points: Array[Vector2i] = path_genertor.generate_random_points()
    var path_start = enemy_start 
    for point in path_points:
        path_cell.append_array(astargrid.get_id_path(path_start,point))
        path_start = point
    var path_end = player_start
    path_cell.append_array(astargrid.get_id_path(path_start,path_end))
    road.set_cells_terrain_connect(path_cell, 0, 0)
代码解释:

  • get_random_path函数负责利用上述的对象,来生成随机道路。
  • 首先使用path_genertor对象来生成一系列的途径点,这些点已经是从起点到终点进行了排序,而且添加了中间拐点。
  • 然后使用astargrid对象来计算从起点到每个随机点的路径
  • astargrid.get_id_path函数用于计算两个点之间的路径,返回值是一个数组,包含从起点到终点的路径上所有紧邻的网格坐标。
  • 使用循环来遍历所有的途径点,将所有的路径保存在path_cell数组中。
  • 最后需要在地图上绘制出道路,使用road.set_cells_terrain_connect函数来实现。函数第一个参数是路径的数组,后面两个参数是使用的地形资源编号,我们在之前手动绘制地图时,已经设置过了地形资源,也就是Terrain Sets

通过以上流程,我们实现了自动选择路径点,自动生成寻路路径,自动绘制道路图块的完整逻辑

5.3 类的实例化和场景实例化

在 Godot 中,我们常常需要创建新的对象来辅助实现逻辑。例如在本章中用于生成路径数据的 PathGenerator,本质上就是一个专门负责计算的工具对象。理解“如何创建对象”,以及“什么样的对象应该如何创建”,是构建清晰工程结构的重要一步。我们需要理解 Godot 中两种常见但用途完全不同的对象创建方式:类的实例化 new() 与场景实例化 instantiate(),并结合实际项目,讨论它们各自的适用场景、设计差异以及使用时需要注意的问题。

使用 new() 实例化类对象

在 Godot 中,任何脚本类都可以通过 new() 的方式创建实例。这种方式非常适合那些不需要加入场景树、仅用于逻辑计算或数据处理的对象。在本章的路径生成系统中,PathGenerator 正是这样一个例子:它的职责只是根据规则生成路径数据,并不需要显示在画面中,也不需要参与游戏的生命周期更新。

这类对象通常继承自 RefCountedRefCounted 是 Godot 提供的一种轻量级逻辑类基类,它不属于场景树的一部分,但具备自动引用计数和内存管理机制。当一个对象不再被任何变量引用时,Godot 会自动将其销毁,因此开发者不需要手动释放内存。这种设计非常适合用于算法、规则、计算器一类的“工具型对象”,既安全又高效。

为什么不使用 Node?

很多初学者在设计类时,会下意识地让所有脚本都继承自 Node。但实际上,Node 的设计目标是作为场景树中的基本运行单元。它拥有完整的生命周期函数,例如 _ready()_process(),可以参与渲染、物理、输入等系统,并且必须被添加到场景树中才能正常工作。

而在我们的项目中,PathGenerator 并不具备这些需求。它既不需要显示在屏幕上,也不需要逐帧执行逻辑,更不依赖场景层级结构。此时如果仍然使用 Node,不仅会引入不必要的系统开销,还会让逻辑类与场景对象混在一起,增加后期维护的复杂度。从工程角度来看,这是一种语义不清晰、结构不合理的设计。因此,对于纯逻辑、纯计算的类,更推荐继承自 RefCounted,并通过 new() 来创建实例;而将 Node 留给真正需要存在于场景中的对象。

什么时候使用场景实例化?

当你要创建的对象本身就是一个游戏中的实体,并且希望它参与到当前场景的运行中时,就应该使用场景实例化。使用 instantiate() 创建的对象,通常来源于一个 .tscn 场景文件,它可能包含多个节点、图像资源、动画、碰撞体以及对应的脚本逻辑。实例化之后,将其添加到场景树中,这个对象就会自然地参与游戏的生命周期。

例如,玩家角色、敌人、子弹、特效、UI 窗口、地图块和道具等,都属于这一类对象。它们需要被渲染、需要响应输入或物理碰撞,也需要在合适的时机被创建和销毁,因此使用场景实例化是最合理、也是最符合 Godot 设计理念的方式。

new()instantiate() 的区别,并不仅仅是语法上的不同,而是逻辑对象与场景对象的本质区分。在实际开发中,如果一个类的职责是“计算、决策或生成数据”,那么它更适合作为一个独立的逻辑类存在;如果一个对象需要出现在游戏世界中,并参与运行和表现,那么它就应该被设计为场景并通过实例化创建。

6 设置障碍物与装饰物

地图构建的最后一步,是在合适的位置随机生成障碍物和装饰物,增加地图的复杂度与视觉层次。这里的障碍物主要是指石头,它们会阻挡单位的通行;装饰物则是树木,它们只是美化环境,不影响导航路径。

这两个元素不能随意放置,必须满足以下条件:不能与道路重叠;不能落在敌方或玩家的出生区域;最好能聚集在一起形成自然形态。为此,我们首先要定义一个可用网格空间,然后再在这些空白区域中有策略地进行填充。

6.1 标记可用网格空间

func setup_free_cell():
    var grass_cell = grass.get_used_cells_by_id(grass_id, nav_grass_atlas)
    var road_cell = road.get_used_cells()

    for cell in grass_cell:
        if (cell not in road_cell and 
            cell not in enemy_safe_area and 
            cell not in player_safe_area):
            free_cell.append(cell)
代码解释:

  • setup_free_cell函数负责计算可用的网格空间
  • 首先通过get_used_cells_by_id函数来获取草地使用的网格,作为初始的候选区域,然后获取道路使用的网格
  • 通过for循环,从草地网格中排除道路网格,也排除敌人和玩家的安全区所占用的网格。防止障碍物阻断道路或妨碍出生。
  • 最终保留的网格存入 free_cell,用于后续放置元素。

6.2 通用的元素填充函数

障碍物和装饰物的生成逻辑非常相似,因此我们设计了一个通用函数 set_item() 来简化流程:

func set_item(tile:TileMapLayer, prob:float, item_id:int, item_alats:Array, cannot_nav:bool):
    var cell = free_cell.pick_random()
    if cannot_nav:
        grass.set_cell(cell, grass_id, nonav_grass_atlas)
    tile.set_cell(cell,item_id,item_alats.pick_random())
    free_cell.erase(cell)
    var near_cells = items.get_surrounding_cells(cell)
    for near_cell in near_cells:
        if randf_range(0,1) < prob and near_cell in free_cell:
            if cannot_nav:
                grass.set_cell(near_cell, grass_id, nonav_grass_atlas)
            tile.set_cell(near_cell,item_id,item_alats.pick_random())
            free_cell.erase(near_cell)
代码解释:

  • 参数tile是要填充的目标图层,如 items 或 tree;
  • 参数prob是邻近区域是否也填充的概率(如 0.5);
  • 参数item_iditem_alats 指定了图块资源;
  • 参数cannot_nav表示是否影响导航(如石头为 true,树木为 false);
  • 函数首先从可用网格中随机获取一个网格,如果这个地方设定为无法通行,则在grass层中换用无导航的草地资源,然后再填充所需要的网格。以一定概率扩展到周围格子,实现“簇状聚集”的自然效果;每填充一次都从 free_cell 中移除,避免重复使用。

6.3 分别设置石头与树木

func setup_stones(item_num: int = 8):
    for i in range(item_num):
        set_item(items,0.5,stone_id,stone_atlas,true)

func setup_trees(item_num: int = 5):
    for i in range(item_num):
        set_item(tree,0.5,brown_tree_id,[brown_tree_atlas],false)
代码解释:

  • setup_stones 函数用于在 items 图层中放置石头,并指定 cannot_nav = true,意味着这些位置会阻挡寻路。
  • setup_trees 函数用于在 tree 图层中放置装饰性树木,虽然不会影响导航,但依旧避免与主路径冲突。

本节通过构建一个通用填充函数,有效地将障碍物与装饰物合理布置在地图空白区域。通过控制生成逻辑、邻接扩散概率和导航影响标志,我们能够创建既美观又合理的地图结构。

7 敌方单位与巡逻点设置

在前面章节中,我们使用编辑器手动在地图上布置了敌方巡逻点和炮台位置。但在程序化地图生成系统中,这些元素也应当自动生成,以配合地图结构的随机性并提高游戏的可重玩性。本节将介绍如何用代码生成巡逻点和敌方炮台位置,并结合前面准备好的地图结构和空闲网格信息,进行合理布局。

7.1 设置巡逻点

巡逻点用于引导敌方单位的移动路线。它们应随机分布在道路网格上,使敌人沿着道路巡逻。

func get_patrol_points(patrol_num:int = 5):
    var patrol_points: Array
    var road_cells = road.get_used_cells()          
    for i in range(patrol_num):
        var pick_point = road_cells.pick_random()
        road_cells.erase(pick_point)
        patrol_points.append(road.map_to_local(pick_point))
    return patrol_points
代码解释:

  • get_patrol_points函数负责生成巡逻点的坐标,参数patrol_num指定了巡逻点的数量。
  • 使用 road.get_used_cells() 获取道路图层上所有有效网格;保存为road_cells
  • 每次从中随机选取一个网格坐标作为巡逻点,并将其转换为实际地图坐标;
  • 为了避免重复抽取,选中的网格将从 road_cells 中移除;
  • 然后将它转换成地图坐标,保存在patrol_points数组中
  • 循环多次就可以生成多个巡逻点

这种方式确保巡逻点分布在不同位置,使敌人行动更具策略性和不确定性。

7.2 设置敌方炮台位置

敌方炮台属于固定式单位,一般设定为位于道路边缘、远离玩家出生点的区域,用于阻挡和压制玩家前进。为了实现这个逻辑,我们通过如下函数生成炮台坐标:

func get_tower_points(tower_num:int = 8, max_try:int = 100):
    var road_cells = road.get_used_cells()
    var tower_points: Array
    var try_count:int = 0
    for i in range(tower_num):
        while try_count< max_try:
            var rand_point = road_cells.pick_random()
            var surround_cell = grass.get_surrounding_cells(rand_point)
            var tower_point = get_surround_free_cell(surround_cell)
            if tower_point:
                tower_points.append(tower_point)
                break
            else:
                try_count += 1
    return tower_points
代码解释:

  • get_tower_points函数负责生成敌方炮台的坐标,参数tower_num指定了炮台的数量,max_try指定了尝试次数的上限。
  • 首先是获取道路使用的所有网格,保存road_cells变量
  • 循环多次,每次都会从道路网格中随机选取一个点 rand_point
  • 获取该点的邻接草地网格 surround_cell,因为我们需要把炮台放在道路边上。
  • 道路的邻居网格有可能是石头,所以需要使用get_surround_free_cell函数进行处理判断,如果这些邻居网格有合法的空位,就保存到tower_points数组中。
  • 为防止死循环,增加 max_try 限制尝试次数。

func get_surround_free_cell(surround_cell):
    for cell in surround_cell:
        if cell in free_cell:
            grass.set_cell(cell, grass_id, nonav_grass_atlas)
            var point = grass.map_to_local(cell)
            if point.distance_to(player_start_pos) > 400:
                return point
    return null
代码解释:

  • get_surround_free_cell函数负责检查邻居网格是否有合法的空位,如果有,就返回合法的空位坐标。
  • 使用for循环遍历surround_cell,如果格子在free_cell中表示未被占用。将草地换成没有导航的草地
  • 然后检查这个网格距离玩家出生点的距离,如果大于400,就返回这个网格的坐标。避免炮台压制出生点。

通过以上逻辑,我们实现了:在道路上随机生成巡逻点,用于动态布置敌人巡逻路径;在道路边缘自动放置敌方炮台,兼顾策略性与公平性。

8 场景与脚本的改造工作

随着地图生成系统从手动编辑转变为自动生成,我们需要同步对敌人单位、玩家角色、管理器脚本及主场景做出相应的修改,确保整个游戏逻辑仍然严密、流畅。

8.1 修改敌方单位

以往我们在编辑器中手动放置巡逻点,并让敌人通过代码读取这些节点。但现在巡逻点是动态生成的,因此敌人单位不再读取固定路径,而是从地图对象中直接获取。

打开enemy_tank.tscn和enemy_mis_vehicle.tscn两个场景,分别在代码中加入如下代码:

@export var map: Node2D
代码解释:添加地图变量map,允许敌人单位访问地图节点,以及地图脚本中提供的函数。

然后需要找到敌人单位的“巡逻状态”脚本,将原有的路径引用方式修改为调用地图节点中的生成函数:

func get_patrol_point():
    patrol_points = enemy.map.get_patrol_points()
这样,每个敌人启动时都能自动获取一组新的巡逻点,实现动态路径行为。

8.2 修改 EnemyManager

EnemyManager本是敌人控制中心,负责生成各类单位。我们需要为其添加地图引用,并根据地图生成炮塔和敌人单位。打开enemy_manager.gd代码,修改如下:

@export var enemy_tank_scene: PackedScene
@export var enemy_mis_scene: PackedScene
@export var enemy_tower_scene: PackedScene
@export var map: Node2D

func _ready() -> void:
    tower_points = map.get_tower_points(enemy_tower_num)
    Gamemanager.entity_died.connect(on_entity_died)
    spawn_tower()
    check_enemy_size()
    spawn_waves()
代码解释:在_ready函数中调用地图中的函数get_tower_points,来获取炮台的位置数组。然后使用spawn_twoer函数放置炮塔。

func spawn_tower():
    for point in tower_points:
        var enemy_tower = enemy_tower_scene.instantiate()
        enemies_holder.add_child(enemy_tower)
        enemy_tower.global_position = point
代码解释:spawn_tower函数负责遍历所有位置数组,在关卡中放置敌方炮塔。

func spawn_enemy():
    var enemy: Node2D
    if randf_range(0,1) < enemy_tank_rat:
        enemy = enemy_tank_scene.instantiate()
    else:
        enemy = enemy_mis_scene.instantiate()
    enemy.speed = randi_range(100,150)
    enemy.global_position = map.enemy_start_pos
    enemy.map = map
    enemies_holder.add_child(enemy)
代码解释:

  • spawn_enemy函数负责随机选择敌人移动单位,然后将其放置在地图中的敌方出生点位置上。
  • 游戏中有两种能移动的敌人单位(普通坦克和导弹攻击车),我们可以进行随机选择任一单位进行场景实例化。
  • 并随机设置其移动速度,然后将其放置在地图中的敌方出生点位置上。
  • 将每个敌人位置中的map变量进行设置,便于其后续巡逻路径读取
  • 最后放置在enemies_holder节点下,方便管理。

8.3 修改Player场景

原来的玩家出生点是手动设置的,现在需通过地图中设定的位置进行初始化。

打开player.tscn场景,修改代码如下:

@export var points: Node2D

func _ready() -> void:
    set_pos()

func set_pos():
    global_position = points.get_node("PlayerStart").global_position
代码解释:set_pos函数负责获取玩家出生点的位置,然后将玩家的位置设置为出生点的位置。然后在_ready函数中调用set_pos函数。

8.4 修改主场景

主场景 game.tscn 需要做如下调整,以配合自动地图生成系统:

  • 步骤一:设置关键点。我们需要调整Points节点下的四个节点位置。之前Points节点下子节点的作用是设置巡逻点,那现在的作用是设置四个关键点,将它们改名并设置位置如下:
节点名称 功能描述 位置 (示例)
EnemyStart 敌人出生点 (90, 536)
PlayerStart 玩家出生点 (2834, 546)
TopLeft 道路区域左上角 (300, 159)
BottomRight 道路区域右下角 (2539, 1251)
  • 步骤二:替换地图场景。将旧的 TileMap 地图节点删除,替换为新的 map_pcg.tscn 场景,并设置其Points属性为当前场景树中的 Points 节点。

  • 步骤三:清理旧单位节点。删除 Enemies 节点下的所有手动放置的敌人单位。现在所有敌人都将通过 EnemyManager 动态生成。

  • 步骤四:确认节点树结构。确保主场景的结构中应该包含以下主要子节点:

alt text

通过本节的修改,我们完成了从静态地图与手动单位放置,向动态地图与程序化生成单位的过渡。提升了地图的多样性与可重玩性。

8.5 运行主场景测试

完成上述改造后,回到主场景 game.tscn,点击运行即可开始游戏测试。你会发现,每次运行游戏时:

  • 地图的草地、道路、石头、树木等元素位置都不同;
  • 敌人出生点固定,但巡逻路径是自动生成的,每次都不同;
  • 敌方炮塔围绕道路随机布置,形成不同的封锁格局;
  • 所有敌人和物体不再手动放置,而是由系统在运行时自动生成。

这说明我们的地图生成系统已经成功生效,游戏进入了程序化地图内容生成的新阶段。

本章小结

本章介绍了如何使用 Godot 实现自动地图生成,并通过程序动态构建一个完整的游戏关卡。我们主要完成了以下几项工作:

  • 我们了解了程序化地图生成的基本概念、优缺点以及在游戏中的常见做法,明确了它在提升游戏可玩性与重复利用方面的重要作用。

  • 在复习 TileMap 和 TileSet 的基础上,本章重点学习了 TileMapLayer 类中常用的方法,并理解这些函数如何控制地图图层。

  • 我们按步骤搭建了完整的地图生成流程,包括:初始化地图与关键位置;随机生成草地与道路;设置边界与安全区;自动布置障碍物与装饰物;生成敌人巡逻点和炮台。每个步骤都有清晰的功能与实现逻辑,便于维护和扩展。

  • 为处理复杂路径生成逻辑,我们使用 PathGenerator 自定义类,并借助 AStarGrid2D 实现从起点到终点的网格寻路,大大简化了路径规划的难度。

  • 最后,我们修改了敌人、玩家和主场景的脚本结构,使它们可以自动获取地图中的生成信息,实现了地图内容与游戏运行的完整联动。

通过本章的学习,我们掌握了程序化地图生成的基本思路与实现方法,让地图从“手工搭建”变成了“自动生成”。这不仅提高了开发效率,也让每一局游戏都有新的体验。