跳转至

第六章:进阶项目实战《Battle Tank》

本章导言

在前几章中,我们已经掌握了Godot的基础知识,完成了一个较为简单的2D游戏项目。现在,是时候迈出更进一步,挑战一个结构更复杂、功能更完整的游戏了。

本章将带你逐步实现一款经典风格的《Battle Tank》2D游戏。这一次,我们不仅要让玩家操控坦克进行移动和射击,还要设计敌人行为、实现子弹碰撞、构建关卡地图、设计用户界面,并通过动画和音效为游戏注入生命。

与之前一次性实现全部功能不同,本章采用迭代开发的方式:我们从最基本的“能控制、能发射、能被击中”开始,逐步加入地图、UI、动画、音效、状态管理等模块,最终完成一个具有完整玩法和用户体验的小游戏。

通过本章的学习,你将掌握以下关键技能:

  • 如何将复杂游戏拆分为多个模块进行开发;
  • 如何使用信号机制实现模块间通信;
  • 如何为游戏角色设计状态和行为逻辑;
  • 如何构建动态用户界面与血量进度条;
  • 如何播放动画与音效,提升游戏体验;
  • 如何使用全局管理器管理游戏状态与胜负判断。

游戏开发不仅仅是让角色“动起来”,更是通过规则、反馈与表现力构建起一个生动的系统世界。在开发过程中,你将亲身体验从“拼出画面”到“设计规则”再到“打磨体验”的全过程。

准备好进入战斗了吗?现在就开始构建你的第一个“战斗型”游戏世界吧!

1. 游戏概述与开发规划

在本章中,我们将从零开始构建一款名为《Battle Tank》的 2D 俯视角(Top-Down)射击游戏。它是作者专为本书教学目的而开发的一款休闲游戏。玩家将操控自己的坦克在地图中移动、瞄准并发射炮弹,与多种敌人单位进行对抗,直至取得胜利。

作为本书的进阶教学项目,《Battle Tank》项目会帮助你学习如何组织较为复杂的游戏结构,并掌握更加系统的游戏开发技能。你将不仅学习如何实现坦克的移动与战斗,更重要的是学习如何构建一个模块化、可扩展的游戏框架。

相比上一章的入门项目,本章游戏项目在以下几个方面有明显的不同:

  • 角色行为更加丰富:实现玩家移动、旋转、指向性瞄准及发射逻辑,并为敌人注入基础 AI 行为。
  • 交互逻辑更复杂:涉及碰撞检测、生命值系统、游戏胜负判断等机制。
  • 视听反馈增强:通过动画、音效、UI以及粒子效果,为游戏带来更强的表现力。
  • 结构更加模块化:采用“分而治之”的思路,各类游戏元素封装为独立场景,通过主场景统一调度和管理。
  • 引入全局状态管理:使用全局脚本管理得分、胜负、信号广播等跨模块逻辑。

1.1 游戏玩法简介

《Battle Tank》的核心玩法逻辑如下:

  • 操控机制:玩家通过键盘控制坦克位移,通过鼠标控制炮塔转向并执行射击。
  • 博弈设计:地图中部署有敌方静止单位或机动单位,它们具备自动感应视野,一旦玩家进入射程即发起攻击。
  • 损耗系统:引入数值化的生命值体系,通过碰撞事件触发减血逻辑。
  • 胜负判定:全歼敌方单位则获得胜利进入下一关,玩家坦克生命值归零则失败。

alt text 最终运行效果预览

这些机制虽然简单,但涵盖了现代游戏开发的五大核心要素:输入控制、目标检测、反馈响应、状态管理及视听表现。掌握这些内容,将为你开发中大型项目打下坚实的基础。

1.2 开发思路与流程规划

由于《Battle Tank》涉及多个系统的联动,为了避免开发过程中的逻辑混乱,我们将遵循“模块化设计”与“迭代式开发”的原则。

模块化设计的核心思想是分而治之,职责分离。将一个复杂的系统拆分为多个独立、可替换、低耦合的模块(Module)。迭代式开发的核心思想是小步快跑,持续进化。先做出一个最小可行性产品(MVP),然后不断添砖加瓦来进行优化。

如果把游戏开发比作盖房子,模块化设计是预先想好哪些是承重墙,哪些是管线。即便以后想换个窗户,也不用拆掉整个房子。迭代式开发是施工顺序。先搭好框架(第一层能住人),再刷墙漆,最后装灯具。不需要等整栋楼精装修完才进去看效果。

功能模块拆解

为了实现清晰的职责分离,我们将项目划分为以下独立功能模块。

模块名称 核心职责 技术要点
玩家控制系统 处理输入响应 实现平滑移动、炮塔旋转转向及射击频率控制。
AI 敌人系统 自动化行为逻辑 包含感知算法(检测玩家)与攻击状态机。
子弹系统 物体运动与判定 负责子弹位移、物理碰撞检测及对象池回收。
环境与地图 战斗空间构建 利用瓦片地图)实现地形阻挡与视觉分层。
生命值与状态 核心数据管理 管理 HP 变化、死亡判定及单位间的伤害传递逻辑。
全局管理器 流程调度中心 负责游戏初始化、信号广播及胜负结果判定(单例模式)。
UI 与交互反馈 信息可视化 实时同步血条高度、击杀统计及动态提示。
视听增强系统 表现力打磨 统一管理粒子爆炸特效、引擎循环音与环境背景音。

迭代开发阶段规划

我们通过三个递进的阶段,将复杂的系统拆解为可落地的任务,确保每一阶段都有一个“可运行、可测试”的版本。

阶段一:构建核心玩法

  • 目标:建立最基本的“移动-射击-反馈”流程。
  • 关键任务:玩家可操作坦克移动、旋转并发射子弹;敌人炮塔会自动瞄准玩家并定时攻击;子弹具备运动与碰撞检测机制;双方单位都有生命值;游戏有胜负判定、基础UI和音效动画反馈。
  • 里程碑:坦克能在地图上跑起来,且能击中目标。

阶段二:完善游戏AI

  • 目标:开发敌方AI,敌人机动单位更聪明。
  • 关键任务:引入有限状态机;丰富地图内容,引入路径规划与导航系统;增加多种敌方机动单位;程序化地图生成;模块间通过信号通信,逻辑更解耦。
  • 里程碑:敌人具备完整的AI功能。

阶段三:体验优化与打磨

  • 目标:提升沉浸感,优化玩家交互体验。
  • 关键任务:优化血条和HUD;优化动画和音效;开发主菜单;引入音效管理器模块;增加光照、阴影和环境氛围。
  • 里程碑:游戏视听反馈完整,具备良好的“打击感”。

alt text 最小可运行版本的游戏示意如上图。

在本章中,我们将聚焦于实现阶段一的任务。在下一章,我们会对阶段一的成果进行重构。在第九章到第十二章,我们将实现阶段二的任务,在第十三章,我们将完成阶段三的任务。

这种迭代式开发最大的优势在于:你可以在每个功能完成后立即运行测试,从而快速定位 Bug。同时,高度解耦的模块化设计允许你在不修改核心架构的情况下,自由替换美术资产或增加新的敌方类型。

2. 项目初始化与资源准备

在正式编写《Battle Tank》的代码之前,我们需要先完成项目的初始化设置和资源导入工作。良好的开端是成功的一半。如果项目结构混乱、资源命名不清、屏幕尺寸设置不当,很容易在后续开发中埋下“隐形炸弹”。

本节将带你完成以下准备步骤:

  1. 环境配置:创建项目并适配屏幕分辨率。
  2. 构建交互:建立动作映射系统(Input Map)。
  3. 资产入库:导入并梳理游戏素材。

2.1 创建项目与显示设置

打开Godot 引擎,点击 New Project(新建项目),设置如下参数:

  • Project Name(项目名称):可以命名为 Tank_Game或其它你喜欢的名字;
  • Project Path(项目路径):参考前面章节的方法,选择一个方便管理的位置;
  • Renderer(渲染器):使用默认设置(Godot 4 默认是Forward+);
  • 点击 Create & Edit 进入项目主界面。

为了确保坦克在广阔的战场上有充足的视野,我们需要调整窗口比例:进入Project > Project Settings > Display > Window

  • Size(窗口尺寸): 设置 Viewport Width 为 1280,Viewport Height 为 720(标准 16:9 比例)。
  • Stretch (自适应拉伸):Mode设置为 canvas_itemsAspect设置为keep。这样做的目的是为了让游戏内容在不同分辨率下自动拉伸,而不发生比例变形。

2.2 设置输入映射(Input Map)

Godot 允许我们为游戏中的操作定义自定义输入名,并将其映射到键盘、鼠标或手柄按钮上,这比直接监听按键更加灵活且易于维护。

进入设置选项:Project > Project Settings > Input Map

添加以下五个自定义输入动作,并绑定对应按键:

动作名称 (Action) 物理按键 (Binding) 逻辑功能
up W 向上
down S 向下
left A 向左
right D 向右
shoot Mouse Left Button 发射炮弹

alt text 设置输入映射完成后如上图所示

这样设置后,我们可以在脚本中使用 Input.is_action_pressed("up") 之类的方法统一检测用户输入,而无需关心具体按键代码。

2.3 导入游戏素材与资源整理

本章所需的素材资源,可从本书提供的 GitHub 仓库下载。请从仓库下载 assets 压缩包并解压,将该文件夹直接拖入 Godot 的 FileSystem面板。

资源文件说明

本章示例中的素材资源,主要来源于Kenney.nl。assets中的不同文件夹包含如下类型的素材资源:

  • enemy: 敌方炮台和子弹图片
  • player: 玩家控制坦克车身和炮管图片
  • map: 地图用的瓦片集
  • vfx: 爆炸动画和粒子系统资源
  • ui: 用户界面图片
  • font: 字体文件
  • item: 游戏中的物品图片
  • sound: 音效文件

至此,我们的“军火库”已经搭建完毕。下一步,我们将正式进入《Battle Tank》的核心模块构建阶段。

3. 核心场景模块搭建

在 Godot 中,我们遵循“一个功能,一个场景”的原则。玩家、敌人、子弹和地图都是独立的模块,拥有各自的逻辑脚本。这种高度解耦的结构,能让我们像搭积木一样轻松组合或扩展游戏功能。

本节我们将首先构建敌人炮塔场景,作为整个战斗系统的第一块拼图。

3.1 敌人炮塔场景 Enemy

炮塔场景功能

敌人是本游戏中的核心对抗对象。本节我们将实现一种固定不动但会攻击玩家的敌方炮塔,它需要具备以下功能:

  • 在场景中显示出一个完整的炮塔形象;
  • 自动旋转炮管,持续追踪玩家位置;
  • 每隔一段时间自动向玩家发射一枚子弹;
  • 能够检测碰撞,接收来自玩家的攻击。

场景节点设计

为了实现精确的碰撞检测,我们选择 Area2D 作为根节点。

节点类型 重命名 核心用途
Area2D Enemy 根节点:负责碰撞检测与脚本挂载。
Sprite2D Base 底座:静态视觉展示。
Sprite2D Gun 炮管:执行旋转,追踪玩家方向。
Marker2D 发射点:标记子弹生成的精确坐标。
CollisionShape2D 碰撞体:定义受击区域
Timer 计时器:控制射击频率与冷却。

根据上面的要求,给根节点增加子节点,注意不同节点的层级关系。最后炮塔场景的节点为树结构如下:

Enemy (Area2D)
├── Base (Sprite2D)
├── Gun (Sprite2D)
│   └── Marker2D
├── CollisionShape2D
└── Timer

在文件系统中,新建一个名为Scenes的文件夹,在之下建立一个子目录enemy,将刚创建的炮塔场景保存为 enemy.tscn,存放在enemy文件夹下。

为什么使用 Area2D?

在 Godot 中,我们可以使用 Node2DArea2DCharacterBody2D 等节点作为2D对象的根节点。目前这个游戏设定比较简单,因此敌人炮塔不需要自身移动或受到物理影响,但需要检测是否被玩家子弹击中,因此我们选择使用 Area2D,因为它提供了简单高效的区域碰撞检测功能。也会让初学者更容易理解和实现。

当然,你也可以使用 Node2D 并手动处理碰撞逻辑,但这样会更复杂。而 Area2D 自带的信号(如 area_entered)能方便地处理“被击中”这类交互事件,是非常适合本场景的节点类型。

在后续章节中我们的游戏设定会更复杂一些,当需要炮塔受到物理影响时,我们会修改这个场景的根节点,到时候你会看到根节点的变化。

关键属性配置

还需要对场景节点的贴图和属性进行一些设置。设置贴图我们已经介绍过,就是从文件系统中找到对应的贴图文件,然后将它拖拽到节点Texture属性上。

  • 底座贴图:将 enemy/towerDefense_tile181.png 拖入 Base 的 Texture 属性;
  • 炮管贴图:将 enemy/towerDefense_tile249.png 拖入 Gun 的 Texture 属性;
  • 炮管偏移:将 Gun 的 Offset 设置为 (10, 0),让炮塔轴心略微偏移一些,这样在后期的旋转逻辑中就会方便一些。
  • 发射点:将Marker2D节点的Position 设置为 (45, 0),这样正好是炮管炮口的位置;
  • 碰撞体:给 CollisionShape2D 节点的shape选择 CircleShape2D,半径设置为 29,与底座大小一致;
  • 计时器:将 Timer 的 One Shot 设为 false,Autostart 设为 false,计时器会负责发射炮弹的冷却时间,我们会通过代码手动启动计时器,

这样,炮塔的节点树就完成了,其结构如下: alt text

实现追踪与发射逻辑

Enemy 根节点增加新脚本 enemy.gd,内容如下:

(1)定义变量:

extends Area2D

# 引用变量
@export var player: Node2D # 外部赋值,定位玩家
@export var bullet_scene: PackedScene # 外部赋值,子弹预制体

@onready var gun: Sprite2D = $Gun
@onready var timer: Timer = $Timer
@onready var marker_2d: Marker2D = $Gun/Marker2D
代码解释:

  • 定义player 属性,我们需要知道玩家的位置,需要存储玩家节点的引用。
  • 定义 bullet_scene 属性,用于存储子弹场景的引用,方便后续实例化。

(2)定时器及追踪逻辑:

func _ready() -> void:
    timer.timeout.connect(on_time_out) # 信号连接
    timer.start(1) # 启动定时器

func _process(delta: float) -> void:
    find_player()

func find_player():
    var player_pos = player.global_position
    # 自动旋转节点,使其X轴正方向指向目标。
    gun.look_at(player_pos)
代码解释:

  • _ready 函数中将定时器的timeout信号连接到on_time_out函数。并用start方法来启动这个定时器,每秒触发一次,也就是每秒发射一次炮弹。
  • _process 函数中调用 find_player 函数,用于找到玩家的位置,并且让炮管指向它。
  • find_player 函数中获取玩家的全局位置,并将其传递给 look_at 函数,让炮管朝向玩家。look_at(target_position) 是 Godot 提供的一个非常实用的函数,用于让节点“朝向”一个目标位置,它的工作原理是:旋转当前节点,使其X轴指向目标位置。

在 Godot 中,position 表示节点相对于父节点的位置,也叫“局部坐标”,而 global_position 表示节点在整个游戏世界中的“全局坐标”。当你在不同节点之间进行位置对齐(比如敌人炮塔追踪玩家),就必须使用 global_position,否则由于节点所处的父子层级不同,坐标参考系不一致,可能会导致朝向错误或偏移异常。相反,如果只是调整某个子节点在父节点内部的位置,比如让炮口稍微偏移一点,就可以使用 position。这两个属性的理解是掌握 2D 场景逻辑的基础。

小知识:Godot 中多数节点(如 Sprite2D, Node2D)的“前方”是X轴正方向。这意味着: 如果你的图片原本是“头朝上”的,那么直接使用 look_at() 会让它“歪头”; 最好确保贴图方向与节点默认方向一致,或者在编辑器中预先调整角度(例如旋转图片素材 90 度);本例中,我们的 Gun 图像本身就是“朝右”的,因此无需额外调整。你也可以通过 print(gun.transform.x) 来观察“前方”的方向向量是否你想要的。

防御式编程提示:在函数find_player中,可以使用if player_pos:先检查player_pos是否不为空。

(3) 发射逻辑

# 用于控制炮弹的发射频率
func on_time_out():
    shoot()
    #  随机化下次射击时间(1-3秒)
    timer.start(randf_range(1,3))

func shoot():
    var bullet = bullet_scene.instantiate() # 实例化一个新的子弹对象
    bullet.global_position = marker_2d.global_position # 确保子弹从炮口发出
    bullet.rotation = gun.rotation # 确保炮弹和炮管方向一致
    bullet.top_level = true # 防止子弹随炮管旋转而偏移
    add_child(bullet) # 将子弹加入当前场景

代码解释:

  • on_time_out 函数负责响应定时器的 timeout 信号,调用 shoot 函数发射子弹。同时再次启动定时器,这里使用了 randf_range 函数来随机发射间隔。随机值在 1 到 3 之间。
  • shoot 函数负责实例化子弹,并将它加入到当前场景中。
  • top_level = true 是关键,它将子弹提升为“世界级节点”,因为在后面add_child调用后,子弹会成为炮塔的子节点。

技术细节:top_level = true 默认情况下,子节点会继承父节点空间属性,包括旋转在内。如果炮管正在旋转,子弹发射后轨迹会像“甩鞭子”一样变弯。开启 top_level 后,炮弹发射后不会受到炮塔的旋转影响,在世界空间独立运动。

除了使用top_level属性之外,还有一种处理方式,在发射后将子弹“移出”父节点。例如可以设置一个静止的关卡节点level,然后通过level.add_child(bullet)来让子弹成为level的子节点,这样子弹发射后就不会受到炮塔的旋转影响了。这种方式我们会在后续章节用到。

添加分组

最后需要给 Enemy 添加一个组名 Enemy,操作如下:

  1. 选中根节点 Enemy
  2. 点击右侧面板 Node > Groups
  3. 添加名为 "Enemy" 的分组名,点击 Add。

这样,我们可以在其它节点中用 is_in_group("Enemy") 快速判断目标是否为敌人对象。这将在碰撞检测与状态管理中起到重要作用。

所有工作完成后,炮塔场景就完成了。但是它现在还不能用于测试,因为它的Bullet Scene还是空值,因为我们还没有为它准备子弹场景。接下来,我们将为它准备专属的子弹场景。

3.2 炮弹场景构建

炮弹场景功能

炮弹或子弹是战斗系统中必不可少的元素。本节我们将为敌方炮塔构建一枚可以发射、移动、碰撞并自动销毁的炮弹。它需要具备以下功能:

  • 在画面中可见,有独立的图像表现;
  • 可以持续朝发射时的朝向方向飞行;
  • 能检测是否击中玩家,并在命中后销毁自身;
  • 如果飞出屏幕范围,也会自动销毁;
  • 拥有一个简单的视觉动效(如闪烁)增强表现力。

场景节点设计

我们将采用 Area2D 作为根节点,整体场景组成如下:

节点类型 重命名 功能说明
Area2D EnemyBullet 负责整体位移、旋转及碰撞信号发射。
Sprite2D Sprite 显示子弹贴图,处理缩放与旋转偏置。
CollisionShape2D Collision 检测与其他对象的碰撞
VisibleOnScreenNotifier2D Notifier 监听子弹是否离开屏幕。
AnimationPlayer Anim 控制子弹的动画。

新建一个场景,使用 Area2D 作为根节点,命名为 EnemyBullet。根据要求给根节点添加子节点,完成后节点树结构如下:

EnemyBullet (Area2D)
├── Sprite2D
├── CollisionShape2D
├── VisibleOnScreenNotifier2D
└── AnimationPlayer

在文件系统中找到Scene文件夹,新建一个enemy_bullet文件夹,将该场景保存在目录下,文件名为 enemy_bullet.tscn

属性设置

  • Sprite2D 添加贴图:将 bulletSand2.png 拖拽到 Texture 属性;并将其Rotation设置为 90 度。因为原始炮弹贴图是朝上的,我们需要将其旋转 90 度,使其与X轴方向一致。
  • 设置碰撞体:
    • 选择 CollisionShape2D,设置形状为 CapsuleShape2D,胶囊形状和炮弹外形比较一致;
    • 设置半径为 4,高度为 14
    • 将其旋转 90 度,使其与子弹方向一致;
  • 设置动画:
    • AnimationPlayer 添加一个新动画,命名为 bullet
    • 动画时长设置为 0.4 秒;
    • 根据 Sprite2Dmodulate 属性,在 0 秒、0.2 秒、0.4 秒设置关键帧;
    • 0s 和 0.4s 设置为白色,0.2s 设置为橙色;
    • 启用循环播放(Loop)与自动播放(Autoplay);

这样,子弹在飞行过程中将会呈现出一种明暗闪烁的效果,提升视觉表现。

alt text 完成设置后的炮弹场景如上图所示。

编写子弹脚本

EnemyBullet 根节点增加一个新脚本 enemy_bullet.gd,内容如下:

extends Area2D

@export var speed:float = 100 # 每秒的移动速度
@onready var visible_on_screen_notifier_2d: VisibleOnScreenNotifier2D = $VisibleOnScreenNotifier2D

func _ready():
    # 信号连接:碰撞与边界检测
    area_entered.connect(on_area_entered)
    visible_on_screen_notifier_2d.screen_exited.connect(on_screen_exited)

func on_area_entered(area:Area2D):
    # 判断碰撞的对象是否属于 "Player" 分组
    if area.is_in_group("Player"): 
        print("hit player")
        queue_free()  # 销毁自身

# 在子弹离开屏幕后销毁自身
func on_screen_exited():
    queue_free()

func _process(delta: float) -> void:
    # 沿着自身的“前方”匀速移动
    position += transform.x * speed * delta

代码解释:

  • _process函数不断修改子弹的position属性,让子弹移动。
  • transform.x 是节点的内置属性,表示当前节点本地X轴的方向向量;
  • 如果节点发生了旋转,那么这个方向向量就会发生变化。由于子弹发射时,本身会根据炮管方向被修改rotation,它的 transform.x也会相应的发生变化;
  • 将它乘以 speeddelta,可以实现匀速直线运动;
  • position 是相对于父节点的位置,因为我们使用了 top_level = true,它的运动方向会保持独立。

理解 transform

在 Godot 中,每个 2D 节点(如 Node2DSprite2DArea2D 等)都拥有一个内置属性 transform,它代表了这个节点的二维空间变换信息,包含了位置、旋转、缩放这三个核心要素。

具体来说,transform 是一个 Transform2D 类型的对象,它由两个方向向量和一个偏移量组成:

  • transform.x 表示节点当前的本地X轴方向向量,也就是它“朝向的方向”;
  • transform.y 表示节点当前的本地Y轴方向向量,一般垂直于 x,表示“向下”;
  • transform.origin 等价于 global_position,是节点在世界坐标中的位置。

“本地”(Local)是相对于节点自身的坐标系来说的,而不是整个游戏世界的坐标系。每个节点在 Godot 中都有自己的“小世界”,它的坐标轴、朝向、位置,都是以它自己为参考点来定义的。这就叫“本地坐标系”或“局部坐标系”。

假设你有一个坦克,它本来的头朝右方(默认方向)的时候:它的“本地 X 轴方向”是 →(右);它的“本地 Y 轴方向”是 ↓(下)。

如果你把它顺时钟旋转了 90 度,让它朝下:它的“本地 X 轴方向”变成了 ↓(下);它的“本地 Y 轴方向”变成了 ←(左)

transform.xtransform.y这两个方向向量是自动根据节点的旋转信息计算出来的。使用 transform.x 可以让对象按照“它当前朝向的方向”移动,而不是固定朝右;如果你的节点发生了旋转,它的 transform.x 也会自动变化,非常适合用来做跟随方向移动、发射、追踪等行为。

理解了 transform 的本质,你就能更加灵活地控制对象在二维空间中的行为。

构造玩家子弹场景

可以用完全相同的方式来构建主角的子弹场景,我们也可以通过复制来完成。

  • 在文件系统找到 enemy_bullet.tscn 文件,右键点击"duplicate",这样得到了一份复制场景文件,重命名为 player_bullet.tscn
  • 同样复制脚本文件,命名为 player_bullet.gd
  • 在场景根节点中,注意要将挂载的代码修改为 player_bullet.gd ;
  • 打开代码,修改碰撞逻辑中的分组判断,将 "Player" 改为 "Enemy"
func on_area_entered(area:Area2D):
    if area.is_in_group("Enemy"):
        print("hit enemy")
        queue_free()

场景复制的进一步讨论

当我们需要两种功能相似的场景时,有两种构建方法,一种是目前我们使用的“场景复制”,另一种是之前我们使用过的“场景继承”。也就是先构建一个基础炮弹场景,然后在此基础上继承出两种不同的炮弹场景。作为开发者,我们需要权衡快速实现与长期维护。这两种方法各有优点和缺点。

方案 优点 缺点
场景复制 简单直观,新手易于理解和操作 产生代码冗余。如果你想修改子弹的飞行逻辑,需要修改两份脚本。
场景继承 逻辑统一,在父场景改代码,所有子弹同步更新。 结构耦合度高,初学者可能在处理特定差异时感到困惑。

另外,还有一种我们没有使用过的方案。就是使用通用子弹场景加脚本参数控制的方法。也就是使用一个 base_bullet.tscn 场景,脚本中通过变量来控制“是玩家子弹还是敌人子弹”。不过这种方法适合于简单场景,扩展性不足。

完成本节后,我们已经准备好了可以发射的子弹。记得要回到Enemy场景,点击Bullet Scene的下拉框,选择quick load,找到敌方子弹的场景,enemy_bullet.tscn,这样Enemy场景就能引用这个场景进行实例化了。

3.3 玩家坦克场景

玩家场景功能

玩家坦克场景 Player是游戏中的主控角色,必须具备响应用户输入的能力,并承担核心玩法体验。本节我们将构建一个玩家可操作的坦克角色,实现以下功能:

  • 接收键盘输入,实现八方向移动;
  • 坦克朝向与移动方向一致;
  • 鼠标控制炮管旋转,瞄准任意方向;
  • 鼠标点击即可发射炮弹;
  • 发射位置准确,方向与炮口一致;
  • 移动带有缓冲感,避免“瞬间转身”或“急停”;
  • 限制坦克不能移出游戏窗口范围。

场景节点设计

和炮塔类似,使用 Area2D 作为根节点,命名为 Player。该场景构成如下:

节点类型 重命名 功能说明
Area2D Player 根节点:承载控制脚本与全身碰撞判定。
Sprite2D Base 坦克底座:随移动方向旋转。
Sprite2D Gun 独立炮管:随鼠标指向旋转瞄准。
Marker2D 发射点:标记炮弹生成的精确坐标。
CollisionShape2D 物理外形:定义被敌方击中的区域。

完成场景中的节点设置后,你的场景节点树应该和下面一致:

Player (Area2D)
├── Base (Sprite2D)
├── Gun (Sprite2D)
│   └── Marker2D
└── CollisionShape2D

设置完成后,保存场景为 player.tscn,放入项目的 player 目录下。

设置贴图与属性

  • Base 贴图:使用 assets/tank/tankBody_bigRed_outline.png
  • Gun 贴图:使用 assets/tank/specialBarrel3_outline.png
  • Gun 的 Offset 设置为 (8, 0),使旋转中心靠近炮尾;
  • Marker2D 的position设置为 (32, 0),正好位于炮口;
  • 设置 CollisionShape2DRectangleShape2D,尺寸为 (54, 46)

alt text 最终结构如上图所示

脚本实现玩家控制逻辑

Player 根节点添加脚本,命名为 player.gd。我们将依次实现方向控制、移动速度平滑处理、炮管瞄准、射击发射、边界限制等逻辑。

(1)定义变量

extends Area2D

var direction: Vector2 = Vector2.ZERO # 用于控制方向
var target_pos : Vector2 # 鼠标指向位置,用于炮管瞄准
var speed: float = 0 # 移动速度

@export var max_speed : float = 300 # 最大速度
@export var bullet_scene: PackedScene # 保存炮弹场景引用
@onready var marker_2d: Marker2D = $Gun/Marker2D
@onready var gun: Sprite2D = $Gun

(2)实现移动与转向

为了让坦克能八方向移动,我们继续在脚本中编写一个简单的移动函数。

func _process(delta):
    simple_move(delta)

func simple_move(delta):
    direction = Input.get_vector("left","right","up","down")
    #print(direction)
    if direction != Vector2.ZERO:
        var angle_rad = direction.angle()
        rotation = angle_rad
        speed = max_speed
    else:
        speed = 0
    position += transform.x * speed * delta
代码解释:

  • 使用Input.get_vector函数处理玩家的输入,它会根据之前设置的键盘动作来计算移动方向,如果玩家按住D,direction会得到向量值(1,0),按住W会得到(0,-1),以此类推。你可以尝试使用print(direction)来观察效果。
  • 如果玩家没有任何输入,direction会得到向量值(0,0),也就是Vector2.ZERO。因此我们在后面增加if条件判断,判断玩家有没有按下任何一个方向键。
  • 如果direction不为零,意味着玩家按下了方向键,此时会根据direction的向量计算一个角度,这个角度是根据direction向量方向和X轴正方向的夹角来计算得到的。将这个角度赋值给坦克的旋转属性rotation。这样坦克的朝向就和direction方向一致了。
  • 接下来,speed设置为最大速度,这样坦克就会以最大速度移动。如果玩家没有按下任何方向键,speed设置为0。
  • 最后,将坦克的位置加上移动方向和移动速度的乘积,来实现坦克的移动。这里transform.x的作用和子弹场景中的作用是一样的,它表示坦克的前进朝向的方向。

将这个函数放到_process函数中,尝试运行当前场景,就可以实现坦克的移动了。

在 Godot 中,Input 是一个内置的全局单例对象,用于检测用户的输入行为(如键盘、鼠标、触屏、手柄等)。它可以在任何脚本中直接使用,无需创建或引用。比如上一章就使用过的Input.is_action_just_pressed。

Input.get_vector() 是 Godot 提供的一个非常实用的函数,用于根据多个方向键的输入组合,自动计算出一个二维方向向量。它常用于控制角色的移动方向,既简洁又直观。函数中的四个参数是我们在 Input Map 中定义的动作名称,例如 "left" 对应 A 键,"up" 对应 W 键等。该函数会根据玩家当前按下的方向键,返回一个 Vector2 向量,表示合成后的移动方向。而且这个函数会自动对方向进行归一化处理(即保持长度为1),所以你可以直接将它作为移动方向来使用,非常适合处理角色的八方向移动或手感灵敏的控制逻辑。

我们通常会在 _process(delta) 函数中使用 Input 的方法,这是因为 _process 是每帧调用,它能持续检测用户的输入状态,从而实现实时控制。相比 _input(event) 等事件方式,Input 单例更适合持续响应式的控制逻辑,例如“玩家按住方向键时持续前进”。

(3)优化移动与转向

但是,目前的移动控制的感觉不是非常好,因为不论是控制转向还是控制加速都非常的突然,例如当玩家放开方向键时,坦克会“立刻停止”。真实世界的物体移动通常是平滑的,带有一点惯性的,所以我们来修改移动函数,让坦克的运动控制更加平滑。为此我们使用 move_toward()rotate_toward() 两个内置的线性插值函数。新建一个move函数,代码如下:

func move(delta):
    direction = Input.get_vector("left","right","up","down")
    #print(direction)
    if direction != Vector2.ZERO:
        var angle_rad = direction.angle()
        rotation = rotate_toward(rotation,angle_rad,2*PI*delta )
        speed = move_toward(speed, max_speed, max_speed*delta)
    else:
        speed = move_toward(speed, 0, 2*max_speed*delta)
    position += transform.x * speed * delta
代码解释:

  • 代码修改了speed变量的逻辑,在之前的simple_move函数中,speed直接赋值为max_speed,而在move函数中,使用move_toward函数进行平滑处理,在每次执行这一行代码的时候,move_toward会从speed起始值出发,向max_speed的方向,增加一定量的大小,这个量就是max_speeddelta乘积。
  • 如果游戏以每秒60帧的速度运行,delta值为0.016。假设坦克初始速度为0,最大速度为300,那么第一次执行move_toward函数,它的返回值就是\((0+0.016\times300)\),就是48个像素单位,这样开始移动的第一帧,坦克就会以48个像素单位的速度移动。第二次执行move_toward函数时,speed的起始值是48,move_toward的返回值就是\((48+0.016\times300)\),就是96个像素单位,开始移动的第二帧,坦克会以96个像素单位的速度移动。这样坦克的加速是平滑的,不会突然变快或变慢。
  • 类似的,当玩家没有按下任何方向键时,也使用move_toward函数进行平滑处理,让坦克的速度慢慢降为0
  • rotate_toward函数是类似的功能,不过它的平滑对象是角度值,这里是让坦克的转向更加平滑。
  • 这里设置的数值是让坦克在1秒钟后可以达到全速,0.5秒后可以从全速降为0.1秒钟内可以旋转360度。你也可以尝试其它的数值。

修改了这几个地方后,我们再来运行当前场景,会发现坦克的控制会有一种惯性的真实感觉。

(4) 限制移动范围

现在的场景中,坦克会跑出屏幕外面去,我们可以给它加一个边界检测,在脚本中添加如下代码:

func check_border():
    var size = get_viewport_rect().size
    position = position.clamp(Vector2.ZERO, size)
代码解释:

  • get_viewport_rect是 Godot 中的一个内置函数,用于获取当前窗口的大小和位置信息,它返回的是一个 Rect2 对象,包含屏幕左上角的位置和尺寸。size属性就是屏幕的尺寸,它是一个Vector2对象。这个函数让我们能够动态获取当前窗口的大小,因此即使窗口尺寸在不同设备上变化,也能适配处理。
  • clamp是一个非常有用的函数,用于限制一个值在给定范围内。当用于 Vector2 时,它的作用是:把当前的坐标值限制在一个矩形区域内部,超出范围就自动“贴边”。
  • position.clamp的作用是,将玩家的坐标位置限制在 (0, 0) 到 (窗口宽度, 窗口高度) 之间,防止跑出视野之外。

check_border函数加到move函数的最后一行。这样就可以保证坦克不会跑出屏幕了。

(5) 实现炮管旋转与子弹发射

我们需要让炮管实时朝向鼠标位置,当点击鼠标左键时从 Marker2D位置发射炮弹。继续在脚本中添加如下代码:

func target():
    target_pos = get_global_mouse_position()
    gun.look_at(target_pos)

func shoot():
    if Input.is_action_just_pressed("shoot"):
        var bullet = bullet_scene.instantiate()
        bullet.global_position = marker_2d.global_position
        bullet.look_at(target_pos)
        bullet.top_level = true
        add_child(bullet)
代码解释:

  • target函数负责让坦克炮管旋转指向目标位置;其中的get_global_mouse_position函数可以获取鼠标在世界坐标中的位置;而look_at函数会让炮管旋转指向目标;
  • shoot函数负责在玩家点击鼠标左键的时候发射子弹。首先判断玩家是否点击了鼠标左键,满足条件后,会实例化子弹场景,然后让子弹指向目标位置,让子弹成为顶层节点,最后添加到当前场景中。此处的逻辑和敌方炮台发射抛弹的逻辑是一样的。

(6) 整合函数

在_process函数中,调用之前编写的三个函数,让坦克移动,让炮管指向目标位置,并让坦克射击。注意delta参数被传递给了move函数。

func _process(delta: float) -> void:
    move(delta)
    target()
    shoot()

回到Player根节点,在右侧的检查器面板可以看到Max Speed和Bullet Scene这两个变量,点击Bullet Scene的下拉框,选择quick load,找到我们之前构建的玩家子弹场景,player_bullet.tscn,这样Player场景就能引用这个场景进行实例化了。

然后,点击检查器面板中的Node标签,将它的Group设置为Player。这样可以让敌方子弹在碰撞检测可以识别到这个坦克。

尝试运行 Player 场景,你应该可以:

  • 使用键盘控制坦克前进后退;
  • 使用鼠标控制炮管旋转;
  • 点击鼠标左键发射子弹。

完成这一节后,我们就拥有了一个完整的玩家控制角色,具备了运动感、攻击力与操作反馈。下一节我们将构建关卡地图,让双方角色在一个真实的战场中交锋!

3.4 地图场景与TileMap绘制

什么是地图

一个完整的战斗场景离不开地图的支撑。本节我们将使用 Godot 提供的 TileMapTileSet 功能,绘制一片战斗场地,为玩家和敌人提供活动空间,这种活动空间被称为“地图”或“关卡”。这两个概念经常混用,不过含义略有不同。地图侧重于静态环境。它是游戏中一块区域的地形、背景、瓦片拼图、场景装饰等,通常由美术和布局构成。它就像是“画布”,构成了游戏世界的外形和路径。

关卡更强调的是动态逻辑。它包括了当前的游戏目标与进程安排,它是一个设计单位,可能包含地图本身、敌人配置、胜利条件、出场顺序、剧情触发等完整的游戏逻辑。你可以把关卡看作是“一局游戏”或“一次挑战”。通常可以这样理解:关卡 = 地图 + 玩法逻辑。

本章我们第一次构建的地图非常简单,就是将一个矩形的草地铺满整个窗口;我们会在后续章节中绘制不同类型的地图。

什么是 TileMap 和 TileSet?

在第二章中我们介绍过瓦片地图,瓦片地图的基本思想是:将游戏世界或场景划分为一块块小图片(称为瓦片,Tile),这些瓦片通常是固定大小的图像块。然后通过拼接这些图块来组成完整的游戏场景。这样游戏可以高效地表示和渲染复杂的场景。形象的讲,构建2D网格场景就像是我们为了装修房间而铺地板砖的过程一样。Godot 提供了两种强大工具来完成这个工作:

  • TileSet:第一步是定义你的地砖仓库。它定义了哪些图片可以作为地砖,以及这些地砖的大小、物理属性等。,也就是定义TileSet。在TileSet标签页中,我们将一整块大的图片资源放进去后,会自动帮我们切成不同的Tile,也就是地砖。
  • TileMap:第二步就是开始施工,为房间铺设地砖,也就是定义TileMap。你从仓库(TileSet)里挑选合适的地砖,然后在网格画布上进行刷涂、填充。

先定义砖,再铺地图。它们的配合可以大大提升地图编辑效率。

场景节点设置

在 Godot 4 中,推荐使用 TileMapLayer 节点,它比旧版的 TileMap 更加轻量且易于分层管理。

新建一个场景,根节点设置为TileMapLayer。将它重命名为 Map。将该场景保存为 map.tscn,放置到项目的 scenes/map 文件夹中。之后按如下步骤来设置TileSet

  1. 选中 Map 节点,在右侧属性面板中找到 Tile Set;
  2. 点击右侧下拉按钮,选择 New TileSet,表示新建一个瓦片库;
  3. 然后点击该新创建的 TileSet,进入瓦片编辑模式,将Tile Size属性,修改为(64,64)的大小,每一个图块是 64x64 像素,正好与素材匹配;
  4. 在底部操作面板中看到两个标签,分别是TileSet操作区和TileMap操作区,点击TileSet标签页,将文件系统中的“map\terrainTiles_default.png"文件拖到左侧空白区域,在弹出对话框中选择Yes。这样创建完成了TileSet。

操作完成后,TileSet面板如图所示,你会看到图块库已经被系统自动分割成多个图块。 alt text

使用 TileMap 绘图

用TileMap绘制关卡就像是画画一样,先定义好颜料盘,也就是你的TileSet,然后在 TileMap中,选择你的绘制工具,选择颜料,再去画布上的位置落笔。本章的关卡比较简单,我们只需要一大块草地就可以了。

  1. 点击编辑器底部操作面板的 TileMap 标签;
  2. 选择箭头右侧的画笔工具,像铅笔的图标。鼠标放上去会提示paint;
  3. 点击图块库左上角的那块草地图块;
  4. 在主窗口中直接点击即可绘制地图。为了高效绘制,可以使用矩形绘制工具,一次性填充大片区域;
  5. 选择第四个矩形工具,在主窗口中尝试框选一块区域,快速将窗口区域铺满草地。

完成后,地图如下图所示: alt text

完成后尝试运行当前的地图场景,观察窗口中草地是否铺满了。地图草地面积尽量画大一些,就算是超过窗口的大小也没关系。

目前我们仅使用了草地图块,但在后续我们还会引入石头、树木、道路等不同类型的地形,通过多个不同的TileMapLayer节点,来分层绘制实现遮挡、阻挡等机制。下一节,我们将把地图、玩家、敌人等组合在一起,构建出完整的游戏主场景。

3.5 构建游戏主场景

到目前为止,我们已经分别完成了地图、玩家、敌人和子弹等核心模块的制作。接下来,我们将创建一个“主场景”,把这些功能模块组合在一起,让游戏具备完整的运行逻辑。

新建一个场景,使用 Node2D 作为根节点,命名为 Game。这是我们整个游戏的主容器。

接下来,在根节点下使用“实例化子场景”按钮依次添加如下场景实例:

  1. 在根节点下添加之前创建的 map.tscn 场景,作为地图背景;
  2. 添加 player.tscn 场景,代表玩家控制的坦克;
  3. 添加 enemy.tscn 场景,代表固定炮塔敌人;
  4. 选择上一步添加的Enemy节点,在属性面板中观察到Player属性还是空值,点击Assign按键,选择Player节点。这样敌方炮塔就会知道玩家的位置。
  5. 将敌人场景复制一份,让关卡中出现多个敌人炮塔。
  6. 选择移动工具,拖动各个节点到合适的位置
    • Player 放在屏幕中央
    • 将两个敌人炮塔移动到地图的不同地方。
  7. 最后,将 Game 场景保存为 game.tscn,从项目设置菜单中进行设置(Project → Project Settings → Application → Run → Main Scene)。将其设置为项目主运行场景。

完成后的主场景布局如下,展示了玩家与敌人分布在同一地图下的实际效果:

alt text

此时,点击运行游戏,你将看到:

  • 一整块铺满草地的地图;
  • 玩家坦克可自由移动并发射子弹;
  • 敌人炮塔自动追踪并攻击玩家;
  • 子弹可以击中目标,触发碰撞响应,可以在Output窗口中看到打印出的信息。

通过构建 Game 主场景,我们完成了整个基础游戏结构的拼装,使地图、角色与战斗机制首次连贯地运行起来。这种模块组合的方式是 Godot 项目开发的典型流程,也为后续添加用户界面、音效、动画、得分机制等系统打下了坚实基础。

4. 游戏功能拓展与完善

目前,《Battle Tank》已经具备了游戏的基础框架:玩家可以移动与射击,敌人会自动攻击,地图与场景结构也初步搭建完成。但这还只是一个“能跑起来的原型”,离一个完整、有反馈、有胜负机制的游戏还有不小差距。

本章后续的开发将聚焦于以下五个维度:

  • 数值系统:实现血量计算与受击判定。
  • 逻辑闭环:确立游戏的胜负裁定条件。
  • 数据中枢:实时记录分数并同步状态。
  • 表现层级:集成爆炸动画与战斗音效。
  • 交互层级:构建动态 HUD(抬头显示)界面。

为了协调这些复杂的系统,我们首先要引入一个核心架构:全局管理器(GameManager)。它将作为游戏中的“控制中心”,负责状态管理、信号转发和数据协调,是多个系统模块之间沟通的桥梁。

4.1 全局管理器 GameManager

为什么需要全局管理器?

在解耦的场景架构中,不同模块往往“互不相识”。例如:

  • 敌人(Enemy) 并不直接认识 UI 界面,但它死亡时必须通知 UI 增加分数。
  • 玩家(Player) 并不认识 关卡管理器,但它阵亡时必须通知系统显示“游戏结束”。

如果让这些模块直接交叉引用,会形成混乱的“蜘蛛网代码”,难以维护。因此,我们引入 GameManager 作为一个专门用于协调全局的总导演。它会作为Autoload的单例形式存在,并提供以下功能:

  • 记录全局数据(如得分、敌人总数);
  • 管理游戏流程状态(胜利 / 失败);
  • 统一发出信号,通知其他场景响应事件;
  • 保持游戏结构清晰,降低模块之间的依赖性。

创建 GameManager 脚本

在文件系统中增加一个manager目录,在目录中点右键,新增一个 scripts 脚本,命名为 gamemanager.gd。编写代码如下:

extends Node

# --- 信号总线 (Signal Bus) ---
signal enemy_killed(pos: Vector2)  # 敌人阵亡,传递坐标用于生成爆炸特效
signal update_health_ui(health: int)  # 通知 UI 更新血量条
signal player_killed               # 玩家阵亡,触发失败逻辑
signal player_win                  # 达成条件,触发胜利逻辑

var score: int = 0 # 获得分值
var enemy_size: int = 3 # 本关敌人总数

func _ready():
    # 敌人阵亡信号连接
    enemy_killed.connect(on_enemy_killed)

func on_enemy_killed(pos):
    score += 1
    if score == enemy_size:
        player_win.emit()
代码解释:on_enemy_killed函数负责响应处理敌人被击杀的情况。将得分加1,然后判断分值是否等于敌人数量,如果条件满足,说明所有敌人被击杀,发出player_win信号。

代码完成后,在项目设置中(Project → Project Settings → AutoLoad),将这个节点设置为自动加载(AutoLoad),名称为 Gamemanager。这样我们在任何脚本中都可以直接使用 Gamemanager 访问其变量和方法。

理解信号集线器 (Signal Hub)

在实际开发中,我们通常有两种组织信号的方式: - 一种是每个节点只定义与自己有关的信号,并在适当时机发出(emit)。这种方式适合于有直接关联的两个模块之间通信。例如父节点和子节点之间的通信。因为它们可以方便的访问彼此,建立信号连接。 - 另一种是将所有重要的信号统一集中到一个全局对象中(如 Gamemanager)。这种设计称为 Signal Hub(信号集线器),类似“广播中心”。这种方式适合于跨模块通信。例如玩家死亡是一个关键游戏状态事件,需要多个模块响应。而这些模块比较分散,无法方便的访问彼此。因此放在全局管理器中,可以统一管理,方便访问和信号连接。

通过这样的全局管理器设计,我们将复杂的游戏状态管理从各个子场景中“解耦”出来,集中处理,使得代码结构更清晰、扩展更方便、维护更高效。在接下来的章节中,我们会让敌人、玩家、用户界面等模块都通过 Gamemanager 来收发信息,实现更完整的游戏流程控制。

4.2 抬头显示界面 HUD

在一个完整的游戏中,HUD(Head-Up Display) 是游戏与玩家沟通的桥梁。它不仅实时同步战斗状态(血量、得分),还要在生死转折点给予明确的视觉反馈。

我们将在本节中构建一个简洁但功能完善的 HUD界面,具备以下功能:

  • 实时战报:显示当前击杀敌人数;
  • 状态监控:动态血量条显示;
  • 游戏结算:胜负状态的延时弹出提醒。

构建完成后的用户界面如下图所示: alt text

场景结构设计

新建一个场景,根节点使用 Control,命名为 HUD(这是 Godot 中所有 UI 元素的基础节点)。设置它的 Anchor Preset 为 Full Rect,以铺满整个窗口。

在根节点下添加多个子节点,组织结构如下:

HUD (Control)
├── MarginContainer
│   └── HBoxContainer
│       ├── PanelContainer (左侧面板)
│       │   └── Label ("Killed: 0")
│       └── PanelContainer (右侧面板)
│           └── HBoxContainer
│               ├── Label ("Health:")
│               └── ProgressBar (血量条)
├── PanelContainer (居中面板)
│   └── Label ("You Win")
└── Timer
场景中各节点的功能说明:

  • MarginContainer:用于设置四周边距,保证界面不贴边;
  • HBoxContainer:水平布局容器,用于放置左右两块信息面板;
  • PanelContainer:面板容器,用于放置信息及背景。面板容器可以让信息更整洁清晰,这里我们使用了三种面板,左侧面板显示击杀数量,右侧面板显示血量,居中面板用于显示胜利或失败提示;
  • Label:文本标签,用于显示信息;
  • ProgressBar:进度条,用于显示血量;
  • Timer:用于延迟显示提示,避免信息过快切换。

保存场景为 hud.tscn,放入 Scenes/UI文件夹中。

设置属性

  • MarginContainer节点设置,找到Theme Overrides,将四个边距都设置为20。这样容器中的子节点就不会贴边。
  • 设置MarginContainer的子节点HBoxContainer属性,找到Container Sizing属性,将vertical设置为Shrink Begin。它会缩到屏幕上方位置。
  • 设置HBoxContainer节点下的两个子节点PanelContainer属性,左侧面板的Horizontal设置为Shink Begin,右侧面板的Horizontal设置为Shink End,这样左侧面板窗口会缩到屏幕左侧,右侧面板窗口会缩到屏幕右侧。
  • 将左侧面板窗口的Custom Minimum Size设置为(200,50),右侧面板窗口的Custom Minimum Size设置为(500,50)。因为右侧面板是血量条,所以需要更大的屏幕空间。
  • 设置左侧面板下的Label节点,设置其text为"Killed:0"。后续会在代码是进行更新。
  • 右侧面板下包括了HBoxContainer容器节点,该节点下包括了Label和ProgressBar节点。设置Label的text为"Health:"。这是血量条的标题。
  • 设置ProgressBar节点,设置step为1,这是血量变化时的步长;value为100,表示初始血量为满格;Custom Minimum Size为(400,20),Horizontal设置为Fill,可以让血条在布局容器中自动填满可用空间。
  • 设置居中面板,Custom Minimum Size设置为(400,100),Horizontal设置为Shrink Center,Vertical设置为Shrink Center。这样它会位于屏幕的中央位置。
  • 设置居中面板下的Label节点,将其text属性设置为"You Win",设置其字体大小为20。
  • 然后居中面板节点设置为不可见。后续我们会在代码中来控制显示和隐藏。
  • Timer 节点设置,将One Shot设置为true,它就只会使用一次定时器,而不会循环使用。。

添加代码脚本

为 HUD 根节点添加新脚本 hud.gd,代码如下:

(1)变量定义

extends Control

@onready var label: Label = $MarginContainer/HBoxContainer/PanelContainer/Label
@onready var progress_bar: ProgressBar = $MarginContainer/HBoxContainer/PanelContainer2/HBoxContainer/ProgressBar
@onready var timer: Timer = $Timer
@onready var panel_container_3: PanelContainer = $MarginContainer/PanelContainer3
@onready var result: Label = $MarginContainer/PanelContainer3/Label

(2)信号连接

func _ready() -> void:
    Gamemanager.enemy_killed.connect(on_enemy_killed)
    Gamemanager.update_health_ui.connect(on_update_health_ui)
    Gamemanager.player_killed.connect(on_player_killed)
    Gamemanager.player_win.connect(on_player_win)

(3)更新击杀数信息

func on_enemy_killed(pos):
    label.text = "Killed: %s" %str(Gamemanager.score)
代码解释:on_enemy_killed函数负责当敌人被击杀时进行响应,Gamemanager中score值会更新,用它来更新label中的text属性,让玩家知道当前击杀了多少个敌人。这里使用了字符串格式化方法。

(4)更新血量条

func on_update_health_ui(health:int):
    var value = 100 * health/10
    progress_bar.value = value
代码解释:on_update_health_ui函数负责当玩家的血量发生变化时进行响应,信号会带一个health参数,用它来更新ProgressBar中的value属性,让玩家知道自己当前的血量。

(5)玩家被击杀时响应

func on_player_killed():
    timer.start()
    await timer.timeout
    result.text = "You Lost"
    panel_container_3.show()
代码解释:

  • on_player_killed函数负责当玩家被击杀时进行响应,定时器会启动
  • 在延迟一段时间后,让HUD显示"You Lost"。此处的await关键字后面跟一个信号,表示程序会等待这个信号触发后再执行后面的代码。
  • panel_container_3.show()用于让胜负提示框从隐藏变为可见

(6)玩家胜利逻辑

func on_player_win():
    timer.start()
    await timer.timeout
    result.text = "You Win"
    panel_container_3.show()
代码解释:on_player_win函数负责当玩家胜利时进行响应,定时器会启动,在延迟一段时间后,让HUD显示"You Win"。

通过构建 HUD 界面,我们为游戏添加了清晰的视觉反馈系统。它不仅增强了游戏的信息表达,还通过与 Gamemanager 的配合,实现了不同模块之间的松耦合联动。

4.3 增加血量与死亡逻辑

血量机制是大多数游戏中不可或缺的机制。它不仅决定角色是否存活,还能带来受伤反馈、增加紧张感,并通过与 UI 的联动展示当前状态。在本节中,我们将为玩家和敌人加入基础的血量机制:

  • 多级承受能力:敌人不再是一触即死,而是拥有多点生命值。
  • 数据实时同步:玩家受击后,UI 血条会精准扣减。
  • 状态强制终止:一旦一方阵亡,该角色的控制逻辑将被立即切断。

(1)敌方血量管理

我们需要为炮塔添加生命属性和“受伤”接口。打开脚本 enemy.gd,修改代码如下:

@export var health: int = 3 # 初始血量

# 负责扣减敌人的血量
func reduce_health():
    # 当血量大于 0 时,每次减少1点
    if health > 0: 
        health -= 1
    # 若血量归零或以下,则触发 `enemy_killed` 信号并销毁自身
    if health <= 0:
        Gamemanager.enemy_killed.emit(global_position)
        queue_free()

(2) 简化玩家引用方式

之前敌人场景代码中是通过 @export var player 和手动定义玩家节点,这种方式在敌人很多时较繁琐。可以改用组机制自动获取玩家引用,修改敌人代码如下:

@onready var player = get_tree().get_first_node_in_group("Player")
代码解释:

  • get_tree函数返回的是当前运行中的场景树对象,也就是 SceneTree 实例。在之前Godot运行机制详解中已经介绍过,场景树(SceneTree)是引擎在运行时维护的节点层级结构的总入口。
  • get_first_node_in_group函数会从整棵场景树中搜索第一个被标记为 "Player" 组的节点,并返回这个节点。
  • 通过这种方式,可以在任何脚本中灵活访问其他模块,而不用担心路径、层级或加载顺序的问题。

为什么用 get_first_node_in_group? 传统的 @export 引用在面对动态生成的敌人时非常麻烦。通过 SceneTree(场景树)的组搜索功能,敌人可以“自主发现”玩家。无论玩家在哪、层级如何变化,只要它在 "Player" 组,敌人就能瞬间锁定它。

(3) 修改炮塔开火逻辑

当玩家被击杀时,敌人炮塔就不再需要开火了。修改炮塔开火逻辑如下:

func _ready() -> void:

    timer.timeout.connect(on_time_out) # 连接信号
    timer.start(1) # 启动定时器,每秒执行一次
    Gamemanager.player_killed.connect(on_player_killed) # 连接信号

func on_player_killed():
    timer.stop() # 停止定时器,这样敌人炮塔就不会再开火

(4) 玩家血量机制

玩家不仅要扣血,还要负责通知 UI 更新。打开 player.gd,添加如下变量与函数:

var health: int = 10 # 初始血量为10点

# 负责扣减玩家的血量
func reduce_health():
    if health > 0:
        health -= 1 # 每次受伤血量减1点
        # 通过 `Gamemanager` 通知 HUD 更新血条
        Gamemanager.update_health_ui.emit(health) # 
    if health <= 0:
        # 若血量归零,发出 `player_killed` 信号
        Gamemanager.player_killed.emit()
这样,若玩家血量归零,发出 player_killed 信号,后续会被 HUD 接收,显示失败提示。为了防止死亡后仍可操作,添加如下处理:

func _ready():
    Gamemanager.player_killed.connect(on_player_killed)

func on_player_killed():
    set_process(false)
代码解释:on_player_killed函数中使用 set_process(false),也就是让玩家节点的 _process() 函数停止工作。这样当玩家被击杀后,玩家就无法继续操作移动或攻击。

(5)修改子弹的碰撞逻辑

分别打开敌人子弹与玩家子弹的脚本,调整碰撞响应,使其调用被击中目标的 reduce_health() 方法。打开enemy_bullet.gd文件,修改代码如下:

func on_area_entered(area:Area2D):
    if area.is_in_group("Player"):
        area.reduce_health()
        queue_free()
代码解释:

  • 函数中的area参数是子弹检测到的碰撞节点
  • 检测到碰撞节点判断是玩家时,就会调用玩家的reduce_health函数,让玩家的血量减少一个单位

打开player_bullet.gd文件,修改代码如下:

func on_area_entered(area: Area2D):
    if area.is_in_group("Enemy"):
        area.reduce_health()
        queue_free()
代码逻辑和上述相同,只是检测到的碰撞节点判断是敌人时,就会调用敌人的reduce_health函数,让敌人的血量减少一个单位。

防御式编程提示:在调用 reduce_health() 之前,可以使用has_method()先检查目标是否有这个函数。这样即使未来你加入了“石头”等不可被伤害的物体,程序也不会因为找不到函数而报错崩溃。

通过本节的实现,我们为游戏中的玩家和敌人都加入了基础的血量管理机制,并通过 Gamemanager 信号联动 HUD,实现了状态可视化反馈。当一枚敌人子弹飞向玩家时,发生了以下连锁反应:

graph TD
    %% 节点定义
    A[子弹信号: area_entered] --> B{逻辑核实: <br/>是否属于 Player 组?}

    %% 判定流程
    B -- 是 --> C[接口调用: <br/>执行 player.reduce_health]
    B -- 否 --> D[忽略碰撞]

    %% 连锁反应
    C --> E[数据广播: <br/>Gamemanager 发出 update_health_ui 信号]
    E --> F[视觉更新: <br/>HUD 接收信号并缩短血条]

    %% 子弹销毁
    C --> G[资源管理: <br/>子弹执行 queue_free 销毁]

5. 动画与音效增强体验

在前面的章节中,我们已经实现了游戏的基本玩法:移动、射击、击杀敌人、生命系统、UI等,但如果运行游戏,会发现它“很安静”,没有任何动态视觉效果或声音反馈。

这种状态虽然功能完备,但缺乏表现力,也无法给玩家带来真正的沉浸感。动画和音效的加入,能极大地提升游戏的打击感、节奏感和氛围感,是游戏体验中不可或缺的部分。

我们将从两个方面增强游戏的表现力:

  • 视觉特效(VFX):实现敌人阵亡时的多级爆炸效果。
  • 音频反馈(SFX):加入射击瞬时音效与坦克持续引擎声。

5.1 爆炸动画管理器 VFXManager

当敌人被击败时,我们希望播放一个爆炸动画,初学者可能最直接的想法是“在敌人节点里添加一个动画”。但这样做存在如下问题:

  • 敌人一被销毁,对应的节点就被从场景树中移除;
  • 如果动画还没播放完,就会被强行中断;
  • 如果等待动画播放,就需要采用延迟销毁的方法,这会增加代码的额外复杂性。

因此,更合理的做法是将动画与敌人解耦,在外部单独播放。这正是我们引入 VFXManager(视觉特效管理器)的原因。

Godot 支持使用 AnimatedSprite2D 播放帧动画,本书提供了三组爆炸动画帧图片,位于项目的 assets/VFX/ 文件夹中。目录中有三个文件夹分别是:sonic_explosionregular_explosionexplosion。每个目录下都有一组帧图片,用于播放不同的爆炸动画。创建爆炸动画的方法和第五章时小鸟动画类似,步骤如下:

步骤一:创建爆炸动画场景

  1. 新建一个场景,根节点选择 AnimatedSprite2D;
  2. 在右侧属性面板,点击 SpriteFramesNew SpriteFrames
  3. 在底部的动画资源编辑器中(SpriteFrames),将explosion目录下所有的帧图片拖入帧列表。
  4. 设置帧速率为 10fps,设置audoplay为false,设置loop为false。
  5. 默认的动画名称是 default,修改动画名为 explosion

底部的动画资源编辑器中,点击add animation,可以新建动画。用同样的方法,将另外两个目录中的帧图片拖入帧列表,分别命名为 sonic_expregular_exp。这样节点中就保存了三组爆炸动画。保存场景为:vfx/exp_anim.tscnalt text 完成创建后的动画如上图所示

步骤二:创建动画管理器 VFXManager

  1. 新建一个场景,根节点选择 Node2D,命名为 VFXManager;
  2. 添加一个子节点 AudioStreamPlayer
  3. 在文件系统中将 sound/Explosion.wav 拖入其 Stream 属性中;
  4. 将此场景保存为 manager/vfx_manager.tscn

步骤三:编写 VFXManager 脚本

VFXManager 根节点添加脚本 vfx_manager.gd,实现爆炸动画的播放逻辑:

extends Node2D

@export var exp_anim_scene: PackedScene  # 爆炸动画的打包场景
@onready var audio_stream_player: AudioStreamPlayer = $AudioStreamPlayer


func _ready():
    # 监听“敌人被击杀”信号
    Gamemanager.enemy_killed.connect(explode)

# 负责播放爆炸动画和爆炸音效
func explode(pos: Vector2):
    var exp_anim = exp_anim_scene.instantiate() # 实例化爆炸动画场景
    exp_anim.global_position = pos # 需要爆炸的坐标传参
    add_child(exp_anim) # 添加到场景树
    exp_anim.play("explosion") # 播放名为"explosion"的动画资源
    audio_stream_player.play() # 播放音效资源
    await exp_anim.animation_finished # 等待动画播放完成
    exp_anim.queue_free() # 删除动画场景

我们会在后续把VFXManager场景加到主场景中,这样就可以在主场景中播放爆炸动画了。

5.2 坦克动作与音效反馈

视觉反馈让玩家看得爽,但听觉反馈才是让操作“有游戏感”的关键。试想一下:当你按下射击键,却没有“砰”的声音;坦克滑行在地图上,却没有任何引擎声;这样的体验会显得干巴巴、缺乏冲击力。在本节中,我们将为玩家坦克添加两个重要的声音反馈:

  • 射击音效:每次发射子弹时播放,增强打击感;
  • 引擎背景音:游戏开始后自动播放,带来持续的存在感和运动氛围。

步骤一:设置音效资源

游戏资源中提供了两个音效文件:

  • sound/shoot.wav:用于射击时播放的音效文件;
  • sound/engine.ogg:用于循环播放的引擎背景音。

因为音效文件需要循环播放,所以需要做一些额外设置。双击文件系统中的sound/engine.ogg,会弹出一个导入设置。勾选Loop等于Enable,再点击Reimport即可。

步骤二:为 Player 添加音效节点

  1. 打开玩家场景 player.tscn
  2. 添加两个 AudioStreamPlayer 子节点,重命名为 ShootSoundEngineSound
  3. 分别将 sound/shoot.wavsound/engine.ogg 拖入各自的 Stream 属性;
  4. 设置 EngineSound 的属性:
    • Autoplay = true 开启自动播放;
    • VolumeDb = -10(可选调节,避免音量过大);

步骤三:修改脚本播放音效

打开 player.gd,添加代码如下:

# 音效节点的引用变量
@onready var shoot_sound: AudioStreamPlayer = $ShootSound

func shoot():
    if Input.is_action_just_pressed("shoot"):
        shoot_sound.play() # 如果按下了射击键,就播放音效
        var bullet = bullet_scene.instantiate()
        bullet.global_position = marker_2d.global_position
        bullet.look_at(target_pos)
        bullet.top_level = true
        add_child(bullet)

代码中不需要额外控制引擎声的播放,它会在游戏开始时自动播放。

现在我们只是添加了最基础的两种音效。在后续开发中,你还可以为以下各种行为添加声音:

  • 玩家受到打击(播放金属撞击声);
  • 子弹命中目标;
  • 获得补给或升级;
  • 游戏胜利或失败提示音。

通过为玩家坦克添加射击音效和引擎背景声,我们成功地为游戏注入了“声音的生命”。这样的反馈不仅提升操作快感,也让游戏显得更真实、专业。

6. 整合与运行测试

经过前几章的开发,我们已经拥有了地图、坦克、炮弹、UI 和特效。现在,我们要像组装精密机器一样,将这些零部件装配进主场景(Main Scene),并进行最后的联调与质量检测。

6.1 游戏主场景最终构建

打开我们的主场景 game.tscn,完成以下整合操作:

步骤一:添加界面 HUD

  • 在主场景中添加一个 CanvasLayer 节点,命名为 UI。
  • 将用户界面场景 hud.tscn 实例化为子节点;
  • CanvasLayer会让用户界面显示在窗口最上层,始终可见。

步骤二:设置敌方布防

  • 在主场景中添加一个 Node2D 节点,命名为 Enemies,用于集中管理场景中的敌方炮塔;
  • 在这个节点下添加三个敌方炮塔子节点,你可以将原有的敌人移进来,也可以重新实例化敌人场景。
  • 使用移动工具,将它们分别布置到地图不同位置;
  • 每个敌人节点的 bullet_scene 属性需设置为 enemy_bullet.tscn,以便它们发射炮弹。
  • 检查Enemy中的Group设置,是否已经设置为"Enemy"标签。

步骤三:添加 VFXManager 节点

  • 在根节点下新增VFSManager场景实例;
  • 在 VFSManager 的右侧属性面板中,将 exp_anim_scene 赋值为 exp_anim.tscn
  • 它会负责监听全局信号,在敌人死亡时播放爆炸动画与音效。

步骤四:检查玩家坦克设置

  • 确保玩家节点的 bullet_scene 引用为 player_bullet.tscn;
  • 检查其已加入 "Player" 分组,以便敌人识别;
  • 将玩家节点放置在地图中心位置,便于测试操作。

步骤五:设置游戏入口场景

  • 进入菜单:Project > Project Settings > Application > Run;
  • 设置 Main Scene 为当前主场景 game.tscn;
  • 这样点击运行时会从主场景启动。

最后保存game.tscn文件。完整的主场景布局如下图所示: alt text

地图显示在最底层,界面显示在最上层。而敌人和玩家坦克在中间层显示。

6.2 游戏运行测试

点击运行按钮后,游戏开始运行。下图是游戏运行画面。 alt text

请对照下表进行功能验收。若某项不符,请参考 6.3 节进行调试。

测试维度 验证指标 状态
操控感 坦克是否具备加速/减速惯性?炮管是否平滑指向鼠标?
战斗逻辑 敌人是否在玩家进入视野后自动开火?双方子弹是否能互相扣血?
视听反馈 射击是否有声音?敌人爆炸时是否有火光和爆炸声?
数值同步 击杀敌人后,HUD 左上角的计分是否增加?玩家血条是否随受击缩短?
胜负闭环 击杀全场 3 个敌人后是否弹出 "You Win"?玩家阵亡是否弹出 "You Lost"?

6.3 游戏调试方法

在开发过程中,调试是不可避免的步骤。当程序“看起来没反应”或者“运行不符合预期”时,尝试以下“调试三板斧”:

使用 “远程” 场景树

运行游戏后,Godot 编辑器的场景树面板会出现两个标签,分别是Remote和Local,Remote会显示游戏运行时的真实节点结构。“远程” 场景树可用于:

  • 查看场景是否正确加载;
  • 检查节点是否在运行时被创建、销毁;
  • 实时查看和修改变量的值(如生命值、位置等);

使用 print() 打印调试信息

最简单直接的方式,在怀疑出问题的代码行插入print()

print("Player Health:", health)
print("Bullet fired at:", bullet.global_position)

用print函数可以检查变量值是否按预期变化;判断函数是否被调用;追踪信号是否触发。

设置断点与逐步调试

Godot 脚本编辑器支持断点调试模式,你可以设置断点来暂停程序运行并查看变量状态。操作步骤如下:

  1. 在需要暂停的代码行号左侧,可以设置红色断点;
  2. 运行游戏,当程序执行到该行,会自动暂停;
  3. 此时,你可以在下方调试窗口中查看变量、堆栈、调用路径;
  4. 使用“逐步执行”按钮一行一行地查看逻辑流程。

调试方法对比总结如下:

调试手段 适用场景 交互性
print() 快速验证信号是否触发、数值是否变化。
远程场景树 检查节点层级关系、实时修改运行时属性。
断点调试 深度分析复杂的逻辑错误、追踪函数调用链。 极强

在之前的开发过程中,每个模块(敌人、玩家、UI、爆炸动画)都作为独立的子场景添加,而非直接耦合在 Game 主场景中,这种松耦合设计便于单独测试、复用与拓展。通过本章的整合与测试,我们将分散开发的模块统一组装为一个完整的游戏原型,并通过有条理的测试流程,确保其每一个部分都能按预期协同运行。这一阶段不仅是“游戏能跑起来”的关键一步,也为今后扩展功能和优化体验打下了基础。

本章小结

本章通过完整的进阶实战案例,带你走过了一个完整 2D 游戏从搭建到可运行的开发流程。与前章的 Flappy Bird 案例相比,本章的项目在结构上更加复杂,功能上更贴近真实游戏开发中的需求。

你学会了如何:

  • 拆解游戏玩法,制定模块化开发计划;
  • 使用 Godot 构建多个独立的功能场景(玩家、敌人、子弹、地图等);
  • 运用信号机制和全局管理器协调模块通信;
  • 设计并实现用户界面、生命系统、胜负逻辑;
  • 添加爆炸动画与音效反馈,提升打击感和表现力;
  • 通过远程场景树、打印输出与断点调试等方式进行功能测试与问题排查;
  • 最终整合所有模块,完成一个具备完整逻辑、表现和交互的 2D 坦克游戏原型。

通过这个项目,你不仅提升了使用 Godot 开发 2D 游戏的综合能力,也对游戏模块结构、流程控制、UI 构建、资源组织与调试工具等关键技能有了更深入的理解。更重要的是,你已经拥有将游戏从想法拆解到产品原型落地的能力。

在接下来的章节中,我们将继续升级这款游戏的玩法,引入更多内容与机制(如障碍、道具、关卡管理、敌人 AI 等),逐步走向一个更加完整、有趣、可扩展的游戏作品。