GameTech/Godot 4.7 互动教程
目录示例游戏
第三部分 · 2D 实战 · 第 13 章

太空射击:对象池、碰撞层与粒子

做一个纵版弹幕射击游戏。用对象池对付成百上千颗子弹,用 Area2D 和命名的碰撞层/遮罩安排"谁检测谁",用 Path2D 让敌人排队飞行,用极坐标写出环形、螺旋、自机狙弹幕,用数据资源描述波次,最后用 GPUParticles2D 和发光做出霓虹爆炸。

射击游戏有一个和前面几章都不一样的特点:同一时刻屏幕上的东西非常多。Boss 放一轮螺旋弹,几秒钟就有三四百颗子弹在飞;玩家每秒打出三四十发;爆炸一个接一个。前面几章里"需要一个东西就 instantiate(),不要了就 queue_free()"的写法在这里会开始吃力。这一章围绕这个问题展开:子弹怎么复用、碰撞怎么分层才不浪费、弹幕的数学怎么写、敌人和波次怎么用数据描述,以及怎样让画面亮起来。

先玩一下。在标题界面按 Enter 开始,方向键移动,按住 空格 射击,X 放炸弹,按住 Shift 低速移动并显示判定点。嫌打波次太慢,可以点右侧的"直接到 Boss"。

动手试试:开始游戏后勾上"自动射击"和"无敌模式",把"射速"拉到 30、"每轮子弹数"拉到 9。看右侧"实时数据"里的"活动子弹"和"每秒新建节点":开着对象池时,新建节点一直是 0。然后取消"使用对象池",新建节点立刻变成每秒几百个,"池中空闲"变成 0。再点一次"弹幕压力测试",看活动子弹涨到七百颗左右时两种模式的数据有什么不同(帧时间的差别取决于你的电脑,后面有实测数据)。

1. 工程结构

示例工程分三块:core/ 是两个自动加载(游戏流程 Flow 和音频 Audio,下一章细讲),game/ 是游戏本体,ui/ 是界面。本章关心的是 game/:

文件作用
game.tscn / game.gd一局游戏的根节点。把玩家、两个子弹池、波次调度、Boss、特效和 HUD 串起来,管分数、生命和炸弹
bullet.gd、player_bullet.tscn、enemy_bullet.tscn子弹(Area2D + CollisionShape2D + Sprite2D)
bullet_pool.gd对象池:预先建好子弹、统一移动、回收复用
player.gd玩家:八方向移动、散射、受击、无敌闪烁
enemy.gd、boss.gd杂兵(沿 Path2D 飞)和三阶段 Boss
danmaku.gd弹幕图案:环形、自机狙、螺旋、花瓣,全是静态函数
wave_data.gd、stage_data.gd、stage1.tres波次数据(自定义 Resource)
wave_director.gd按数据一波一波地生成敌人
fx.gd爆炸粒子、冲击波圆环

场景里有一个叫 Field 的 Node2D,放在 x = 180 处,宽 600、高 540,所有游戏物体都是它的子节点,用它的局部坐标。左右两侧是 HUD 面板,盖住了场地外面的部分。这样"出界"判断只需要和一个固定矩形比较,HUD 的排版也和游戏坐标互不干扰。

2. 为什么需要对象池

先看不用池的写法:每发射一颗子弹就 bullet_scene.instantiate()、add_child(),飞出屏幕就 queue_free()。逻辑完全正确,问题出在代价上。一次 instantiate() 加 add_child() 要做这些事:

  1. 按 PackedScene 里的记录创建 3 个节点(Area2D、CollisionShape2D、Sprite2D),给每个分配内存、设置属性;
  2. 节点进入场景树:发出 NOTIFICATION_ENTER_TREE、执行脚本的 _ready()、连接信号;
  3. Area2D 进树时会在物理服务器里创建对应的对象,加入物理空间的宽相结构;Sprite2D 会在渲染服务器里创建一个画布项。

queue_free() 则在帧末把这些全部反着做一遍:从物理空间和渲染服务器里删除、退出场景树、释放内存。

单独一次很快(微秒级),但射击游戏里它们是成批发生的:玩家每秒 30 轮、每轮 9 颗就是每秒 270 次创建加 270 次销毁;Boss 一圈环形弹 24 颗是在同一帧里创建的。场景稍复杂一些(子节点多、_ready() 里做初始化),这些工作挤在少数几帧里,就会表现为帧时间上的尖刺。Web 版是单线程运行的 WebAssembly,同样的工作比桌面慢好几倍。

对象池的思路是把这些代价挪到不在乎的时候(加载时)一次付清:开局就建好几百颗子弹,全部隐藏、停用,放在一个"空闲"数组里。需要子弹时从数组里拿一颗,重新设置位置和速度,启用;不需要时停用、放回数组。整局游戏里节点数量不变,创建和销毁的次数是零。

下面是示例里的池。spawn() 优先从 _free 里取;池空了(或者没开池)才现场新建:

game/bullet_pool.gd第 36–51 行在工程中查看 ↗
## 发射一颗子弹。pos/vel 用 Field 的局部坐标。
func spawn(pos: Vector2, vel: Vector2, kind: Bullet.Kind) -> Bullet:
	var b: Bullet
	if use_pool and not _free.is_empty():
		b = _free.pop_back()                 # 有空闲的:直接拿来用,零分配
	else:
		b = _create()                        # 没开池,或池被掏空了:现场新建
		b.from_pool = use_pool
		if use_pool:
			_owned += 1                      # 池不够用时自动扩容(也可以选择丢弃这颗子弹)
	b.setup(pos, vel, kind)
	b.active = true
	b.show()
	b.process_mode = Node.PROCESS_MODE_INHERIT   # 重新加入物理空间,开始参与碰撞
	active.append(b)
	return b

回收时有一个细节:子弹命中敌人是在物理引擎的回调(area_entered 信号)里处理的,这时物理服务器正在遍历碰撞对,不允许把一个 Area2D 移出物理空间。所以 release() 只做立即安全的事(标记为不活动、隐藏),真正的停用用 call_deferred() 推迟到这一帧的末尾:

game/bullet_pool.gd第 56–79 行在工程中查看 ↗
## 回收一颗子弹。命中时它是在物理回调里被调用的,
## 这时不能把 Area2D 移出物理空间,所以真正的"停用"推迟到这一帧末尾。
func release(b: Bullet) -> void:
	if not b.active:
		return
	b.active = false
	b.hide()
	if b.from_pool:
		_park.call_deferred(b)
	else:
		b.queue_free()                       # 不用池:直接销毁,下次再新建


func _park(b: Bullet) -> void:
	# PROCESS_MODE_DISABLED:不再处理,并且(disable_mode 默认是 REMOVE)从物理空间里拿掉。
	b.process_mode = Node.PROCESS_MODE_DISABLED
	if _owned > pool_size and use_pool:
		_owned -= 1                          # 池缩小了:多出来的直接释放
		b.queue_free()
	elif not use_pool:
		_owned -= 1                          # 运行中关掉了池:旧子弹用完就扔
		b.queue_free()
	else:
		_free.append(b)

"停用"用的是 process_mode = PROCESS_MODE_DISABLED:节点不再处理 _process/_physics_process,而且因为 CollisionObject2D 的 disable_mode 默认是 DISABLE_MODE_REMOVE,它会被移出物理空间,完全不参与碰撞检测。重新取出时改回 PROCESS_MODE_INHERIT,它又会以新位置加入物理空间。

池化的对象最容易出的问题是带着上一条命的状态:上次的速度、加速度、旋转、贴图都还在。所以每次取出都要完整重置一遍:

game/bullet.gd第 37–48 行在工程中查看 ↗
## 每次从池里取出(或新建)后调用,把所有状态重置一遍——池化对象最容易出的 bug 就是"带着上一条命的状态"。
func setup(pos: Vector2, vel: Vector2, k: Kind) -> void:
	position = pos
	velocity = vel
	accel = 0.0
	turn = 0.0
	damage = 1
	if k != kind or sprite.texture == null:
		kind = k
		sprite.texture = TEXTURES[k]
		shape.scale = Vector2.ONE * HIT_SCALE.get(k, 1.0)
	rotation = vel.angle() if k == Kind.RICE or k == Kind.PLAYER else 0.0

统一移动:一个循环代替几百个回调

子弹脚本里没有 _physics_process。每个节点的 _physics_process 都是一次从引擎进入 GDScript 的调用,400 颗子弹就是每帧 400 次调用。示例让池在一个循环里移动所有活动子弹,被回收的子弹用"和最后一个交换再 pop_back()"的方式从数组里删掉,删除是 O(1):

game/bullet_pool.gd第 84–102 行在工程中查看 ↗
## 所有活动子弹在这一个循环里移动。被回收的从数组里"交换删除"(和最后一个交换再 pop),O(1)。
func _physics_process(delta: float) -> void:
	var i := 0
	while i < active.size():
		var b := active[i]
		if b.active:
			if b.turn != 0.0:
				b.velocity = b.velocity.rotated(b.turn * delta)
				b.rotation = b.velocity.angle() if b.kind == Bullet.Kind.RICE else 0.0
			if b.accel != 0.0:
				b.velocity += b.velocity.normalized() * b.accel * delta
			b.position += b.velocity * delta
			if not bounds.has_point(b.position):
				release(b)
		if not b.active:
			active[i] = active[active.size() - 1]
			active.pop_back()
			continue
		i += 1

注意:开池和关池时子弹都由这个循环移动,两种模式唯一的区别就是"取出/归还"还是"新建/销毁",这样对比才公平。

实测数据

说"新建很慢"要有数据。工程里的 gen/bench_pool.gd 是一个微基准:用敌弹场景各做 2000 次"新建 + 加入场景树 + 销毁"和"从池里取出 + 放回",在桌面版(Apple Silicon,无窗口模式)上跑三轮,第 2、3 轮的结果稳定在:

操作(每颗子弹)耗时
instantiate() + add_child()约 2.2 µs
销毁(free(),即 queue_free() 在帧末做的事)约 1.3 µs
池:取出(设位置、show()、PROCESS_MODE_INHERIT)约 0.3 µs
池:放回(hide()、PROCESS_MODE_DISABLED)约 0.1 µs

一颗子弹的一生,池化大约快 9 倍。但也要看清绝对值:桌面上每秒 2000 颗子弹全部新建销毁,总共也只花 7 ms 左右(分摊到 60 帧,每帧 0.1 ms)。这颗子弹只有 3 个节点、没有脚本信号;场景越复杂(更多子节点、_ready() 里做初始化、连接信号、加载资源),新建的代价越高。在 Web(单线程 WebAssembly)和手机上,同样的工作要慢上好几倍。

在 Web 版里做同样的对比(Chrome 无头模式 + SwiftShader 软件渲染;自动射击、射速 30、每轮 7 颗,再触发"弹幕压力测试",活动子弹约 700 颗):

模式每秒新建节点节点总数平均帧时间
对象池开(384)0;压力测试时池被掏空,扩容期间 700~1500约 2500 → 310041~67 ms
对象池开(1024)始终 0约 6300,不变49~75 ms
对象池关约 2000随子弹数在 770~2300 之间涨落42~64 ms

"每秒新建节点"的差别一目了然,帧时间却分不出高下:软件渲染时画几百个精灵本身就要几十毫秒,创建销毁那几毫秒淹没在里面。这正是性能优化的一般规律——先测量瓶颈在哪。对象池省下的是 CPU 上的创建/销毁开销,如果瓶颈在渲染,它帮不上忙;反过来,池子开得过大(1024 × 2 个池,6000 多个节点)也有代价:内存占用和加载时间都变大。合适的池大小是"正常游戏中的峰值再留一点余量",示例取 384,Boss 战够用,压力测试时允许扩容。

对象池的常见坑

  • 状态没重置:取出时忘了清某个字段(比如上一次的弯曲角速度),子弹会莫名其妙拐弯。把重置集中在一个 setup() 里。
  • 重复回收:一颗子弹同一帧里先打中敌人、又出界,被放回池两次,之后会被同时发射成两颗。用 active 标志挡住第二次。
  • 信号重复连接:如果在"取出"时 connect(),每复用一次就多连一次。信号只在 _ready() 里连一次。
  • 在物理回调里直接停用:在 area_entered 里改 monitoring、process_mode 或移除节点,会报"Can't change this state while flushing queries"。用 call_deferred() 或 set_deferred()。
  • Godot 3 的 Area2D 用 monitoring 来停用很常见,到了 4.x 同样在回调里不能直接改,记得用 set_deferred("monitoring", false)。

玩家子弹在 area_entered 回调里打中了敌人,下面哪种回收方式是安全的?

  1. 直接 process_mode = PROCESS_MODE_DISABLED,然后放回空闲数组
  2. 先标记为不活动、隐藏,再用 call_deferred 在帧末停用并放回空闲数组
  3. 直接 remove_child(),下次用时再 add_child()

物理服务器在发出 area_entered 时正在处理碰撞查询,这时不能把对象移出物理空间(1 和 3 都会这样做)。先做立即安全的部分,把会影响物理空间的操作推迟到帧末。

3. Area2D 还是 PhysicsBody2D

Godot 的 2D 碰撞对象有两大类:

节点会被物理推开吗典型用途
Area2D不会。只报告"谁进来了、谁出去了"子弹、拾取物、触发区域、伤害判定
CharacterBody2D不会被推,但 move_and_slide() 会让它沿墙滑动平台跳跃、俯视角角色
RigidBody2D会,完全由物理模拟箱子、弹球、布娃娃
StaticBody2D不动地面、墙

射击游戏里没有"撞墙后滑开"这种需求:子弹不会被敌人挡住弹回去,飞船碰到子弹就是中弹。只需要重叠检测,所以示例里的玩家、子弹、敌人、Boss、道具全都是 Area2D。Area2D 比 PhysicsBody2D 便宜:不需要求解接触、不需要计算穿透深度和分离方向,物理引擎只做形状重叠测试。

玩家的根节点是 Area2D,上面的圆形判定半径只有 4 像素,比画出来的飞船(约 26 像素宽)小得多。弹幕游戏都这么做:子弹擦过机翼不算中弹,玩家才敢在密集的弹幕里穿行。按住 Shift 时会画出这个判定点。另外玩家身上还挂了一个半径 46 的 PickupArea,专门用来吸道具——两个 Area2D 用不同的遮罩,各管各的:

game/player.tscn第 11–26 行玩家场景:根节点是判定,子节点 PickupArea 只检测道具在工程中查看 ↗
[node name="Player" type="Area2D"]
collision_layer = 1
collision_mask = 12
script = ExtResource("1")

[node name="Hurtbox" type="CollisionShape2D" parent="."]
shape = SubResource("hurt")

[node name="PickupArea" type="Area2D" parent="."]
collision_layer = 0
collision_mask = 16
monitorable = false

[node name="Shape" type="CollisionShape2D" parent="PickupArea"]
shape = SubResource("magnet")

受击处理:碰到的如果是敌弹,就把它回收(敌弹撞到玩家就消失);然后检查无敌时间和护盾:

game/player.gd第 81–97 行在工程中查看 ↗
func _on_area_entered(area: Area2D) -> void:
	if area is Bullet:
		if not area.active:
			return
		area.pool.release(area)               # 敌弹撞到玩家就消失
	elif not (area is Enemy or area is Boss):
		return
	if invincible or _invul > 0.0:
		return
	if shield:
		shield = false
		_invul = 1.0
		game.fx.ring(position, Color(0.4, 1.8, 2.4), 70.0)
		Audio.play("hurt", -6.0)
		return
	_invul = 2.5
	hit.emit()

中弹后 _invul 设为 2.5 秒,在这段时间里 _physics_process 用 visible = _invul <= 0.0 or fmod(_t, 0.12) < 0.07 让飞船闪烁:每 0.12 秒里有 0.07 秒可见。

4. 碰撞层和遮罩

场景里同时有几百颗敌弹。如果物理引擎把每一对物体都拿来比一下形状,敌弹和敌弹之间、敌弹和敌人之间都要比,绝大部分是白费功夫——敌弹之间根本不需要互相检测。碰撞层(layer)和遮罩(mask)就是用来告诉引擎"哪些对需要检测"的。

  • collision_layer:我是谁。这个物体属于哪些层。
  • collision_mask:我检测谁。这个物体关心哪些层里的东西。

两者都是 32 位整数,每一位代表一层:第 1 层是第 0 位(值 1),第 2 层是第 1 位(值 2),第 n 层的值是 1 << (n - 1)。一个物体可以同时属于多层、检测多层,把对应的位"或"起来就行。判断 A 会不会发现 B:

(A.collision_mask & B.collision_layer) != 0

也就是 A 的遮罩和 B 的层至少有一位相同。对 Area2D 来说,A 真正收到 area_entered(B) 还要两个条件:A 的 monitoring 为 true(A 在监视),B 的 monitorable 为 true(B 允许被监视)。

给层起名字

"第 4 层"不好记。在"项目设置 → 层名称 → 2D 物理"里可以给每一层起名字,检查器里的层/遮罩格子上会显示这些名字。它们存在 project.godot 里:

project.godot第 98–104 行在工程中查看 ↗
[layer_names]

2d_physics/layer_1="player"
2d_physics/layer_2="player_bullet"
2d_physics/layer_3="enemy"
2d_physics/layer_4="enemy_bullet"
2d_physics/layer_5="pickup"

示例的规划如下。设计原则是:让数量最多的东西不去监视别人。敌弹最多,所以敌弹的 mask 是 0、monitoring 关掉,只由唯一的玩家去检测它们:

物体layer(我是谁)mask(我检测谁)二进制 maskmonitoring
玩家判定1 player3 enemy + 4 enemy_bullet = 4 + 8 = 120b01100开
玩家 PickupArea无(0)5 pickup = 160b10000开
玩家子弹2 player_bullet3 enemy = 40b00100开
敌人 / Boss3 enemy00b00000关
敌弹4 enemy_bullet00b00000关
道具5 pickup00b00000关

在代码里设置时,可以直接写整数,也可以用常量和位运算,或者用按层号操作的方法(层号从 1 开始):

snippets/collision_bits.gd第 6–23 行在工程中查看 ↗
const PLAYER := 1 << 0          # 第 1 层 → 0b00001 = 1
const PLAYER_BULLET := 1 << 1   # 第 2 层 → 0b00010 = 2
const ENEMY := 1 << 2           # 第 3 层 → 0b00100 = 4
const ENEMY_BULLET := 1 << 3    # 第 4 层 → 0b01000 = 8
const PICKUP := 1 << 4          # 第 5 层 → 0b10000 = 16


func setup_player_hurtbox(area: Area2D) -> void:
	area.collision_layer = PLAYER                 # 我是谁:玩家
	area.collision_mask = ENEMY | ENEMY_BULLET    # 我检测谁:敌人和敌弹,4 | 8 = 12
	# 也可以按层号(从 1 开始)逐个开关,不用自己算位:
	area.set_collision_mask_value(5, false)       # 不检测道具
	print(area.get_collision_mask_value(4))       # true


## "A 能发现 B" 的条件:A 的 mask 和 B 的 layer 至少有一位相同。
static func detects(a_mask: int, b_layer: int) -> bool:
	return (a_mask & b_layer) != 0

层号和位号差 1:编辑器和 set_collision_mask_value(n, ...) 用的是层号(从 1 开始),而整数里是第 n−1 位。"第 3 层"的值是 4,不是 3。在 .tscn 里看到 collision_mask = 12,意思是第 3 层和第 4 层。

另外,Area2D 的 monitoring 默认是开着的。敌弹这种数量巨大的东西,记得在场景里把它关掉(示例的 enemy_bullet.tscn 里 monitoring = false),否则每颗敌弹都会维护一份"有谁在我里面"的列表。

动手试试:在示例里勾选"显示碰撞形状"。绿色小点是玩家的判定,外面淡绿色的大圈是 PickupArea,红色是敌弹,橙色是敌人。对比一下飞船的外形和那个小绿点有多大差别。(编辑器里的"调试 → 显示碰撞区域"只在调试版本里有效,Web 导出版看不到,所以示例自己画了一个覆盖层。)

一个 Area2D 的 collision_mask = 20,它检测哪几层?

  1. 第 20 层
  2. 第 2 层和第 4 层
  3. 第 3 层和第 5 层

20 = 16 + 4 = 0b10100,第 2 位和第 4 位为 1(从 0 数),对应第 3 层和第 5 层。在示例里就是 enemy 和 pickup。

5. 用 Path2D 做编队

杂兵不是直线往下掉,而是沿弧线进场、在屏幕中间兜个圈再飞走,一队几架首尾相接。这种轨迹用 Path2D 描述最方便:Path2D 持有一条 Curve2D(贝塞尔曲线),可以在编辑器里拖控制点画出来。PathFollow2D 是 Path2D 的子节点,设置它的 progress(沿曲线走过的像素长度)或 progress_ratio(0~1),它就会自动移动到曲线上对应的位置。把敌人挂在 PathFollow2D 下面,敌人就跟着走。

生成敌人:新建一个 PathFollow2D,rotates = false(不跟着曲线切线旋转,敌人始终朝下),loop = false(走到头不回到起点):

game/wave_director.gd第 85–92 行在工程中查看 ↗
func _spawn(w: WaveData, path: Path2D) -> void:
	var follow := PathFollow2D.new()
	follow.loop = false
	follow.rotates = false           # 敌人始终"脸朝下",不跟着曲线转
	var e: Enemy = ENEMY_SCENE.instantiate()
	e.setup(w, game, follow, enemy_speed, hp_mult)
	follow.add_child(e)
	path.add_child(follow)

敌人每帧把 progress 往前推,速度就是"像素/秒"。WaveData 可以要求敌人在路径的某个比例处停一会儿(炮台就是这样:下来、停住开火、再离开):

game/enemy.gd第 51–60 行在工程中查看 ↗
if _hold_left > 0.0:
	_hold_left -= delta                     # 停在半路射击
else:
	follow.progress += speed * delta        # progress 是沿曲线走过的像素长度
if not _held and data.hold_at >= 0.0 and follow.progress_ratio >= data.hold_at:
	_held = true
	_hold_left = data.hold_time
if follow.progress_ratio >= 1.0:            # 走完整条路径:飞出屏幕,悄悄消失(不加分)
	follow.queue_free()
	return

一队敌人用同一条路径,只是出发时间错开(间隔 = 波次数据里的 gap × 全局"生成间隔"),首尾相接就是一条"长蛇"。左右对称的编队不用画两条曲线:把 Path2D 复制一份,scale.x = -1 翻转,再平移到场地右边,曲线数据完全共用:

game/game.gd第 88–96 行在工程中查看 ↗
## 每条 Path2D 复制一份左右镜像的:scale.x = -1 再平移到右边,曲线数据本身不用改。
func _mirror_paths() -> void:
	for p: Path2D in paths.get_children():
		var m := Path2D.new()
		m.name = p.name + "_M"
		m.curve = p.curve
		m.scale = Vector2(-1, 1)
		m.position = Vector2(600.0 - p.position.x, p.position.y)
		paths.add_child(m)

镜像的 Path2D 带着 scale.x = -1,挂在它下面的敌人的全局变换也是翻转的。敌人用 _draw() 画的是左右对称的图形,看不出区别;如果是不对称的贴图,可以给敌人自己再乘一个 scale.x = -1 翻回来。

6. 数据驱动的波次

一关有十来波敌人,每波的类型、路径、数量、间隔、血量、射击方式都不同。把这些写进代码(一长串 if wave == 3:)会很难改。示例把"一波敌人"写成一个自定义 Resource:

game/wave_data.gd第 10–23 行在工程中查看 ↗
@export var label := ""                       ## 显示在屏幕上的名字
@export var kind := Kind.DRONE                ## 敌人类型(决定外形、血量基数)
@export var path_name: StringName = &"SwoopLeft"   ## 沿哪条 Path2D 飞
@export var mirror := false                   ## 同时在左右镜像的路径上各出一队
@export var count := 6                        ## 一队几架
@export var gap := 1.0                        ## 同队相邻两架的出发间隔(乘以全局"生成间隔")
@export var speed_scale := 1.0                ## 相对全局"敌人速度"的倍数
@export var hp := 3                           ## 血量(还会乘以全局"血量倍数")
@export var fire := Fire.NONE                 ## 射击方式
@export var fire_interval := 1.5              ## 射击间隔(秒)
@export_range(-1.0, 1.0) var hold_at := -1.0  ## 走到路径的这个比例时停下来(-1 表示不停)
@export var hold_time := 0.0                  ## 停多久
@export var wait_after := 3.0                 ## 这一波最后一架出发后,再等几秒开始下一波
@export_range(0.0, 1.0) var drop_chance := 0.12   ## 击落后掉道具的概率

一关就是一个 StageData,里面是 Array[WaveData]。存成 stage1.tres 之后,可以在编辑器的检查器里直接展开数组、改每一波的字段,不用碰代码。文件本身是文本,每一波是一个子资源,只记录和默认值不同的字段:

game/stage1.tres第 20–34 行在工程中查看 ↗
[sub_resource type="Resource" id="Resource_0ng37"]
script = ExtResource("2_tf8uh")
label = "狙击小队"
kind = 1
path_name = &"ZigZag"
count = 4
gap = 2.2
speed_scale = 0.8
hp = 5
fire = 1
fire_interval = 1.4

[sub_resource type="Resource" id="Resource_gntdf"]
script = ExtResource("2_tf8uh")
label = "回旋编队"

调度器 WaveDirector 读这份数据。它没有用 await get_tree().create_timer(t).timeout 来等待,而是自己维护一个时钟和一个"待出发"列表,每个物理帧检查有没有到点的:

game/wave_director.gd第 36–80 行在工程中查看 ↗
func _physics_process(delta: float) -> void:
	_clock += delta
	while not _pending.is_empty() and _pending[0][0] <= _clock:
		var item: Array = _pending.pop_front()
		_spawn(item[1], item[2])
	if not running:
		return
	if _pending.is_empty() and _clock >= _next_wave_at:
		if wave_index + 1 < stage.waves.size():
			_start_wave(wave_index + 1)
		elif game.enemy_count() == 0:          # 最后一波打完(或飞走)了,交给 Boss
			running = false
			stage_cleared.emit()


func _start_wave(i: int) -> void:
	wave_index = i
	var w: WaveData = stage.waves[i]
	wave_started.emit(i, w)
	_next_wave_at = _schedule(w) + w.wait_after


## 把一波敌人排进待出发队列,返回最后一架的出发时间。Boss 召唤护卫也用它。
func _schedule(w: WaveData) -> float:
	var paths: Array[Path2D] = [game.get_path2d(w.path_name)]
	if w.mirror:
		paths.append(game.get_path2d(StringName(str(w.path_name) + "_M")))
	var t := _clock
	for k in w.count:
		for p in paths:
			_pending.append([t, w, p])
		t += w.gap * spawn_interval
	_pending.sort_custom(func(a: Array, b: Array) -> bool: return a[0] < b[0])
	return t


func spawn_escort() -> void:
	var w := WaveData.new()
	w.kind = WaveData.Kind.DRONE
	w.path_name = &"SwoopLeft"
	w.mirror = true
	w.count = 3
	w.hp = 3
	w.drop_chance = 0.2
	_schedule(w)

为什么不用 await?await 写起来最直观,但在"随时可能重新开始"的游戏里有两个麻烦:一是玩家在等待期间按了"重新开始",旧场景被释放了,计时器到点后协程要恢复到一个已经不存在的对象上,Godot 会报错;二是暂停时 create_timer() 默认(process_always = true)还会继续走。自己记时钟的写法没有这两个问题:节点没了,时钟也就没了;暂停时 _physics_process 不运行,时钟自然停住。

数据驱动的另一个好处是全局参数可以统一作用在所有波次上。示例里的"生成间隔""敌人速度""敌人血量倍数""弹幕密度"都是乘在数据上的倍数,调一个滑块,整关难度一起变。

7. 弹幕的数学

几乎所有弹幕图案都可以用极坐标描述:一颗子弹从发射点出发,方向是角度 θ,速率是 v,速度向量就是

velocity = (cos θ, sin θ) × v,在 GDScript 里写作 Vector2.from_angle(θ) * v。

注意 Godot 的 2D 坐标 y 轴向下:θ = 0 朝右,θ = π/2 朝下,θ = −π/2 朝上。玩家子弹往上飞,用的就是 −π/2。

环形

n 颗子弹均匀分布在一整圈上,第 i 颗的角度是 θᵢ = offset + i × 2π / n。如果每圈都从同一个角度开始,相邻两圈的子弹会排成一条条直线,中间的空隙也对齐,玩家站在空隙里就永远打不中。示例每圈把 offset 转半格(π / n),两圈的空隙就错开了:

game/danmaku.gd第 9–14 行在工程中查看 ↗
## 环形:n 颗子弹均匀分布在一整圈上,第 i 颗的角度 θᵢ = offset + i × (2π / n)。
static func ring(pool: BulletPool, origin: Vector2, n: int, speed: float,
		offset := 0.0, kind := Bullet.Kind.PINK) -> void:
	for i in n:
		var theta := offset + TAU * i / n
		pool.spawn(origin, Vector2.from_angle(theta) * speed, kind)

螺旋

螺旋其实就是"很小的环形 + 很短的发射间隔 + 每次转一点"。Boss 第二阶段每 0.07 秒发一次,每次 4 颗(4 条臂),每次把起始角加 0.19 弧度。子弹本身走直线,但不同时刻发出的子弹连起来就成了旋转的螺旋臂。再叠一组反方向旋转、速度不同的,两组螺旋交叉成网格:

game/danmaku.gd第 31–35 行在工程中查看 ↗
## 螺旋:每次只发 arms 颗(均匀分布),但发射角 angle 由调用者每帧累加。
## 子弹沿直线飞,不同时刻发出的子弹排在一起,看起来就是一条旋转的螺旋臂。
static func spiral(pool: BulletPool, origin: Vector2, angle: float, arms: int, speed: float,
		kind := Bullet.Kind.VIOLET) -> void:
	ring(pool, origin, arms, speed, angle, kind)

自机狙

"瞄准玩家"的关键是求出从发射点指向玩家的角度:base = atan2(玩家.y − 发射点.y, 玩家.x − 发射点.x)。Godot 里 (target - origin).angle() 就是这个值。想打一把扇形,就在 base 两侧均匀展开:

game/danmaku.gd第 19–26 行在工程中查看 ↗
## 自机狙:先求出从发射点指向玩家的角度 base = atan2(dy, dx),
## 再在 base 两边均匀张开 spread 弧度的扇形(n = 1 时就是正对玩家的一颗)。
static func aimed(pool: BulletPool, origin: Vector2, target: Vector2, n: int, spread: float,
		speed: float, kind := Bullet.Kind.RICE) -> void:
	var base := (target - origin).angle()          # 等价于 atan2(target.y - origin.y, target.x - origin.x)
	for i in n:
		var t := 0.0 if n == 1 else float(i) / (n - 1) - 0.5    # -0.5 … +0.5
		pool.spawn(origin, Vector2.from_angle(base + t * spread) * speed, kind)

自机狙的特点是"玩家只要一直移动就打不中"——子弹是对准发射那一刻玩家的位置飞的。所以弹幕游戏的基本功是"小幅度地持续移动",而不是站着不动。

花瓣

仍然是一圈子弹,但速率随角度变化:v(θ) = v₀ × (1 + depth × sin(k × θ))。k 个方向上的子弹跑得最快,一圈子弹飞出去后会变形成 k 片花瓣:

game/danmaku.gd第 40–47 行在工程中查看 ↗
## 花瓣:仍是一圈,但速率随角度变化 v(θ) = v₀ × (1 + depth × sin(petals × θ)),
## 跑得快的方向形成"花瓣尖"。
static func flower(pool: BulletPool, origin: Vector2, n: int, speed: float, petals: int,
		depth: float, offset := 0.0, kind := Bullet.Kind.ORANGE) -> void:
	for i in n:
		var theta := offset + TAU * i / n
		var v := speed * (1.0 + depth * sin(petals * (theta - offset)))
		pool.spawn(origin, Vector2.from_angle(theta) * v, kind)

Boss 的三个阶段就是这几种图案的组合,血量每掉三分之一换一种。换阶段时把场上所有敌弹清掉(变成分数),给玩家一个喘息的机会:

game/boss.gd第 58–114 行在工程中查看 ↗
func _phase_rings(delta: float) -> void:
	var dens := game.director.bullet_density
	var spd := game.director.bullet_speed
	_timers[0] -= delta
	if _timers[0] <= 0.0:
		_timers[0] = 0.9
		var n := maxi(6, roundi(24 * dens))
		_spin += PI / n                    # 每圈旋转半格
		Danmaku.ring(game.enemy_bullets, position, n, 125.0 * spd, _spin, Bullet.Kind.PINK)
		Audio.play("enemy_shot", -4.0)
	_timers[1] -= delta
	if _timers[1] <= 0.0:
		_timers[1] = 1.7
		Danmaku.aimed(game.enemy_bullets, position, game.player.position, maxi(3, roundi(7 * dens)),
			0.8, 210.0 * spd)


func _phase_spiral(delta: float) -> void:
	var dens := game.director.bullet_density
	var spd := game.director.bullet_speed
	_timers[0] -= delta
	if _timers[0] <= 0.0:
		_timers[0] = 0.07
		var arms := maxi(2, roundi(4 * dens))
		_spin += 0.19
		# 两组螺旋:一组顺时针、一组逆时针,速度不同,交叉出网格
		Danmaku.spiral(game.enemy_bullets, position, _spin, arms, 120.0 * spd, Bullet.Kind.VIOLET)
		Danmaku.spiral(game.enemy_bullets, position, -_spin * 0.8, arms, 95.0 * spd, Bullet.Kind.PINK)
	_timers[1] -= delta
	if _timers[1] <= 0.0:
		_timers[1] = 0.6
		Audio.play("enemy_shot", -8.0)
	_timers[2] -= delta
	if _timers[2] <= 0.0:
		_timers[2] = 4.0
		game.director.spawn_escort()       # 召唤一队护卫机


func _phase_flower(delta: float) -> void:
	var dens := game.director.bullet_density
	var spd := game.director.bullet_speed
	_timers[0] -= delta
	if _timers[0] <= 0.0:
		_timers[0] = 0.55
		_spin += 0.21
		Danmaku.flower(game.enemy_bullets, position, maxi(10, roundi(40 * dens)), 110.0 * spd, 5, 0.45,
			_spin, Bullet.Kind.ORANGE)
		Audio.play("enemy_shot", -4.0)
	_timers[1] -= delta
	if _timers[1] <= 0.0:
		_timers[1] = 0.28
		Danmaku.aimed(game.enemy_bullets, position, game.player.position, 3, 0.25, 260.0 * spd)
	_timers[2] -= delta
	if _timers[2] <= 0.0:
		_timers[2] = 2.2
		Danmaku.ring(game.enemy_bullets, position, maxi(4, roundi(10 * dens)), 70.0 * spd, randf() * TAU,
			Bullet.Kind.BIG)

动手试试:点"直接到 Boss",勾上上面第一个示例里的"无敌模式"(两个示例共享参数),然后把"弹幕密度"拉到 2.5、"敌弹速度倍数"降到 0.5。慢速、高密度的环形和螺旋最能看清图案的结构。再把密度拉回 1、速度拉到 2,体会一下"同样的图案,速度不同,难度完全不一样"。

Boss 在 (300, 110),玩家在 (300, 460)。用 (target - origin).angle() 算出的自机狙角度是多少?

  1. π/2(朝下)
  2. −π/2(朝上)
  3. 0(朝右)

差向量是 (0, 350),指向 y 正方向。Godot 2D 里 y 向下,所以角度是 atan2(350, 0) = π/2,朝下。

8. 粒子:GPUParticles2D 还是 CPUParticles2D

爆炸用粒子做:几十个小光点从中心炸开、减速、变暗消失。Godot 有两种 2D 粒子节点:

GPUParticles2DCPUParticles2D
在哪里算GPU(着色器里更新每个粒子)CPU(引擎 C++ 代码逐个更新)
参数放在哪ParticleProcessMaterial(或自写粒子着色器)节点自身的属性
大量粒子几万个也轻松几千个开始吃 CPU
Compatibility 渲染器都支持。本示例实测:桌面版和 Web 版(WebGL 2)上 GPUParticles2D 都正常工作
适合大量粒子、复杂效果老旧设备、需要逐帧读取粒子数据、粒子很少

两者的参数几乎一一对应,CPUParticles2D.convert_from_particles(gpu_node) 可以把一个 GPUParticles2D 的设置(包括过程材质里的参数)原样转换过来——编辑器的"转换为 CPUParticles2D"菜单做的也是这件事。示例用代码搭了一个 GPU 版模板,CPU 模式下直接转换:

game/fx.gd第 29–83 行在工程中查看 ↗
## 用代码搭一个 GPUParticles2D 当模板。CPU 模式下用 convert_from_particles() 把同样的设置抄过去。
func _make_gpu() -> GPUParticles2D:
	var p := GPUParticles2D.new()
	p.amount = maxi(1, particle_amount)
	p.lifetime = 0.7
	p.one_shot = true
	p.explosiveness = 1.0              # 1 = 所有粒子在同一瞬间发出
	p.emitting = false
	p.texture = SPARK
	var add := CanvasItemMaterial.new()
	add.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD   # 加法混合:重叠处越叠越亮
	p.material = add
	var m := ParticleProcessMaterial.new()
	m.particle_flag_disable_z = true
	m.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
	m.emission_sphere_radius = 4.0
	m.spread = 180.0
	m.initial_velocity_min = 60.0
	m.initial_velocity_max = 280.0
	m.gravity = Vector3.ZERO
	m.damping_min = 150.0
	m.damping_max = 250.0
	m.scale_min = 0.9
	m.scale_max = 2.0
	var fade := Gradient.new()                   # 从亮白到透明
	fade.set_color(0, Color(1.25, 1.25, 1.25, 1.0))
	fade.set_color(1, Color(1, 1, 1, 0.0))
	var ramp := GradientTexture1D.new()
	ramp.gradient = fade
	m.color_ramp = ramp
	p.process_material = m
	return p


func _rebuild() -> void:
	if not is_inside_tree():
		return
	for e in _emitters:
		e.queue_free()
	_emitters.clear()
	if particle_amount <= 0:
		return
	for i in EMITTERS:
		var gpu := _make_gpu()
		if particle_mode == "CPU":
			var cpu := CPUParticles2D.new()
			cpu.convert_from_particles(gpu)      # 把 GPU 版的所有参数(包括过程材质)转换过来
			cpu.emitting = false
			cpu.material = gpu.material
			gpu.free()
			add_child(cpu)
			_emitters.append(cpu)
		else:
			add_child(gpu)
			_emitters.append(gpu)

几个要点:

  • one_shot = true + explosiveness = 1.0:所有粒子在同一瞬间喷出一次,正好是爆炸。
  • 爆炸也用了"池"的思路:固定 16 个发射器轮流使用,每次 restart() 让它从头再喷一次,不为每次爆炸新建节点。
  • 改 amount 会让 GPU 重新分配粒子缓冲区,开销不小,不要每次爆炸都改;示例只在参数变化时整体重建。
  • 颜色用节点的 modulate 染:同一组发射器,敌人炸是粉色、玩家炸是青色。

发光:HDR 2D + WorldEnvironment

霓虹感来自"亮的地方向外渗光"。示例用了两层手段:

  1. 加法混合:粒子和冲击波用 CanvasItemMaterial 的 BLEND_MODE_ADD,重叠的地方颜色相加,越叠越亮。这在任何渲染器、任何设置下都有效。
  2. Glow:场景里放一个 WorldEnvironment,background_mode = 3(Canvas),打开 glow_enabled。在 Compatibility 渲染器下实测,单靠这一步看不到效果,还需要在项目设置里打开 rendering/viewport/hdr_2d,让 2D 画布用浮点缓冲区,颜色才能超过 1.0。之后把飞船描边、敌人核心的颜色写成 Color(0.4, 1.8, 2.4) 这种分量大于 1 的值,超过 glow_hdr_threshold(1.0)的部分就会发光。Web 版(WebGL 2)同样有效。

发光是一个全屏后处理,有代价。在 Web 版里实测(软件渲染),关掉"发光"后平均帧时间从约 58 ms 降到约 28 ms。普通显卡上没有这么夸张,但在低端手机上值得做成一个画质选项。

HUD 放在第 1 层的 CanvasLayer 上,而 Environment 的 background_canvas_max_layer 默认是 0,所以 HUD 不受发光影响,文字保持清晰。

Compatibility 渲染器里的 Glow 和 Forward+ 不一样:文档说明 Compatibility 下 glow 是另一套简化实现,glow_levels/*、glow_blend_mode、glow_mix 等属性不可用(检查器里会被隐藏)。能调的主要是 glow_intensity、glow_bloom、glow_hdr_threshold、glow_hdr_scale。从 Forward+ 教程照抄参数时要注意。

动手试试:在第二个示例里关掉"发光",画面立刻变"平";再打开。然后把"爆炸粒子数"拉到 300,把"粒子节点"在 GPU 和 CPU 之间切换,打几架敌机看看——在这个粒子量级下两者看不出差别,这正说明对于少量粒子,CPUParticles2D 完全够用。最后把"屏幕震动强度"拉到 30 放一个炸弹(X 键或"清屏炸弹"按钮)。

屏幕震动

震动只动 Camera2D 的 offset,HUD 在 CanvasLayer 上不受影响。示例用了"创伤值"的做法:每次受击给创伤值加一点,随时间线性衰减;实际偏移量和创伤值的平方成正比。这样小爆炸几乎察觉不到,大爆炸才明显,连续的小爆炸也不会叠成持续的晃动:

game/game.gd第 131–140 行在工程中查看 ↗
## 屏幕震动用"创伤值":每次受击加一点,随时间衰减;偏移量和创伤值的平方成正比,
## 小震动几乎察觉不到,大爆炸才明显。只动 Camera2D 的 offset,HUD 在 CanvasLayer 上不受影响。
func shake(amount: float) -> void:
	_trauma = minf(_trauma + amount / 24.0, 1.0)


func _update_shake(delta: float) -> void:
	_trauma = maxf(0.0, _trauma - delta * 1.8)
	var k := _trauma * _trauma * shake_strength * 2.0
	camera.offset = Vector2(randf_range(-k, k), randf_range(-k, k))

小结

  • 频繁 instantiate()/queue_free() 的代价集中在少数帧里,表现为卡顿。对象池把创建挪到加载时,运行中只做"取出/归还"。回收要重置全部状态,物理回调里的停用要延迟。
  • 只需要重叠检测时用 Area2D。碰撞层是"我是谁",遮罩是"我检测谁",(A.mask & B.layer) != 0 时 A 能发现 B。让数量最多的东西不去监视别人。
  • Path2D + PathFollow2D 描述飞行轨迹,scale.x = -1 免费得到镜像路径。
  • 弹幕 = 极坐标:环形是均分 2π,螺旋是每次转一点,自机狙是 atan2,花瓣是速率随角度变化。
  • 波次写成自定义 Resource,关卡设计改数据不改代码。
  • GPU/CPU 粒子在 Compatibility 下都能用;发光要 HDR 2D + WorldEnvironment glow,再加上加法混合。

下一章接着用这个示例,讲标题界面、暂停菜单、结算和存档这些"游戏外壳",以及音频总线和程序化音效。