重构:信号总线 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 列表是从上往下依次加载的,最终顺序要是这样 👇
| 顺序 | 名称 | 为什么在这个位置 |
|---|---|---|
| 1 | EventBus | 大家都要连它,必须第一个准备好 |
| 2 | SaveManager | 下一章讲,GameManager 要用它读档 |
| 3 | GameManager | _ready() 里要连 EventBus 的信号 |
| 4 | AudioManager | 同样要连 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 | 不改,新模块自己去听 |
| 死区的职责 | 检测碰撞 + 派活 | 只检测碰撞 |
游戏跑起来的表现完全一样 — 但下次你想加东西的时候,会真切感受到差别。
下一章我们做存档系统,让最高分在关掉游戏之后还能留住 💾