谢夏戈 @ xiexiage.com

障碍物

May 12

Flappy Bird 最经典的玩法 — 上下成对的水管,小鸟要从中间缝隙穿过去。这一章我们做:

  1. 障碍物本体 — 上下两根水管 + 一个得分区
  2. 死区 Killzone — 撞上就死
  3. 障碍物生成器 — 定时刷新、自动移动、屏幕外销毁

这里用 Area2D(死区)而不是 StaticBody2D,因为 Area2D 不会真的"卡住"小鸟,只会触发信号。这样我们就能在信号里写"游戏结束"的逻辑,而不是把小鸟物理地卡在水管里动弹不得。

创建死区 Killzone

先创建死区,使用到的节点:

  • Area2D — 死区根节点(重命名:Killzone
    • CollisionShape2D — 碰撞形状(先留空,下面会解释为什么)

04-obstacles-死区

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 — 得分区域

04-obstacles-阻碍物

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

04-obstacles-阻碍物2

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

04-obstacles-得分区域

然后把得分区域放在中间偏后的位置,这样只有完全通过的时候才算得分!

障碍物设置好后,就可以保存该场景为:scenes/pillar_pair.tscn

给 Killzone 挂脚本

04-obstacles-死区脚本

选中 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 章做的底部边界,也可以变成死区 — 小鸟掉到地上就算游戏结束。

04-obstacles-地板死区

障碍物移动 + 销毁 + 得分

障碍物根节点要做三件事:

  1. 往左移动 — 营造小鸟在向右飞的错觉
  2. 离开屏幕后销毁 — 不然会一直累积,性能爆炸
  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)

04-obstacles-障碍物脚本

这段代码做了啥?

  • @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

04-obstacles-生成器节点

设置 Timer 节点的属性:

04-obstacles-生成器节点2

pillar_pair.tscn 拖到 Pillar Scene 槽里:

04-obstacles-生成器节点3

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

04-obstacles-生成器节点4

生成器脚本

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 秒生成一根,高度随机
  • ✅ 小鸟穿过会在控制台打印「得分!」
  • ✅ 小鸟撞到水管或地面会在控制台打印「撞击死亡!」

04-obstacles-尝试运行

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

2023-PRESENT © 谢夏戈