太空射击:对象池、碰撞层与粒子
做一个纵版弹幕射击游戏。用对象池对付成百上千颗子弹,用 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() 要做这些事:
- 按 PackedScene 里的记录创建 3 个节点(Area2D、CollisionShape2D、Sprite2D),给每个分配内存、设置属性;
- 节点进入场景树:发出
NOTIFICATION_ENTER_TREE、执行脚本的_ready()、连接信号; - Area2D 进树时会在物理服务器里创建对应的对象,加入物理空间的宽相结构;Sprite2D 会在渲染服务器里创建一个画布项。
queue_free() 则在帧末把这些全部反着做一遍:从物理空间和渲染服务器里删除、退出场景树、释放内存。
单独一次很快(微秒级),但射击游戏里它们是成批发生的:玩家每秒 30 轮、每轮 9 颗就是每秒 270 次创建加 270 次销毁;Boss 一圈环形弹 24 颗是在同一帧里创建的。场景稍复杂一些(子节点多、_ready() 里做初始化),这些工作挤在少数几帧里,就会表现为帧时间上的尖刺。Web 版是单线程运行的 WebAssembly,同样的工作比桌面慢好几倍。
对象池的思路是把这些代价挪到不在乎的时候(加载时)一次付清:开局就建好几百颗子弹,全部隐藏、停用,放在一个"空闲"数组里。需要子弹时从数组里拿一颗,重新设置位置和速度,启用;不需要时停用、放回数组。整局游戏里节点数量不变,创建和销毁的次数是零。
下面是示例里的池。spawn() 优先从 _free 里取;池空了(或者没开池)才现场新建:
## 发射一颗子弹。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() 推迟到这一帧的末尾:
## 回收一颗子弹。命中时它是在物理回调里被调用的,
## 这时不能把 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,它又会以新位置加入物理空间。
池化的对象最容易出的问题是带着上一条命的状态:上次的速度、加速度、旋转、贴图都还在。所以每次取出都要完整重置一遍:
## 每次从池里取出(或新建)后调用,把所有状态重置一遍——池化对象最容易出的 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):
## 所有活动子弹在这一个循环里移动。被回收的从数组里"交换删除"(和最后一个交换再 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 → 3100 | 41~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 回调里打中了敌人,下面哪种回收方式是安全的?
- 直接
process_mode = PROCESS_MODE_DISABLED,然后放回空闲数组 - 先标记为不活动、隐藏,再用
call_deferred在帧末停用并放回空闲数组 - 直接
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 用不同的遮罩,各管各的:
[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")受击处理:碰到的如果是敌弹,就把它回收(敌弹撞到玩家就消失);然后检查无敌时间和护盾:
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 里:
[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(我检测谁) | 二进制 mask | monitoring |
|---|---|---|---|---|
| 玩家判定 | 1 player | 3 enemy + 4 enemy_bullet = 4 + 8 = 12 | 0b01100 | 开 |
| 玩家 PickupArea | 无(0) | 5 pickup = 16 | 0b10000 | 开 |
| 玩家子弹 | 2 player_bullet | 3 enemy = 4 | 0b00100 | 开 |
| 敌人 / Boss | 3 enemy | 0 | 0b00000 | 关 |
| 敌弹 | 4 enemy_bullet | 0 | 0b00000 | 关 |
| 道具 | 5 pickup | 0 | 0b00000 | 关 |
在代码里设置时,可以直接写整数,也可以用常量和位运算,或者用按层号操作的方法(层号从 1 开始):
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,它检测哪几层?
- 第 20 层
- 第 2 层和第 4 层
- 第 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(走到头不回到起点):
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 可以要求敌人在路径的某个比例处停一会儿(炮台就是这样:下来、停住开火、再离开):
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 翻转,再平移到场地右边,曲线数据完全共用:
## 每条 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:
@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 之后,可以在编辑器的检查器里直接展开数组、改每一波的字段,不用碰代码。文件本身是文本,每一波是一个子资源,只记录和默认值不同的字段:
[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 来等待,而是自己维护一个时钟和一个"待出发"列表,每个物理帧检查有没有到点的:
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),两圈的空隙就错开了:
## 环形: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 弧度。子弹本身走直线,但不同时刻发出的子弹连起来就成了旋转的螺旋臂。再叠一组反方向旋转、速度不同的,两组螺旋交叉成网格:
## 螺旋:每次只发 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 两侧均匀展开:
## 自机狙:先求出从发射点指向玩家的角度 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 片花瓣:
## 花瓣:仍是一圈,但速率随角度变化 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 的三个阶段就是这几种图案的组合,血量每掉三分之一换一种。换阶段时把场上所有敌弹清掉(变成分数),给玩家一个喘息的机会:
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() 算出的自机狙角度是多少?
- π/2(朝下)
- −π/2(朝上)
- 0(朝右)
差向量是 (0, 350),指向 y 正方向。Godot 2D 里 y 向下,所以角度是 atan2(350, 0) = π/2,朝下。
8. 粒子:GPUParticles2D 还是 CPUParticles2D
爆炸用粒子做:几十个小光点从中心炸开、减速、变暗消失。Godot 有两种 2D 粒子节点:
| GPUParticles2D | CPUParticles2D | |
|---|---|---|
| 在哪里算 | GPU(着色器里更新每个粒子) | CPU(引擎 C++ 代码逐个更新) |
| 参数放在哪 | ParticleProcessMaterial(或自写粒子着色器) | 节点自身的属性 |
| 大量粒子 | 几万个也轻松 | 几千个开始吃 CPU |
| Compatibility 渲染器 | 都支持。本示例实测:桌面版和 Web 版(WebGL 2)上 GPUParticles2D 都正常工作 | |
| 适合 | 大量粒子、复杂效果 | 老旧设备、需要逐帧读取粒子数据、粒子很少 |
两者的参数几乎一一对应,CPUParticles2D.convert_from_particles(gpu_node) 可以把一个 GPUParticles2D 的设置(包括过程材质里的参数)原样转换过来——编辑器的"转换为 CPUParticles2D"菜单做的也是这件事。示例用代码搭了一个 GPU 版模板,CPU 模式下直接转换:
## 用代码搭一个 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
霓虹感来自"亮的地方向外渗光"。示例用了两层手段:
- 加法混合:粒子和冲击波用
CanvasItemMaterial的BLEND_MODE_ADD,重叠的地方颜色相加,越叠越亮。这在任何渲染器、任何设置下都有效。 - 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 上不受影响。示例用了"创伤值"的做法:每次受击给创伤值加一点,随时间线性衰减;实际偏移量和创伤值的平方成正比。这样小爆炸几乎察觉不到,大爆炸才明显,连续的小爆炸也不会叠成持续的晃动:
## 屏幕震动用"创伤值":每次受击加一点,随时间衰减;偏移量和创伤值的平方成正比,
## 小震动几乎察觉不到,大爆炸才明显。只动 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,再加上加法混合。
下一章接着用这个示例,讲标题界面、暂停菜单、结算和存档这些"游戏外壳",以及音频总线和程序化音效。