障碍物
May 12
Flappy Bird 最经典的玩法 — 上下成对的水管,小鸟要从中间缝隙穿过去。这一章我们做:
- 障碍物本体 — 上下两根水管 + 一个得分区
- 死区 Killzone — 撞上就死
- 障碍物生成器 — 定时刷新、自动移动、屏幕外销毁
这里用
Area2D(死区)而不是StaticBody2D,因为Area2D不会真的"卡住"小鸟,只会触发信号。这样我们就能在信号里写"游戏结束"的逻辑,而不是把小鸟物理地卡在水管里动弹不得。
创建死区 Killzone
先创建死区,使用到的节点:
Area2D— 死区根节点(重命名:Killzone)CollisionShape2D— 碰撞形状(先留空,下面会解释为什么)

CollisionShape2D 暂时不要选形状,因为后续这个死区既可以放到"障碍物"上,也可以放到"地板"上(小鸟没按飞掉地上也会 Game Over)。形状到时候根据位置再单独设置。
障碍物的组成
每个障碍物由 3 个区域 组成:
- 上水管(Killzone — 死区)→ 撞到就死
- 下水管(Killzone — 死区)→ 撞到就死
- 中间的得分区(Goal)→ 穿过加 1 分
这里我们同样先用 Godot 的默认素材(也就是默认的 icon.svg 来作为图片精灵图)
等后续我们再一起更新美术资源素材!
Node2D(重命名:PillarPair)Killzone(我们刚刚创建的死区)Sprite2D— 放入 Godot 默认素材(icon.svg)CollisionShape2D— 碰撞形状(矩形,调整到一根水管的大小)
Killzone2(复制Killzone一份,改名即可)Sprite2D— 放入 Godot 默认素材(icon.svg)CollisionShape2D— 碰撞形状(矩形,调整到一根水管的大小)
Area2D(重命名:Goal)CollisionShape2D— 得分区域

这里可以调整碰撞体的颜色,用红色来区分。
然后在设置好一个 Killzone 里的 Sprite2D 和 CollisionShape2D 后可以复制

然后调整两个的位置,在中间空出一道间隙。

然后把得分区域放在中间偏后的位置,这样只有完全通过的时候才算得分!
障碍物设置好后,就可以保存该场景为:scenes/pillar_pair.tscn
给 Killzone 挂脚本

选中 Area2D 节点(Killzone) → 右侧【节点】面板找到 body_entered 信号 → 双击 → 连接到自己。Godot 会自动生成 _on_body_entered 函数:
extends Area2D
func _on_body_entered(body: Node2D) -> void:
if body.name == "Bird":
print("撞击死亡!")
body_entered:任何物理体进入这个区域时触发的信号if body.name == "Bird":— 判断进来的是不是小鸟(防止其他东西误触发)- 暂时用
print打印日志,下一章做完GameManager后再换成真正的"游戏结束"逻辑
💡 后续优化预览(下一章 GameManager + 第 8 章 SoundManager 后会改成这样,现在不要这么写):
extends Area2D func _on_body_entered(body: Node2D) -> void: if body.name == "Bird" and GameManager.current_state == GameManager.GameState.PLAYING: SoundManager.play_die() GameManager.game_over()
GameManager是整个游戏的整体管理SoundManager是整个游戏的音效管理
地板也可以变成 Killzone
之前第 3 章做的底部边界,也可以变成死区 — 小鸟掉到地上就算游戏结束。

障碍物移动 + 销毁 + 得分
障碍物根节点要做三件事:
- 往左移动 — 营造小鸟在向右飞的错觉
- 离开屏幕后销毁 — 不然会一直累积,性能爆炸
- 检测小鸟穿过 — 加分
这些都写在障碍物根节点(Node2D)的脚本里:
extends Node2D
@export var speed := 200.0
@onready var goal: Area2D = $Goal
func _physics_process(delta: float) -> void:
# 统一向左移动
position.x -= speed * delta
# 离开屏幕后自动销毁
if position.x < -500:
queue_free()
# 当物体进入得分区域【得分】
func _on_goal_body_entered(body: Node2D) -> void:
# 检查进入的是不是小鸟(防止其他东西误触发)
if body.name == "Bird":
print("得分!")
# 重要:得分后立即禁用这个检测区域,防止重复得分
# 使用 set_deferred 是因为在碰撞回调中不能直接修改物理属性
goal.set_deferred("monitoring", false)

这段代码做了啥?
@export var speed := 200.0— 把移动速度暴露到面板,方便实时调@onready var goal: Area2D = $Goal— 拿到子节点Goal的引用(用于得分后禁用它)position.x -= speed * delta— 每帧整个障碍物往左移一点if position.x < -500: queue_free()— 飞出屏幕外就销毁自己,回收内存_on_goal_body_entered— 小鸟进入Goal时触发(同样要在编辑器里连接信号)
为什么要 set_deferred("monitoring", false)?
为了防止重复得分。
如果不禁用,小鸟身上的碰撞体可能在 Goal 区域里横跨多帧,每帧都触发一次 body_entered,你就从 1 分跳到 5 分了 😅
set_deferred 是因为在物理回调里不能直接修改物理属性,要"延迟到下一帧"再改。
💡 后续优化预览(下一章 GameManager + 第 8 章 SoundManager 后会改成这样):
extends Node2D @export var speed := 200.0 @onready var goal: Area2D = $Goal func _physics_process(delta: float) -> void: if GameManager.current_state == GameManager.GameState.PLAYING: position.x -= speed * delta if position.x < -500: queue_free() func _on_goal_body_entered(body: Node2D) -> void: if body.name == "Bird": SoundManager.play_score() if GameManager.has_method("add_score"): GameManager.add_score(1) goal.set_deferred("monitoring", false)升级点:
- 加了
if GameManager.current_state == PLAYING— 游戏暂停/结束时不再移动- 得分时调用
GameManager.add_score(1)和SoundManager.play_score()
障碍物生成器
光有一根水管不够,需要源源不断的水管。用 Timer 节点定时生成:
使用到的节点:
Node2D— 生成器根节点(重命名:PillarSpawner)Timer— 定时器(Wait Time = 2.5s,勾选Autostart)

设置 Timer 节点的属性:

把 pillar_pair.tscn 拖到 Pillar Scene 槽里:

最后,把 PillarSpawner 整个节点拖到游戏窗口右边外面(这样水管才会从屏幕右侧"飞"进来):

生成器脚本
extends Node2D
@export var pillar_scene: PackedScene
@export var y_range := 250.0
func _on_timer_timeout() -> void:
spawn_pillar()
func spawn_pillar() -> void:
var new_pillar = pillar_scene.instantiate()
var spawn_pos = global_position
spawn_pos.y += randf_range(-y_range, y_range)
new_pillar.global_position = spawn_pos
# 重点:加到主场景或者专门的容器里,防止柱子跟着生成器动
get_tree().current_scene.add_child(new_pillar)
关键点
@export var pillar_scene: PackedScene— 把"水管场景"暴露到面板,你需要在面板上把pillar.tscn拖进这个槽里@export var y_range := 250.0— 水管在垂直方向上的随机偏移范围_on_timer_timeout— 每次 Timer 触发就生成新的(同样要连接信号)pillar_scene.instantiate()— 把场景"实例化"成一个真正的节点randf_range(-y_range, y_range)— 随机一个垂直偏移get_tree().current_scene.add_child(new_pillar)⚠️ 重点!把水管加到主场景而不是生成器自己。否则如果生成器以后会移动,水管会跟着一起动,整个画面就乱套了。
💡 后续优化预览(下一章 GameManager 之后):在
spawn_pillar最外层包一个if GameManager.current_state == GameManager.GameState.PLAYING:,这样游戏结束后就不会继续生成水管了。
测试运行
应该能看到:
- ✅ 水管从右往左移动
- ✅ 每 2.5 秒生成一根,高度随机
- ✅ 小鸟穿过会在控制台打印「得分!」
- ✅ 小鸟撞到水管或地面会在控制台打印「撞击死亡!」

下一章我们来做 GameManager,把"撞击死亡"真正变成"游戏结束 + 显示分数 + 重新开始"。