谢夏戈 @ xiexiage.com

重构:信号总线 EventBus

Sep 3

游戏已经能玩、能上架了。但回头看代码,有个地方越写越别扭 — 这一章我们把它重构掉。

这章不加新功能,只改结构。改完游戏行为一模一样,但以后加东西会轻松很多。

先看问题出在哪

回到 第 5 章 的死区脚本,现在长这样:

extends Area2D

func _on_body_entered(body: Node2D) -> void:
    if body.name == "Bird" and GameManager.current_state == GameManager.GameState.PLAYING:
        AudioManager.play_die()    # 通知音频:放死亡音效
        GameManager.game_over()    # 通知管理器:游戏结束

看起来没毛病。但你数一下:小鸟撞一次墙,这个脚本要挨个去通知 2 个人

现在假设你想加点东西:

  • 想加屏幕震动 → 回来改一行 CameraShake.shake()
  • 想加死亡粒子特效 → 再回来改一行
  • 想加成就统计(死亡次数 +1)→ 再回来改一行

每加一个功能,就要回来改这个死区脚本一次。而死区本身的职责其实只有一句话:"我检测到小鸟撞上来了"。它凭什么要知道游戏里有音频系统、有相机、有成就系统?

这就是所谓的 「发信人必须认识所有收信人」 — 耦合。

换个思路:广播

想象一下机场广播:

  • 广播员喊一句「CA1234 航班开始登机」
  • 不需要知道候机厅里坐了谁
  • 谁买了这班机票,谁自己站起来往登机口走

广播员和乘客互不认识,但信息传到了。

我们要做的就是给游戏装一个广播站 — EventBus(信号总线)

  • 死区只负责喊:「鸟死了!」
  • 至于谁听见了、听见了要干啥,死区一概不管
  • 以后想加震屏、加特效、加成就,只要让新模块自己去"听"这个广播,死区一行都不用改

创建 EventBus

新建文件 scripts/autoload/event_bus.gd

extends Node
## 全局信号总线:只定义信号,不写逻辑
## 谁发生了事就在这里喊,谁关心就自己连接

@warning_ignore("unused_signal")
signal bird_died                  # 小鸟死亡

@warning_ignore("unused_signal")
signal point_scored(amount: int)  # 得分

就这么点东西。整个文件没有一个函数 — 这是故意的。

为什么 EventBus 里不能写逻辑?

这是新手最容易踩的坑:一看到"全局单例"就忍不住往里塞代码,最后 EventBus 变成第二个 GameManager,两边职责打架。

记住这条规矩:

  • EventBus 只管「喊」 — 它是个空壳广播站,只定义"有哪些事件"
  • GameManager 只管「游戏规则」 — 分数怎么算、状态怎么转
  • 一个是喇叭,一个是裁判,不要合并

@warning_ignore("unused_signal") 是干嘛的?

如果不加这行,Godot 编辑器会给你画一条黄色波浪线警告:「这个信号定义了但从来没在本文件里 emit 过」

Godot 这个检查本身是好意 — 通常你定义了信号却不发,多半是写漏了。但 EventBus 的设计初衷就是"在别的文件里 emit",所以这个警告在这里是误报。

@warning_ignore("unused_signal") 就是告诉编辑器:这里我是故意的,别提醒我了

注意它只对紧接着的下一行生效。所以两个信号要各写一次,不能只在文件顶上写一个。

注册 Autoload — 顺序很重要

第 5 章 一样,项目 → 项目设置 → 全局 → 自动加载,加入 event_bus.gd,命名 EventBus

但这次有个关键细节:Autoload 列表是从上往下依次加载的,最终顺序要是这样 👇

顺序名称为什么在这个位置
1EventBus大家都要连它,必须第一个准备好
2SaveManager下一章讲,GameManager 要用它读档
3GameManager_ready() 里要连 EventBus 的信号
4AudioManager同样要连 EventBus 的信号

为什么顺序会出问题?

因为 GameManager 的 _ready() 里会写 EventBus.point_scored.connect(...)。如果 EventBus 排在 GameManager 后面,那么 GameManager 跑 _ready() 的那一刻,EventBus 这个名字还不存在 — 游戏会直接报错崩掉。

在自动加载列表里可以直接拖动条目调整上下顺序。

改造发信方

死区(killzone.gd

extends Area2D

func _on_body_entered(body: Node2D) -> void:
    if body.name == "Bird" and GameManager.current_state == GameManager.GameState.PLAYING:
        EventBus.bird_died.emit()

原来的两行通知,变成一行广播。这个脚本现在完全不知道音频系统和 GameManager 的存在了 — 它只知道"鸟死了"这件事发生了。

障碍物(pillar_pair.gd

extends Node2D

@export var speed := 200.0
@onready var goal: Area2D = $Goal

func _physics_process(delta: float) -> void:
    if GameManager.current_state == GameManager.GameState.PLAYING:
        position.x -= speed * delta
        if position.x < -500:
            queue_free()

# 小鸟穿过得分区
func _on_goal_body_entered(body: Node2D) -> void:
    if body.name == "Bird":
        EventBus.point_scored.emit(1)

同样,原来那一坨「播音效 + 调用 add_score + 禁用区域」三件事,缩成一行 emit

改造收信方

发信方轻松了,那"接活"的代码去哪了?搬到了关心这件事的人自己家里。

GameManager 自己去听

extends Node

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

var total_score: int = 0
var high_score: int = 0
signal score_changed(new_score)
signal state_changed(new_state)

func _ready() -> void:
    ## 我关心「得分」,得分了我就加分
    EventBus.point_scored.connect(add_score)
    ## 我关心「小鸟死亡」,死了我就结算
    EventBus.bird_died.connect(game_over)

connect 的意思是:「这个信号响的时候,请调用我的这个函数」

  • EventBus.point_scored.connect(add_score) — 注意 add_score 后面没有括号
  • 有括号 add_score() = "现在就执行它,把返回值给我"
  • 没括号 add_score = "把这个函数本身交给你,等信号响了你再执行"

信号带的参数会自动传进去point_scored 定义时带了 amount: int,emit 时传了 1,所以 add_score 会收到 amount = 1

AudioManager 自己去听

extends Node

@onready var score_sfx: AudioStreamPlayer = $SFX/score_sfx
@onready var strike_sfx: AudioStreamPlayer = $SFX/strike_sfx
@onready var wing_sfx: AudioStreamPlayer = $SFX/wing_sfx

func _ready() -> void:
    ## 得分了我就放得分音效
    EventBus.point_scored.connect(func(_amount): play_score())
    ## 死了我就放撞击音效
    EventBus.bird_died.connect(play_die)

func play_score():
    score_sfx.play()

func play_die():
    strike_sfx.play()

func play_wing():
    wing_sfx.play()

这里有个小技巧:

EventBus.point_scored.connect(func(_amount): play_score())

point_scored 信号带一个参数,但 play_score() 这个函数不接收参数 — 直接连会因为参数数量对不上而报错。

所以中间垫了一个匿名函数func(...): ... 这种没有名字的临时函数):它负责接住那个参数,然后调用不带参数的 play_score()

参数名前面的下划线 _amount 是 GDScript 的约定:「这个参数我收到了,但我用不上」。加了下划线,编辑器就不会警告你"变量未使用"。

什么时候不该用 EventBus?

这点很重要 — EventBus 好用,但不是所有通信都该走广播

看小鸟自己扇翅膀的代码(bird.gd):

if Input.is_action_just_pressed("fly") and GameManager.current_state == GameManager.GameState.PLAYING:
    velocity.y = jump_force
    AudioManager.play_wing()   # 👈 这里是直接调用,没走 EventBus

为什么这里可以直接调用?

因为翅膀声只有小鸟自己关心。没有第二个系统需要知道"小鸟扇了一下翅膀"这件事 — 相机不关心、成就不关心、UI 也不关心。

给一个只有一个听众的事件建广播站,就是过度设计:白白多一层间接,读代码的人还得多跳一次才知道最后发生了什么。

判断标准很简单:

情况用什么
一件事,多个互不相干的系统都要响应✅ EventBus 广播
一件事,只有一个对象关心✅ 直接调用
父节点操作自己的子节点✅ 直接调用
子节点要通知父节点✅ 节点自己的 signal 就够了,不用上总线

一句话:EventBus 是给"跨系统"的事件用的,不是用来取代所有函数调用的。

重构完,对比一下

重构前重构后
死区要认识几个系统2 个(音频 + 管理器)0 个
加"死亡震屏"要改几个文件killzone.gd不改,新模块自己去听
死区的职责检测碰撞 + 派活只检测碰撞

游戏跑起来的表现完全一样 — 但下次你想加东西的时候,会真切感受到差别。


下一章我们做存档系统,让最高分在关掉游戏之后还能留住 💾

2023-PRESENT © 谢夏戈