unity-game-architecture について
Unity のゲームアーキテクチャを選ぶための判断パターンをまとめたスキルです。プロジェクト規模や要件に応じたサービスアクセス、クラス設計、イベント通信、起動方法と、避けるべき設計を解説します。
主な機能
- 01グローバルサービスアクセスの選択肢
- 02MonoBehaviour と通常の C# クラスの使い分け
- 03コンポジションと継承の判断
- 04イベントアーキテクチャの選択
- 05マネージャーの起動方法
- 06設計上のアンチパターンと代替案
活用シーン
- 01システム依存にサービスロケーターか DI を選ぶ
- 02純粋なロジックを Unity のライフサイクル処理から分離する
- 03シーンをまたぐイベント通信を設計する
- 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 テストでモックを使う方法