暂停与结算
Sep 8
UI 层与分数
Main 下加 CanvasLayer 命名 UI,里面 Label 命名 ScoreLabel:
extends CanvasLayer
## UI 只是显示器:自己不存任何数据,全靠听信号刷新
@onready var score_label: Label = $ScoreLabel
func _ready() -> void:
GameManager.score_changed.connect(_on_score_changed)
score_label.text = str(GameManager.total_score) # 开局同步一次
func _on_score_changed(new_score: int) -> void:
score_label.text = str(new_score)
为什么 _ready() 里要"开局同步一次"?信号只在变化时才响。UI 刚创建出来那一刻还没人喊过,不主动读一次,标签会是空的。
为什么 UI 放在 CanvasLayer 下?CanvasLayer 上的东西不受摄像机影响,永远贴在屏幕上。贪吃蛇没有移动的摄像机,现在看不出区别,但这是习惯——以后做有镜头的游戏,UI 不放这里会跟着地图跑。
暂停菜单:先讲坑再动手
Godot 的"暂停"是一句话:get_tree().paused = true。它会冻结整棵场景树——包括你的暂停菜单本身。菜单被冻住 = 按钮点不了、按 Esc 也没反应,你就再也出不来了。
解法是给菜单设 process_mode = Always("暂停时我照常工作")。但 process_mode 会向下传染:一个节点设了 Always,它的所有子节点默认跟着 Always。所以要精确地只给该活着的节点设 Always:
| 档 | 意思 | 给谁 |
|---|---|---|
| Inherit(默认) | 跟父节点一样 | 大多数节点 |
| Pausable | 暂停时停 | 蛇、四个 Timer、任何游戏逻辑 |
| Always | 暂停时照常跑 | 暂停菜单、监听 Esc 的节点 |
Always 设在 UI 上,不是 Main 上。 Main 设 Always 会传染给四个 Timer,暂停时蛇照走、食物照长。设在 UI 上,传染范围正好是"所有面板和按钮",而 Main 及其下的 Timer 保持 Pausable。传染是个特性,关键是把它设在正确的分支上。
UI 下加 Panel 命名 PauseMenu,里面两个 Button:ResumeButton、RestartButton:
@onready var pause_menu: Panel = $PauseMenu
@onready var resume_button: Button = $PauseMenu/HBoxContainer/ResumeButton
func _unhandled_input(event: InputEvent) -> void:
if not event.is_action_pressed("pause"):
return
match GameManager.current_state:
GameManager.GameState.PLAYING:
get_tree().paused = true
GameManager.set_state(GameManager.GameState.PAUSED)
GameManager.GameState.PAUSED:
get_tree().paused = false
GameManager.set_state(GameManager.GameState.PLAYING)
func _on_state_changed(new_state: GameManager.GameState) -> void:
pause_menu.visible = (new_state == GameManager.GameState.PAUSED)
if pause_menu.visible:
resume_button.grab_focus() # 手柄:面板一出现,焦点就给"继续"
func _on_resume_button_pressed() -> void:
get_tree().paused = false
GameManager.set_state(GameManager.GameState.PLAYING)
func _on_restart_button_pressed() -> void:
get_tree().paused = false # ⚠️ 先解冻,再重载
get_tree().reload_current_scene()
三个"为什么":
- Esc 的监听为什么写在
ui.gd而不是main.gd:暂停时Main是 Pausable,它的_input也会停,写在那里按 Esc 就出不来了。UI是 Always,暂停时它还醒着 - 重开前为什么要先
paused = false:paused是场景树的属性,不属于任何场景。重载场景时它不会自动复位,你会得到一个一开局就冻住的新场景。这是暂停菜单第二常见的坑 grab_focus()为什么写在visible = true之后:隐藏的控件拿不到焦点。先显示,再给焦点,顺序反了手柄就按不动
_unhandled_input 和 _input:一封信的三站
一个按键事件在 Godot 里按固定顺序传递:
_input()—— 所有节点最先看到,相当于门口保安先把每封信都瞧一眼- UI 控件 —— 有焦点的按钮、文本框。是它要的键就收下("吃掉"),后面的站就收不到了:Enter / 空格 / 手柄 A 给有焦点的按钮,字母给正在输入的文本框
_unhandled_input()—— 最后一站,只收到 ② 没要的,相当于没人认领的信送到总务处
例子:暂停面板开着,焦点在"继续"上。玩家按 Enter → ② 按钮要这个键,吃掉,触发 pressed,③ 什么都没收到。玩家按 Esc → 没有控件要 Esc → 掉到 ③ → 我们的暂停切换。所以游戏逻辑的按键写在 ③:让 UI 先挑,剩下的才是游戏的。写在 ① 会出现"按一下空格,按钮和游戏动作同时触发"的双响。
第 4 章的方向键用的是 ①,没出事是因为暂停时有 PLAYING 守卫挡着;规矩上游戏按键都该用 ③。
结算面板 + 回菜单
UI 下再加 GameOverPanel,里面 FinalScore(Label)、GameOverRestartButton、MenuButton:
if new_state == GameManager.GameState.GAME_OVER:
game_over_panel.visible = true
final_score.text = "Score %d Best %d" % [GameManager.total_score, GameManager.high_score]
game_over_restart_button.grab_focus()
func _on_menu_button_pressed() -> void:
get_tree().paused = false
get_tree().change_scene_to_file("res://scenes/menu.tscn")
为什么要有"回菜单"?立项表说模式"开局前选"。没有回菜单的路,玩家想换模式只能关游戏重开。每个"进去"的路都要有"出来"的路。

现在整条链通了:菜单 → 游戏 → 暂停/继续 → 撞死 → 结算 → 重开 / 回菜单。下一章拔掉鼠标,只用手柄再走一遍——你会发现按 A 没反应。