UI 界面 + 主场景
May 12
GameManager 把状态和分数管理好了,这一章让玩家"看得见"。我们要做:
- 开始菜单(
menu.tscn)— 一个开始按钮 - 游戏主场景(
main.tscn)— 整合小鸟、水管、UI - 分数 UI — 实时显示当前分数
- 结束画面 — 显示最终分数 + 重新开始按钮
整体流程
整理一下我们要搭的"两个场景"的关系:
menu.tscn(开始菜单)
↓ 点击「开始」按钮
main.tscn(游戏主场景)
├─ READY 状态:等待玩家按下 fly
├─ PLAYING 状态:正常游戏
└─ GAME_OVER 状态:显示分数 + 重新开始按钮
开始菜单 menu.tscn
新建场景,根节点选 Node2D,保存为 scenes/menu.tscn。
使用到的节点:
Node2D— 菜单根节点CanvasLayer— UI 图层(不随摄像机移动)Button(重命名StartButton)— 开始按钮
为什么 UI 要放在
CanvasLayer下?因为CanvasLayer的内容不会被摄像机变换影响,永远固定在屏幕上。

菜单脚本
选中根节点挂脚本 scripts/menu.gd,然后在【节点】面板把 StartButton 的 pressed 信号连接到根节点:
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"Label(FinalScore)— 最终分数Button(RestartButton)— 重新开始

主场景脚本 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)
⚠️ 别忘了把
RestartButton的pressed信号连接到 main 场景根节点的_on_restart_button_pressed()。
设置主场景
最后把【主场景】改成 menu.tscn(这样游戏启动直接到菜单):
路径:项目 → 项目设置 → 常规 → 运行 → 主场景

测试运行
按下运行键,整个流程应该是:
- ✅ 启动 → 显示菜单 + 开始按钮
- ✅ 点击开始 → 切换到游戏场景,画面冻结,等待按键
- ✅ 按下 fly → 游戏开始,水管生成,小鸟可控
- ✅ 撞死 → 显示结束面板 + 最终分数
- ✅ 点重新开始 → 回到游戏初始状态
下一章我们替换美术资源,把这些灰色矩形变成真正的小鸟和水管 🎨