mcpField
D
dependency-injection

dependency-injection

github.com/jame581開発929177 回インストール2 週間前に更新
リポジトリ

dependency-injection について

Godot 4.3+ でノード間の依存関係を設計する開発者向けのスキルです。疎結合とテスト容易性を重視し、Autoload、@export 注入、Service Locator、シーン注入の使い分けを説明します。

主な機能

  1. 014 種類の依存性注入パターンの比較
  2. 02@export による依存関係の宣言
  3. 03Service Locator による差し替え可能なサービス管理
  4. 04親シーンから子ノードへの注入
  5. 05フェイクやモックを使ったノードのテスト

活用シーン

  1. 01単体テスト向けのノード分離
  2. 02レベルごとのゲーム依存関係の設定
  3. 03実行時のサービス実装の切り替え
  4. 04Autoload による結合の削減

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/jame581/godotprompter --skill dependency-injection

インストール後、Claude Code で/dependency-injectionを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
autoloads.md
export-injection.md
scene-injection.md
service-locator.md
testing-with-di.md

詳細

Godot 4.3+ における依存性注入

システム間の依存関係をつなぐパターンを紹介します。ノードの疎結合、置き換え可能性、テスト容易性を保つことができます。すべての例は、非推奨の API を使わずに Godot 4.3+ を対象としています。

関連スキル: テストしやすいアーキテクチャについては godot-testing、シグナルベースの疎結合については event-bus、オートロードの登録については godot-project-setup を参照してください。


1. 問題点

密結合のコードは、テスト、拡張、置き換えが困難になります。Godot で最もよく見られるのは、コードベースのあらゆる場所からグローバルなオートロードに直接アクセスするパターンです。

# BAD — tight coupling via direct autoload access scattered everywhere

# player.gd
func take_damage(amount: int) -> void:
    health -= amount
    AudioManager.play_sfx("hurt")          # hard dependency on AudioManager
    UIManager.update_health_bar(health)    # hard dependency on UIManager
    if health <= 0:
        GameState.record_death()           # hard dependency on GameState

# enemy.gd
func attack() -> void:
    AudioManager.play_sfx("attack")        # same AudioManager dependency again
// BAD — tight coupling via direct autoload / global access scattered everywhere

// Player.cs
public partial class Player : CharacterBody3D
{
    private int _health = 100;

    public void TakeDamage(int amount)
    {
        _health -= amount;
        GetNode<AudioManager>("/root/AudioManager").PlaySfx("hurt");        // hard dependency
        GetNode<UIManager>("/root/UIManager").UpdateHealthBar(_health);     // hard dependency
        if (_health <= 0)
            GetNode<GameState>("/root/GameState").RecordDeath();            // hard dependency
    }
}

// Enemy.cs
public partial class Enemy : CharacterBody3D
{
    public void Attack()
    {
        GetNode<AudioManager>("/root/AudioManager").PlaySfx("attack");      // same dependency again
    }
}

この方法の問題点:

  • AudioManager を直接呼び出すすべてのノードが、その具体的な実装に依存します。
  • AudioManager を別の実装に置き換えるには、呼び出し元をすべて変更する必要があります。
  • Player を単独でユニットテストすることはできません。AudioManager、UIManager、GameState がすべて存在し、有効である必要があります。
  • オートロードの初期化順序に関するバグにより、シーンの読み込み時に予期せず動作が壊れることがあります。
  • 隠れた依存関係があると、クラスが機能するために実際に何を必要としているのかが分かりにくくなります。

2. アプローチの比較

パターン複雑さテスト容易性最適な用途
オートロード低低オーディオ、設定、プラットフォームサービスなど、本当にグローバルなシングルトン
@export 注入低高ほとんどのノード。エディターで依存関係をつなぐため、実行時の検索は不要です
サービスロケーター中中プラグイン、オプションのシステム、実行時に置き換え可能な実装
シーン注入低高親から子への接続。Level が Enemy を、HUD がサブパネルを設定します

3. シングルトンとしてのオートロード

スクリプトを Project Settings → Globals → Autoload に登録すると、グローバルにアクセスできます(AudioManager.play_sfx(...)、GameState.score = 100)。オーディオ、セーブデータ、イベントバス、設定など、横断的な関心事に最適です。ドメイン固有のシステムをオートロードにするのは避けてください(それらはシーン注入するべきです)。

GDScript + C# による AudioManager の完全な例(効果音 + 音楽のクロスフェード)については、references/autoloads.md を参照してください。


4. @export によるノード注入

コラボレーターとなるノードを @export var health_component: HealthComponent のように公開し、インスペクターまたは親シーンから接続します。ライフサイクルについて:@export プロパティは _ready() の前に割り当てられます。

GDScript + C# による @export の完全なパターンとライフサイクルに関する注意事項については、references/export-injection.md を参照してください。


5. サービスロケーターパターン

String キーをサービスインスタンスに対応付ける中央レジストリのオートロードです。サービスは _ready() で登録し、_exit_tree() で登録解除します。利用側は ServiceLocator.get_service(name) を呼び出します。実行時に実装を柔軟に切り替えたい場合(テスト、MOD、A/B バリアント)に便利です。

型付きヘルパーメソッドを備えた、GDScript + C# によるサービスロケーターの完全な例については、references/service-locator.md を参照してください。


6. シーン注入

親シーンが子を読み込んだ後、_ready() でツリーをたどり、依存関係を割り当てます(enemy.player = $Player)。子は @export プロパティを宣言しますが、それを設定するのはインスペクターではなく親です。レベルごとに変わるゲーム固有の依存関係に最適です。

親から子へ注入するパターン(GDScript + C#)については、references/scene-injection.md を参照してください。


7. 依存性注入によるテスト

フェイクやテストダブルを注入することで、ノードをテスト可能にできます。オートロードの場合は、テストシーンの読み込み前にモックに置き換えます。@export による注入の場合は、エクスポート値をテストダブルに差し替えます。サービスロケーターの場合は、同じキーでフェイクを登録します。

各注入手法を示す GUT ベースのテストパターンについては、references/testing-with-di.md を参照してください。


8. 使い分け

状況推奨パターン
ほぼすべてのシーンのほぼすべてのノードで使うサービスAutoload シングルトン
ノードに必要な依存関係が1〜3個で、シーンがエディター上で作成されている@export による注入
システムが任意である、または実行時に差し替え可能であるサービスロケーター
親シーンが子を構築し、その子に必要なものを把握しているシーン注入
外部依存関係を持つノードのテストを書く@export またはプロパティ注入+スタブ
どのプロジェクトでも動作する必要があるプラグインサービスロケーター(自ら登録し、前提を設けない)
兄弟ノード2つが同じ依存関係を必要とする親に保持させ、下位へ注入する

簡単な判断ガイド:

Does every scene in the project need it?
  YES → Autoload singleton
  NO  ↓

Is the dependency known at edit-time and wired in the Inspector?
  YES → @export injection
  NO  ↓

Does the dependency need to be swapped at runtime (plugins, A/B testing)?
  YES → Service Locator
  NO  ↓

Does a parent scene own both the consumer and the dependency?
  YES → Scene injection
  NO  → Reconsider — either promote to autoload or restructure ownership

9. アンチパターン

何でも Autoload にする
# BAD — GameManager, EnemySpawner, InventorySystem, DialogueSystem all as autoloads.
# Every node in the game is coupled to every other system at module level.
# Test one component → must initialise all autoloads.

# GOOD — Only AudioManager, Settings, and SceneTransition are autoloads.
# EnemySpawner is a node in the Level scene, injected into enemies that need it.
// BAD — GameManager, EnemySpawner, InventorySystem, DialogueSystem all as autoloads.
// Every node in the game is coupled to every other system at module level.
// Test one component → must initialise all autoloads.

// GOOD — Only AudioManager, Settings, and SceneTransition are autoloads.
// EnemySpawner is a node in the Level scene, injected into enemies that need it.
深い依存関係の連鎖
# BAD — Player needs HealthComponent, which needs AudioManager,
# which needs SoundBank, which needs FileSystem...
# A change deep in the chain breaks everything above it.

# GOOD — flatten: HealthComponent takes only AudioManager (or a narrow interface).
# Each node declares only immediate dependencies.
// BAD — Player needs HealthComponent, which needs AudioManager,
// which needs SoundBank, which needs FileSystem...
// A change deep in the chain breaks everything above it.

// GOOD — flatten: HealthComponent takes only AudioManager (or a narrow interface).
// Each node declares only immediate dependencies.
循環依存
# BAD
# PlayerController._ready() calls ServiceLocator.get_service("inventory")
# InventorySystem._ready() calls ServiceLocator.get_service("player")
# Neither can fully initialise because the other isn't ready yet.

# GOOD — break the cycle with a signal.
# InventorySystem emits item_used; PlayerController connects to it.
# PlayerController never holds a reference to InventorySystem at all.
// BAD
// PlayerController._Ready() calls ServiceLocator.GetService("inventory")
// InventorySystem._Ready() calls ServiceLocator.GetService("player")
// Neither can fully initialise because the other isn't ready yet.

// GOOD — break the cycle with a signal.
// InventorySystem emits ItemUsed; PlayerController connects to it.
// PlayerController never holds a reference to InventorySystem at all.
サービスロケーターを神オブジェクトとして使う
# BAD — everything is registered: enemies, UI panels, individual nodes.
# ServiceLocator becomes a second, untyped scene tree.

# GOOD — only register stable, long-lived services (audio, analytics, save system).
# Short-lived nodes are wired by their parent via scene injection.
// BAD — everything is registered: enemies, UI panels, individual nodes.
// ServiceLocator becomes a second, untyped scene tree.

// GOOD — only register stable, long-lived services (audio, analytics, save system).
// Short-lived nodes are wired by their parent via scene injection.
注入後の null チェックを忘れる
# BAD — crashes if the @export was never set in the editor
func take_damage(amount: int) -> void:
    audio.play_sfx("hurt")   # "Nonexistent function 'play_sfx' in base 'Nil'" if audio was not wired

# GOOD — guard or assert clearly
func take_damage(amount: int) -> void:
    assert(audio != null, "HealthComponent: audio dependency was not injected")
    audio.play_sfx("hurt")

# OR — treat it as optional
func take_damage(amount: int) -> void:
    if audio != null:
        audio.play_sfx("hurt")
// BAD — crashes if the [Export] was never set in the editor
public void TakeDamage(int amount)
{
    _audio.PlaySfx("hurt");   // NullReferenceException if _audio was not wired
}

// GOOD — guard or assert clearly
public void TakeDamage(int amount)
{
    if (_audio == null)
    {
        GD.PushError("HealthComponent: audio dependency was not injected");
        return;
    }
    _audio.PlaySfx("hurt");
}

// OR — treat it as optional
public void TakeDamage(int amount)
{
    _audio?.PlaySfx("hurt");
}

10. チェックリスト

  • Autoload は、真にグローバルなサービス(オーディオ、設定、プラットフォーム)にのみ使用する
  • ノードは、遠く離れた親族ノードに対して get_node を呼び出すのではなく、依存関係を明示的に宣言する(@export またはパブリックプロパティ)
  • @export フィールドは、使用前に検証する(assert または null チェック)
  • サービスロケーターのサービスは、_exit_tree() / _ExitTree() で unregister を呼び出す
  • シーン注入は、子が完全に初期化された後、親の _ready() で行う
  • サービス間または Autoload 間に循環依存がない
  • 各ノードが依存するのは直接の協調オブジェクトのみとし、深い依存関係の連鎖を避ける
  • テスト用のスタブ/モックは、実際のサービスと同じインターフェースを実装する通常のノードにする
  • C# の @export([Export])依存関係がイベント購読を保持する場合、_ExitTree() で購読解除/クリアする
  • サービスロケーターを、シーン固有または短命なノードの保存に使わない

コメント