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、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()で切断されることを確認している