mcpField
E
event-bus

event-bus

github.com/jame581開発889178 回インストール5 日前に更新
リポジトリ

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、C# 固有のシグナルパターンについては csharp-signals、別の疎結合化の方法については dependency-injection、アビリティイベントで EventBus を使用する例については ability-system を参照してください。


1. イベントバスとは

EventBus は、シグナルの中央レジストリとして機能するシングルトンのオートロードです。ノード同士が直接接続する代わりに、すべてのノードが共有の 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. イベントバスと直接シグナルの使い分け

シナリオ推奨される方法
親から自身の子に通知する直接シグナルまたはメソッド呼び出し
子から親に通知する直接シグナル(上位へ伝播)
同じ親を持つ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. 型付きシグナルパラメーター

すべてのシグナルパラメーターに型を指定してください。型指定のないバスでは、呼び出し箇所ごとに「このペイロードの形式は何か」を調べる作業が発生し、パラメーター数の入力ミスも実行時まで表面化しません。2つか3つのプリミティブ型を超えるデータを渡す場合は、パラメーターリストを増やすのではなく、小さな Resource または class_name を指定したデータオブジェクトを渡してください。

型付きシグナルの宣言、ペイロードオブジェクトのパターン、および C# の [Signal] デリゲートに相当するものについては、references/typed-signals.md を参照してください。


7. アンチパターン

よくある失敗は4つあります。親が自身の子に直接アクセスできるのにすべてをバス経由にする(過剰な疎結合化)、ハンドラーの副作用からさらにシグナルが発行されるため、1つのイベントを追うのにすべてのハンドラーを読む必要がある、リスナーが受信したシグナルを再発行して無限ループする循環チェーン、そして 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 オートロードが プロジェクト → プロジェクト設定 → Globals → Autoload に登録されている
  • すべてのシグナルで型付きパラメーターを使用している(signal foo(bar: int))。型なしシグナルは使用しない
  • すべての受信側が _ready() で接続し、_exit_tree() で切断している(C# では必須)
  • 送信側は、受信側のメソッドを直接呼び出すのではなく、EventBus を介して発行している
  • シグナルを発行または受信するためだけに、無関係な別のノードへの直接参照を保持しているノードがない
  • 複雑なペイロードには、生の Dictionary ではなく Resource サブクラスを使用している
  • ハンドラーが、受信したばかりの同じシグナルを再発行していない(無限ループを防止)
  • 親子間の直接呼び出しやシグナルのほうが簡潔な場合に、イベントバスのシグナルを使用していない
  • GUT テストで、重要なシグナルの発行(送信側)と受信(受信側)の両方をカバーしている
  • マージ前に、C# ハンドラーが _ExitTree() で切断されることを確認している

コメント