mcpField
U
unity-game-architecture

unity-game-architecture

github.com/nice-wolf-studio開発4087 回インストール6 週間前に更新
リポジトリ

unity-game-architecture について

Unity のゲームアーキテクチャを選ぶための判断パターンをまとめたスキルです。プロジェクト規模や要件に応じたサービスアクセス、クラス設計、イベント通信、起動方法と、避けるべき設計を解説します。

主な機能

  1. 01グローバルサービスアクセスの選択肢
  2. 02MonoBehaviour と通常の C# クラスの使い分け
  3. 03コンポジションと継承の判断
  4. 04イベントアーキテクチャの選択
  5. 05マネージャーの起動方法
  6. 06設計上のアンチパターンと代替案

活用シーン

  1. 01システム依存にサービスロケーターか DI を選ぶ
  2. 02純粋なロジックを Unity のライフサイクル処理から分離する
  3. 03シーンをまたぐイベント通信を設計する
  4. 04依存関係のあるマネージャーの起動を計画する

インストール

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

$npx skills add https://github.com/nice-wolf-studio/unity-claude-skills --skill unity-game-architecture

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

同梱ファイルreferences/architecture-patterns.md

詳細

ゲームシステムアーキテクチャ -- 意思決定パターン

前提スキル: unity-scripting(MonoBehaviour、ScriptableObjects、イベント)、unity-lifecycle(RuntimeInitializeOnLoadMethod、DontDestroyOnLoad、SubsystemRegistration)、unity-foundations(コンポーネント、GameObjects)

これらのパターンは、最もよくあるアーキテクチャ上の問題に対処します。Claude は、直接参照とシングルトンを多用した肥大化した MonoBehaviour をデフォルトで作成しがちです。これはプロトタイプでは機能しますが、システムの規模が大きくなると破綻します。


パターン:グローバルなサービスアクセス

WHEN:システムが実行時に他のシステム(AudioManager、InputManager、SaveManager など)を見つける必要がある場合

DECISION:

  • 遅延シングルトン -- 小規模なプロジェクトで、マネージャーが1~3個、テストが不要な場合。static Instance + DontDestroyOnLoad。すぐに書けますが、モック化は不可能です。
  • サービスロケーター -- テストのために実装を入れ替えたい中規模プロジェクト(モック音声、スタブ保存など)。Register<T>/Get<T>を備えた中央レジストリです。依存関係は暗黙的ですが、差し替え可能です。
  • コンストラクター/メソッドDI -- 大規模プロジェクトまたはライブラリーコードで、最大限のテスト容易性が必要な場合。依存関係を明示的に渡します。MonoBehaviourへの注入にはVContainerまたはZenjectを使用します。

SCAFFOLD(サービスロケーター):

public static class Services
{
    private static readonly Dictionary<Type, object> _services = new();

    public static void Register<T>(T service) where T : class
    {
        _services[typeof(T)] = service;
    }

    public static T Get<T>() where T : class
    {
        if (_services.TryGetValue(typeof(T), out var service))
            return (T)service;
        throw new InvalidOperationException($"Service {typeof(T).Name} not registered");
    }

    public static bool TryGet<T>(out T service) where T : class
    {
        if (_services.TryGetValue(typeof(T), out var obj))
        {
            service = (T)obj;
            return true;
        }
        service = null;
        return false;
    }

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    static void Reset() => _services.Clear();
}

// Registration (in a bootstrap MonoBehaviour or RuntimeInitializeOnLoadMethod):
Services.Register<IAudioService>(new AudioService());
Services.Register<ISaveService>(new SaveService());

// Usage (anywhere):
Services.Get<IAudioService>().PlaySFX("explosion");

GOTCHA:サービスロケーターは依存関係を隠すため、コンストラクターを見てもクラスが何を必要としているか分かりません。真のインフラストラクチャサービス(音声、保存、分析など)にのみ使用し、ゲームプレイの依存関係には使用しないでください。テストでモックを登録できるよう、サービスには必ずインターフェース(AudioManagerではなくIAudioService)を使用してください。ドメインリロードを無効にしたEnter Play Mode Optionsでは、SubsystemRegistrationによるリセットが不可欠です。


パターン:MonoBehaviourとプレーンなC#クラス

WHEN:新しいクラスをMonoBehaviourから継承させるべきか判断する場合

DECISION:

  • MonoBehaviour -- 次のいずれかが必要な場合:[SerializeField]によるInspectorのシリアライズ、Unityのコールバック(Update、OnTriggerEnter)、Transform/GameObjectへのアクセス、destroyCancellationTokenを使うコルーチン/Awaitable、またはGameObject上のコンポーネントであること。
  • プレーンなC#クラス -- 純粋なロジックの場合:ステートマシン、経路探索アルゴリズム、データ処理、インベントリ処理、ダメージ計算、保存/読み込み用DTO。MonoBehaviourが所有し、制御します。

SCAFFOLD(MonoBehaviourが所有するプレーンなC#クラス):

// Pure logic class -- testable without Unity
public class HealthSystem
{
    public int Current { get; private set; }
    public int Max { get; }
    public bool IsDead => Current <= 0;
    public event Action OnDied;
    public event Action<int, int> OnChanged; // current, max

    public HealthSystem(int maxHealth)
    {
        Max = maxHealth;
        Current = maxHealth;
    }

    public void TakeDamage(int amount)
    {
        if (IsDead) return;
        Current = Mathf.Max(0, Current - amount);
        OnChanged?.Invoke(Current, Max);
        if (IsDead) OnDied?.Invoke();
    }

    public void Heal(int amount)
    {
        if (IsDead) return;
        Current = Mathf.Min(Max, Current + amount);
        OnChanged?.Invoke(Current, Max);
    }
}

// Thin MonoBehaviour wrapper -- bridges Unity and logic
public class HealthComponent : MonoBehaviour
{
    [SerializeField] private int maxHealth = 100;

    public HealthSystem Health { get; private set; }

    void Awake()
    {
        Health = new HealthSystem(maxHealth);
    }

    // Optional: expose events for Unity-side wiring
    void OnEnable() => Health.OnDied += HandleDeath;
    void OnDisable() => Health.OnDied -= HandleDeath;

    void HandleDeath()
    {
        // Unity-specific: play VFX, disable collider, etc.
        Destroy(gameObject, 2f);
    }
}

GOTCHA:プレーンなC#クラスでは[SerializeField]を使用できません。Inspectorで入れ子表示する場合は[System.Serializable]を使用してください。destroyCancellationTokenは持たないため、非同期処理を行う場合は所有するMonoBehaviourから渡してください。UnityEngineを参照するプレーンなC#クラスでUnity互換の数学処理を行う場合は、System.MathではなくMathfを使用してください。


パターン:コンポーネント合成と継承

WHEN:複数のGameObjectで一部の動作を共有しつつ、詳細が異なる場合(異なる攻撃を持つ敵、操作可能なオブジェクトなど)

DECISION:

  • インターフェースによる合成(デフォルト) -- 機能を個別のコンポーネント(Health、Mover、DamageDealer)に分けます。利用側はGetComponent<IDamageable>()で取得します。柔軟性が最大で、自由に組み合わせられます。
  • 抽象基底クラス -- 共有インターフェースだけでなく、状態と実装を共有する明確なIS-A関係がある場合に限ります。例:速度と寿命のロジックを共有するBulletProjectileとRocketProjectileの基底クラスであるProjectile。

SCAFFOLD(インターフェース+合成):

// Capability interface
public interface IDamageable
{
    void TakeDamage(float amount, Vector3 hitPoint, Vector3 hitNormal);
    bool IsAlive { get; }
}

// Focused component implementing the interface
[RequireComponent(typeof(Collider))]
public class DamageReceiver : MonoBehaviour, IDamageable
{
    [SerializeField] private float maxHealth = 100f;
    private float _currentHealth;

    public bool IsAlive => _currentHealth > 0;
    public event Action<float> OnDamaged;

    void Awake() => _currentHealth = maxHealth;

    public void TakeDamage(float amount, Vector3 hitPoint, Vector3 hitNormal)
    {
        if (!IsAlive) return;
        _currentHealth -= amount;
        OnDamaged?.Invoke(amount);
    }
}

// Consumer queries the interface, not the concrete type
void OnTriggerEnter(Collider other)
{
    if (other.TryGetComponent(out IDamageable target) && target.IsAlive)
    {
        target.TakeDamage(damage, transform.position, transform.forward);
    }
}

GOTCHA:UnityではGetComponent<IInterface>()が機能し、インターフェースを検索できます。RequireComponentは、コンポーネント追加時に依存関係を強制します。GetComponentの結果をnullチェックするよりもTryGetComponentを優先してください(古いUnityバージョンでnullラッパーの割り当てを避けられます)。MonoBehaviourの深い継承階層(3階層以上)は、Unityアーキテクチャにおける最大のアンチパターンです。


パターン:イベントアーキテクチャの選択

WHEN:2つ以上のシステムが、直接参照せずに通信する必要がある場合

DECISION:

  • C# イベント/Action -- 単一クラス内、または同じ GameObject 上の密結合したコンポーネント間の通信に使用します。最もシンプルで、型安全性が高く、オーバーヘッドがありません。
  • ScriptableObject イベントチャネル -- シーンをまたぐ通信や、デザイナーが設定でき、Inspector でドラッグ&ドロップによる配線ができる場合に使用します。ゲームプレイイベント(プレイヤーの死亡、レベルのクリアなど)に最適です。実装の詳細は unity-scripting/references/scriptableobjects.md を参照してください。
  • 静的イベントバス -- プロジェクト全体で型付きイベントを使用でき、コードのみで管理できます(管理するアセットはありません)。プロトタイプを素早く作成でき、システムレベルのイベント(シーンの読み込み、設定の変更など)に適しています。SO チャネルよりデバッグが難しくなります。

SCAFFOLD(静的型付きイベントバス):

// Event definitions
public struct PlayerDiedEvent { public Vector3 Position; public string CauseOfDeath; }
public struct ScoreChangedEvent { public int NewScore; public int Delta; }

// Bus implementation
public static class EventBus
{
    private static readonly Dictionary<Type, Delegate> _handlers = new();

    public static void Subscribe<T>(Action<T> handler) where T : struct
    {
        var type = typeof(T);
        if (_handlers.TryGetValue(type, out var existing))
            _handlers[type] = Delegate.Combine(existing, handler);
        else
            _handlers[type] = handler;
    }

    public static void Unsubscribe<T>(Action<T> handler) where T : struct
    {
        var type = typeof(T);
        if (_handlers.TryGetValue(type, out var existing))
        {
            var result = Delegate.Remove(existing, handler);
            if (result == null) _handlers.Remove(type);
            else _handlers[type] = result;
        }
    }

    public static void Publish<T>(T evt) where T : struct
    {
        if (_handlers.TryGetValue(typeof(T), out var handler))
            ((Action<T>)handler)?.Invoke(evt);
    }

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    static void Reset() => _handlers.Clear();
}

// Usage
EventBus.Subscribe<PlayerDiedEvent>(OnPlayerDied);
EventBus.Publish(new PlayerDiedEvent { Position = pos, CauseOfDeath = "lava" });

GOTCHA:OnEnable での登録に合わせて、必ず OnDisable(OnDestroy ではなく)で登録を解除してください。静的イベントバスはシーンの読み込み後も残るため、ハンドラーのオブジェクトが登録解除されずに破棄されると、MissingReferenceException が発生します。割り当てを避けるには struct イベントを使用してください。デザイナーがイベントを視覚的に配線する必要がある場合は、SO イベントチャネルを推奨します。


パターン:マネージャーのブートストラップ順序

WHEN:複数のマネージャーシステムが、起動時に互いに依存している場合

DECISION:

  • ブートシーン -- 専用の「Boot」シーンを最初に読み込み、すべてのマネージャーを作成してから、ゲームプレイシーンをアディティブに読み込みます。視覚的で、非同期処理に対応し、ロード画面もサポートします。本番プロジェクトに最適です。
  • RuntimeInitializeOnLoadMethod -- シーンに依存しないコードのみのブートストラップです。BeforeSceneLoad を通じてマネージャーを作成します。プラグイン、パッケージ、またはどのシーンからでもエントリーポイントになり得るプロジェクトに最適です。

SCAFFOLD(ブートシーン):

public class Bootstrapper : MonoBehaviour
{
    [SerializeField] private string firstGameplayScene = "MainMenu";

    async Awaitable Start()
    {
        // Create persistent managers
        DontDestroyOnLoad(gameObject);

        // Initialize services in dependency order
        var audio = gameObject.AddComponent<AudioService>();
        var save = gameObject.AddComponent<SaveService>();

        Services.Register<IAudioService>(audio);
        Services.Register<ISaveService>(save);

        // Load saved settings before gameplay
        await save.LoadSettingsAsync(destroyCancellationToken);

        // Load the first gameplay scene (replaces Boot scene)
        await SceneManager.LoadSceneAsync(firstGameplayScene);
    }
}

SCAFFOLD(RuntimeInitializeOnLoadMethod):

public static class GameBootstrap
{
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void Bootstrap()
    {
        // Create a persistent GameObject for managers
        var go = new GameObject("[Services]");
        Object.DontDestroyOnLoad(go);

        var audio = go.AddComponent<AudioService>();
        Services.Register<IAudioService>(audio);

        // NOTE: Cannot use async/await here -- this is a static method
        // For async init, have a manager use async Awaitable Start()
    }
}

GOTCHA:RuntimeInitializeOnLoadMethod では await を使用できません(静的な同期メソッドのためです)。ブートストラップで非同期処理(設定の読み込み、認証など)が必要な場合は、BeforeSceneLoad を使ってブートストラップ用 MonoBehaviour を作成し、その Start() で非同期処理を行ってください。ブートシーン方式では、Build Settings のシーン 0 として Boot シーンを追加する必要があります。関連項目:unity-lifecycle では、RuntimeInitializeOnLoadMethod の実行タイミングを詳しく説明しています。


アーキテクチャのアンチパターン

アンチパターン問題代替案
God MonoBehaviour(1000行以上)テストできず、変更が難しく、マージ時に競合する役割を絞ったコンポーネントと、通常の C# ロジッククラスに分割する
あらゆるものにシングルトンを使う密結合になり、テストできず、依存関係が隠れるインフラには Service Locator、ゲームプレイには DI を使う
Update 内での FindObjectOfType毎フレーム O(n) の検索が発生し、処理が遅いStart/Awake で参照をキャッシュするか、イベント/SO を使う
マネージャーのマネージャー集中化によるボトルネックと循環依存が生じる各システムが個別に Service Locator に登録する
システム間の直接参照どちらかのシステムが削除/置換されると破綻するインターフェースを使ったイベントチャネルまたは Service Locator
ドメインリロード時にリセットされない静的状態Editor のプレイセッション間で古いデータが残る[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]

関連スキル

  • unity-scripting/references/scriptableobjects.md -- SO イベントチャネル、ランタイムセット、変数参照(完全な実装)
  • unity-lifecycle -- RuntimeInitializeOnLoadMethod の実行タイミング、SubsystemRegistration、DontDestroyOnLoad
  • unity-state-machines -- MonoBehaviour ラッパーパターンに従う通常の C# クラスとしての FSM/BT
  • unity-testing -- Service Locator によって Edit Mode テストでモックを使う方法

追加リソース

コメント