跳转至

第七章:项目优化重构

本章导言

在上一章中,我们实现了一个具备基本玩法的《Battle Tank》游戏:玩家可以移动坦克、发射子弹、与敌方炮台交战,并体验完整的胜负流程。但要打造一个更具规模、可扩展性强的2D射击游戏,仅仅完成这些功能还远远不够。本章将带你进行一次全面的项目重构与系统升级,包括:

  • 将角色和子弹功能抽象为可复用的组件结构;
  • 使用碰撞层系统优化物理交互逻辑;
  • 构建可配置的敌人生成与波次管理机制;
  • 增加动态特效、拾取道具系统、UI美化;
  • 构建可复用的爆炸粒子、特效管理器、子弹轨迹系统;
  • 最终整合为一个结构清晰、行为明确、模块松耦合的完整项目架构。

通过这一章的学习,你将掌握如何将一个原型游戏逐步升级为一个系统化、工程化的完整项目,具备良好的维护性与扩展性,也为后续引入AI、关卡系统、升级机制等内容打下坚实基础。

1 系统优化与基础架构重构

在扩展游戏功能之前,我们需要为项目奠定一套更清晰、更稳固的基础架构。这不仅能提升当前的可维护性,也为后续引入复杂系统铺平道路。

本章重构将从三个关键维度入手:

  • 组件化结构设计:放弃臃肿的“继承式结构”,转而采用“组件组合”模式构建角色。通过模块化的功能组件(如武器、血量、受伤检测),实现对象的高度解耦。
  • 精细化碰撞系统:利用 Godot 的 Collision Layer(碰撞层) 与 Collision Mask(碰撞遮罩) 建立清晰的物理规则,解决逻辑耦合并提升运行性能。
  • GameManager 职能升级:重构全局管理器,使其真正成为项目的“神经中枢”,统一处理事件通信、得分统计与胜负判定。

1.1 组合优于继承:构建灵活的角色系统

在上一章的实现中,我们已经开发出了玩家坦克和敌方炮塔两个场景。你可能会发现,它们之间有很多重复功能。它们都需要发射子弹;都会受到攻击并损失血量;都需要播放被击中的动画。如果每个场景都独立编写代码,会导致功能冗余,代码重复;一旦逻辑变更(如修改受伤算法),需逐个场景修改,极易出错。为了解决这个问题,我们需要将这些可复用功能进行抽象和提取。这时,就涉及到两种常见的设计思路:

方法一:继承(Inheritance)

在编程中,继承指的是一个类可以继承另一个类的属性和方法。例如,我们可以设计一个 BaseTank 场景,把所有坦克共有的功能(移动、开火、受伤)写在其中。然后玩家坦克和玩家坦克均继承 BaseTank。看上去不错,但存在如下问题:

  • 如果以后玩家和敌人功能差异越来越大,继承关系就变得笨重难改;
  • 想给敌人坦克添加雷达组件,但不想让玩家拥有怎么办?
  • Godot 的场景系统不支持多重继承,只能继承一个父场景,灵活性受限。

方法二:组合(Composition)

组合的思路是:将每个功能做成一个单独的“组件场景”,然后用谁需要就添加谁。例如:

功能 组件名 应用场景
受伤处理 HurtBoxComponent 玩家、敌人、炮塔都需要
发射子弹 WeaponComponent 玩家坦克、敌人炮塔、敌人坦克都用
血量逻辑 HealthComponent 所有可被击毁的单位使用

这些组件就像“乐高积木”,你可以灵活组合构建不同角色,不会相互干扰,结构清晰。

两者的设计思路对比如下表:

维度 继承 (Inheritance) 组合 (Composition)
灵活性 受限于单一父类,难以应对需求变更 像“乐高”一样自由组合多个功能模块
扩展性 修改父类可能引发“牵一发动全身”的 Bug 单独升级或替换某个组件,互不干扰
可维护性 功能耦合紧密,层级越深越难拆分 模块独立,职责清晰(高内聚、低耦合)
重用性 必须携带父类的所有属性,不够精简 任意对象按需挂载,代码零冗余

因此,本项目会选择组合的方案,它更符合游戏开发中快速构建、快速调整、快速迭代的需求。

本项目的组件设计方案

我们将这些可复用逻辑抽象为如下的“组件场景”,谁需要就给谁挂载:

组件名 功能简述 使用对象
HurtBoxComponent 接收伤害,触发受伤反馈 玩家坦克、敌人单位
HitBoxComponent 对击中对象造成伤害 玩家子弹、敌方子弹
HealthComponent 管理血量值、发出死亡信号 所有可被摧毁的对象
WeaponComponent 控制射击、冷却、火光及音效 玩家坦克、敌人单位
DetectComponent 探测玩家位置,供敌人判断攻击 敌人单位
TrailComponent 生成履带印等移动轨迹特效 所有移动单位

通过组件组合,我们可以用最少的代码快速构建功能丰富的游戏角色,修改和测试也变得更加方便高效。

1.2 优化碰撞系统

告别“组标签”

在上一章的实现中,我们通过 is_in_group("Enemy")组标签判断对象类型,进而执行逻辑处理,例如

if other.is_in_group("Enemy"):
    # 扣血逻辑

这种做法虽然简单直观,但在实际项目中,在游戏规模扩大、对象种类增多后,这种方式存在以下严重缺陷:

  • 逻辑紧耦合,代码难以维护:每个物体都需要手动判断与之碰撞的对象是否属于某个组,导致逻辑散落在多个对象中。例如子弹类、敌人类、道具类都要自行判断碰撞对象是否是“敌人”“玩家”或“墙体”,代码重复且易错。随着敌人种类增加(如普通敌人、Boss、空中单位等),判断条件会迅速复杂化:
  • 性能浪费,物理引擎仍执行了无效检测:Godot 的物理引擎会对所有具有碰撞体的对象尝试做初步碰撞检测,即使你后续逻辑中排除了这些对象,碰撞计算本身已经发生了,浪费了 CPU/GPU 资源。例如,敌方子弹本不应该和敌方子弹碰撞,但只用组标签逻辑处理的话,引擎仍会去检测它们是否碰到了彼此,再由代码判断“不需要处理”。当游戏中同时存在几十个子弹、坦克、道具、障碍物时,这种“无效碰撞”会对帧率造成影响。
  • 缺乏可视化支持,调试困难:组标签是一个逻辑概念,无法在编辑器中直观显示对象之间是否会产生碰撞。相比之下,Collision Layer 和 Collision Mask 是可视化配置的物理规则,在 Inspector 中一目了然,可以提前预知哪些对象会相互作用,有利于调试和团队协作。

Collision Layer 与 Collision Mask

为了理解这两个概念,先来看一个类比的例子。在一场军事演习中,红方的单位与蓝方的单位对战。为了避免“误伤友军”,他们使用了颜色标识系统。通过这种颜色标识,红方单位“只识别”蓝色标识,攻击蓝方单位;蓝方单位“只识别”红色标识,攻击红方单位;红色单位永远不攻击红色单位,避免误伤;同样的,蓝色单位永远不攻击蓝色单位作。如果用表格来表示就是:

单位 自身的标识 识别目标
红方坦克 红色 蓝色
红方步兵 红色 蓝色
蓝方坦克 蓝色 红色
蓝方步兵 蓝色 红色

在 2D 游戏中,游戏角色、子弹、障碍物等都可能涉及碰撞检测。Godot 提供了一种高效、灵活的机制:Collision Layer(碰撞层)与Collision Mask(碰撞遮罩),类似上述的“识别机制”,它通过设置下面两项属性来实现:

  • Collision Layer(碰撞层):对象的“所属”层,即我是谁,设定自身的身份。
  • Collision Mask(碰撞遮罩):对象要检测的“目标”层,即我关注谁,我希望检测谁进到我的碰撞区里了。

来看一个常见的设置:

单位 Layer(我是谁) Mask(我想检测谁)
玩家子弹 2 1
玩家坦克 2 1
敌方子弹 1 2
敌方坦克 1 2

上面表格设置中,玩家坦克和玩家子弹的碰撞层是“2”,碰撞遮罩是“1”。也意味着玩家坦克和子弹不会相互进行碰撞检测,只会和敌方坦克和子弹进行碰撞检测;同样的,敌方坦克和子弹的碰撞层是“1”,碰撞遮罩是“2”。也意味着敌方坦克和子弹不会相互进行碰撞检测,只会和玩家坦克和子弹进行碰撞检测。

用严格的表述就是:Collision Layer 定义了一个物理对象属于哪个或哪些“层”。每个层可以用作逻辑分组,例如地面、墙壁、敌人、子弹等。Collision Mask 决定了这个物体能够“看到”或“检测到”的其他物体的层。系统从底层就屏蔽了“不该发生”的碰撞,提升了效率。

理解碰撞检测的双向设置

Godot 中的碰撞检测是双向可配置的:你既可以让 A单位去 检测是否有 B 进入,也可以让 B 检测是否有 A 进入。但这两种方式下,Collision Layer 与 Collision Mask 的设置逻辑完全不同。

我们以 Area2D 节点为例,当 A 和 B 都是Area2D 节点,当我们需要检测二者是否发生碰撞时,有两种处理方式:

方式一:A 检测 B 的进入。此时,A 是“监听者”,B 是“被检测者”,A节点的Monitoring属性需要设置为 true,B节点的Monitorable属性需要设置为 true,二者的碰撞检测设置如下:

角色 Collision Layer(我是谁) Collision Mask(我想检测谁)
A 不重要 必须包含 B 的 Layer
B 必须设置 不重要

当A检测到B进入时,会触发A节点的area_entered信号,如果需要编写相应的逻辑,在A的代码中连接信号如下:

# A 的代码中
area_entered.connect(on_area_entered)

方式二:B 检测 A 的进入。此时,B 是“监听者”,A 是“被检测者”,B节点的Monitoring属性需要设置为 true,A节点的Monitorable属性需要设置为 true,二者的碰撞检测设置如下:

角色 Collision Layer(我是谁) Collision Mask(我想检测谁)
B 不重要 必须包含 A 的 Layer
A 必须设置 不重要

当B检测到A进入时,会触发B节点的area_entered信号,如果需要编写相应的逻辑,在B的代码中连接信号如下:

# B 的代码中
area_entered.connect(on_area_entered)

所以在开发过程中,这两种方式都可以实现碰撞检测,需要根据不同的需求,选择不同的处理方式。一般的思路是,让“逻辑控制者”作为主动方,去监听“目标”的进入,例如:子弹监听被击中单位,触发伤害逻辑,此时子弹是主动方;拾取道具监听玩家进入,此时道具是主动方。

不同节点的碰撞检测响应

除了Area2D,还有三种节点它们都具备Collision layer和Collision Mask的属性:它们分别是StaticBody2D, RigidBody2D, CharacterBody2D,它们都是PhysicsBody2D的子类。所以,在需要进行碰撞检测时,Area2D和PhysicsBody2D节点都需要根据这两个属性是否匹配,来决定是否进行检测碰撞。

而且,不同的节点在进行碰撞检测交互时有着不同的表现。如果需要检测两个Area2D之间碰撞交互,则只需要在任一个Area2D上设置area_entered信号即可,这种情况常用于触发器、感应范围、视野检测等,不产生物理反应。如果需要检测一个Area2D和一个PhysicsBody2D之间碰撞交互,则需要在Area2D上设置body_entered信号,这种情况不会影响物理运动,仅用于检测某个物体是否进入或离开某个区域的事件。如果需要检测两个PhysicsBody2D之间碰撞交互,则会产生物理碰撞影响物理运动,比如推开、阻挡、弹开等。

在瓦片地图中也可以设置碰撞属性。当我们在TileSet中,为某个瓦片(Tile)设置了Physics Layers时,瓦片的碰撞行为默认是当作 StaticBody2D 来处理的,一般用于阻挡玩家、敌人等场景。

设置自定义碰撞层

在本章项目中,游戏对象种类显著增加,除了玩家与敌人双方,还包含子弹、障碍物、道具等多个类型。为了高效地管理这些对象之间的碰撞关系,我们需要使用 Collision Layer 与 Collision Mask 构建出清晰的碰撞关系矩阵。

Godot 支持最多 32 个碰撞层,我们可以通过项目设置为每一层命名,以便后续在节点的 Inspector 面板中直观使用。进入如下菜单设置 Project > Project Settings > Layer Names > 2D Physics

在此菜单下,为前 8 层自定义名称如下:

层编号 自定义名称 用途说明
1 PlayerHitBox 玩家攻击层(挂在玩家子弹上)
2 PlayerHurtBox 玩家受伤层(用于接收敌方伤害)
3 EnemyHitBox 敌人攻击层(挂在敌人子弹上)
4 EnemyHurtBox 敌人受伤层(用于接收玩家伤害)
5 Barrier 障碍物图层(用于阻挡移动和子弹)
6 PlayerBody 玩家物理碰撞层(防止玩家穿墙)
7 EnemyBody 敌人物理碰撞层(防止敌方重叠或穿墙)
8 PickUp 道具层(可被玩家拾取)

设置完成后如下图所示 alt text

这样我们就完成了自定义图层名称,在后续构建不同物理对象的时候,它们的属性面板中会包括Collision Layer和Collision Mask。我们会使用这些自定义层进行设置。

1.3 升级游戏管理器 GameManager

随着游戏系统的复杂化,玩家、敌人、子弹、UI 等模块之间需要频繁通信。我们将采用全局单例 GameManager(挂载为 AutoLoad)来作为通信的中枢,统一管理以下内容:

  • 全局信号定义(如击中、死亡、胜利、得分等);
  • 全局变量(如当前分数、敌人总数);
  • 提供 restart() 等统一控制接口。

打开gamemanager.gd,修改代码如下:

(1)定义信号和变量

signal bullet_hit(pos: Vector2)  # 子弹击中
signal entity_died(pos: Vector2, groups: Array) # 单位死亡(触发爆炸特效)
signal player_killed   #  玩家战败
signal player_win   #  玩家胜利
signal score_update   # 得分更新
signal update_health_ui(health: float) # 更新生命值 UI
signal update_score_ui(score: int, total: int) # 更新得分板 UI

var score: int = 0  # 保存分值
var total_enemy_size: int  # 保存敌人数量

(2)定义核心函数

func _ready() -> void:
    score_update.connect(on_score_update) # 连接信号

# 设置敌人总数变量
func set_total_enemy_size(value):
    total_enemy_size = value

# 负责更新得分
func on_score_update():
    score += 1
    update_score_ui.emit(score,total_enemy_size)

# 负责重启游戏
func restart():
    score = 0
    get_tree().reload_current_scene() # 重新加载当前场景

2 核心功能组件实现

我们将依次构建以下六个核心功能组件,这些组件如同“乐高积木”,可以灵活组合进玩家、敌人、炮塔等对象中,实现高度解耦的架构设计。

  • 承伤组件(HurtBoxComponent):接收伤害,通知血量系统处理;
  • 伤害组件(HitBoxComponent):对其他对象造成伤害;
  • 血量组件(HealthComponent):管理血量值、触发死亡信号;
  • 武器组件(WeaponComponent):控制子弹发射、火光动画和声音;
  • 探测组件(DetectComponent):探测玩家位置,用于敌人行为决策;
  • 轨迹组件(TrailComponent):在地面上留下履带痕迹,增强运动反馈。

2.1 承伤组件

承伤组件的职责是:定义“被击中”的区域,计算护甲抵扣,并将最终伤害传递给血量系统。

在文件系统中新建一个components文件夹,然后新建一个场景,根节点为Area2D类型,重命名为HurtBoxComponent,保存文件为hurt_box_component.tscn,给节点附加代码如下:

extends Area2D

signal get_damage(damage) # 传递处理后的伤害值

@export var armor := 0 # 护甲值,用于减免伤害

func get_hurt(damage:int):
    var final_damage = max(damage - armor, 0) # 确保伤害不为负数
    get_damage.emit(final_damage)
代码解释:get_hurt函数负责接受伤害的逻辑,将传入的伤害值减去护甲值得到最终伤害值。然后触发get_damage信号,传递最终伤害值。

现在我们并不需要给场景添加CollisionShape2D子节点,因为不同单位的承伤区域不同,我们会在后续根据需要再增加。

我们可以在玩家或敌方单位场景中重复使用该组件,并添加一个 CollisionShape2D 子节点用于检测子弹;当组件的父节点或者说宿主受到攻击时,get_hurt() 会被调用,计算后的伤害会通过 get_damage 信号传递给血量组件进行处理。

2.2 伤害组件

伤害组件职责是:定义“攻击”区域,检测碰撞并主动向目标的 HurtBox 发送伤害指令。

在 components 文件夹中,新建一个新场景。根节点为Area2D类型,重命名为HitBoxComponent,保存文件为hit_box_component.tscn,给节点附加代码如下:

(1)定义变量和实施伤害

extends Area2D

signal hit  # 击中反馈信号

@export var damage := 1  # 基础伤害
@export var hit_multiple := false  # 是否具备群体杀伤能力

func _ready() -> void:
    # 信号连接到响应函数
    area_entered.connect(on_area_entered)
    body_entered.connect(on_body_entered)

# 触发对方的get_hurt函数,传递伤害值
func apply_hit(hurt_box: Area2D):
    if hurt_box.has_method("get_hurt"):
        hurt_box.get_hurt(damage)
    set_deferred("monitoring", hit_multiple)
代码解释:设置monitoring为hit_multiple,monitoring属性是设置Area2D是否继续进行碰撞检测,所以这行代码是为了判断子弹是否会继续对其它单位产生伤害。

代码中在调用get_hurt函数时,首先需判断对方对象是否有 get_hurt() 方法(即实现了承伤接口)。has_method() 方法是Godot 中的动态方法检查与调用,它体现了一种非常实用的 “接口松耦合”技巧。这样做的优点是:

  • 避免崩溃(防御式编程):如果你直接调用 hurt_box.get_hurt(damage),而 hurt_box 没有这个方法,Godot 会报错崩溃。使用 has_method() 可以先检查,再调用,防止因类型不一致而导致程序中断。
  • 支持多种对象类型(松耦合):假设子弹击中的是敌人、墙体、障碍物等,有些对象需要响应伤害;有些对象根本不需要这个函数。你不需要去写复杂的 if 分支判断“是不是敌人”,只需要约定:谁想处理伤害,就自己实现 get_hurt() 方法。这样代码只依赖“能力”,不依赖“身份”或“类型”,更灵活、可扩展。

monitoring属性是 Area2D 控制是否持续检测碰撞的开关。我们这样设置的原因:当子弹只能命中一次时(hit_multiple = false),第一次命中后就关闭碰撞检测,防止重复触发;如果希望子弹具有穿透群伤的能力,可将 hit_multiple = true,让它继续检测后续目标。

set_deferred() 是 Godot 中一个非常实用的函数,用于延迟设置属性。你可以理解它为:“我想改这个属性,但不是现在立刻改,而是等 Godot 安排合适的时候再改。”当你在某些特殊的生命周期阶段(比如正在处理碰撞、信号回调、场景树更新)中直接修改某些属性,Godot 引擎内部可能正在使用这些数据,这时立刻修改会造成冲突、报错,甚至崩溃。

假设子弹刚刚命中了一个敌人,触发 area_entered 信号。这时如果要立刻修改monitoring属性,可能会报错。使用set_deferred则会等到在安全的时机,比如下一帧,再修改属性,防止冲突。

(2)定义响应函数

func on_area_entered(area: Area2D):
    hit.emit()
    apply_hit(area)

func on_body_entered(body: Node2D):
    hit.emit()
代码解释:

  • 当子弹击中敌人后会调用on_area_entered函数
  • 而当子弹击中墙壁等障碍物时会调用on_body_entered函数。

我们需要重点理解area_enteredbody_entered 的区别。Area2D 是 Godot 中用于“区域检测”的节点,它可以感知有其他对象进入自己的区域,并通过信号通知脚本逻辑。当一个 Area2D 与其他对象发生“接触”时,可能会触发两种不同的信号:

信号名称 触发条件 检测对象类型
area_entered(area: Area2D) 有另一个 Area2D 对象进入本区域 另一方是 Area2D
body_entered(body: PhysicsBody2D) 有一个 PhysicsBody2D 对象进入本区域 另一方是 物理实体(如 CharacterBody2D, StaticBody2D

当子弹场景中挂了一个伤害组件,玩家身上挂的是 HurtBoxComponent(同样是 Area2D),命中时会触发 area_entered信号。而地图中的石头或墙壁是 Body类型的物理实体,命中时会触发 body_entered信号。

2.3 探测组件

探测组件职责是:作为敌人的“雷达”,实时获取探测范围内玩家的位置,用于开火和追踪决策。

新建一个场景,根节点为Area2D类型,重命名为DetectComponent,保存文件为detect_component.tscn,给节点附加代码如下:

extends Area2D

# 负责获取玩家的位置
func get_player_pos():
    if has_overlapping_areas():
        var targets = get_overlapping_areas()
        # targets如果不为空,返回玩家的全局位置
        if not targets.is_empty(): 
            return targets[0].global_position
代码解释:

  • has_overlapping_areas是Area2D的内建函数,返回是否有其他 Area2D 对象进入该区域。
  • get_overlapping_areas也是Area2D的内建函数,用于获取进入该区域的所有Area2D对象。得到的targets是一个数组,数组的第一个元素就是玩家的Area2D对象。

在探测组件中,我们使用 has_overlapping_areas() 和 get_overlapping_areas() 来获取当前重叠的目标对象,而不是使用 area_entered 信号的方式,是因为敌人的行为(如开火)通常需要每帧持续判断是否有玩家在视野内,而不是仅在某一瞬间触发一次。area_entered 更适合“进入即触发一次”的场景,而 has_overlapping_areas() 可以主动轮询、持续判断,更适合实时决策逻辑。

此外,get_overlapping_areas() 返回的是当前探测区域中所有与之重叠的 Area2D 对象。因为我们会在碰撞层中设置只检测玩家,所以这个数组中只会包含玩家相关的探测节点。因此,我们可以放心地直接取第一个元素认为是玩家,并返回其全局位置进行定位。

2.4 血量组件

血量组件的职责是:纯逻辑组件,管理通用的生命值系统,不参与物理碰撞。

新建一个场景,根节点选择为Node节点,重命名为HealthComponent,保存文件为health_component.tscn,附加代码如下:

extends Node

signal died   # 死亡信号
signal health_changed(health_percent:float)  # 血量变化信号

@export var max_health: float = 5   # 最大血量值
@onready var current_health = max_health  # 当前血量值

# 用于计算当前血量所占的百分比
func get_health_percent():
    if current_health <= 0:
        return 0
    return min(current_health / max_health, 1)

# 处理受到伤害的逻辑
func get_damage(damage_value:float):
    current_health = max(current_health-damage_value, 0)
    health_changed.emit(get_health_percent())  #触发血量变化信号
    check_death()

# 用于检测血量是否小于等于0
func check_death():
    if current_health <= 0:
        died.emit() # 触发死亡信号

# 负责血量升级逻辑
func upgrade(value):
    current_health = min(max_health, current_health+value)
    health_changed.emit(get_health_percent())
代码解释:

  • 血量变化信号会负责传递血量百分比,死亡信号会负责触发死亡逻辑。
  • get_health_percent函数中,返回值是当前血量除以最大血量的比值。为了避免这个比值大于1,我们使用min函数来取最小值。
  • get_damage函数,用当前血量值来扣减受到的伤害值,得到新的血量。为了避免血量小于0,我们使用max函数。然后触发血量变化信号。最后调用check_death函数。
  • upgrade函数中,将current_health加上value,表示血量增加,为了避免超出最大血量,我们使用min函数来取最小值。然后触发血量变化信号。

这种模块化的血量组件,不仅可以简化每个单位的生命值逻辑,也方便后续扩展(如护盾、持续回血、暴击扣血等机制)。只需添加该组件并连接信号,就能让任意对象具备完整的生命系统。

2.5 轨迹组件

轨迹组件的职责是:在坦克行驶路径上定时留下履带印,然后让其逐渐淡出并销毁,形成连续、动态的运动痕迹,以增强视觉表现力。

构建轨迹组件场景

新建一个场景,根节点选择为Node2D节点,重命名为TrailCompoment,再增加一个timer节点。保存文件为trail_component.tscn,给节点附加代码如下:

extends Node2D

@export var trail_scene : PackedScene  #履带轨迹场景
@onready var timer: Timer = $Timer

func _ready():
    # 定时器信号连接generate_trail函数
    timer.timeout.connect(generate_trail)

# 控制定时器的启动
func start():
    if timer.is_stopped():
        timer.start()

# 控制定时器的停止
func stop():
    if not timer.is_stopped():
        timer.stop()

# 用于生成履带轨迹
func generate_trail():
    var trail = trail_scene.instantiate()
    trail.global_position = owner.global_position
    trail.rotation = owner.rotation
    trail.top_level = true
    add_child(trail)
代码解释:

  • generate_trail函数,用于生成履带轨迹。将场景实例化后,设置履带轨迹的位置和旋转角度,最后将履带轨迹添加到当前节点下。
  • 此处的owner是指当前场景的所有者,如果这个组件被Player节点所所有和使用,那么owner就是Player节点。

理解 owner 和父节点

上面的代码中我们使用了owner这个东西,需要理解 owner 和父节点(get_parent)的区别与联系,它是深入掌握场景系统和组件设计的关键。

名称 类型 含义
get_parent() 方法 返回节点树中的直接父节点
owner 属性 返回当前节点的资源所有者

以下面的场景结构为例说明:

Node A
├── Node B    
│   ├── Node C
在这个示例场景中根节点是A,它有子节点B,那么B的owner就是A。B的父节点也是A,所以B节点的owner 和父节点是同一个。B节点有一个子节点C,C的父节点是B,但是C的owner是A,所以C节点的owner 和父节点不是同一个。

你可以把这个场景理解为一个“户口本”,当保存场景数据时,根节点A就设定是户主,其它节点B和C,它们的owner都是A。不过从“辈分”上看,B的父节点是A,C的父节点是B。

get_parent() 是“节点树的父子结构”,非常直观容易理解;而owner是指在保存场景数据时,归属谁保存。在大多数情况下,它们可能一致,但不总是一样,特别是当一个场景中组件结构复杂时。

构建履带轨迹场景

trail_scene变量中还没有具体的值,因为我们还没有构建好履带场景。下面我们来建构这个场景。新建一个场景根节点选择Sprite2D,重命名为TankTrail,从文件系统中找到tracksLarge.png放入texture属性。保存文件为tank_trail.tscn,并附加代码如下:

extends Sprite2D

func _ready() -> void:
    var tw = create_tween()
    tw.tween_interval(1)
    tw.tween_property(self, "modulate",Color(1,1,1,0),0.5)
    tw.tween_callback(queue_free)
代码解释:

  • 使用create_tween函数定义了一个tween,它是一种用于构建动画的工具。
  • 动画逻辑如下:先延迟等待1秒种,然后再用0.5秒种时间将履带的透明度变为0,最后调用queue_free函数来销毁自身。这种做法可以让轨迹自动逐渐消失,不需要额外逻辑清理。

回到trail_component场景中,将trail_scene变量设置为刚刚构建的tank_trail场景。这样未来使用时,每次定时器触发时,就会生成一个新的履带轨迹,履带轨迹和父节点的位置和旋转角度一样,然后慢慢变淡直到消失。

理解Tween和AnimationPlayer

这里我们第一次使用Tween,需要来详细介绍下。Tween是 Godot 中的一种程序化动画工具,全称是 “in-betweening”(插值动画),就是让属性值从 A 变到 B,中间会平滑过渡。

想象你在用遥控器调节灯光亮度。灯光的起始亮度是 20%,然后你想调到 80%。你不想“瞬间跳变”,而是希望它慢慢亮起来(比如 2 秒内逐渐变化)。这就是一个 Tween,它会在一段时间里,平滑地把属性值从旧值变化到新值。

我们在之前章节中使用过AnimationPlayer,它是 Godot 中的可视化动画工具:

  • 可以添加多个“时间轴”;
  • 可以设置关键帧(比如第 0 秒颜色是红色,第 1 秒颜色变白);
  • 控制多个属性、多个节点、多个事件同步播放动画。

这两种动画的区别在于:

特点 Tween AnimationPlayer
创建方式 用代码动态创建 在编辑器中图形化设置
动画内容 通常只修改一个或几个属性 可以同时修改多个节点、属性、播放音效等
灵活性 可动态控制起始值、时间等 需要确定动画时间,可视化便于编辑和复用
使用难度 简单直接,适合轻量动画 学习曲线稍高,但更强大

Tween 灵活快速,适合做“临时动画”。AnimationPlayer 功能强大,适合做“成品动画”。

使用modulate属性和Color函数

modulate 是 Sprite、Control 等节点的一个颜色属性,意思是“叠加颜色”。它可以改变这个节点的颜色、亮度、透明度。而 Color() 是用来设置这个颜色的函数。modulate的默认值是 Color(1, 1, 1, 1),表示不改变颜色、不透明。

Color函数是用来创建颜色的函数,就像你在调色板上调出你想要的颜色一样。代码示例如下:

Color(red, green, blue, alpha)

每个值都是 0 到 1 之间的浮点数:

参数 含义 范围 举例
red 红色成分 0 ~ 1 1 表示最红
green 绿色成分 0 ~ 1 0.5 表示一半绿
blue 蓝色成分 0 ~ 1 0 表示无蓝
alpha 透明度(可选) 0 ~ 1 1 不透明,0 完全透明

Color(1,1,1,0) 是透明白色,表示保持原图颜色,只改变透明度。

2.6 武器组件

武器组件职责是:集成了动画、音效、冷却控制及子弹生成的复杂逻辑。它将支持子弹发射、冷却时间控制、后座力动画和炮口火光特效,并可方便地挂载到玩家或敌人场景上复用。

场景结构搭建

新建一个场景,根节点选择为Node2D节点,重命名为WeaponComponent。给根节点增加若干个子节点,场景的结构如下:

WeaponComponent (Node2D)
├── Gun (Sprite2D)
│   ├── Marker2D(子弹发射位置)
│   ├── Fire(Sprite2D,炮口火光)
├── ShootSound(AudioStreamPlayer2D)
├── Timer(控制冷却时间)
├── AnimationPlayer(播放发射动画)

设置各节点的属性如下:

  • Gun:Sprite2D节点用于显示炮管图像,使用 specialBarrel3_outline.png文件,offset 设置为 (10, 0)。
  • Marker2D:表示子弹发射的位置,position 设置为 (32, 0)。
  • Fire:作为火光特效,贴图使用 shotOrange.png,位置 (40, 0),因为图片原始方向是朝下的,设置rotation为-90。将初始 visible 设为 false。
  • ShootSound:AudioStreamPlayer2D节点用于播放子弹发射声音,volume_db = -10,max_distance = 1200。max_distance 表示声音的最大播放距离。节点中并不需要设置stream,我们会在动画中处理。
  • AnimationPlayer:用于播放发射动画,包括炮管后座、火光闪烁、声音触发。
  • Timer:用于控制发射间隔。

动画轨道设置

在 AnimationPlayer 中创建一个动画 shoot,时长 0.45 秒,建立以下的动画轨道:

  1. 基于Gun 的 position属性创建property track,用于模拟后座力动画,关键帧设置为:
    • 0s:x=0
    • 0.1s:x=-10
    • 0.45s:x=0
  2. 基于Fire 的 visible属性 和 modulate属性创建property track,用于控制火光出现与淡出,关键帧设置为:
    • visible:0s → true,0.3s → false
    • modulate:0s → 全透明,0.1s → 白色,0.3s → 全透明
  3. 基于ShootSound 创建audio player trackback,用于播放子弹发射声音,在第0秒选择插入关键帧,将shoot.wav文件设置为属性值。

动画设置完成后的效果如下图所示。 alt text

编写节点脚本

将设置好的场景保存文件为weapon_component.tscn,然后给节点附加代码如下:

extends Node2D

var can_shoot: bool = true  # 判断当前能否射击
var min_cool_down: float = 0.2  # 最小冷却时间

@export var cool_down: float = 0.5  # 射击冷却时间
@export var bullet_scene: PackedScene  # 保存子弹场景
@onready var marker_2d: Marker2D = $Gun/Marker2D
@onready var shoot_sound: AudioStreamPlayer2D = $ShootSound
@onready var timer: Timer = $Timer
@onready var animation_player: AnimationPlayer = $AnimationPlayer

func _ready() -> void:
    timer.timeout.connect(on_time_out)

# 用于设置炮管的方向
func target(target_pos:Vector2):
    look_at(target_pos) # 让炮管指向目标

func on_time_out():
    can_shoot = true # 冷却时间结束后方可发射子弹

# 负责处理发射子弹的逻辑
func shoot(target_pos:Vector2):
    if can_shoot:
        timer.start(cool_down) # 启动定时器
        if cool_down < 0.5:
            animation_player.speed_scale = 0.5/cool_down
        animation_player.play("shoot") # 播放发射动画
        can_shoot = false  # 进入冷却时间
        var bullet = bullet_scene.instantiate()  # 子弹场景实例化
        bullet.global_position = marker_2d.global_position  # 设置子弹的位置
        bullet.look_at(target_pos)  # 设置子弹的方向
        bullet.top_level = true
        add_child(bullet)  # 添加到节点树中
代码解释:这里需要注意的细节是,如果冷却时间变短,动画播放的速度也要更快,否则动画还没有来得及放完,子弹就已经发射出去了。因此通过修改speed_scale增加了对动画播放速度的设置。

func upgrade(value):
    cool_down = max(min_cool_down,cool_down-value )
代码解释:upgrade函数用于武器升级函数,来缩短冷却时间,让武器可以更快的速度发射。这里使用max函数来保证冷却时间不会小于最小冷却时间。

通过这个组件,我们实现了一个功能完备、动画流畅、支持冷却与升级的发射逻辑。只需在玩家或敌人节点中引用它,即可统一管理武器行为,极大提升代码复用性与模块性。

3 游戏角色场景重构

在完成了功能组件的封装后,我们终于可以回过头来,重构我们的游戏角色了。本部分将带你把之前“功能堆叠”的坦克结构,重构为一个基于组件化架构的可维护、可扩展角色体系。通过这一过程,你将学会如何将多个功能模块组合成一个完整的游戏角色,并通过信号实现它们之间的通信与解耦,让整个角色的逻辑更加清晰、稳定、易于修改。

3.1 玩家坦克重构

在上一章版本的坦克大战中,玩家坦克的功能都集中写在一个脚本或场景中,包括移动、攻击、受伤、死亡处理、音效等。这种“功能集中式”写法虽然开发速度快,但不利于功能扩展和维护。当功能增加时,代码会变得越来越冗长、耦合度高,修改一个小功能可能会引发连锁错误。

因此,在本节中我们采用组件化设计的方法来对玩家坦克进行重构。上一节中,我们已经将不同的功能拆分为多个独立模块(如轨迹、武器、血量等),本节中我们会将这些组件组合在一起,构建一个职责清晰、结构灵活的玩家角色。

场景结构设计

我们可以选择从头新建一个玩家场景,也可以在原有基础上进行修改。为了让玩家和地图中的障碍物、敌人等对象正确产生物理碰撞,我们需要将根节点设置为 CharacterBody2D 类型。如果是对已有场景修改,可以右键选择“Change Type”将原根节点替换为 CharacterBody2D。

原有的如下三个旧节点可以保留:

  • 定义碰撞检测的collisionShape2D
  • 显示坦克车身的Base节点
  • 播放发动机声音EngineSound

其它节点可以删除掉。然后将之前刚构建好的四个组件场景,添加到根节点之下。分别是血量组件,承伤组件,武器组件,轨迹组件。当前的节点结构如下:

Player (CharacterBody2D)
├── CollisionShape2D(碰撞检测)
├── Base (Sprite2D,坦克车身)
├── EngineSound (AudioStreamPlayer,发动机声音)
├── HealthComponent(血量组件)
├── HurtBoxComponent(承伤组件)
├── WeaponComponent(承伤组件)
├── TrailComponent(轨迹组件)

节点参数设置

HurtBoxComponent组件还没有设置碰撞检测区,需要添加一个新的CollisionShape2D作为其子节点。在节点右侧的检查器面板中,形状选择 CircleShape2D,Radius设置为25。

然后设置HurtBoxComponent的碰撞属性,找到Collision属性,修改Layer为2,修改Mask为3,这样敌方子弹就会和HurtBoxComponent产生碰撞,让玩家受到伤害。

alt text HurtBoxComponent的Collision属性设置完成后上图所示

选择Player根节点,设置其Collistion属性,在layer处选择6,在mask处选择5、7、8。也就是说Player节点本身是处于PlayerBody碰撞层,而它需要识别的有三种,分别是障碍物、道具和敌方单位。

alt text Player根节点的Collistion属性设置完成后如上图所示

目前场景中有两个CollisionShape2D,但是它们服务不同的父节点,所以它们的目的是不一样,位于根节点下的碰撞检测区用于物理碰撞检测,防止它和墙壁之类的物体产生重叠。位于HurtBoxComponent下的碰撞检测区用于检测敌方子弹的碰撞,让玩家受到伤害。

增加烟雾粒子特效

为了让坦克移动时更有动感,可以给玩家控制的坦克增加一个粒子系统,来模拟坦克运行时排放的黑烟。在根节点下新建一个CPUParticle2D,重命名为Smoke,设置如下:

  • Amount: 20
  • Lifetime: 2
  • Random: 0.5
  • Direction: (-1,0)
  • Spread: 5
  • Gravity: (0,0)
  • Initial Velocity Min: 40
  • Initial Velocity Max: 50
  • Damping Min: 20
  • Damping Max: 40

这样你会看到大量白色粒子向左侧飘动,然后修改粒子的纹理。在文件系统中找到white_glowing_circle.png放置到Texture属性中。烟雾的颜色可以通过Color来设置,点击Color Ramp新建一个颜色渐变如下:

  • 最左侧的颜色设置为深灰色(#00000087)
  • 最右侧的颜色设置为淡灰色(#45454545)。 这样烟雾的颜色会由浓转淡。

最后修改粒子的尺寸变化,设置Scale Amount Curve,用鼠标点选两个点

  • 开始时间,纵轴值为0.3
  • 结束时间,纵轴值为0.5 这样模拟出烟雾变大慢慢扩散的效果。

修改Smoke节点的坐标位置,设置为(-23,-9),这样烟雾就会从坦克左侧尾部飘出来。

最终的效果如下图: alt text

增加摄像机跟随

为让玩家能在大地图中自由移动并保持视野,所以需要增加一个摄像机来跟随玩家的位置。在Player根节点下新增一个Camera2D节点。Camera2D 节点用于处理2D场景摄像机行为的节点。它允许你控制场景的视角,比如移动、缩放和平滑跟随等。在这里,我们需要限制摄像机不要超出地图的范围。将limit有关属性设置如下:

  • limit_left: -128
  • limit_top: -128
  • limit_right: 2880
  • limit_bottom: 1470
  • limit_smoothed: true 然后找到Position Smoothing属性,将位置平滑选项打开,让视角移动更自然。

修改脚本代码

下面来修改player.gd文件。

(1) 变量定义和_ready

extends CharacterBody2D

var direction: Vector2 = Vector2.ZERO  #方向控制
var speed: float = 0  #速度控制
var can_shake := false  #控制镜头抖动

@export var max_speed : float = 300
@onready var engine_sound: AudioStreamPlayer = $EngineSound
@onready var weapon_component: Node2D = $WeaponComponent
@onready var trail_compoment: Node2D = $TrailCompoment
@onready var health_component: Node = $HealthComponent
@onready var hurt_box_component: Area2D = $HurtBoxComponent
@onready var camera_2d: Camera2D = $Camera2D
@onready var timer: Timer = $Timer
@onready var animation_player: AnimationPlayer = $AnimationPlayer

func _ready() -> void:
    hurt_box_component.get_damage.connect(health_component.get_damage)
    health_component.health_changed.connect(on_health_changed)
    health_component.died.connect(on_died)
    Gamemanager.player_win.connect(on_player_win)
代码解释:

  • 承伤组件的get_damage信号会交由血量组件的函数去响应
  • 血量组件的health_changed信号会交由on_health_changed函数去响应
  • 血量组件的died信号会交由on_died函数去响应
  • Gamemanager的player_win信号会交由on_player_win函数去响应。

(2)核心函数

func on_health_changed(health):
    Gamemanager.update_health_ui.emit(health)
代码解释:on_health_changed函数中,让Gamemanager去传递主角的血量值,让UI可以正常显示。

func on_died():
    Gamemanager.entity_died.emit(global_position,get_groups())
    Gamemanager.player_killed.emit()
    set_physics_process(false)
    hide()
    hurt_box_component.set_deferred("monitorable",false)

func on_player_win():
    set_physics_process(false)
代码解释:

  • on_died函数中,让Gamemanager去传递对象死亡的信号以及玩家被击杀的信号,
  • 使用set_physics_process(false)让玩家无法控制自己的坦克
  • hide()让玩家隐藏显示,
  • hurt_box_component.set_deferred("monitorable",false)让玩家的承伤组件不再起作用,因为玩家已经死亡,不需要被承伤
  • on_player_win函数中则是让玩家无法控制自己的坦克。

(3) 处理移动逻辑

func move(delta):
    direction = Input.get_vector("left","right","up","down")
    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)
        trail_compoment.start()

    else:
        speed = move_toward(speed, 0, 2*max_speed*delta)
        trail_compoment.stop()

    velocity = transform.x * speed 
    move_and_slide()
代码解释:move函数有两个不一样的地方,一个是调用轨迹组件的函数,当玩家移动坦克时会生成履带轨迹,否则就停下来。另一个地方是不再直接设置坦克的位置坐标,而是设置坦克的速度属性velocity,最后是使用move_and_slide()函数来让坦克移动。

(4) 处理输入

func _unhandled_input(event: InputEvent) -> void:
    var target_pos = get_global_mouse_position()  #获取鼠标的世界坐标
    weapon_component.target(target_pos)
    if event.is_action_pressed("shoot"):
        weapon_component.shoot(target_pos)
代码解释:函数会跟踪用户的鼠标位置,调用武器组件的target函数设置目标位置,如果按下了射击键,就调用武器组件的shoot函数发射子弹。

用户输入函数的选择

在 Godot 中,处理用户输入(例如键盘、鼠标、手柄等)是游戏开发的重要一环。Godot 提供了多种输入处理函数,每种函数的触发时机和用途略有不同。Godot 在每一帧中,每个输入事件都会被如下响应函数处理:

  • _input(event)函数 : 最优先接收响应所有输入事件,它可以“消费”或“拦截”输入事件。一般用于全局输入处理,如快捷键、截图、暂停游戏等。
  • 然后控件(Control)节点的 _gui_input(event) 函数被触发。 一般用于处理UI 控件上接受到的输入事件,比如点击按钮等。
  • 如果输入事件没有被上面两种函数处理,才会传递给 _unhandled_input(event),也就是这个短语的字面意思,“没有被处理的输入”。常用于处理游戏中的“玩家控制行为”,如移动、跳跃、攻击。这样可以避免与 UI 交互冲突,例如当你点击某个UI上的按钮时,不会同时让坦克发射子弹。
  • 特定对象(如 Area2D)会触发 _input_event()函数。一般用于游戏中非UI对象的点击事件,例如即时策略游戏中点击单位时的行为。

此外还可以使用 Input.is_action_pressed()。它一般放在 _process() 函数中来主动轮询输入状态。用于持续监听某个动作是否被按住。一般用于玩家持续操作,如连续移动。Input.is_action_just_pressed() 也是主动轮询输入状态,判断是否“刚刚”按下某键,一般用于开火、跳跃、交互等只触发一次的操作。

这些函数的使用建议如下:

你想做的事情 推荐用法
暂停、截图、全局快捷键 _input(event)
控件被点击、拖动、滑动 控件的 _gui_input(event)
玩家控制角色移动或射击 _unhandled_input(event)
玩家点击某个物体触发交互 _input_event()
持续监听某个按键是否按住 Input.is_action_pressed()
检查是否刚按下某个动作(如跳跃) Input.is_action_just_pressed()

镜头晃动特效

当玩家坦克被敌方单位击中时,需要让坦克瞬间变成白色,然后让摄像机镜头产生晃动。我们先来看镜头晃动效果是如何实现的。在Player根节点下新增一个AnimationPlayer节点和Timer节点。镜头晃动需要改变Camer2D节点的属性。继续修改代码如下:

func shake():
    camera_2d.offset = Vector2(randf_range(-3,3),randf_range(-3,3))
代码解释:

  • shake函数中,使用randf_range函数生成一个-3到3的随机数
  • 然后赋值给Camer2D节点的offset属性,这样就可以让镜头产生晃动的效果。

var can_shake := false

func _physics_process(delta: float) -> void:
    move(delta)
    if can_shake:
        shake()
代码解释:在_physics_process函数中调用shake函数。但是shake函数的调用只是在玩家被击中时调用,而且晃动一段时间后也需要自动停止。在代码中增加一个逻辑变量can_shake。

hurt_box_component.get_damage.connect(on_get_damage)

func on_get_damage(value):
    animation_player.play("flash")
    can_shake = true
    timer.start()

func on_time_out():
    can_shake = fals
代码解释:

  • on_get_damage函数中,让AnimationPlayer节点播放flash动画,然后设置can_shake变量为true,并且启动Timer节点。
  • on_time_out函数响应定时器的超时事件,将can_shake变量设置为false。

闪烁特效和Shader

让坦克出现白色闪烁的效果,有两种思路,简单的一种是用图像编辑软件生成一张纯白色的坦克图片,然后在AnimationPlayer中将坦克图片切换成白色图片。难点的一种是使用Shader并结合AnimationPlayer节点来实现。我们看看第二种思路,顺便来学习一下Shader。

Shader(着色器) 是一种运行在显卡(GPU)上的特殊脚本,它的作用是直接告诉显卡每一个像素应该画成什么样子。在 Godot 中,我们可以用 Shader 来让图片变亮、变暗、发光、闪烁、变形、流动等。总之,它可以实现许多炫酷的视觉效果,从而实现高度定制化的视觉效果。在我们这个2D游戏中,使用的Shader类型称之为CanvasItem Shader。

因为它是运行在显卡上的程序,需要非常高效地处理每一个像素,而且是“并行”地处理的,所以不能像普通代码那样随便操作逻辑。Godot 的 Shader 语言是基于 GLSL 的精简版本,称为 "GDScript for Shaders",但具有更易读的语法,并且与Godot的编辑器紧密集成。

点击根节点Player,找到Material属性,点击new shader material,新建一个材质,在其中Shader属性中选择新建一个shader代码资源。Godot编辑器会在底部窗口出现Shader编辑区。在其中编写代码如下:

shader_type canvas_item;

uniform bool active; // 外部控制是否闪烁
uniform vec3 flash_color : source_color = vec3(1.0); //闪烁的颜色


void fragment(){
    vec4 t = texture(TEXTURE, UV);
    if (active == true) {
        COLOR = vec4(flash_color, t.a);
    }
}
代码解释:

  • 第一行定义了shader类型canvas_item
  • 之后定义了两个变量可以用于外部修改。flash_color是一个颜色变量,它是一个三维向量,通常用于表示颜色的红、绿、蓝分量。action是一个逻辑变量。
  • fragment函数是片段着色器(Fragment Shader),负责计算每个像素的颜色。你可以理解为GPU会并行对图片中的每一个像素来调用此函数。
  • texture(TEXTURE, UV)是指从当前纹理中采样出一个颜色值,保存在变量t中,t是一个四维向量,前面三维分量是颜色分量(红、绿、蓝),最后一维是透明度分量(alpha)。
  • COLOR = vec4(flash_color, t.a)是指将flash_color设置为flash的颜色,并将透明度设置为t.a的值,这样原本的图片就会被flash颜色覆盖。这里的颜色我们设置为白色。

将代码保存为flash.gdshader,然后将材质也保存为资源,命名为flash.tres。这样可以在敌方单位上复用这个效果。

回到animationplayer节点,新建一个flash动画资源,动画时间设置为0.1秒,然后点击player根节点,你会在shader parameter上看到两个参数,在Active参数右侧点击钥匙形状的图标,它会自动建立一个关键帧,在开始时设置active为true,然后结束时设置active为false。这样当flash动画播放时,shader中的active参数会被设置为true,从而实现闪烁。

最后给场景节点加上Player组标签。本节完成后,玩家角色就完成了从“集中式脚本”向“组件式组合结构”的重构。这种结构清晰、便于拓展,为后续构建敌人角色、增加升级系统打下坚实基础。

3.2 敌方炮塔重构

在本节中,我们将利用之前实现的各类功能组件,快速而清晰地构建一个完整的敌方炮塔场景。通过这种“组件组合”的方式,可以显著提升开发效率和代码复用性,让角色构建过程变得模块化、可维护。

场景结构设置

新建一个场景,根节点使用StaticBody2D,StaticBody2D 表示静态物体,用于参与碰撞但不会自己移动。这样玩家的坦克就不会穿透或重叠炮塔。重命名为EnemyTower,添加子节点如下:

  • Sprite2D:用于显示炮塔底座图像,设置 texture 属性为 towerDefense_tile181.png
  • CollisionShape2D:用于物理碰撞,设置 shape 为 CircleShape2D,半径与炮塔底座图像大小相匹配

设置根节点的碰撞属性:

  • Collision属性 > Layer 设置为 7,表示自身为敌方单位
  • Collision属性 > Mask 设置为 6,表示会和玩家坦克进行碰撞检测

添加功能组件

将以下四个组件作为子节点添加到炮塔根节点下:

  • WeaponComponent(武器组件):用于发射子弹
  • HurtBoxComponent(承伤组件):检测是否被击中
  • DetectComponent(探测组件):识别玩家位置
  • HealthComponent(血量组件):追踪当前生命值

组件设置:

  • 给 HurtBoxComponent 添加 CollisionShape2D子节点,形状选择CircleShape2D,半径约为 25。
  • 修改 HurtBoxComponent 的 Collision 属性,设置 Layer 为 4,Mask 为 1。
  • 给 DetectComponent 添加 CollisionShape2D,代表探测范围,形状选择CircleShape2D,半径约为 350。
  • 修改 DetectComponent 的 Collision 属性,设置 Mask 为 2,无需设置 Layer。它只起到一个雷达的作用。

添加视觉反馈

为了让敌方炮塔被击中也出现闪烁的效果,操作方法和玩家坦克一样。为根节点添加:

  • Shader 材质,用于闪白处理(参考上一节操作)
  • AnimationPlayer节点,用于管理闪白动画,添加名为 "flash" 的动画,动画时长 0.1 秒,在第0帧设置shader 参数 active为true,在最后一帧设置为false。

完成后,敌方炮塔的节点树结构如下图所示 alt text

编写脚本逻辑

下面给炮塔场景增加代码如下:

extends StaticBody2D

@onready var detect_component: Area2D = $DetectComponent
@onready var weapon_component: Node2D = $WeaponComponent
@onready var hurt_box_component: Area2D = $HurtBoxComponent
@onready var health_component: Node = $HealthComponent
@onready var animation_player: AnimationPlayer = $AnimationPlayer

func _ready() -> void:
    hurt_box_component.get_damage.connect(health_component.get_damage)
    health_component.died.connect(on_died)
    hurt_box_component.get_damage.connect(on_get_damage)
代码解释:

  • _ready函数中,将承伤组件的get_damage信号连接到血量组件的get_damage函数
  • 将血量组件的died信号连接到on_died函数
  • 将承伤组件的get_damage信号连接到on_get_damage函数。

# 负责响应死亡信号
func on_died():
    Gamemanager.entity_died.emit(global_position,get_groups())
    queue_free()

# 负责响应承伤信号
func on_get_damage(damage):
    animation_player.play("flash")
代码解释:

  • on_died函数会让Gamemanager去发出敌方单位死亡的信号,信号会传递敌方炮塔的位置和组标签,然后销毁敌方炮塔。
  • on_get_damage函数负责响应承伤信号,被击中后播放闪烁动画。

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

func find_player():
    var player_pos = detect_component.get_player_pos()
    if player_pos:
        weapon_component.target(player_pos)
        weapon_component.shoot(player_pos)
代码解释:

  • _process函数中,调用find_player函数以发现玩家坦克位置。
  • find_player函数负责搜寻玩家并发出子弹,其中调用探测组件的get_player_pos函数,检测到玩家坦克的位置,然后让武器组件的target函数设置目标位置,让武器组件的shoot函数发射子弹。

最后在根节点上设置组标签,加上"Enemy"和"EnemyTower"两个标签,表示敌方单位和敌方炮塔。这样敌方炮塔场景就很快构建完成了。可以看到,通过组件化方式,我们只需专注于搭建结构、配置参数、连接逻辑,不必每次从头开始来制作。未来我们可以基于这种组件化方式快速扩展更多类型的敌方单位。

3.3 子弹场景重构

子弹是游戏中关键的攻击手段,负责在击中目标时造成伤害。为了实现通用、可扩展的设计,本节将采用“组件 + 继承”的思路,重构子弹场景,使其具备基础功能、良好视觉表现,并方便拓展为不同类型。

构建基础子弹场景

子弹场景需要给对方制成伤害,但本身并不需要承担伤害,所以只要给它加上伤害组件即可。而且玩家子弹和敌人子弹的基本功能都一致,所以,我们会先构建一个基础的子弹场景,然后再继承它构建玩家子弹和敌方子弹。

新建一个场景,根节点选择 Node2D,命名为 Bullet。给根节点添加子节点如下:

  • Sprite2D:用于显示子弹图像。将 bulletSand2.png 设置为 Texture,设置 rotation = 90°,使其朝向右侧。
  • VisibleOnScreenNotifier2D:用于检测子弹是否飞出屏幕范围。
  • HitBoxComponent(伤害组件):负责与敌人或玩家发生碰撞,造成伤害。在伤害组件下添加子节点 CollisionShape2D,设置为 CapsuleShape2D,调整大小使其覆盖子弹图像即可。

保存一下本场景为bullet_base.tscn文件。它将作为后续子弹的基础模板。

添加子弹飞行轨迹

为了提升视觉效果,我们为子弹添加飞行轨迹。操作步骤如下:

  • 根节点下添加 Node2D,再在其下添加 Line2D 节点,命名为 BulletTrail。Line2D节点用于在2D空间中绘制线条。
  • 点击BulletTrail的属性编辑区,将Width修改为10个像素单位,然后修改Width Curve,让线条的宽度可以动态变化,
  • 然后修改Fill属性中的Gradient,增加一个颜色渐变,让线条的颜色也可以动态变化。

目前只有子弹场景中需要飞行轨迹,如果需要把它作为一个可重复使用的组件场景,可以点击右键,选择 save Branch as Scene,保存为一个单独的场景文件,命名为bulet_trail.tscn。再给它附加代码如下:

extends Line2D

@export var length = 3
@onready var parent = get_parent() # 获取其父节点引用

func _ready() -> void:
    clear_points()
    top_level = true

func _process(delta: float) -> void:
    add_point(parent.global_position)

    if points.size() > length:
        remove_point(0)
代码解释:

  • 定义length变量等于3,也就是会使用三个点来绘制轨迹
  • _ready函数中,使用clear_points清除在编辑器中调试用的点数据,并将轨迹设置为顶层节点。
  • _process函数中,每一帧都会添加一个点,其位置为父节点的位置。
  • 如果点的数量大于length,就删除最开始的一个点,这样就实现了子弹的飞行轨迹效果。

理解Line2D的使用

在 Godot 中,Line2D 是一个专门用于在 2D 场景中绘制线条的节点。你可以把它想象成一支“画笔”,可以在游戏中动态地画出路径、轨迹或轮廓。它的核心属性是 points,这是一个由坐标组成的数组,每一个点代表线条上的一个拐点,Godot 会自动用线段将这些点连接起来,形成连续的路径。 我们可以通过编辑这个points属性来改变线条的形状。本例中,我们就是借助 Line2D 来实时记录子弹飞行过程中的路径,然后同时让这个线条可以动态的跟着子弹移动,从而绘制出拖尾轨迹。

编写子弹场景代码

给子弹场景附加代码如下:

extends Node2D

@export var speed:float = 400  #速度变量
@onready var visible_on_screen_notifier_2d: VisibleOnScreenNotifier2D = $VisibleOnScreenNotifier2D
@onready var hit_box_component: Area2D = $HitBoxComponent

func _ready():
    hit_box_component.hit.connect(on_hit)
    visible_on_screen_notifier_2d.screen_exited.connect(on_screen_exited)
    var tw = create_tween()
    tw.set_loops()
    tw.tween_property(self, "modulate",Color("ff6e00"),0.2)
    tw.tween_property(self, "modulate",Color("ffffff"),0.2)
代码解释:

  • _ready函数中将伤害组件的hit事件和函数进行关联
  • 将screen_exited信号和响应函数进行关联
  • 定义了一个补间动画Tween来让子弹的颜色产生变化。

func _process(delta: float) -> void:
    position += transform.x * speed * delta

func on_hit():
    Gamemanager.bullet_hit.emit(global_position)
    queue_free()

func on_screen_exited():
    queue_free()
代码解释:

  • _process函数中负责让子弹朝前方移动
  • on_hit函数中触发一个bullet_hit信号,然后让子弹销毁
  • on_screen_exited函数中让子弹销毁,也就是子弹飞出屏幕时销毁

bullet_base场景的最终节点树结构如下 alt text

继承子弹场景

现在我们已经有了子弹基础场景,然后在该场景基础上,继承构建两个新的场景,分别用于敌方单位和玩家单位的子弹。操作步骤如下:

  • 找到Scene菜单中的new inherited scene,打开子弹基础场景,此时会创建出一个新的、未保存的继承场景,场景中节点名会用黄色显示。
  • 保存为此场景为player_bullet.tscn
  • 同样的操作再继承一次,得到enemy_bullet.tscn。
  • 设置属性,打开player_bullet.tscn,修改玩家子弹的伤害组件其碰撞属性设置如下图所示。它会和敌方单位和障碍物产生碰撞。

alt text

  • 打开enemy_bullet.tscn,将敌方子弹的伤害组件其碰撞属性设置如下图。

alt text

通过以上重构,我们构建了功能完整的“子弹系统”。回到玩家坦克场景和敌方炮塔场景,分别在场景的武器组件中,将Bullet Scene属性设置为刚刚创建的player_bullet和enemy_bullet。

3.4 构建敌方机动单位

目前游戏中只有一种敌人,固定位置的炮塔。为了让战场更加丰富和有挑战性,我们现在将增加一种可以移动的敌人单位:敌方机动坦克。

在 Godot 中,要让一个角色在游戏世界中运动,通常有两种选择:

  • 方式一:使用 CharacterBody2D节点。这种方式需要编写代码来设置方向和速度,并每一帧更新角色的位置。玩家控制的主角坦克就是这样实现的。但如果用它来实现敌人移动,还需要配合AI算法来判断如何寻找玩家、躲避障碍物等。我们将在后续章节介绍这种更复杂但更灵活的方式。

  • 方式二:使用 PathFollow2D节点。PathFollow2D 是具备“自动跟随路径”的节点。具体方法是首先在编辑器中使用 Path2D 节点来绘制一条路径,然后让PathFollow2D节点设置为Path2D 的子节点,并修改其的 progress 属性值,它就会自动跟随路径行走。这种方式不需要复杂的AI算法,非常适合用于构建“巡逻”类敌人。其缺点是只能沿着已经定义好的路径移动,不能自主寻找目标,也不能避开障碍物。本节将采用 PathFollow2D 实现一个可以移动的敌方坦克,作为移动敌人的入门示例。

敌方坦克场景搭建

新建一个场景,根节点选择PathFollow2D,重命名为EnemyTank,保存文件为enemy_tank.tscn。在根节点下增加如下子节点:

  • Sprite2D:用于显示坦克车身,贴图选择 tankBody_blue_outline.png,重命名为Base;
  • 武器组件:cooldown 设置为 0.8,Bullet Scene 选择 enemy_bullet.tscn;在武器节点上右键菜单中选择editable children,展开武器组件的子节点进行设置,子节点Gun的贴图设置为 TankBlue_barrel3_outline.png;
  • 血量组件:Max Health 设置为 4;
  • 探测组件:添加 CollisionShape2D,shape 设置为圆形,Radius 设置为 350,用于检测玩家是否靠近;
  • 轨迹组件:点击Trail Scene右侧的quick load选择,选择tank_trail.tscn。
  • 承伤组件:设置其碰撞属性,使其设置和敌方炮塔一致。

此时,根节点 PathFollow2D 会提示“缺少路径”,我们会在后续的主场景中再处理。

编写敌方坦克脚本

给根节点附加代码如下:

extends PathFollow2D

@export var speed: float = 100  #速度变量

func _ready() -> void:
    hurt_box_component.get_damage.connect(health_component.get_damage)
    health_component.died.connect(on_died)
    hurt_box_component.get_damage.connect(on_get_damage)
代码解释:

  • _ready函数中,将承伤组件的get_damage信号和血量组件的get_damage函数进行关联。将承伤组件的get_damage信号和on_get_damage函数进行关联,将血量组件的died信号和on_died函数进行关联。

func on_died():
    Gamemanager.entity_died.emit(global_position,get_groups())
    queue_free()

func on_get_damage(damage):
    animation_player.play("flash")
代码解释:

  • on_died函数负责响应死亡信号,函数会让Gamemanager去传递敌方坦克的位置和组标签,然后销毁自身
  • on_get_damage函数负责响应承伤信号,函数会播放被击中的闪烁动画。

func _process(delta: float) -> void:
    progress += speed * delta
    trail_compoment.start()
    find_player()

func find_player():
    var player_pos = detect_component.get_player_pos()
    if player_pos:
        weapon_component.target(player_pos)
代码解释:

  • _process函数负责修改progress属性值,让敌方坦克沿着路径移动,启动轨迹组件的start函数,调用find_player函数以发现玩家坦克位置。
  • find_player函数是调用探测组件的get_player_pos函数,检测到玩家坦克的位置,然后让武器组件的target函数设置目标位置。

最后在根节点上设置组标签,加上"Enemy"和"EnemyTank"两个标签,表示敌方单位和敌方坦克。这样通过组件化设计,我们只需挂载已有的功能模块,就能轻松构建出功能完整的敌方坦克,并通过 PathFollow2D 实现其沿固定路线移动的能力。

4 游戏系统拓展

在完成了核心玩法和基本敌人系统后,我们可以开始着手扩展游戏系统,为游戏加入更丰富的内容与挑战性。拓展系统的目的是提高游戏的可玩性与重复游玩价值,同时也为后续的关卡设计和游戏节奏打下基础。

4.1 游戏地图设计

在前面的章节中,我们已经通过 TileMapLayer 构建过简单的地图。本节将带你搭建一个更大、更完整、更具层次感的游戏地图。

创建地图场景

设计游戏地图时,我们通常将不同类型的元素分层绘制,而不是全部画在一个图层上,这是为了提高地图的清晰度、管理效率和功能灵活性。比如,草地是背景,石头是障碍物,大树是装饰,道路是导航线,这些在逻辑和美术上属于不同类别。如果都画在一个图层中,不仅编辑困难,还无法分别设置它们的碰撞属性、遮挡关系,也不利于后续的动态修改或效果控制。而通过分层,每一层只负责一个职责,使得地图更清晰,开发维护更方便,也能实现更复杂的游戏机制。

在这个新地图,我们使用五个图层来组织不同类型的地图元素,分别为:

  • Grass:草地图层,构成地图的基础地形,用于铺设背景;
  • Border:边界图层,放置无法穿越的树木,用于限制玩家活动范围;
  • Road:道路图层,使用自动地形工具(Terrain)绘制出自然连续的道路路径;
  • Items:障碍物图层,放置石头掩体,阻挡玩家与子弹移动;
  • Tree:装饰图层,放置非交互式的大树,用于丰富场景视觉效果。

新建一个场景,根节点选择 Node2D,命名为 Map,并保存为 map.tscn。该场景将作为我们地图的主要容器。然后在根节点下依次创建五个TileMapLayer子节点,分别命名为 Grass、Road、Border、Items和Tree。

Grass图层和TileSet资源设置

Grass图层用于铺设地图的基础地形。在Grassa节点的属性面板中,点击Tile Set,新建一个TileSet,设置Tile Size为64x64,在文件系统中找到terrainTiles_default.png文件,将它拖拽到Tiles中。引擎会自动的根据64个像素大小切出对应的Tile。

我们会在五个图层中使用同一个TileSet,所以将下列图片都加入到TileSet中:

  • treeGreen_larg.png
  • treeBrown_larg.png
  • stone.png

最后点击Save,将它保存为一个资源文件,命名为map.tres.tres文件是一种资源文件格式,可以用来保存各种资源数据,比如材质、TileSet、动画、声音等。它的作用是将资源配置单独保存,便于多个场景之间复用。以 TileSet 为例,当我们从图片中切出瓷砖、设置瓦片属性(如碰撞、地形、动画)后,可以将这个 TileSet 保存为 .tres 文件。这样以后不管在多少个场景中使用地图编辑器,都可以加载同一个 TileSet 资源,无需重复设置,从而实现资源共享、集中管理、减少冗余,提高开发效率。

在TileMap中选择左上角的草地图块,然后点击矩形工具,在主窗口中绘制出一个长方形的大片草地,左上角坐标是(-2,-2),右下角坐标是(44,22),这样草地的大小就是(46,24)的网格大小。完成后如下图所示

alt text

Border图层设置

在TileMap中选择绿色大树图块,然后点击线形工具,在主窗口中绘制出一个长方形的边界,左上角坐标是(-2,-2),右下角坐标是(44,22)。这个边界会像一个围栏一样限制玩家的活动范围。

目前的边界图层没有设置碰撞属性,后续会设置其碰撞属性。完成后如下图所示 alt text

Road图层设置

Road图层中需要绘制地图中的道路,你可以从TileMap中看到有各种不同形状样式的道路图块,例如直线、转弯和交叉的道路图块,这些道路的样式有很大区别,如果是手动来选择并绘制的话,会非常麻烦,这里我们来使用Terrain工具来绘制。

Terrain(自动地形)是一种可以根据周围图块自动判断自身样式的地形绘制系统。适用于道路、围墙、河流等场景。使用Terrain工具可以自动处理复杂的瓦片布局,减少了人为错误的可能性。大幅缩短了创建详细和复杂地图所需的时间。

使用Terrain的步骤如下

  1. 建立Terrain集合。双击文件系统中资源文件map.tres,在右侧找到Terrain Sets,选择Add Element新建一组Terrain集。将mode选择为match sides,这项设置表示只根据周围前后左右四个方向的图块来判断自身的样式。如果是选择match corners and sides,那么会根据周围八个方向的图块来判断自身的样式。因为道路只有四个方向,所以match sides是最好的选择。

  2. 配置Terrain的掩码模式。点击底部窗口中的TileSet,选择Paint标签,在Paint Properties中选择Terrains,然后在Terrain中选择Terrain 0。选择某一个道路瓷砖,例如图中上下走向的道路,点选上侧和下侧。意思就是当自身的上侧和下侧两个瓷砖都是道路时,使用这个瓷砖来绘制。如果是左右走向的道路,那么就是左侧和右侧两个瓷砖都需要道路。使用类似的方法,把绿色道路都绘制好。

alt text

  1. 在关卡中绘制道路。选择底部窗口的TileMap,选择Terrains标签,选择Terrain 0,然后选择Path模式,然后你就可以自由的在地图中绘制道路了。你可以自由发挥,也可以模仿下图的样子,绘制出一个复杂的道路。如下图所示

alt text

Items图层设置

在TileMap中选择石头图块,然后点击画笔工具,在主窗口中选择任意的位置来绘制石头,注意石头的位置不要和道路或边界重合。石头的作用是作为一个掩体。阻挡玩家的移动,还会阻挡子弹,如下图所示 alt text

Tree图层设置

在TileMap中棕色大树图块,使用画笔工具,在主窗口中选择任意的位置来绘制大树,添加地图装饰,以增强视觉丰富度。完成后如下图所示

alt text

设置图块的碰撞层

所有的地图显示部分都已经完成,还需要设置两个地方。为了让边界和石头具备阻挡作用,还需要为它们配置碰撞属性。操作步骤如下:

  • 打开map.tres资源文件,你可以看到Physics Layer 属性,这里就是对应的碰撞层。
  • 选择Add element新建一个碰撞层。
  • 修改物理层的碰撞属性,将Collision Layer设置为5,将Collision Mask设置为6和7。
  • 点击底部窗口中的TileSet,找到Paint,选择Physics Layer 0,然后找到绿色大树和石头,点击选中它们,图片会盖上一层浅蓝色,意思就是这些图块已经具体了碰撞属性。

这样地图上已经画好的所有绿色大树和石头图块就自动具备碰撞属性了。它们会阻挡坦克。坦克就没办法跑出边界,也没法穿过石头障碍。最终设置如下图所示 alt text

通过以上步骤,我们构建了一个完整且具有功能性的游戏地图,草地为主要移动区域,道路通过自动地形快速绘制,边界与障碍具备阻挡作用,石头和大树丰富视觉层次。

4.2 UI设计与美化

在本节中,我们将对用户界面进行美化,使其更加统一、清晰并具有风格。我们的目标是在界面顶部展示击杀数和当前血量,在玩家胜利或失败时,在屏幕中间展示提示信息和“继续”按钮。为了统一风格,我们将使用 Theme 主题资源 来设定字体、颜色、背景纹理等外观。

HUD场景的节点树以及布局和之前一样,我们可以修改原来的场景文件,根节点仍然为Control,命名为HUD。保存为hud.tscn文件。

面板主题设置

首先来美化PanelContainer,选中任一个PanelContainer节点,在右侧属性面板中找到 Theme Overrides > Styles > Panel ,创建一个新的 StyleBoxTexture,并设置如下:

  • Texture:使用 tile_0029.png 背景图
  • Texture Margins 四个方向都设为 10,这样面板会用纹理图片来自动填充;
  • Expand Margins > Top 设置为 5,让面板的上侧多一点空隙;
  • 将该样式保存为 panel.tres 资源,方便复用。 完成后的结果如下图所示:

alt text

这会让所有的面板带有边框、蓝灰色背景,并适配不同大小,具有柔和的界面风格。

Label主题设置

选中面板内的任一Label节点,在 Label Settings 中进行如下操作:

  • 新建字体风格,使用 Rockboxcon12.ttf
  • 字号设为 35
  • 增加描边,Outline Size设置为7,Color设置为黑色
  • 增加阴影,Shadow Size设置为7,Offset设置为(2, 2),Color: 灰色
  • 将该样式保存为 font35.tres 资源,方便复用。 完成后的结果如下图所示: alt text

依次将 panel.tresfont35.tres 分别加载到其他 PanelContainer 和 Label 中,保持 UI 风格一致。这样整个界面看起来统一又简洁。

血条 ProgressBar 主题设置

点击血量条的 ProgressBar 节点的主题属性,依次设置:

  • Background 样式:此处设置背景风格,新建 StyleBoxFlat,按下图参数设置。这样背景就会有呈现蓝色,同时在上侧边缘处蓝色更深,视觉上感觉是一个凹槽的效果。 alt text
  • Fill 样式:此处设置填充风格,新建 StyleBoxFlat,颜色为红色,同时在下侧边缘处红色更深,视觉上感觉是一个凸起的效果。设置完成后如下图所示 alt text

中央提示面板和按钮设置

屏幕中部提示面板节点和Label节点,同样使用 panel.tresfont35.tres 设置风格。

面板窗口中我们还需要有按钮让玩家可以重新开始游戏。在VBoxContainer节点下新增Button节点,在右侧Text属性中输入Continue。修改Container Sizing属性,将Vertical设置为Fill。这样按钮会和上方的Label平分空间。然后修改Button中的字体风格,这里不能使用资源文件,而是要重新设置字体,所以找到Theme Overrieds下的Fonts,使用同样的字体文件,然后设置Font Size为30。这样按键部分的风格修改完成了。按键完成设置后如下图所示: alt text

完成后,HUD 样式统一、信息清晰、交互友好。游戏运行时,你可以看到HUD整体如下图所示: alt text

HUD代码编写

为了处理新增的按键功能,需要修改部分代码:

func _ready() -> void:
    Gamemanager.update_score_ui.connect(on_update_score_ui)
    Gamemanager.update_health_ui.connect(on_update_health_ui)
    Gamemanager.player_killed.connect(on_player_killed)
    Gamemanager.player_win.connect(on_player_win)
    button.pressed.connect(Gamemanager.restart)
    on_update_score_ui(Gamemanager.score,Gamemanager.total_enemy_size)
代码解释:_ready函数中将Gamemanager中的四个信号和响应函数相关联。按键的pressed信号和Gamemanager中的restart函数相关联。并且在开始的时候,调用on_update_score_ui函数,让UI可以显示初始状态。

func on_update_score_ui(score,total):
    label.text = "Killed: %s/%s" %[str(score),str(total)]

func on_update_health_ui(value:float):
    progress_bar.value = value
代码解释:

  • on_update_score_ui函数中,用字符串格式化的方法来显示击杀的数量和总敌人数量。
  • on_update_health_ui函数中,将玩家的血量显示在进度条中。

func on_player_killed():
    timer.start()
    await timer.timeout
    result.text = "You Lost"
    panel_container_3.show()
代码解释:on_player_killed函数负责当玩家被击杀时的逻辑,启动一个计时器,等待计时器结束,然后显示屏幕中部的信息面板。

func on_player_win():
    timer.start()
    await timer.timeout
    result.text = "You Win"
    panel_container_3.show()
代码解释:on_player_win函数是类似的,只不过显示内容不区别。

这样我们就完成了 UI 的美化与逻辑绑定,让整个游戏界面更加精致与可操作。

5 游戏管理系统

为了让游戏中的各类单位、道具与特效能高效协同运行,我们需要建立一套“管理系统”,将这些功能模块统一调度、集中管理,确保游戏逻辑有序、性能稳定。

本节将开发三类核心管理模块,分别对应不同的功能职责:

  • 道具管理系统:包括可供拾取的道具场景,以及专门生成和控制这些道具的“道具管理者”。它负责在适当位置生成道具,并监听玩家拾取事件。
  • 特效管理系统:负责显示如子弹击中、单位死亡等视觉反馈。我们将创建一个通用的“特效管理者”,复用击中特效资源,避免每次都重复实例化。
  • 敌人管理系统:用于动态控制敌人单位的生成和数量,根据玩家表现调整敌人分布节奏,使游戏挑战性更具节奏感。

5.1 道具管理系统

为了让游戏更加丰富,我们引入了“道具机制”:当敌人被击毁时,有机会掉落道具,玩家拾取后可获得强化效果。本系统包含两种道具:

  • 血量道具:恢复玩家血量
  • 武器道具:提升玩家攻击速度

这两种道具具备相同的结构与行为,因此我们首先创建一个通用的道具基础场景,然后通过继承扩展出具体的道具类型。

道具基础场景

新建一个场景,根节点为 Area2D,命名为 PickUp,保存为 pick_up.tscn。添加以下子节点:

  • Sprite2D:用于显示道具图标
  • CollisionShape2D:道具的碰撞检测区
  • AnimationPlayer:播放拾取动画
  • AudioStreamPlayer:播放拾取音效
  • GPUParticles2D:粒子特效提示

节点树结构如下图所示: alt text

各节点的设置如下:

  • Sprite2D,设置贴图为shield_gold.png,其实作为基础场景并不需要图片显示,因为我们会在继承的场景中来设置图片。但是为了调试方便先暂时设置一张图片,要记得在最后将这个Texture中的图片删除。
  • CollisionShape2D,设置Shape为CircleShape2D,Radius设置为15。
  • 修改根节点的碰撞属性,修改Collision Layer为8,Mask为6。这样只会和玩家产生碰撞检测。

设置GPUParticle2D

这个粒子系统的作用是显示金色的小圆点围绕着道具,以提示玩家道具的存在。将white_glowing_circle.png设置为texture。再点击 Process Material,新建一个Particle Process Material

其它参数设置具体如下:

  • emitting: true
  • Amount: 8
  • Emission Shape: Ring
  • Orbit Velocity: (0.5,1)
  • Gravity: (0,0)
  • Color: 设置为金黄色,色值为#ffd941 最后将Scale Curve设置为从1变化到0.2,让小圆点从大到小。

设置动画效果

道具有两种动画,一种是当道具还没有被拾取时,这时候需要播放的是idle动画,另一种是当道具被拾取后,需要播放的是pickup动画。

在AnimationPlayer节点中增加一个名为idle的动画,点击add track,选择property track,新增一个属性动画轨道,选择对象为Sprite2D的Scale属性和Rotation属性。

  • 设置Scale属性如下:在0秒处值为1,在0.2秒处值为1.5,在0.4秒处值为1。
  • 设置Rotation属性如下:在0秒处值为0,0.1秒处值为-10,0.3秒处值为10。0.4秒处值为0。
  • 动画时长1秒。将idle动画设置为自动播放和循环播放。

动画表现的效果就是图片会短暂的放大,同时小幅晃动,然后还原。

然后再来设置pickup动画,分五条属性轨道,分别是Sprite2D节点的position、scale和Modulate,以及GPUParticles2D的Modulate、Scale。动画设置参数如下:

  • 动画的时长为0.5秒
  • 在0秒处,Sprite2D的Position为(0,0),scale为(1,1),modulate为白色,GPUParticles2D的modulate为白色,scale为(1,1)。
  • 在0.5秒处,Sprite2D的Position为(0,-60),scale为(3,3),modulate为全透明,GPUParticles2D的modulate为全透明,scale为(1.5,1.5)。

这样就会实现让道具从原来的位置向上飞出,并慢慢消失的效果。

最后还需要给pickup动画增加一个音效,点击add track,选择Audio Playback track,新增一个音效轨道,音效文件为pickup.wav。

道具场景代码

下面给场景根节点附加代码,代码如下:

extends Area2D

enum Pickups {GUN, HEALTH}   #定义枚举值

signal get_pickup(pos:Vector2, pickup_type:Pickups)

@export var pickup_type :Pickups = Pickups.HEALTH    #道具类型
@onready var animation_player: AnimationPlayer = $AnimationPlayer
代码解释:

  • 定义enum变量Pickups,我们这里的枚举值有两个,分别是表示两种不同的道具GUN和HEALTH,这样在后续的代码中可以直接使用Pickups.GUN来表示GUN。
  • 定义了一个信号get_pickup,用于告知其他节点道具被拾取,信号附带两个参数,pos是道具的位置,pickup_type是道具的类型。
  • 定义了变量pickup_type,用于定义自己是什么道具类型。

enum(枚举)是一种特殊的数据类型,它由一组命名的常量组成。枚举主要用于定义一组相关的常量值。通过使用枚举,你可以为这些数值赋予有意义的名字,从而提高代码的可读性和维护性。

func _ready() -> void:
    body_entered.connect(on_body_entered)

func on_body_entered(body:Node2D):
    if body.is_in_group("Player"):
        if pickup_type == Pickups.GUN:
            body.upgrade_weapon()
        elif pickup_type == Pickups.HEALTH:
            body.upgrade_health()
        animation_player.play("pickup")
        await animation_player.animation_finished
        queue_free()
代码解释:

  • _ready函数中,将body_entered信号连接到on_body_entered函数,这个函数的作用是当玩家拾取道具时,调用对应的函数。
  • on_body_entered函数会判断碰撞的节点是否在Player节点组中,如果是,就调用Player场景中的升级函数,然后播放pickup动画,等待动画播放完成后,销毁道具节点。

衍生道具类型场景

将pickup场景的Sprite2D图片删除后保存场景文件,然后在此基础上继承出两个道具场景,分别是武器道具场景pick_up_gun和血量道具场景pick_up_health。

打开武器道具场景,将其SPrite2D图片设置为bolt_gold.png,在根节点处,设置道具类型为Gun。打开血量道具场景,将其SPrite2D图片设置为shield_gold.png,在根节点处,设置道具类型为Health。这样我们就完成了两种道具的开发。后续我们会再构建一个道具管理系统来负责道具的统一投放。

道具管理者

新建一个场景,根节点选择Node,重命名为PickupManager。保存为pickup_manager.tscn。给它附加代码如下:

extends Node

@export var gun_pickup_scene: PackedScene 
@export var health_pickup_scene: PackedScene 
@onready var pickup_array = [gun_pickup_scene,health_pickup_scene]
代码解释:代码中定义了三个变量,分别是武器道具和血量道具的场景,然后将它们组合成一个数组。

func _ready() -> void:
    Gamemanager.entity_died.connect(on_entity_died)

func on_entity_died(pos:Vector2, groups:Array):
    if "Enemy" in groups:
        if randi_range(1,10) > 5:
            call_deferred("spawn_pickup",pos)
代码解释:

  • _ready函数中将对象死亡信号和函数进行关联
  • on_entity_died函数负责判断死亡对象是否是带有Enemy组标签的对象,然后根据随机数来判断是否调用spawn_pickup函数来生成道具。这里randi_range函数会生成1到10之间的随机数,这里的概率差不多是0.5。
  • call_deferred是一个延迟调用函数,意思是让spawn_pickup函数不要立刻执行,而是在等到空闲的时候执行,

在 Godot 中,call_deferred()set_deferred() 是用于延迟执行函数调用或属性设置的方法,它们会等到当前帧中的所有物理和节点处理完成后(通常是下一帧)再执行。这样做的主要目的是避免在节点正在被处理、添加或移除的过程中直接修改场景树或属性,从而导致引擎报错或行为异常。比如当一个敌人刚被销毁,其节点正在清理过程中,如果立即添加新节点或修改某个属性,可能会和内部的资源管理冲突,造成“正在处理时不能修改树”的报错。这时使用 call_deferred()set_deferred() 就可以安全地将这些操作推迟到系统空闲时执行,确保稳定性。

func spawn_pickup(pos:Vector2):
    var pickup_scene = pickup_array.pick_random()
    var pickup = pickup_scene.instantiate()
    pickup.global_position = pos 
    add_child(pickup)
代码解释:

  • spawn_pickup函数和之前放置子弹场景是类似的逻辑,它会从道具数据中随机选择一个道具的场景,然后实例化出来,修改它的坐标位置,然后使用add_child放置在场景树中。场景树中,所有的道具场景节点,都会在PickupManger节点下面。

最后回到PickupManager根节点,在检查器面板上将两个场景变量进行赋值,选择我们之前创建好的两个道具场景文件。

通过以上构建,我们实现了一个功能完整、结构清晰的道具系统。道具逻辑集中在统一的基础类与管理者中,后续如需增加更多种类的拾取物,只需继承基础道具场景并赋予不同的行为即可。

5.2 特效管理系统

在游戏中视觉反馈非常重要。为了增强打击感和画面表现,我们可以在敌人被击中或被击毁时,添加爆炸特效。这些特效不仅提升了游戏的观感,也有助于让玩家清晰地获取游戏反馈信息。游戏中需要两种不同的特效,分别是当某个单位被击中时特效,以及单位被击毁时的特效。上一章我们已经开发了击毁敌人的特效场景,本节需要新开发击中敌人的特效。

为了统一管理这些特效,我们将构建一个特效管理系统,它的主要职责是接收事件请求,在指定位置生成对应的特效节点,并在播放完毕后自动销毁。

击中特效场景

建立一个新的场景,根节点类型为CPUParticle2D,重命名为ExpParticle,在文件系统中找到white_glowing_circle.png,拖拽到Texture属性中,其它设置如下:

  • emitting: false
  • Amount: 12
  • Lifetime: 0.2
  • One Shot: true
  • Explosiveness: 1.0
  • Random: 0.3
  • Direction: (1,0)
  • Spread: 180
  • Gravity: (0,0)
  • Initial Velocity Min: 200
  • Initial Velocity Max: 300
  • Damping Min: 100
  • Damping Max: 200

然后设置尺寸渐变,将Scale Amount Curve设置如下图所示,让粒子的尺寸变化从大到小。 alt text

最后设置颜色渐变,将Color Ramp设置如下图所示,让粒子的颜色由浅到深。 alt text

你可以在编辑器中测试这个特效,点击Emitting属性,粒子系统会马上播放一次。可以看到一团黄色粒子从中心向外扩散,然后颜色变黑,模拟出一个爆炸火光的效果。

特效管理者

新建一个场景,根节点选择Node,重命名为VFXManager。保存为vfx_manager.tscn。爆炸效果需要播放对应的音效,给根节点增加两个AudioStreamPlayer2D节点,一个用于播放击中音效,重命名为HitSound,另一个用于播放击毁音效,重命名为ExpSound。

找到Explosion.wav文件,拖入到ExpSound节点中。然后在HitSound节点的Stream属性中,选择新建一个New AudioStreamRandomizer。在其下方的Streams属性中加入五个音频文件,均是impactMining开头的ogg文件,这样的效果是可以随机播放其中任意一个击中的音效,因为在游戏中击中的声音会播放很多次,用略有差异的音效来模拟会更好。

给根节点附加代码如下:

extends Node

@export var exp_anim_scene: PackedScene   # 保存击毁特效场景
@export var exp_particle_scene: PackedScene  # 保存击中后特效场景
@onready var hit_sound: AudioStreamPlayer2D = $HitSound
@onready var exp_sound: AudioStreamPlayer2D = $ExpSound


func _ready():
    Gamemanager.bullet_hit.connect(on_bullet_hit)
    Gamemanager.entity_died.connect(on_entity_died)

func on_entity_died(pos:Vector2, groups:Array):
    if "Enemy" in groups:
        explode(pos,"regular_exp")
    elif "Player" in groups:
        explode(pos,"sonic_exp")

func on_bullet_hit(pos: Vector2):
    spawn_exp_particle(pos)
    hit_sound.global_position = pos
    hit_sound.play()
代码解释:

  • _ready函数将子弹击中信号和单位死亡信号和函数进行关联
  • on_entity_died函数会根据单位的组标签来判断是敌方单位还是玩家单位,然后调用explode函数来播放不同的爆炸动画。
  • on_bullet_hit函数会调用spawn_exp_particle函数来播放爆炸粒子,然后播放击中音效。

func explode(pos: Vector2, name: String):
    var exp_anim = exp_anim_scene.instantiate()
    exp_anim.global_position = pos
    add_child(exp_anim)
    exp_anim.play(name)
    exp_sound.global_position = pos
    exp_sound.play()
    await exp_anim.animation_finished
    exp_anim.queue_free()
代码解释:explode函数会根据传入的爆炸动画名称来播放不同的爆炸动画,然后播放爆炸音效,等待动画播放完成后,再销毁动画节点。

func spawn_exp_particle(pos: Vector2):
    var exp_particle = exp_particle_scene.instantiate()
    exp_particle.global_position = pos
    add_child(exp_particle)
    exp_particle.emitting = true
    await exp_particle.finished
    exp_particle.queue_free()   
代码解释:spawn_exp_particle函数会根据传入的坐标位置来实例化爆炸粒子场景,然后播放粒子系统,等待粒子播放完成后,再销毁粒子节点。

最后,回到根节点的检查器面板,将之前建立好的两个特效场景文件拖入到对应的属性中。通过这样的结构,我们就构建了一个灵活而高效的特效管理系统,它不仅能大幅提升游戏表现力,还能让代码更加清晰易维护。

5.3 敌方坦克管理者

在本章的游戏中,我们设计了两类敌人单位:一种是固定位置的敌方炮塔,另一种是会移动的敌方坦克。由于炮塔的位置不会变化,我们直接在主场景中手动摆放它们。而敌方坦克则采用“分波次生成”的方式,通过脚本动态投放到场景中。

创建管理场景

我们将所有敌方坦克的生成和管理逻辑封装在一个独立的管理节点中。新建一个场景,根节点为Node类型,重命名为EnemyManager,保存为enemy_manager.tscn文件。给它附加代码如下:

extends Node

signal wave_died  #整个波次的坦克全部被击毁

@export var  enemy_tank_scene: PackedScene   #存储坦克场景
var died_tank: int   #被击毁的坦克数量
var died_enemy: int  #被击毁的敌方单位数量
var total_enemy_size: int   #游戏中所有敌方单位数量

@export var path_holder: Node2D  #用于管理路径节点
@export var wave_number: int = 3   #敌方攻击波次数量
@export var enemy_in_wave: int = 8  #每个波次的坦克数量
@export var enemy_spawn_time: int = 2  #坦克的出生间隔时间
@export var enemies_holder: Node2D   #容器节点用于收纳坦克
@onready var paths: Array  #用于保存路径的数组


func _ready() -> void:
    Gamemanager.entity_died.connect(on_entity_died)
    check_enemy_size()
    paths = path_holder.get_children()
    spawn_waves()
代码解释:

  • _ready函数中会将entity_died信号和on_entity_died函数进行关联
  • 然后调用check_enemy_size函数来计算游戏中所有敌方单位的数量,
  • 调用path_holder.get_children()函数来计算path_holder下有多少个路径节点,将其保存到paths数组中。
  • 最后调用spawn_waves函数来开始放置坦克。

func check_enemy_size():
    for enemy in enemies_holder.get_children():
        if "EnemyTower" in enemy.get_groups():
            total_enemy_size += 1
    total_enemy_size += wave_number*enemy_in_wave
    Gamemanager.set_total_enemy_size(total_enemy_size)
代码解释:

  • check_enemy_size函数会计算游戏中所有敌方单位的数量。敌方坦克单位数量等于波次数乘以每个波次的坦克数量。
  • 敌方炮台的数量是手动放置的,所以需要从场景树中获取。
  • 这样计算出游戏中所有敌方单位的数量,这个数字会在游戏的UI中显示
  • 调用函数将这个数字传给GameManager进行保存。

func spawn_waves():
    for i in wave_number:
        await spawn_wave()
        await wave_died

func spawn_wave():
    died_tank = 0
    for i in enemy_in_wave:
        call_deferred("spawn_enemy")
        await get_tree().create_timer(enemy_spawn_time).timeout

func spawn_enemy():
    var enemy_tank = enemy_tank_scene.instantiate()
    var path = paths.pick_random()
    enemy_tank.speed = randi_range(100,150)
    path.add_child(enemy_tank)
代码解释:

  • spawn_waves函数负责启动所有敌人的放置。它通过一个for循环来调用多个波动的放置,然后await关键词会让函数等待这个波次的敌人全部被击杀,才会进入下一个循环的执行。
  • spawn_wave函数负责每个波次的敌人的放置。它也是通过一个for循环来投放本波次的多个敌人。它是通过一个固定的定时器,每过一段时间来放置一个敌人。注意函数中使用了await关键字,在外部调用时,也使用了await关键字,这叫做await链。
  • spawn_enemy函数负责每个敌人的放置。首先实例化敌人,然后从路径节点中随机选择一个路径,将敌人添加到对应的路径节点下面。这样不同的敌方坦克会走不同的路。

在 Godot 开发中,await 就像是一个“暂停键”。当你在一个函数里使用了 await(比如等待计时器或某个信号),这个函数就会变成异步执行。这时,你需要遵守一个重要的原则:Await 链(Await Chain)

Await 链是指,如果 函数 A 内部有 await,那么当 函数 B 调用 A 时,通常也应该在前面使用 await。如果不加 await,函数 B 会像“火星撞地球”一样,不等 函数 A 执行完就直接冲向下一行代码。这通常会导致逻辑顺序混乱(例如:敌人还没生成完,关卡就开始结算了)。如果加上 await:函数 B 会乖乖原地等候,直到 函数 A 彻底完成使命,再继续后续逻辑。 除非你明确希望这个函数在后台“偷偷运行”,而主逻辑继续去做别的事(比如播放背景音乐的同时异步加载资源),否则请务必保持 Await 链 的完整性,这样你的游戏逻辑才会稳如泰山。

func on_entity_died(pos, groups):
    check_wave_end(groups)
    check_killed_enemy(groups)

func check_wave_end(groups):
    if "EnemyTank" in groups:
        died_tank += 1
        if died_tank == enemy_in_wave:
            wave_died.emit()

func check_killed_enemy(groups):    
    if "Enemy" in groups :
        died_enemy += 1
        Gamemanager.score_update.emit()
        if died_enemy == total_enemy_size:
            Gamemanager.player_win.emit()
代码解释:

  • 当某个单位被击杀的时候,响应函数on_entity_died会做两件事,一个是检查这个波次的坦克是否全部被击毁,另一个是检查敌方单位是否全部被击毁。
  • 本波次坦克全部被击毁则触发wave_died信号
  • 如果所有敌人被击毁,则触发player_win信号
  • check_killed_enemy还会负责更新分数。

通过将坦克生成、路径分配、波次控制和击杀统计全部集中管理,EnemyManager 极大地提高了代码的模块化和可维护性。后续如果要增加更多类型的敌人或不同的生成机制,只需扩展该管理器即可。

6 游戏主场景构建

现在我们将完成整个游戏的最终整合,构建主场景,整合地图、角色、路径、敌人、管理系统和UI,形成完整的可运行游戏。

主场景设置

新建一个场景,根节点为Node2D,重命名为Game。接着,将你先前制作好的 关卡地图场景 拖入到该场景中,然后再将 玩家坦克(Player) 拖入场景。玩家起始位置可以根据关卡设计自由摆放,但要确保其初始位置不在墙体或障碍物中。

构建敌人路径

为了让敌方坦克具有移动路线,我们需要使用路径系统:

  • 在根节点下新增一个 Node2D 节点,命名为 PathHolder,用于集中管理所有路径。
  • 在 PathHolder 下添加多个 Path2D 节点,每个节点代表一条路径。
  • 选中某个 Path2D 节点后,顶部工具栏会出现路径编辑工具。点击“添加控制点(Add Point)”按钮,在地图上沿道路依次点击,为路径绘制多个控制点。

刚开始使用的时候可能不熟悉,稍加练习就会明白路径编辑器的使用。对于不需要的路径,直接删除Path2D节点,再重新创建就会得到一个空的路径。当你练习熟练后,我们需要对照着地图中的道路来建立路径。路径的第一个控制点可以选择道路的某个起点位置,然后其它控制点沿着道路的延申方向进行放置。最后的控制点可以选择道路的另一个出口位置。下图是一个路径的示例:

alt text

我们需要绘制多条不同的路径,以方便敌方坦克选择某条路径进行移动。

放置敌方炮塔

下面我们需要手工放置敌方炮台,在根节点下新增一个Node2D节点,重命名为Enemies,在此节点下将之前开发的EnemyTower场景放置进来。你可以根据地图布局和预期难度,自由决定炮台的数量和位置。但需要仔细选择每一个炮台的位置,注意不要离主角坦克太近,也不要放在道路或是障碍物上。布局要平衡,确保既有挑战又不过分压制玩家。

下图是敌方炮塔的示例,图中可以看到炮塔的探测范围。 alt text

设置管理者场景和UI

随后将之前开发的三种游戏管理者放置进来:

  • EnemyManager(敌方单位管理)
  • PickupManager(道具管理)
  • VFXManager(特效管理)

点击EnemyManager,在右侧属性中,点击Path Holder,选择Path节点。这样EnemyManager会知道当前场景中路径存放在哪个节点下。然后点击Enemies Holder,选择Enemies节点,这样它就会知道场景中的敌方坦克在哪个节点下。

最后,为了实现游戏中的UI,需要在根节点下,添加一个CanvasLayer节点,并将之前制作好的 HUD 场景作为其子节点,以确保 UI 始终显示在画面最上层。

场景结构参考

最终的场景节点树结构如下图所示:

alt text

通过以上步骤,我们就完成了游戏主场景的搭建工作。现在,你已经拥有一个具备地图、角色、敌人路径、多波次敌军、特效与完整 UI 的完整 2D 坦克游戏原型。

你可以点击 Godot 编辑器右上角的运行按钮,开始测试整个游戏。如果一切设置正确,你应该可以看到以下效果:

  • 玩家坦克出现在地图上,可以正常移动和射击;
  • 敌方炮台固定在指定位置,并对玩家进行攻击;
  • 敌方坦克按波次从地图入口处陆续出现,沿着路径前进;
  • 道具在敌人被击毁后有概率掉落,拾取后会提升玩家能力;
  • 每击败一名敌人,UI 上的击杀计数器会更新;
  • 玩家受伤时生命条减少,击中敌人会显示击中特效;
  • 若敌人全灭,将显示胜利信息;玩家被击毁则会看到失败提示。

如果运行过程中发现异常,例如敌人不移动、路径无效、UI 不显示等问题,请逐一检查场景引用、信号连接、节点层级和变量赋值,确保与之前设置保持一致。

本章小结

本章带领你完成了一款功能更为丰富、结构更为完整的 2D 坦克大战游戏。在这一过程中,你不仅进一步熟悉了 Godot 引擎的核心开发流程,也掌握了构建大型关卡、管理多个敌人单位、制作击中特效、实现掉落道具、搭建用户界面等关键技能。

我们从地图系统开始,学习了如何使用多图层构建复杂的 TileMap,并使用 Terrain 工具高效绘制道路。通过资源文件 .tres 的复用,也提升了地图与 UI 开发的灵活性和美观度。接着,我们构建了道具系统和特效系统,掌握了如何设计基础场景并通过继承扩展多个变种,从而实现结构清晰、可维护性强的功能架构。

在敌人管理方面,我们实现了波次生成、路径移动、胜负判定等机制,提升了游戏的策略性与挑战性。通过 EnemyManager、PickupManager 和 EffectManager 等管理系统的协作,游戏逻辑变得更模块化、更加可控。

最终,我们将所有模块集成到游戏主场景中,建立起一套完整的游戏框架,并完成了从资源布局、路径绘制到 UI 接入的各项操作。

本章不仅仅是一次项目实践,更是一次系统的游戏开发能力提升之旅。你所掌握的这些经验,将为后续构建更复杂、更具创意的游戏项目打下坚实的基础。