谢夏戈 @ xiexiage.com

UI 界面 + 主场景

May 12

GameManager 把状态和分数管理好了,这一章让玩家"看得见"。我们要做:

  1. 开始菜单menu.tscn)— 一个开始按钮
  2. 游戏主场景main.tscn)— 整合小鸟、水管、UI
  3. 分数 UI — 实时显示当前分数
  4. 结束画面 — 显示最终分数 + 重新开始按钮

整体流程

整理一下我们要搭的"两个场景"的关系:

menu.tscn(开始菜单)
   ↓ 点击「开始」按钮
main.tscn(游戏主场景)
   ├─ READY 状态:等待玩家按下 fly
   ├─ PLAYING 状态:正常游戏
   └─ GAME_OVER 状态:显示分数 + 重新开始按钮

开始菜单 menu.tscn

新建场景,根节点选 Node2D,保存为 scenes/menu.tscn

使用到的节点:

  • Node2D — 菜单根节点
    • CanvasLayer — UI 图层(不随摄像机移动)
      • Button(重命名 StartButton)— 开始按钮

为什么 UI 要放在 CanvasLayer 下?因为 CanvasLayer 的内容不会被摄像机变换影响,永远固定在屏幕上。

06-ui-menu-场景

菜单脚本

选中根节点挂脚本 scripts/menu.gd,然后在【节点】面板把 StartButtonpressed 信号连接到根节点:

extends Node2D

func _on_start_button_pressed() -> void:
    get_tree().change_scene_to_file("res://scenes/main.tscn")
  • change_scene_to_file() — 切换到指定场景,路径填你保存 main.tscn 的位置

游戏主场景 main.tscn

现在把之前章节做的所有东西整合到一起。如果之前你已经做了 game.tscn,可以直接改名为 main.tscn,或者新建一个。

使用到的节点(核心结构):

  • Node2D — 主场景根节点(挂 main.gd
    • Camera2D — 摄像机
    • Parallax2D(重命名 BG)— 滚动背景(第 7 章会做
    • Parallax2D(重命名 Progress)— 滚动地面
    • Bird — 小鸟(之前做的 bird.tscn
    • PillarSpawner — 障碍物生成器
    • CanvasLayer — UI 层
      • Label(重命名 ScoreLabel)— 分数显示
      • Panel(重命名 GameOverPanel)— 结束面板
        • Label — "Game Over"
        • LabelFinalScore)— 最终分数
        • ButtonRestartButton)— 重新开始

06-ui-main-场景

主场景脚本 main.gd

extends Node2D

func _ready() -> void:
    # 刚加载场景,设置为准备状态
    GameManager.set_state(GameManager.GameState.READY)
    GameManager.state_changed.connect(_on_game_state)  # 监听信号
    Engine.time_scale = 0  # 冻结游戏
    GameManager.clear_score()  # 游戏开始前清空分数

func _input(event: InputEvent) -> void:
    # 准备状态下,按一下 fly 就开始游戏
    if GameManager.current_state == GameManager.GameState.READY:
        if event.is_action_pressed("fly"):
            start_game()

func start_game():
    GameManager.set_state(GameManager.GameState.PLAYING)
    Engine.time_scale = 1  # 解冻游戏

## 重新开始游戏
func _on_restart_button_pressed() -> void:
    get_tree().reload_current_scene()

func _on_game_state(new_state) -> void:
    # 游戏结束时,让背景和地面停止滚动
    if new_state == GameManager.GameState.GAME_OVER:
        $BG.autoscroll.x = 0
        $Progress.autoscroll.x = 0

这段代码做了啥?

函数作用
_ready()场景一加载就执行:把状态设为 READY、监听状态变化、冻结整个游戏、清空分数
_input()监听全局输入:在 READY 状态下,按下 fly 就调用 start_game()
start_game()把状态切到 PLAYING,解冻游戏
_on_restart_button_pressed()重启按钮:直接重新加载当前场景
_on_game_state()监听 GameManager 的 state_changed 信号,GAME_OVER 时停止背景滚动

Engine.time_scale — 全局时间缩放

Engine.time_scale = 0  # 完全冻结
Engine.time_scale = 1  # 正常速度
Engine.time_scale = 0.5  # 慢动作

这是 Godot 的"全局慢放/暂停"开关,影响所有用 delta 计算的逻辑(包括小鸟下落、水管移动、Timer 等)。比一个一个写"如果暂停就别动"省心得多

信号的连接

GameManager.state_changed.connect(_on_game_state) — 这就是订阅信号

你也可以在编辑器的【节点】面板里手动连接信号,效果一样。代码连接的好处是更明确、便于版本管理。

分数 UI

ScoreLabel 挂个脚本 score_label.gd

extends Label

func _ready() -> void:
    text = "0"
    GameManager.score_changed.connect(_on_score_changed)

func _on_score_changed(new_score: int) -> void:
    text = str(new_score)
  • _ready 里订阅 score_changed 信号
  • 每次分数变化,自动更新 Label 文本

这就是信号解耦的好处 — Label 不需要主动去问 GameManager 当前几分,GameManager 也不知道有这么个 Label 存在,但分数照样能正确显示。

结束面板

GameOverPanel 默认隐藏(在编辑器把 visible 取消勾选),监听到 GAME_OVER 状态时才显示。

挂脚本 game_over_panel.gd

extends Panel

@onready var final_score: Label = $FinalScore

func _ready() -> void:
    visible = false
    GameManager.state_changed.connect(_on_state_changed)

func _on_state_changed(new_state) -> void:
    if new_state == GameManager.GameState.GAME_OVER:
        final_score.text = "得分:%d" % GameManager.total_score
        visible = true
  • 监听 state_changed
  • GAME_OVER 时显示面板 + 填上最终分数
  • %d 是字符串占位符,等同于 str(GameManager.total_score)

⚠️ 别忘了把 RestartButtonpressed 信号连接到 main 场景根节点的 _on_restart_button_pressed()

设置主场景

最后把【主场景】改成 menu.tscn(这样游戏启动直接到菜单):

路径:项目 → 项目设置 → 常规 → 运行 → 主场景

06-ui-主场景设置

测试运行

按下运行键,整个流程应该是:

  1. ✅ 启动 → 显示菜单 + 开始按钮
  2. ✅ 点击开始 → 切换到游戏场景,画面冻结,等待按键
  3. ✅ 按下 fly → 游戏开始,水管生成,小鸟可控
  4. ✅ 撞死 → 显示结束面板 + 最终分数
  5. ✅ 点重新开始 → 回到游戏初始状态

下一章我们替换美术资源,把这些灰色矩形变成真正的小鸟和水管 🎨

2023-PRESENT © 谢夏戈