第六章:进阶项目实战《Battle Tank》
本章导言
在前几章中,我们已经掌握了Godot的基础知识,完成了一个较为简单的2D游戏项目。现在,是时候迈出更进一步,挑战一个结构更复杂、功能更完整的游戏了。
本章将带你逐步实现一款经典风格的《Battle Tank》2D游戏。这一次,我们不仅要让玩家操控坦克进行移动和射击,还要设计敌人行为、实现子弹碰撞、构建关卡地图、设计用户界面,并通过动画和音效为游戏注入生命。
与之前一次性实现全部功能不同,本章采用迭代开发的方式:我们从最基本的“能控制、能发射、能被击中”开始,逐步加入地图、UI、动画、音效、状态管理等模块,最终完成一个具有完整玩法和用户体验的小游戏。
通过本章的学习,你将掌握以下关键技能:
- 如何将复杂游戏拆分为多个模块进行开发;
- 如何使用信号机制实现模块间通信;
- 如何为游戏角色设计状态和行为逻辑;
- 如何构建动态用户界面与血量进度条;
- 如何播放动画与音效,提升游戏体验;
- 如何使用全局管理器管理游戏状态与胜负判断。
游戏开发不仅仅是让角色“动起来”,更是通过规则、反馈与表现力构建起一个生动的系统世界。在开发过程中,你将亲身体验从“拼出画面”到“设计规则”再到“打磨体验”的全过程。
准备好进入战斗了吗?现在就开始构建你的第一个“战斗型”游戏世界吧!
1. 游戏概述与开发规划
在本章中,我们将从零开始构建一款名为《Battle Tank》的 2D 俯视角(Top-Down)射击游戏。它是作者专为本书教学目的而开发的一款休闲游戏。玩家将操控自己的坦克在地图中移动、瞄准并发射炮弹,与多种敌人单位进行对抗,直至取得胜利。
作为本书的进阶教学项目,《Battle Tank》项目会帮助你学习如何组织较为复杂的游戏结构,并掌握更加系统的游戏开发技能。你将不仅学习如何实现坦克的移动与战斗,更重要的是学习如何构建一个模块化、可扩展的游戏框架。
相比上一章的入门项目,本章游戏项目在以下几个方面有明显的不同:
- 角色行为更加丰富:实现玩家移动、旋转、指向性瞄准及发射逻辑,并为敌人注入基础 AI 行为。
- 交互逻辑更复杂:涉及碰撞检测、生命值系统、游戏胜负判断等机制。
- 视听反馈增强:通过动画、音效、UI以及粒子效果,为游戏带来更强的表现力。
- 结构更加模块化:采用“分而治之”的思路,各类游戏元素封装为独立场景,通过主场景统一调度和管理。
- 引入全局状态管理:使用全局脚本管理得分、胜负、信号广播等跨模块逻辑。
1.1 游戏玩法简介
《Battle Tank》的核心玩法逻辑如下:
- 操控机制:玩家通过键盘控制坦克位移,通过鼠标控制炮塔转向并执行射击。
- 博弈设计:地图中部署有敌方静止单位或机动单位,它们具备自动感应视野,一旦玩家进入射程即发起攻击。
- 损耗系统:引入数值化的生命值体系,通过碰撞事件触发减血逻辑。
- 胜负判定:全歼敌方单位则获得胜利进入下一关,玩家坦克生命值归零则失败。
最终运行效果预览
这些机制虽然简单,但涵盖了现代游戏开发的五大核心要素:输入控制、目标检测、反馈响应、状态管理及视听表现。掌握这些内容,将为你开发中大型项目打下坚实的基础。
1.2 开发思路与流程规划
由于《Battle Tank》涉及多个系统的联动,为了避免开发过程中的逻辑混乱,我们将遵循“模块化设计”与“迭代式开发”的原则。
模块化设计的核心思想是分而治之,职责分离。将一个复杂的系统拆分为多个独立、可替换、低耦合的模块(Module)。迭代式开发的核心思想是小步快跑,持续进化。先做出一个最小可行性产品(MVP),然后不断添砖加瓦来进行优化。
如果把游戏开发比作盖房子,模块化设计是预先想好哪些是承重墙,哪些是管线。即便以后想换个窗户,也不用拆掉整个房子。迭代式开发是施工顺序。先搭好框架(第一层能住人),再刷墙漆,最后装灯具。不需要等整栋楼精装修完才进去看效果。
功能模块拆解
为了实现清晰的职责分离,我们将项目划分为以下独立功能模块。
| 模块名称 | 核心职责 | 技术要点 |
|---|---|---|
| 玩家控制系统 | 处理输入响应 | 实现平滑移动、炮塔旋转转向及射击频率控制。 |
| AI 敌人系统 | 自动化行为逻辑 | 包含感知算法(检测玩家)与攻击状态机。 |
| 子弹系统 | 物体运动与判定 | 负责子弹位移、物理碰撞检测及对象池回收。 |
| 环境与地图 | 战斗空间构建 | 利用瓦片地图)实现地形阻挡与视觉分层。 |
| 生命值与状态 | 核心数据管理 | 管理 HP 变化、死亡判定及单位间的伤害传递逻辑。 |
| 全局管理器 | 流程调度中心 | 负责游戏初始化、信号广播及胜负结果判定(单例模式)。 |
| UI 与交互反馈 | 信息可视化 | 实时同步血条高度、击杀统计及动态提示。 |
| 视听增强系统 | 表现力打磨 | 统一管理粒子爆炸特效、引擎循环音与环境背景音。 |
迭代开发阶段规划
我们通过三个递进的阶段,将复杂的系统拆解为可落地的任务,确保每一阶段都有一个“可运行、可测试”的版本。
阶段一:构建核心玩法
- 目标:建立最基本的“移动-射击-反馈”流程。
- 关键任务:玩家可操作坦克移动、旋转并发射子弹;敌人炮塔会自动瞄准玩家并定时攻击;子弹具备运动与碰撞检测机制;双方单位都有生命值;游戏有胜负判定、基础UI和音效动画反馈。
- 里程碑:坦克能在地图上跑起来,且能击中目标。
阶段二:完善游戏AI
- 目标:开发敌方AI,敌人机动单位更聪明。
- 关键任务:引入有限状态机;丰富地图内容,引入路径规划与导航系统;增加多种敌方机动单位;程序化地图生成;模块间通过信号通信,逻辑更解耦。
- 里程碑:敌人具备完整的AI功能。
阶段三:体验优化与打磨
- 目标:提升沉浸感,优化玩家交互体验。
- 关键任务:优化血条和HUD;优化动画和音效;开发主菜单;引入音效管理器模块;增加光照、阴影和环境氛围。
- 里程碑:游戏视听反馈完整,具备良好的“打击感”。
最小可运行版本的游戏示意如上图。
在本章中,我们将聚焦于实现阶段一的任务。在下一章,我们会对阶段一的成果进行重构。在第九章到第十二章,我们将实现阶段二的任务,在第十三章,我们将完成阶段三的任务。
这种迭代式开发最大的优势在于:你可以在每个功能完成后立即运行测试,从而快速定位 Bug。同时,高度解耦的模块化设计允许你在不修改核心架构的情况下,自由替换美术资产或增加新的敌方类型。
2. 项目初始化与资源准备
在正式编写《Battle Tank》的代码之前,我们需要先完成项目的初始化设置和资源导入工作。良好的开端是成功的一半。如果项目结构混乱、资源命名不清、屏幕尺寸设置不当,很容易在后续开发中埋下“隐形炸弹”。
本节将带你完成以下准备步骤:
- 环境配置:创建项目并适配屏幕分辨率。
- 构建交互:建立动作映射系统(Input Map)。
- 资产入库:导入并梳理游戏素材。
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_items;Aspect设置为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 |
发射炮弹 |
设置输入映射完成后如上图所示
这样设置后,我们可以在脚本中使用 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 | 无 | 计时器:控制射击频率与冷却。 |
根据上面的要求,给根节点增加子节点,注意不同节点的层级关系。最后炮塔场景的节点为树结构如下:
在文件系统中,新建一个名为Scenes的文件夹,在之下建立一个子目录enemy,将刚创建的炮塔场景保存为 enemy.tscn,存放在enemy文件夹下。
为什么使用 Area2D?
在 Godot 中,我们可以使用
Node2D、Area2D或CharacterBody2D等节点作为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,计时器会负责发射炮弹的冷却时间,我们会通过代码手动启动计时器,
这样,炮塔的节点树就完成了,其结构如下:

实现追踪与发射逻辑
为 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,操作如下:
- 选中根节点
Enemy; - 点击右侧面板
Node > Groups; - 添加名为
"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秒; - 根据
Sprite2D的modulate属性,在 0 秒、0.2 秒、0.4 秒设置关键帧; - 0s 和 0.4s 设置为白色,0.2s 设置为橙色;
- 启用循环播放(Loop)与自动播放(Autoplay);
- 给
这样,子弹在飞行过程中将会呈现出一种明暗闪烁的效果,提升视觉表现。
完成设置后的炮弹场景如上图所示。
编写子弹脚本
为 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也会相应的发生变化;
- 将它乘以
speed和delta,可以实现匀速直线运动; position是相对于父节点的位置,因为我们使用了top_level = true,它的运动方向会保持独立。
理解 transform
在 Godot 中,每个 2D 节点(如 Node2D、Sprite2D、Area2D 等)都拥有一个内置属性 transform,它代表了这个节点的二维空间变换信息,包含了位置、旋转、缩放这三个核心要素。
具体来说,transform 是一个 Transform2D 类型的对象,它由两个方向向量和一个偏移量组成:
transform.x表示节点当前的本地X轴方向向量,也就是它“朝向的方向”;transform.y表示节点当前的本地Y轴方向向量,一般垂直于x,表示“向下”;transform.origin等价于global_position,是节点在世界坐标中的位置。
“本地”(Local)是相对于节点自身的坐标系来说的,而不是整个游戏世界的坐标系。每个节点在 Godot 中都有自己的“小世界”,它的坐标轴、朝向、位置,都是以它自己为参考点来定义的。这就叫“本地坐标系”或“局部坐标系”。
假设你有一个坦克,它本来的头朝右方(默认方向)的时候:它的“本地 X 轴方向”是 →(右);它的“本地 Y 轴方向”是 ↓(下)。
如果你把它顺时钟旋转了 90 度,让它朝下:它的“本地 X 轴方向”变成了 ↓(下);它的“本地 Y 轴方向”变成了 ←(左)
transform.x 和 transform.y这两个方向向量是自动根据节点的旋转信息计算出来的。使用 transform.x 可以让对象按照“它当前朝向的方向”移动,而不是固定朝右;如果你的节点发生了旋转,它的 transform.x 也会自动变化,非常适合用来做跟随方向移动、发射、追踪等行为。
理解了 transform 的本质,你就能更加灵活地控制对象在二维空间中的行为。
构造玩家子弹场景
可以用完全相同的方式来构建主角的子弹场景,我们也可以通过复制来完成。
- 在文件系统找到
enemy_bullet.tscn文件,右键点击"duplicate",这样得到了一份复制场景文件,重命名为player_bullet.tscn; - 同样复制脚本文件,命名为
player_bullet.gd; - 在场景根节点中,注意要将挂载的代码修改为
player_bullet.gd; - 打开代码,修改碰撞逻辑中的分组判断,将
"Player"改为"Enemy"。
场景复制的进一步讨论
当我们需要两种功能相似的场景时,有两种构建方法,一种是目前我们使用的“场景复制”,另一种是之前我们使用过的“场景继承”。也就是先构建一个基础炮弹场景,然后在此基础上继承出两种不同的炮弹场景。作为开发者,我们需要权衡快速实现与长期维护。这两种方法各有优点和缺点。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 场景复制 | 简单直观,新手易于理解和操作 | 产生代码冗余。如果你想修改子弹的飞行逻辑,需要修改两份脚本。 |
| 场景继承 | 逻辑统一,在父场景改代码,所有子弹同步更新。 | 结构耦合度高,初学者可能在处理特定差异时感到困惑。 |
另外,还有一种我们没有使用过的方案。就是使用通用子弹场景加脚本参数控制的方法。也就是使用一个 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.tscn,放入项目的 player 目录下。
设置贴图与属性
Base贴图:使用assets/tank/tankBody_bigRed_outline.png;Gun贴图:使用assets/tank/specialBarrel3_outline.png;- 将
Gun的 Offset 设置为(8, 0),使旋转中心靠近炮尾; Marker2D的position设置为(32, 0),正好位于炮口;- 设置
CollisionShape2D为RectangleShape2D,尺寸为(54, 46)。
最终结构如上图所示
脚本实现玩家控制逻辑
为 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_speed和delta乘积。 - 如果游戏以每秒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函数。
回到Player根节点,在右侧的检查器面板可以看到Max Speed和Bullet Scene这两个变量,点击Bullet Scene的下拉框,选择quick load,找到我们之前构建的玩家子弹场景,player_bullet.tscn,这样Player场景就能引用这个场景进行实例化了。
然后,点击检查器面板中的Node标签,将它的Group设置为Player。这样可以让敌方子弹在碰撞检测可以识别到这个坦克。
尝试运行 Player 场景,你应该可以:
- 使用键盘控制坦克前进后退;
- 使用鼠标控制炮管旋转;
- 点击鼠标左键发射子弹。
完成这一节后,我们就拥有了一个完整的玩家控制角色,具备了运动感、攻击力与操作反馈。下一节我们将构建关卡地图,让双方角色在一个真实的战场中交锋!
3.4 地图场景与TileMap绘制
什么是地图
一个完整的战斗场景离不开地图的支撑。本节我们将使用 Godot 提供的 TileMap 和 TileSet 功能,绘制一片战斗场地,为玩家和敌人提供活动空间,这种活动空间被称为“地图”或“关卡”。这两个概念经常混用,不过含义略有不同。地图侧重于静态环境。它是游戏中一块区域的地形、背景、瓦片拼图、场景装饰等,通常由美术和布局构成。它就像是“画布”,构成了游戏世界的外形和路径。
关卡更强调的是动态逻辑。它包括了当前的游戏目标与进程安排,它是一个设计单位,可能包含地图本身、敌人配置、胜利条件、出场顺序、剧情触发等完整的游戏逻辑。你可以把关卡看作是“一局游戏”或“一次挑战”。通常可以这样理解:关卡 = 地图 + 玩法逻辑。
本章我们第一次构建的地图非常简单,就是将一个矩形的草地铺满整个窗口;我们会在后续章节中绘制不同类型的地图。
什么是 TileMap 和 TileSet?
在第二章中我们介绍过瓦片地图,瓦片地图的基本思想是:将游戏世界或场景划分为一块块小图片(称为瓦片,Tile),这些瓦片通常是固定大小的图像块。然后通过拼接这些图块来组成完整的游戏场景。这样游戏可以高效地表示和渲染复杂的场景。形象的讲,构建2D网格场景就像是我们为了装修房间而铺地板砖的过程一样。Godot 提供了两种强大工具来完成这个工作:
TileSet:第一步是定义你的地砖仓库。它定义了哪些图片可以作为地砖,以及这些地砖的大小、物理属性等。,也就是定义TileSet。在TileSet标签页中,我们将一整块大的图片资源放进去后,会自动帮我们切成不同的Tile,也就是地砖。TileMap:第二步就是开始施工,为房间铺设地砖,也就是定义TileMap。你从仓库(TileSet)里挑选合适的地砖,然后在网格画布上进行刷涂、填充。
先定义砖,再铺地图。它们的配合可以大大提升地图编辑效率。
场景节点设置
在 Godot 4 中,推荐使用 TileMapLayer 节点,它比旧版的 TileMap 更加轻量且易于分层管理。
新建一个场景,根节点设置为TileMapLayer。将它重命名为 Map。将该场景保存为 map.tscn,放置到项目的 scenes/map 文件夹中。之后按如下步骤来设置TileSet:
- 选中
Map节点,在右侧属性面板中找到 Tile Set; - 点击右侧下拉按钮,选择 New TileSet,表示新建一个瓦片库;
- 然后点击该新创建的
TileSet,进入瓦片编辑模式,将Tile Size属性,修改为(64,64)的大小,每一个图块是 64x64 像素,正好与素材匹配; - 在底部操作面板中看到两个标签,分别是TileSet操作区和TileMap操作区,点击TileSet标签页,将文件系统中的“map\terrainTiles_default.png"文件拖到左侧空白区域,在弹出对话框中选择Yes。这样创建完成了TileSet。
操作完成后,TileSet面板如图所示,你会看到图块库已经被系统自动分割成多个图块。

使用 TileMap 绘图
用TileMap绘制关卡就像是画画一样,先定义好颜料盘,也就是你的TileSet,然后在 TileMap中,选择你的绘制工具,选择颜料,再去画布上的位置落笔。本章的关卡比较简单,我们只需要一大块草地就可以了。
- 点击编辑器底部操作面板的 TileMap 标签;
- 选择箭头右侧的画笔工具,像铅笔的图标。鼠标放上去会提示paint;
- 点击图块库左上角的那块草地图块;
- 在主窗口中直接点击即可绘制地图。为了高效绘制,可以使用矩形绘制工具,一次性填充大片区域;
- 选择第四个矩形工具,在主窗口中尝试框选一块区域,快速将窗口区域铺满草地。
完成后,地图如下图所示:

完成后尝试运行当前的地图场景,观察窗口中草地是否铺满了。地图草地面积尽量画大一些,就算是超过窗口的大小也没关系。
目前我们仅使用了草地图块,但在后续我们还会引入石头、树木、道路等不同类型的地形,通过多个不同的TileMapLayer节点,来分层绘制实现遮挡、阻挡等机制。下一节,我们将把地图、玩家、敌人等组合在一起,构建出完整的游戏主场景。
3.5 构建游戏主场景
到目前为止,我们已经分别完成了地图、玩家、敌人和子弹等核心模块的制作。接下来,我们将创建一个“主场景”,把这些功能模块组合在一起,让游戏具备完整的运行逻辑。
新建一个场景,使用 Node2D 作为根节点,命名为 Game。这是我们整个游戏的主容器。
接下来,在根节点下使用“实例化子场景”按钮依次添加如下场景实例:
- 在根节点下添加之前创建的
map.tscn场景,作为地图背景; - 添加
player.tscn场景,代表玩家控制的坦克; - 添加
enemy.tscn场景,代表固定炮塔敌人; - 选择上一步添加的
Enemy节点,在属性面板中观察到Player属性还是空值,点击Assign按键,选择Player节点。这样敌方炮塔就会知道玩家的位置。 - 将敌人场景复制一份,让关卡中出现多个敌人炮塔。
- 选择移动工具,拖动各个节点到合适的位置
- 将
Player放在屏幕中央 - 将两个敌人炮塔移动到地图的不同地方。
- 将
- 最后,将
Game场景保存为game.tscn,从项目设置菜单中进行设置(Project → Project Settings → Application → Run → Main Scene)。将其设置为项目主运行场景。
完成后的主场景布局如下,展示了玩家与敌人分布在同一地图下的实际效果:

此时,点击运行游戏,你将看到:
- 一整块铺满草地的地图;
- 玩家坦克可自由移动并发射子弹;
- 敌人炮塔自动追踪并攻击玩家;
- 子弹可以击中目标,触发碰撞响应,可以在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()
代码完成后,在项目设置中(Project → Project Settings → AutoLoad),将这个节点设置为自动加载(AutoLoad),名称为 Gamemanager。这样我们在任何脚本中都可以直接使用 Gamemanager 访问其变量和方法。
理解信号集线器 (Signal Hub)
在实际开发中,我们通常有两种组织信号的方式: - 一种是每个节点只定义与自己有关的信号,并在适当时机发出(emit)。这种方式适合于有直接关联的两个模块之间通信。例如父节点和子节点之间的通信。因为它们可以方便的访问彼此,建立信号连接。 - 另一种是将所有重要的信号统一集中到一个全局对象中(如 Gamemanager)。这种设计称为 Signal Hub(信号集线器),类似“广播中心”。这种方式适合于跨模块通信。例如玩家死亡是一个关键游戏状态事件,需要多个模块响应。而这些模块比较分散,无法方便的访问彼此。因此放在全局管理器中,可以统一管理,方便访问和信号连接。
通过这样的全局管理器设计,我们将复杂的游戏状态管理从各个子场景中“解耦”出来,集中处理,使得代码结构更清晰、扩展更方便、维护更高效。在接下来的章节中,我们会让敌人、玩家、用户界面等模块都通过 Gamemanager 来收发信息,实现更完整的游戏流程控制。
4.2 抬头显示界面 HUD
在一个完整的游戏中,HUD(Head-Up Display) 是游戏与玩家沟通的桥梁。它不仅实时同步战斗状态(血量、得分),还要在生死转折点给予明确的视觉反馈。
我们将在本节中构建一个简洁但功能完善的 HUD界面,具备以下功能:
- 实时战报:显示当前击杀敌人数;
- 状态监控:动态血量条显示;
- 游戏结算:胜负状态的延时弹出提醒。
构建完成后的用户界面如下图所示:

场景结构设计
新建一个场景,根节点使用 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)更新击杀数信息
代码解释:on_enemy_killed函数负责当敌人被击杀时进行响应,Gamemanager中score值会更新,用它来更新label中的text属性,让玩家知道当前击杀了多少个敌人。这里使用了字符串格式化方法。
(4)更新血量条
代码解释: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 和手动定义玩家节点,这种方式在敌人很多时较繁琐。可以改用组机制自动获取玩家引用,修改敌人代码如下:
- 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文件,修改代码如下:
- 函数中的area参数是子弹检测到的碰撞节点
- 检测到碰撞节点判断是玩家时,就会调用玩家的reduce_health函数,让玩家的血量减少一个单位
打开player_bullet.gd文件,修改代码如下:
代码逻辑和上述相同,只是检测到的碰撞节点判断是敌人时,就会调用敌人的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_explosion、regular_explosion 和 explosion。每个目录下都有一组帧图片,用于播放不同的爆炸动画。创建爆炸动画的方法和第五章时小鸟动画类似,步骤如下:
步骤一:创建爆炸动画场景
- 新建一个场景,根节点选择
AnimatedSprite2D; - 在右侧属性面板,点击
SpriteFrames→New SpriteFrames; - 在底部的动画资源编辑器中(SpriteFrames),将explosion目录下所有的帧图片拖入帧列表。
- 设置帧速率为 10fps,设置audoplay为false,设置loop为false。
- 默认的动画名称是
default,修改动画名为explosion。
底部的动画资源编辑器中,点击add animation,可以新建动画。用同样的方法,将另外两个目录中的帧图片拖入帧列表,分别命名为 sonic_exp 和 regular_exp。这样节点中就保存了三组爆炸动画。保存场景为:vfx/exp_anim.tscn。
完成创建后的动画如上图所示
步骤二:创建动画管理器 VFXManager
- 新建一个场景,根节点选择
Node2D,命名为VFXManager; - 添加一个子节点
AudioStreamPlayer; - 在文件系统中将
sound/Explosion.wav拖入其Stream属性中; - 将此场景保存为
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 添加音效节点
- 打开玩家场景
player.tscn; - 添加两个
AudioStreamPlayer子节点,重命名为ShootSound和EngineSound; - 分别将
sound/shoot.wav和sound/engine.ogg拖入各自的Stream属性; - 设置
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文件。完整的主场景布局如下图所示:

地图显示在最底层,界面显示在最上层。而敌人和玩家坦克在中间层显示。
6.2 游戏运行测试
点击运行按钮后,游戏开始运行。下图是游戏运行画面。

请对照下表进行功能验收。若某项不符,请参考 6.3 节进行调试。
| 测试维度 | 验证指标 | 状态 |
|---|---|---|
| 操控感 | 坦克是否具备加速/减速惯性?炮管是否平滑指向鼠标? | □ |
| 战斗逻辑 | 敌人是否在玩家进入视野后自动开火?双方子弹是否能互相扣血? | □ |
| 视听反馈 | 射击是否有声音?敌人爆炸时是否有火光和爆炸声? | □ |
| 数值同步 | 击杀敌人后,HUD 左上角的计分是否增加?玩家血条是否随受击缩短? | □ |
| 胜负闭环 | 击杀全场 3 个敌人后是否弹出 "You Win"?玩家阵亡是否弹出 "You Lost"? | □ |
6.3 游戏调试方法
在开发过程中,调试是不可避免的步骤。当程序“看起来没反应”或者“运行不符合预期”时,尝试以下“调试三板斧”:
使用 “远程” 场景树
运行游戏后,Godot 编辑器的场景树面板会出现两个标签,分别是Remote和Local,Remote会显示游戏运行时的真实节点结构。“远程” 场景树可用于:
- 查看场景是否正确加载;
- 检查节点是否在运行时被创建、销毁;
- 实时查看和修改变量的值(如生命值、位置等);
使用 print() 打印调试信息
最简单直接的方式,在怀疑出问题的代码行插入print():
用print函数可以检查变量值是否按预期变化;判断函数是否被调用;追踪信号是否触发。
设置断点与逐步调试
Godot 脚本编辑器支持断点调试模式,你可以设置断点来暂停程序运行并查看变量状态。操作步骤如下:
- 在需要暂停的代码行号左侧,可以设置红色断点;
- 运行游戏,当程序执行到该行,会自动暂停;
- 此时,你可以在下方调试窗口中查看变量、堆栈、调用路径;
- 使用“逐步执行”按钮一行一行地查看逻辑流程。
调试方法对比总结如下:
| 调试手段 | 适用场景 | 交互性 |
|---|---|---|
print() |
快速验证信号是否触发、数值是否变化。 | 弱 |
| 远程场景树 | 检查节点层级关系、实时修改运行时属性。 | 强 |
| 断点调试 | 深度分析复杂的逻辑错误、追踪函数调用链。 | 极强 |
在之前的开发过程中,每个模块(敌人、玩家、UI、爆炸动画)都作为独立的子场景添加,而非直接耦合在 Game 主场景中,这种松耦合设计便于单独测试、复用与拓展。通过本章的整合与测试,我们将分散开发的模块统一组装为一个完整的游戏原型,并通过有条理的测试流程,确保其每一个部分都能按预期协同运行。这一阶段不仅是“游戏能跑起来”的关键一步,也为今后扩展功能和优化体验打下了基础。
本章小结
本章通过完整的进阶实战案例,带你走过了一个完整 2D 游戏从搭建到可运行的开发流程。与前章的 Flappy Bird 案例相比,本章的项目在结构上更加复杂,功能上更贴近真实游戏开发中的需求。
你学会了如何:
- 拆解游戏玩法,制定模块化开发计划;
- 使用 Godot 构建多个独立的功能场景(玩家、敌人、子弹、地图等);
- 运用信号机制和全局管理器协调模块通信;
- 设计并实现用户界面、生命系统、胜负逻辑;
- 添加爆炸动画与音效反馈,提升打击感和表现力;
- 通过远程场景树、打印输出与断点调试等方式进行功能测试与问题排查;
- 最终整合所有模块,完成一个具备完整逻辑、表现和交互的 2D 坦克游戏原型。
通过这个项目,你不仅提升了使用 Godot 开发 2D 游戏的综合能力,也对游戏模块结构、流程控制、UI 构建、资源组织与调试工具等关键技能有了更深入的理解。更重要的是,你已经拥有将游戏从想法拆解到产品原型落地的能力。
在接下来的章节中,我们将继续升级这款游戏的玩法,引入更多内容与机制(如障碍、道具、关卡管理、敌人 AI 等),逐步走向一个更加完整、有趣、可扩展的游戏作品。