谢夏戈 @ xiexiage.com

系统:喇叭、裁判、档案

Sep 6

阶段 2 明明跑通了,为什么要拆?因为接下来的东西——分数显示、暂停、结算、存档、设置菜单——全都要问同一个问题:"现在什么状态?分数多少?玩家选了什么?"这些数据现在散在 main.gd 里,菜单场景、UI 场景想拿到它们,要么互相引用(一团乱),要么放到一个大家都能叫到的地方——Autoload。

为什么不一开始就拆?一开始拆,你会在四个文件之间来回跳,逻辑还没跑通就先迷路。先能跑,再拆干净。

单例只管比喻
EventBus喊一声「发生了 X」,零逻辑🔊 喇叭
GameManager状态、分数、最高分、开局设置⚖️ 裁判
SaveManager读写存档文件💾 档案柜

EventBus:整个文件就两个信号

extends Node
## 全局信号总线:只定义信号,谁关心谁自己来连

@warning_ignore("unused_signal")
signal food_eaten(points: int)     # 吃到东西了,值几分(普通食物 1、星星 2)

@warning_ignore("unused_signal")
signal snake_died                  # 蛇死了

一个函数都不许有。它是喇叭,喇叭只负责"喊",喊完谁该做什么是听的人自己的事。一旦往里塞逻辑,它就变成第二个 GameManager,两边职责打架。

为什么 food_eaten 带一个数字,不拆成 food_eatenstar_eaten?听众关心的是"值几分",不是"是哪种"。加分的人拿到 2 就加 2;放音效的人看到 2 就放星星音。以后加第三种食物值 5 分,信号一个字不用改。信号的参数是"事实的细节",不是"指令"。

@warning_ignore("unused_signal"):不加,编辑器会画黄线警告"这个信号定义了但没在本文件里发出"。EventBus 的设计就是"在别的文件里发",所以这是误报。这算 Godot 的设计失误吗?谈不上——检查器一次只看一个文件,没法知道别的文件会不会发这个信号;而"定义了却没发"在 99% 的情况下确实是写错了。它选择宁可误报也不漏报,然后给你一行注解当逃生口。黄线是警告(请你确认),红线才是错误(跑不起来)。

GameManager:状态机 + 分数

extends Node
## 裁判:状态和分数只存这一份,别人要么问它、要么听它喊

enum GameState { READY, PLAYING, PAUSED, GAME_OVER }
var current_state := GameState.READY

var total_score := 0
var high_score := 0

signal score_changed(new_score: int)
signal state_changed(new_state: GameState)

func _ready() -> void:
    SaveManager.load_game()                       # 开机读档
    EventBus.food_eaten.connect(add_score)        # 听「吃到 N 分」→ 加 N 分
    EventBus.snake_died.connect(game_over)        # 听「死了」→ 结算

## 改状态的唯一入口 —— 每次改都自动广播,不会漏
func set_state(new_state: GameState) -> void:
    current_state = new_state
    state_changed.emit(new_state)

func add_score(points: int) -> void:
    total_score += points
    score_changed.emit(total_score)

func reset_score() -> void:
    total_score = 0
    score_changed.emit(total_score)

func game_over() -> void:
    set_state(GameState.GAME_OVER)
    if total_score > high_score:
        high_score = total_score
        SaveManager.save_game()                   # 只在破纪录时写盘
  • enum GameState:本质是 0、1、2、3,但写名字比写数字清楚一百倍。游戏在任何时刻只处于其中一种——这就叫"状态机"。比小鸟多一个 PAUSED,因为这个游戏有暂停菜单:状态是玩法决定的
  • set_state() 是唯一入口:所有改状态的地方都走这一个函数,它顺手 emit。"状态变了但没人收到通知"这种 bug 不可能发生
  • food_eaten.connect(add_score):信号带 1 个参数,接它的函数就要收 1 个参数。add_score 后面没有括号——有括号是"现在就执行",没括号是"把这个函数交给你,信号响了你再执行"
  • save_game() 缩进在 if 里:只有数据真的变了才写盘。每帧写会卡,每次死都写是白写

main.gd 改成"只管蛇,不管分数"

三处改动:_ready() 里连上状态、清分、设 PLAYING;移动函数开头加一行守卫,die() 和吃东西改成发信号;删掉 die(),换成监听状态:

func _on_move_timer_timeout() -> void:
    if GameManager.current_state != GameManager.GameState.PLAYING:
        return                                  # 不是进行中,什么都不做
    # ……撞墙 / 撞自己:
        EventBus.snake_died.emit()
        return
    # ……吃到:
        EventBus.food_eaten.emit(1)             # 喊一声「吃到 1 分」,加分和音效各自去听
    # ……吃到星星:
        EventBus.food_eaten.emit(2)

func _on_state_changed(new_state: GameManager.GameState) -> void:
    if new_state == GameManager.GameState.GAME_OVER:
        for timer in [$MoveTimer, $FoodTimer, $StarSpawnTimer, $StarLifeTimer]:
            timer.stop()                        # 死了,四个计时器全停

为什么 main.gd 撞墙时只"喊一声",不自己停 Timer?因为"死了之后该发生什么"不是蛇该操心的事:要结算分数(GameManager 的事)、要弹面板(UI 的事)、要放音效(AudioManager 的事)。蛇只知道"我撞了",喊一声,谁关心谁自己来。 以后想加"死亡震屏",是新增一个文件去听 snake_diedmain.gd 一个字不用改。

那 Timer 谁停?main.gd 自己听 state_changed,看到 GAME_OVER 就停。它听的是"状态变了"这个结果,而不是自己去决定结果。这个区别就是"裁判"和"球员"的区别。

为什么四个 Timer 全停,不只停 MoveTimer?只停移动,死了以后食物还在长、星星还在出现,画面看起来像没死。"结束"要对所有会动的东西生效。

SaveManager:和小鸟几乎一样

extends Node
## 存档:读写 user://save.json

const SAVE_PATH := "user://save.json"

func save_game() -> void:
    var data := { "high_score": GameManager.high_score }
    var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if file == null:
        push_warning("存档写入失败:%s" % FileAccess.get_open_error())
        return
    file.store_string(JSON.stringify(data))

func load_game() -> void:
    if not FileAccess.file_exists(SAVE_PATH):
        return                                  # 首次游玩,没存档
    var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
    var data = JSON.parse_string(file.get_as_text())
    if data == null:
        return                                  # 存档损坏,防崩
    GameManager.high_score = data.get("high_score", 0)

这就是"外壳可复用":搬小鸟的,改一个字段名。res:// 是游戏本体,导出后打包成 .pck 只读;user:// 是玩家电脑上的用户数据目录,可读可写。编辑器里往 res:// 写可能成功、导出后必定失败——这坑特别隐蔽。

音效也是听信号

extends Node
## 音响:只负责 play(),不判断该不该播

@onready var eat_sfx: AudioStreamPlayer = $SFX/EatSfx
@onready var star_sfx: AudioStreamPlayer = $SFX/StarSfx
@onready var die_sfx: AudioStreamPlayer = $SFX/DieSfx

func _ready() -> void:
    EventBus.food_eaten.connect(_on_food_eaten)
    EventBus.snake_died.connect(die_sfx.play)     # 没参数的信号,可以直接把 play 交给它

func _on_food_eaten(points: int) -> void:
    if points >= 2:
        star_sfx.play()
    else:
        eat_sfx.play()

一个不报错、但没声音的坑:food_eaten 不能直接 connect(eat_sfx.play)play() 其实有一个参数——从第几秒开始播。信号带着 points = 2 过来,Godot 会老老实实调 play(2):从第 2 秒开始播一个 0.3 秒的音效 = 什么都听不到,而且一个错都不报。所以带参数的信号,要自己写一个函数接住参数再决定播什么。

下一章做外壳:菜单怎么把玩家的选择交给游戏。

2023-PRESENT © 谢夏戈