关于 event-bus
面向 Godot 4.3+ 项目的全局信号总线技能,通过 Autoload 单例让互不相关的节点发布和接收事件,减少直接引用。内容涵盖 GDScript 与 C# 的实现、测试和常见反模式。
主要功能
- 01Autoload 全局信号中心
- 02节点间解耦事件通信
- 03带类型的信号参数
- 04GDScript 与 C# 示例
- 05GUT 事件测试指导
适用场景
- 01让 UI 响应游戏状态变化
- 02让音频或存档系统监听游戏事件
- 03在不相关节点间传递事件而不保留引用
- 04验证事件发布和接收行为
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ jame581/ godotprompter --skill event-bus安装后在 Claude Code 中输入/event-bus确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/anti-patterns.mdtesting.mdtyped-signals.md详细介绍
Godot 4.3+ 中的事件总线
全局信号中心,让彼此无关的节点无需互相持有引用即可通信。所有示例均针对 Godot 4.3+,不使用已弃用的 API。
相关技能: component-system 用于组件之间直接进行信号通信,csharp-signals 用于特定于 C# 的信号模式,dependency-injection 用于其他解耦方法,ability-system 提供了使用 EventBus 处理能力事件的示例。
1. 什么是事件总线
EventBus 是一个单例自动加载项,充当信号的中央注册表。节点不再彼此直接连接,而是都连接到共享的 EventBus(或在其上发出信号)。这样便无需让一个节点持有另一个节点的引用。
Without EventBus With EventBus
────────────── ──────────────────────────
NodeA ──signal──► NodeB NodeA ──emit──► EventBus ──signal──► NodeB
──signal──► NodeC
──signal──► NodeD
流程图
┌─────────┐ emit(player_died) ┌───────────┐ player_died ┌──────────┐
│ NodeA │ ────────────────────► │ EventBus │ ───────────────► │ NodeB │
│(Player) │ │(Autoload) │ │ (UI) │
└─────────┘ └───────────┘ ───────────────► └──────────┘
player_died ┌──────────┐
│ NodeC │
│(AudioMgr)│
└──────────┘
NodeA 发出信号。NodeB 和 NodeC 各自独立连接到 EventBus。它们彼此并不知道对方的存在。
2. 何时使用事件总线,何时使用直接信号
| 场景 | 推荐方式 |
|---|---|
| 父节点通知自己的子节点 | 直接信号或方法调用 |
| 子节点通知父节点 | 直接信号(向上传递) |
| 两个拥有相同父节点的节点 | 通过父节点发送直接信号 |
| 场景树中完全无关的节点 | 事件总线 |
| UI 响应游戏状态变化 | 事件总线 |
| 音频管理器响应游戏事件 | 事件总线 |
| 数据管理器/存档系统响应事件 | 事件总线 |
| 对性能敏感的紧密内循环 | 直接方法调用 |
**经验法则:**如果你原本需要使用 get_node("../../SomeDistantNode") 或硬编码的 NodePath,那么事件总线会更合适。
3. 基本 EventBus
创建 res://autoloads/event_bus.gd(或 EventBus.cs),然后在 Project → Project Settings → Globals → Autoload 中注册,并命名为 EventBus。
GDScript(autoloads/event_bus.gd)
extends Node
## Emitted when the player character has died.
signal player_died
## Emitted whenever the score changes.
signal score_changed(new_score: int)
## Emitted when a level finishes successfully.
signal level_completed(level_id: int)
## Emitted when the player picks up a collectible.
signal item_collected(item_name: String)
## Emitted when the player's health changes.
signal health_changed(current: int, maximum: int)
C#(Autoloads/EventBus.cs)
using Godot;
/// <summary>
/// Global signal hub. Register as an autoload named "EventBus".
/// </summary>
public partial class EventBus : Node
{
/// <summary>Emitted when the player character has died.</summary>
[Signal] public delegate void PlayerDiedEventHandler();
/// <summary>Emitted whenever the score changes.</summary>
[Signal] public delegate void ScoreChangedEventHandler(int newScore);
/// <summary>Emitted when a level finishes successfully.</summary>
[Signal] public delegate void LevelCompletedEventHandler(int levelId);
/// <summary>Emitted when the player picks up a collectible.</summary>
[Signal] public delegate void ItemCollectedEventHandler(string itemName);
/// <summary>Emitted when the player's health changes.</summary>
[Signal] public delegate void HealthChangedEventHandler(int current, int maximum);
}
4. 连接事件
接收方在 _ready() 中连接。在 C# 中,务必在 _ExitTree() 中断开连接:总线的生命周期比其接收方更长,而且其信号是自定义信号。Godot 在接收方被释放时不会断开这类连接,因此下一次发出信号时会抛出 ObjectDisposedException。
GDScript
extends CanvasLayer
# A connection to this node's method is removed automatically when the
# node is freed, but explicit disconnection is still good practice
# for long-lived nodes that reconnect frequently.
func _ready() -> void:
EventBus.player_died.connect(_on_player_died)
EventBus.score_changed.connect(_on_score_changed)
EventBus.health_changed.connect(_on_health_changed)
func _exit_tree() -> void:
EventBus.player_died.disconnect(_on_player_died)
EventBus.score_changed.disconnect(_on_score_changed)
EventBus.health_changed.disconnect(_on_health_changed)
func _on_player_died() -> void:
$DeathScreen.show()
func _on_score_changed(new_score: int) -> void:
$ScoreLabel.text = "Score: %d" % new_score
func _on_health_changed(current: int, maximum: int) -> void:
$HealthBar.value = float(current) / float(maximum) * 100.0
C#
using Godot;
public partial class HudLayer : CanvasLayer
{
private EventBus _eventBus;
public override void _Ready()
{
_eventBus = GetNode<EventBus>("/root/EventBus");
// Connect using strongly-typed delegate handlers
_eventBus.PlayerDied += OnPlayerDied;
_eventBus.ScoreChanged += OnScoreChanged;
_eventBus.HealthChanged += OnHealthChanged;
}
// IMPORTANT: Always disconnect in _ExitTree() in C#.
// A += on a custom [Signal] is not cleaned up when this node is freed.
// Failing to disconnect leaves the EventBus holding a delegate to the
// freed node: a leak, and ObjectDisposedException on the next emit.
public override void _ExitTree()
{
_eventBus.PlayerDied -= OnPlayerDied;
_eventBus.ScoreChanged -= OnScoreChanged;
_eventBus.HealthChanged -= OnHealthChanged;
}
private void OnPlayerDied()
{
GetNode<Control>("DeathScreen").Show();
}
private void OnScoreChanged(int newScore)
{
GetNode<Label>("ScoreLabel").Text = $"Score: {newScore}";
}
private void OnHealthChanged(int current, int maximum)
{
GetNode<ProgressBar>("HealthBar").Value = (double)current / maximum * 100.0;
}
}
5. 发出事件
发送方调用 EventBus.<signal_name>.emit(...)(GDScript)或 EmitSignal(SignalName.*)(C#)。发送方无需知道有哪些节点在监听。
GDScript
extends CharacterBody2D
@export var max_health: int = 100
var current_health: int = max_health
var score: int = 0
func take_damage(amount: int) -> void:
current_health = clampi(current_health - amount, 0, max_health)
EventBus.health_changed.emit(current_health, max_health)
if current_health == 0:
EventBus.player_died.emit()
func add_score(points: int) -> void:
score += points
EventBus.score_changed.emit(score)
func collect_item(item_name: String) -> void:
EventBus.item_collected.emit(item_name)
func complete_level(level_id: int) -> void:
EventBus.level_completed.emit(level_id)
C#
using Godot;
public partial class Player : CharacterBody2D
{
[Export] public int MaxHealth { get; set; } = 100;
private int _currentHealth;
private int _score;
private EventBus _eventBus;
public override void _Ready()
{
_currentHealth = MaxHealth;
_eventBus = GetNode<EventBus>("/root/EventBus");
}
public void TakeDamage(int amount)
{
_currentHealth = Mathf.Clamp(_currentHealth - amount, 0, MaxHealth);
_eventBus.EmitSignal(EventBus.SignalName.HealthChanged, _currentHealth, MaxHealth);
if (_currentHealth == 0)
_eventBus.EmitSignal(EventBus.SignalName.PlayerDied);
}
public void AddScore(int points)
{
_score += points;
_eventBus.EmitSignal(EventBus.SignalName.ScoreChanged, _score);
}
public void CollectItem(string itemName)
{
_eventBus.EmitSignal(EventBus.SignalName.ItemCollected, itemName);
}
public void CompleteLevel(int levelId)
{
_eventBus.EmitSignal(EventBus.SignalName.LevelCompleted, levelId);
}
}
6. 有类型的信号参数
为每个信号参数指定类型。无类型的总线会让每个调用点都变成“这个负载是什么结构?”的考古现场,而参数数量的拼写错误只有在运行时才会暴露。如果参数不止两三个基本类型,请传递小型 Resource 或带有 class_name 的数据对象,而不是不断增加参数列表。
有类型的信号声明、负载对象模式,以及 C# [Signal] 委托的对应写法:references/typed-signals.md
7. 反模式
四种常见问题:父节点本可直接访问自己的子节点,却将所有内容都通过总线传递(过度解耦);处理程序的副作用会发出更多信号,导致追踪一个事件就要阅读所有处理程序;循环链中,监听方重新发出刚收到的信号,造成无限循环;以及在 C# 中连接信号却不断开连接,导致处理程序在总线的整个生命周期内一直存在。
每种反模式都附有有问题的代码、问题原因和修复方法,并分别提供 GDScript 和 C# 版本:references/anti-patterns.md
8. 测试
使用 GUT 验证生产端的发射(watch_signals(event_bus),然后调用 assert_signal_emitted_with_parameters(...))和消费端的响应(在总线上发射信号,然后断言消费者的状态)。始终使用通过 get_tree().root.get_node("EventBus") 获取的真实自动加载 EventBus 进行测试,而不是新建实例。
完整的生产端和消费端测试文件以及 GUT 辅助函数参考表,请参阅 references/testing.md。
9. 检查清单
- 已在 项目 → 项目设置 → 全局 → 自动加载 中注册
EventBus自动加载项 - 所有信号都使用带类型的参数(
signal foo(bar: int))——不使用未指定类型的信号 - 每个消费者都在
_ready()中连接,并在_exit_tree()中断开(C# 中必须如此) - 生产者通过
EventBus发射信号,而不是直接调用消费者方法 - 没有节点仅为发射或接收信号而持有对其他无关节点的直接引用
- 复杂负载使用
Resource子类,而不是原始Dictionary - 没有处理程序重新发射刚刚接收到的同一信号(以防止无限循环)
- 如果直接的父子节点调用或信号更简单,则不使用事件总线信号
- GUT 测试涵盖关键信号的发射(生产者)和接收(消费者)
- 合并前,已确认 C# 处理程序会在
_ExitTree()中断开连接