谢夏戈 @ xiexiage.com

暂停与结算

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,里面两个 ButtonResumeButtonRestartButton

@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 = falsepaused 是场景树的属性,不属于任何场景。重载场景时它不会自动复位,你会得到一个一开局就冻住的新场景。这是暂停菜单第二常见的坑
  • grab_focus() 为什么写在 visible = true 之后:隐藏的控件拿不到焦点。先显示,再给焦点,顺序反了手柄就按不动

_unhandled_input_input:一封信的三站

一个按键事件在 Godot 里按固定顺序传递:

  1. _input() —— 所有节点最先看到,相当于门口保安先把每封信都瞧一眼
  2. UI 控件 —— 有焦点的按钮、文本框。是它要的键就收下("吃掉"),后面的站就收不到了:Enter / 空格 / 手柄 A 给有焦点的按钮,字母给正在输入的文本框
  3. _unhandled_input() —— 最后一站,只收到 ② 没要的,相当于没人认领的信送到总务处

例子:暂停面板开着,焦点在"继续"上。玩家按 Enter → ② 按钮要这个键,吃掉,触发 pressed,③ 什么都没收到。玩家按 Esc → 没有控件要 Esc → 掉到 ③ → 我们的暂停切换。所以游戏逻辑的按键写在 ③:让 UI 先挑,剩下的才是游戏的。写在 ① 会出现"按一下空格,按钮和游戏动作同时触发"的双响。

第 4 章的方向键用的是 ①,没出事是因为暂停时有 PLAYING 守卫挡着;规矩上游戏按键都该用 ③。

结算面板 + 回菜单

UI 下再加 GameOverPanel,里面 FinalScore(Label)、GameOverRestartButtonMenuButton

    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")

为什么要有"回菜单"?立项表说模式"开局前选"。没有回菜单的路,玩家想换模式只能关游戏重开。每个"进去"的路都要有"出来"的路。

08-pause-暂停

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

2023-PRESENT © 谢夏戈