😽

UnityのDI超ざっくり入門 2 - [SerializeField]とコンストラクタでPureC#込みのゲーム設計をする

2024/05/29に公開

はじめに

前回の記事では、UnityにおけるDI(UnityDI)の基本的な概念について見てきました。

https://zenn.dev/qemel/articles/ad6cf484d8280a

今回は、具体的な実装例を見ながら、UnityDIの基本的・実践的な使い方を見ていきます。

[SerializeField]とコンストラクタを使ってゲームの依存関係を解決する

ここからは、実際に前回の機能を利用してゲームの依存関係を解決する方法を見ていきましょう。

この話を抜きにしていきなりVContainerなどの話をしてもDIコンテナのメリットがあまり伝わらないと思うので、まずはUnityDIの基本を理解しておくことが大事だと思って書いています。

例題

例題として、以下のようなゲームを作ることを考えます。前回の例のもうちょっと実践的なバージョンです。

  • GameLoopSystemはPlayerのHPを監視し、HPが0になったらGameOverUIを表示する
  • PlayerがCoinと衝突したらScoreを10ポイント加算する
  • PlayerがEnemyと衝突したらScoreを5ポイント減算し、PlayerのHPを1減らす
  • Scoreが変わったらScoreUIに反映させる

また、MonoBehaviourを継承しているクラス・そうでないクラスの対応は以下です。全部MonoBehaviourを継承してもいいですが、今回はDIの効果を分かりやすくするため、MonoBehaviourを継承する必要がないクラスは継承していません。

クラス名 MonoBehaviourを継承しているか
Score x
GameLoopSystem x
Player o
Enemy o
Coin o
GameOverUI o
ScoreUI o

クラス図

クラス図は以下のようになります。

実装

では、各クラスの実装を見ていきましょう。

Score

public class Score
{
    public int Value { get; private set; }

    public void Add(int value)
    {
        Value += value;
    }

    public void Subtract(int value)
    {
        Value -= value;
    }
}

GameLoopSystem

public class GameLoopSystem
{
    private Player _player;
    private GameOverUI _gameOverUI;

    // PureC#のクラスはコンストラクタで依存関係を解決する
    public GameLoopSystem(Player player, GameOverUI gameOverUI)
    {
        _player = player;
        _gameOverUI = gameOverUI;
    }

    private bool _isGameOver;

    public void Update()
    {
        if (_player.Hp <= 0)
        {
            if (!_isGameOver)
            {
                _gameOverUI.Show();
                _isGameOver = true;
            }
        }
    }
}

Player

public class Player : MonoBehaviour
{
    [SerializeField] private int _maxHp;

    public int Hp { get; private set; }

    private void Start()
    {
        Hp = _maxHp;
    }

    public void AddDamage(int damage)
    {
        Hp -= damage;
        if (Hp <= 0)
        {
            Hp = 0;
        }
    }
}

Enemy

public class Enemy : MonoBehaviour
{
    private Score _score;

    // MonoBehaviourを継承しているクラスはInit()で初期化する
    public void Init(Score score)
    {
        _score = score;
    }

    private void OnCollisionEnter(Collision collision)
    {
        // Playerと衝突したら
        if (collision.gameObject.TryGetComponent<Player>(out var player))
        {
            _score.Subtract(5);
            player.AddDamage(1);
        }
    }
}

Coin

public class Coin : MonoBehaviour
{
    private Score _score;

    public void Init(Score score)
    {
        _score = score;
    }

    private void OnCollisionEnter(Collision collision)
    {
        // Playerと衝突したら
        if (collision.gameObject.TryGetComponent<Player>(out var player))
        {
            _score.Add(10);
        }
    }
}

GameOverUI

public class GameOverUI : MonoBehaviour
{
    public void Show()
    {
        gameObject.SetActive(true);
    }
}

ScoreUI

public class ScoreUI : MonoBehaviour
{
    private Score _score;

    public void Init(Score score)
    {
        _score = score;
    }

    private void Update()
    {
        _scoreText.text = _score.Value.ToString();
    }
}

このように、Find()やシングルトンを使わずに実装できているのが分かると思います。

DIの導入

次に、DIを導入していきます。こういう時は、GameEntryPointとかGameLifetimeScopeみたいなクラス名で全部管理するのが一般的です。

using UnityEngine;

public class GameEntryPoint : MonoBehaviour
{
    // シーン上に配置しているGameObjectをInspectorからアタッチする
    [SerializeField] private Player _player;
    [SerializeField] private Enemy _enemy;
    [SerializeField] private Coin _coin;
    [SerializeField] private GameOverUI _gameOverUI;
    [SerializeField] private ScoreUI _scoreUI;

    private GameLoopSystem _gameLoopSystem; // updateを呼び出すために保持
    
    private void Awake()
    {
        // 依存関係の要素を生成
        var score = new Score(); // ScoreはMonoBehaviourを継承していないのでnewでインスタンス化

        // 疑似的なコンストラクタで依存関係を解決(DI)
        _enemy.Init(score);
        _coin.Init(score);
        _scoreUI.Init(score);

        // GameLoopSystemはPureC#のクラスなのでコンストラクタで依存関係を解決(DI)
        _gameLoopSystem = new GameLoopSystem(_player, _gameOverUI);
    }

    private void Update()
    {
        // GameLoopSystem(PureC#)のUpdateを呼び出すことで、MonoBehaviourを継承しなくてもUpdateを使える
        _gameLoopSystem.Update();
    }
}

このように、GameEntryPointで、各クラスの依存関係を解決することができます。

各クラス内部での実装はコンストラクタのみで済むため、依存関係が一目瞭然になります。

Find()・シングルトンを使う場合との比較

比較までに、Find()やシングルトンを使う場合の実装例を見てみましょう。

前提として、これらを使うということは基本的にMonoBehaviourを継承します。

また、Scoreはいろんな場所から使っているので、ScoreManagerとして、シングルトンで実装します。

クラス名 MonoBehaviourを継承しているか
ScoreManager o
GameLoopSystem o
Player o
Enemy o
Coin o
GameOverUI o
ScoreUI o
public class ScoreManager : MonoBehaviour
{
    private static ScoreManager _instance;

    public static ScoreManager Instance => _instance;

    public int Score { get; private set; }

    private void Awake()
    {
        _instance = this;
    }

    public void Add(int value)
    {
        Score += value;
    }

    public void Subtract(int value)
    {
        Score -= value;
    }
}
public class GameLoopSystem : MonoBehaviour
{
    private Player _player;
    private GameOverUI _gameOverUI;

    private void Awake()
    {
        _player = FindObjectOfType<Player>();
        _gameOverUI = FindObjectOfType<GameOverUI>();
    }

    private bool _isGameOver;

    private void Update()
    {
        if (_player.Hp <= 0)
        {
            if (!_isGameOver)
            {
                _gameOverUI.Show();
                _isGameOver = true;
            }
        }
    }
}
public class Player : MonoBehaviour
{
    // 同じ
    ...
}
public class Enemy : MonoBehaviour
{
    private void OnCollisionEnter(Collision collision)
    {
        // Playerと衝突したら
        if (collision.gameObject.TryGetComponent<Player>(out var player))
        {
            ScoreManager.Instance.Subtract(5); // ScoreManagerに依存しているが、コンストラクタに比べてちょっとわかりづらい
            player.AddDamage(1);
        }
    }
}
public class Coin : MonoBehaviour
{
    private void OnCollisionEnter(Collision collision)
    {
        // Playerと衝突したら
        if (collision.gameObject.TryGetComponent<Player>(out var player))
        {
            ScoreManager.Instance.Add(10);
        }
    }
}
public class GameOverUI : MonoBehaviour
{
    ...
}

UnityDIでの実装との基本的な違いとしては以下があります。

  • 各クラスが自分で依存関係を解決するためにそれぞれのAwake()等で探しているため、ヒエラルキーの変更などがあると各自修正が必要になるケースがある
    • DIでの実装では、GameEntryPointが依存関係を解決しているため、どれだけ変更があってもEntryPointの修正だけで済む
  • シングルトンを使っているため、どこからでも簡単に参照できる上、依存関係がちょっとわかりにくい
    • DIでの実装では、コンストラクタやInit()を見れば一目瞭然
  • 単純にFind()系メソッドのコストがあるのでちょっと重い

UnityDIのメリット

UnityDIを使うメリットは以下の通りです。

  • ヒエラルキーの変更に強い
  • PureC#を使える
  • 依存関係が一目瞭然
  • テストがしやすい
  • 循環参照、シングルトンパターンなどの問題を回避しやすい
    • 依存関係が確認しやすくなると、おのずと循環参照を検出しやすくなる

特にPureC#を使えるのはUnityDIの大きなメリットです。MonoBehaviourを継承していないクラスは以下のようなメリットを得ることができます。

  • わかりづらいMonoBehaviourのライフサイクルを気にしなくていい
  • コンストラクタが使える
  • 不要な内部的なコードがなくなる
  • テストがしやすい
  • メモリ消費量が少ない
  • ヒエラルキーに置かなくていいので、ヒエラルキーがスッキリする

もうちょっと実践的な例

さきほどの例はゲームとしてはかなり特殊でした。

例えば、ゲームとして以下の部分がおかしいように感じます。

  • Enemyが動的に生成されていない・1個しかない
  • Coinが1個しかステージ上に配置できない

以上の部分を修正してみましょう。

Enemyの動的生成

Enemyを動的に生成するために、EnemySpawnerクラスを作成します。コンストラクタを使えばPureC#のクラスで実装できます。

public class EnemySpawner
{
    private Enemy _enemyPrefab;
    private Score _score;

    public EnemySpawner(Enemy enemyPrefab, Score score)
    {
        _enemyPrefab = enemyPrefab; // PrefabをDI
        _score = score; // スポーン用にScoreをDI
    }

    public void Spawn(Vector3 position)
    {
        var enemy = Object.Instantiate(_enemyPrefab, position, Quaternion.identity);
        enemy.Init(_score);
    }
}

これを使って、GameLoopSystemにEnemySpawnerを追加します。

public class GameLoopSystem
{
    private Player _player;
    private GameOverUI _gameOverUI;
    private EnemySpawner _enemySpawner; // 新しく追加

    // PureC#のクラスはコンストラクタで依存関係を解決する
    public GameLoopSystem(Player player, GameOverUI gameOverUI, EnemySpawner enemySpawner)
    {
        _player = player;
        _gameOverUI = gameOverUI;
        _enemySpawner = enemySpawner; // 追加
    }

    private bool _isGameOver;
    private float _spawnDuration = 3f;
    private float _spawnTimer;

    public void Update()
    {
        if (_player.Hp <= 0)
        {
            if (!_isGameOver)
            {
                _gameOverUI.Show();
                _isGameOver = true;
            }
        }

        _spawnTimer += Time.deltaTime;
        if (_spawnTimer >= _spawnDuration)
        {
            _enemySpawner.Spawn(new Vector3(Random.Range(-5f, 5f), 0, Random.Range(-5f, 5f)));
            _spawnTimer = 0;
        }
    }
}

GameEntryPointの設定

この時点でコンパイルエラーが出るので、GameEntryPointにEnemySpawnerを追加します。

public class GameEntryPoint : MonoBehaviour
{
    [SerializeField] private Player _player;
+   [SerializeField] private Enemy _enemy;
-   [SerializeField] private Coin _coin;
+   [SerializeField] private List<Coin> _coins; // ステージ上の複数のCoinをDIできるように変更
    [SerializeField] private GameOverUI _gameOverUI;
    [SerializeField] private ScoreUI _scoreUI;

    private GameLoopSystem _gameLoopSystem;

    private void Awake()
    {
        var score = new Score();
+       var enemySpawner = new EnemySpawner(_enemy, score);

-       _enemy.Init(score);
-       _coin.Init(score);

+       // coinを全部DI
+       foreach(var coin in _coins)
+       {
+           coin.Init(score);
+       }

        _scoreUI.Init(score);

-       _gameLoopSystem = new GameLoopSystem(_player, _gameOverUI);
+       _gameLoopSystem = new GameLoopSystem(_player, _gameOverUI, enemySpawner); // エラーが出るので修正しやすい
    }

    private void Update()
    {
        _gameLoopSystem.Update();
    }
}

これで、Enemyが動的に生成されるようになったほか、Coinも複数配置できるようになりました。

まとめ

前回紹介した方法で依存関係をしっかりと記述できるうえ、UnityDIを使うことで、依存関係を解決する際に様々なメリットを得ることができることがわかっていただけたでしょうか。

しかしながら、今度はGameEntryPointの記述が少しややこしくなってしまうというデメリットもあります。

ちょっと実践的なシチュエーションにしただけで、GameEntryPointの記述がかなり複雑になってしまったのが分かると思います。

enemyのInit()の順番をミスってしまうとエラーになってしまったりと、新たなめんどくささも出てきてしまいました。

そこで、次回はVContainerというDIコンテナを使ってこの面倒な記述を解消し、もっと楽に依存関係を解決する方法を見ていきたいと思います。

Discussion