mcpField
E
event-bus

event-bus

github.com/jame581开发889178 次安装4 天前更新
代码仓库

关于 event-bus

面向 Godot 4.3+ 项目的全局信号总线技能,通过 Autoload 单例让互不相关的节点发布和接收事件,减少直接引用。内容涵盖 GDScript 与 C# 的实现、测试和常见反模式。

主要功能

  1. 01Autoload 全局信号中心
  2. 02节点间解耦事件通信
  3. 03带类型的信号参数
  4. 04GDScript 与 C# 示例
  5. 05GUT 事件测试指导

适用场景

  1. 01让 UI 响应游戏状态变化
  2. 02让音频或存档系统监听游戏事件
  3. 03在不相关节点间传递事件而不保留引用
  4. 04验证事件发布和接收行为

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/jame581/godotprompter --skill event-bus

安装后在 Claude Code 中输入/event-bus确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
anti-patterns.md
testing.md
typed-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() 中断开连接

评论

支持 Markdown