ゲーム開発で理解するSOLID原則サンプル(特に依存性逆転の原則)

本記事は、クロスマート・テックの2024アドベントカレンダーの12日目の記事です!

最近、Web系の業務委託の傍ら、VLCNP物語というゲームの制作を行っています。

VLCNP物語 – 洞窟物語ライクの2Dアクションゲーム

本業はWeb系システムの開発ですが、ゲームの開発を行ったことで本業にもかなりメリットがありました。

というのも、ゲーム開発はオブジェクト指向が格段にやりやすい

で、オブジェクト指向と言えば、SOLID原則。

今回はゲームプログラミングで使ったSOLID原則について、サンプルコードを交えながら説明してみます!

SOLID原則とは

SOLID原則は下記の5つで構成されます。

今回は⭐️部分の依存性逆転の原則にフォーカスしてお話します。

【まったく】依存性逆転の原則の解説【分からない】

教科書的な説明によると、依存性逆転の原則は下記です。

高レベルのモジュールは低レベルのモジュールに依存すべきではない。
どちらも抽象に依存すべきである

例えばMMO RPGを作る場合を考えます。

下記のような動きをさせたいとします。

  • 場所をマウスクリックした場合は、Moveクラスを使ってプレイヤーをその地点に移動させる
  • 敵をクリックした場合は、Attackクラスを使ってプレイヤーに敵を攻撃させる

それぞれ依存性逆転していない例と依存性逆転している例は下記です。

依存性逆転してない
依存性逆転している

マジで良く分からない

ここまできて、ウンウンと頷いている人はこの先を読む必要がありません。

この手の図。僕が大学生だった20年ぐらい前から腐る程登場するんですが、理解している人ってどのぐらいいるのでしょうか??

体感ですが、ソフトウェアエンジニアの80%ぐらいは理解した「ふり」をしている気がします。

安心してください、僕もでした。

ゲーム開発のコードから 依存性逆転の原則の身体感覚を獲得しよう

インターン時代の研修か何かでこの概念に初めて出会った気がするんですが、そのときからこいつにはキレてます。

だいたい依存性逆転の原則を語るのに、動物interfaceをつくって、猫クラスと犬クラスに実装するからわかんなくなるんですよ。なんだよそのクラス。なんの意味があるの?

ということで、今回は、僕がVLCNP物語で書いた実コードを元に、依存性逆転の原則を体得してみます。

ボスの実装を書いてみる

では早速、VLCNP物語のボス、シモーヌの動作をコードで書いてみましょう。

Unity+C#で書くことになりますが、使ったことない人でもだいたい分かります。(たぶん)

ボスの仕様

VLCNP物語の最初の難関ボス、シモーヌの仕様を確認します。

シモーヌの動作は下記4つの動作の組み合わせでできています。

待機
ジャンプ+剣投げ
剣投げ
牙◯(がとつ)

各動作は例えば下記のようなUIでUnityのInspector上から、ゲームデザイナーが自由に決定できるようになると最高です。

では、さっそく実装していきましょう!

依存性逆転の原則を使わずに実装

各動作をクラスで分離し、シモーヌにアタッチされたEnemyV2Controllerで制御をする構造にしてみます。

まず、Waitingクラスの実装を書いてみます。

public class Waiting : MonoBehaviour
{
	[SerializeField]
	float waitTimeSecond = 1.0f;

	bool IsExecuting = false;
	bool IsDone = false;

	public void Execute()
	{
		if (IsExecuting) return;
		IsExecuting = true;
		StartCoroutine(Wait());
	}

	private IEnumerator Wait()
    {
        yield return new WaitForSeconds(waitTimeSecond);
        IsExecuting = false;
        IsDone = true;
    }
    
    public void Reset()
    {
		IsExecuting = false;
		IsDone = false;
    }
}
  • EnemyV2Controllerから、Execute()が呼ばれ、非同期でWait()関数が呼ばれる
  • 指定のwaitTimeSecond秒待ったあと、IsExecuting=false、IsDone=true になって処理が終わる

コントローラー側はWaitingクラスを下記のように使用します。

public class EnemyV2Controller : MonoBehaviour
{
    Waiting waiting;
    ...

	void Awake()
	{
		waiting = GetComponent<Waiting>();
	}

    void Update()  // 1フレーム毎に呼ばれる関数
    {
        if (GetCurrentAction() == 待ち)
        {
			if (!waiting.IsExecuting) waiting.Execute();
            if (!waiting.IsDone) return;
            // 現在の行動が終了した場合はリセットして次の行動に進む
			waiting.Reset();
			NextAction();  // GetCurrentAction()の値を次の行動に変更する
        }
        ...
        ...
    }
    ...
	private string GetCurrentAction() { /* いい感じに実装 */}
		
	private void NextAction() { /* いい感じに実装 */}
}
  • 毎フレームごとに、GetCurrentAction() で現在の行動が何かを取得
  • 現在の行動が「待ち」だった場合、WaitingインスタンスをExecute()する
  • waiting.IsDone=trueになるまで、繰り返し待つ
  • waiting.IsDone=trueになったら、次の行動に進める

依存性など考えずになんとなく実装するとだいたいこんな感じになりそうです。

さらに、剣投げの行動をコントローラーに加えるとしたら、下記のような感じでしょうか。

public class EnemyV2Controller : MonoBehaviour
{
	...
	[SerializeField]
    SwordThrow swordThrow;  // UnityのInspectorからインスタンスを入れる
    ...

	void Awake()
	{
		waiting = GetComponent<Waiting>();
		swordThrow = GetComponent<SwordThrow>();
	}

    void Update()
    {
        if (GetCurrentAction() == 待ち)
        {
            if (!waiting.IsExecuting) waiting.Execute();
			...
        }
		else if (GetCurrentAction() == 剣投げ)  // ⭐️追加
        {
			if (!swordThrow.IsExecuting) swordThrow.Execute();
			...
		}
        ...
    }

いやあ、、、とってもありそうなコードですよね??

100点ではないけど、60点ぐらいな感じの。

読めばまあ分かりますよね〜みたいな、ありふれたやつです。

各動作は例えば下記のようなUIでUnityのInspector上から、ゲームデザイナーが自由に決定できるようになると最高

↑がまだできてなさそうですが、きっとGetCurrentAction()やNextAction()をいじってなんとかなるでしょう。

クラスの実装に依存していることの問題点

3ヶ月ぐらいは↑のコードで元気よく動くかもしれません。

スッキリして見やすいですし、各行動はクラスで分離され、特に問題ないように見えます。

満足したあなたはEnemyV2Controllerの担当を離れ、別の機能を作っていたとします。

で、数カ月ぶりにEnemyV2Controllerを見たら、下記のような実装になっていました。

public class EnemyV2Controller : MonoBehaviour
{
	...

	void Update()
	{
		...
		else if (GetCurrentAction() == 剣投げ)
		{
			if (!swordThrow.IsExecuting) swordThrow.Execute();
			...
		}
		else if (GetCurrentAction() == ダブル剣投げ)  // ⭐️ディレクターが突如思いついた技
		{
			if (!swordThrow.IsExecuting) swordThrow.Execute();
			if (!swordThrow.IsDone) return;
			swordThrow.Reset();
			if (!swordThrow.IsExecuting) swordThrow.Execute();
			if (!swordThrow.IsDone) return;
			...
		}
		...
	}

このコードを見て状況を想像します。

  • ディレクターが急遽仕様変更で、高速で2回剣投げを行う行動を足したらしい
  • 新しく入った日の浅いプログラマー(納期明日)が、急いで入れ込んだ

みたいなところでしょうか。

このコードは責務、という観点で見ると下記の点でまずいです。

  • コントローラー側は行動順とその実行だけに責務を持つ。が、ダブル剣投げにおいては行動の内容に口を出している
  • このコードがあることで、敵の行動は、コントローラー側と行動側の合せ技で作られているとチームが理解する。結果、敵の行動の内容を作るのにコントローラー側、行動側、両方を見ながら作るという意識になる
  • 前例にならい、この先コントローラー側に、どんどん行動の内容が書かれる。すると、いつか、コントローラーをいじったら行動が、行動をいじったらコントローラーがバグる現象が起きる。コントローラーと行動が密結合になる

この調子だと、ダブル剣投げの条件分岐の中に、なぜかWaitingクラスが乱入してくる日も近いです。

で、それを、「最近責務理解してないやつがこういうの書いててさ〜!」みたいにXに晒し上げてインプを稼ぐのは簡単なのですが、

そもそもこうなる構造が悪いんじゃないか? と考えるとより人生面白くなるんじゃないでしょうか?

依存性逆転の原則を使って実装

では、いよいよ。

依存性逆転の原則に従って、これらを書き直してみましょう。

まずは、いまある依存しまくりのEnemyV2Controllerを観察してみます。

    void Update()  // 1フレーム毎に呼ばれる関数
    {
        if (GetCurrentAction() == 待ち)
        {
			if (!waiting.IsExecuting) waiting.Execute();
            if (!waiting.IsDone) return;
            // 現在の行動が終了した場合はリセットして次の行動に進む
			waiting.Reset();
			NextAction();  // GetCurrentAction()の値を次の行動に変更する
        }
        ...
        ...
    }

コントローラーは下記の手続きを行っているのが見えてきます。

  • 行動が実行中か確認し、未実行であれば行動を実行
  • 行動が実行済みならリセットして次の行動に移る

まずは、↑を敵の行動という一連の動作インターフェースとして抽象化します。

public interface IEnemyAction
{
    public void Execute();
    public bool IsExecuting { get; }
    public bool IsDone { get; }
    public void Reset();
}

このインターフェースを使って、下記のクラス図のとおりに依存性を逆転させます。

まずはWaitingクラスを作り直します

public class Waiting : MonoBehaviour, ⭐️IEnemyAction
{
	[SerializeField]
	float waitTimeSecond = 1.0f;

	bool isExecuting = false;
	bool isDone = false;
	public bool IsDone { get => isDone; }
    public bool IsExecuting { get => isExecuting; }

	public void Execute()
	{
		isExecuting = true;
		StartCoroutine(Wait());
	}

	private IEnumerator Wait() { ... }
    
    public void Reset() { ... }
}

作り直すと言っても、やったことは

  • IEnemyActionをimplementsし
  • IEnemyActionに書かれたインターフェースの中身を実装

しただけです。ほぼ、前のWaitingクラスと変わっていません。

同様に、SwordThrowクラスについてもIEnemyActionを実装します。

public class SwordThrow : MonoBehaviour, ⭐️IEnemyAction
{
	...なんかいいかんじの じっそう
}

で、EnemyV2Controller側ですが、激変します。

下記のような形になります。

public class EnemyV2Controller : MonoBehaviour
{
    [SerializeField] public List<SerializableInterface<IEnemyAction>> enemyActions;
    // Inspector上にinterfaceを置くには Unity3D-SerializableInterfaceが必要 https://tsubasamusu.com/serializable-interface/
    ...

    void Update()
    {
        // 現在の行動を取得する
        IEnemyAction currentAction = GetCurrentAction();
        if (currentAction == null) return;
        // 現在の行動を実行する
        if (!currentAction.IsExecuting)
        {
            currentAction.Execute();
        }
        if (!currentAction.IsDone) return;
        // 現在の行動が終了した場合はリセットして次の行動に進む
        currentAction.Reset();
        NextAction();
    }

    private IEnemyAction GetCurrentAction()
	{
		// 今の行動を返す
		// enemyActionsをもとに適当に作る...
		...
		return enemyActions[currentActionIndex].Value;
	}
		
	public void NextAction()
    {
        // 現在の行動のインデックスをインクリメントする
        currentActionIndex++;
        // 現在の行動のインデックスが行動の配列の長さ以上の場合は0に戻す
        if (currentActionIndex >= enemyActions.Count)
            currentActionIndex = 0;
    }
}

Updateメソッドのスリムさ。。。!

ひとつひとつ見ていくと、まず、各行動は下記によって

[SerializeField] public List<SerializableInterface<IEnemyAction>> enemyActions;

「interfaceのリスト」として「Inspectorから」入れ込まれます。

下記のような形でUnityからゲームデザイナーの方がポコポコ自由にドラッグ&ドロップしていきます。

プログラムの中身を見なくてもゲームデザイナーが自由に順番を決められる

ここで入れ込まれたenemyActionsが、Updateメソッド内で順番に実行されていきます。

    void Update()
    {
        // 現在の行動を取得する ⭐️ enemyActionsから順番にWaitingクラスなどが取れる
        IEnemyAction currentAction = GetCurrentAction();
        if (currentAction == null) return;
        // 現在の行動を実行する
        if (!currentAction.IsExecuting)
        {
            currentAction.Execute();
        }
        if (!currentAction.IsDone) return;
        // 現在の行動が終了した場合はリセットして次の行動に進む
        currentAction.Reset();
        NextAction();
    }

どうでしょうか?

かなりスッキリしましたよね。

このコードを見ながら、依存性逆転の教科書的な説明を再び読んでみます。

高レベルのモジュールは低レベルのモジュールに依存すべきではない。

どちらも抽象に依存すべきである

確かに抽象にしか依存してない〜〜〜〜!!!!

だからなんだってんだよ 「なんだ」ってんだよ「 だから」

ただ、だから何?? 感は否めません。

めっちゃ分岐なくなったし、なんかこう、かっこいいコードですよね。

ではきちんと、依存性逆転したことによるメリットも考えてみます。

もう一度コードを見てください。

    void Update()
    {
        // 現在の行動を取得する ⭐️ enemyActionsから順番にWaitingクラスなどが取れる
        IEnemyAction currentAction = GetCurrentAction();
        if (currentAction == null) return;
        // 現在の行動を実行する
        if (!currentAction.IsExecuting)
        {
            currentAction.Execute();
        }
        if (!currentAction.IsDone) return;
        // 現在の行動が終了した場合はリセットして次の行動に進む
        currentAction.Reset();
        NextAction();
    }

コントローラー側にWaitingやSwordThrowクラスなどが一切現れてきません。

これは、ダブル剣投げの悲劇を起こしようがないことを意味します。

つまり、コントローラーは行動順を管理し、行動を実行するという責務のみに集中できるのです。

逆に行動側は、IEnemyActionをimplementsしたクラスを用意し、行動の中身は「そこにしか書きようがない」のです。ダブル剣投げをしたかったらダブル剣投げクラスを作るしかなくなるのです。

コントローラーと行動を実行するクラスが完全に疎結合になります。

コントローラーの変更が、行動そのものをバグらせることもありませんし、行動側の変更がコントローラーや他の行動に影響を及ぼす可能性も原理的に限りなく低くなります。

また、クラスを直接叩いていた場合は、

public class EnemyV2Controller : MonoBehaviour
{
	...

    void Update()
    {
        if (GetCurrentAction() == 待ち)
        {
            if (!waiting.IsExecuting) waiting.Execute();
			...
        }
		else if (GetCurrentAction() == 剣投げ)  // ⭐️追加
        {
			if (!swordThrow.IsExecuting) swordThrow.Execute();
			...
		}
        ...
    }

↑のような感じでした。

じゃあこのEnemyV2Controllerを、他のボスで使おうとしたらどうでしょう???

えっとお〜〜。。。ってなっちゃいますよね? Updateメソッドにそのボス用の行動分岐を追加し続ける羽目になります。こっちはボスに応じて行動を追加したり変更したりしたいだけなんですが、コントローラーくんの依存っぷり半端ない。

その点、interfaceを使った依存性逆転の原則で作った方は

IEnemyActionsを実装した新しい行動を作って、EnemyActionsに入れていけば、ボスに応じて自由に行動をデザインできます。依存性の注入ってやつ。

いやあ〜〜〜「疎」ですね〜〜。

依存性逆転の原則の使い所(めっちゃ大事)

いかがだったでしょうか?動物Interfaceを犬クラスに実装する、の例よりはかなりわかりやすいものとなったのではないでしょうか?

最後に依存性逆転の原則の使い所の気づきだけ共有させてください。

当然のことながら、すべての実装をやたらインターフェースに置き換えるのは微妙かなと感じています。

依存性逆転の原則が気持ち良いのは「同じ概念で似た手続きを取るが、内容が違うもの」のときだと思います。逆に言うと「似た手続きを取るが全く概念が違うもの」には適用しない方が良いとすら感じます。

interfaceを使うと、分岐が減らせたりして、すわDRYの文脈でも便利そうだぞ、と思うのですが、全く概念の違うものを同じインターフェースにして爆死している様をよく見ます。(インターフェース、をベースクラスに置き換えるとピンと来る方もいるかもしれません)

また、interfaceを使わずとも、ベースクラスを継承させて同じことをするのももちろん可能です。

可能ですが、2年ぐらいするとベースクラスをいじり出す人がでてきませんか?

ベースクラスをいじる=継承先のクラス全部に影響が出るので、SOLID原則の

開放閉鎖の原則(open/closed principle)
-> 既存の動作はあまり変更するなよ? 追加はいいけどね? みたいなやつ

↑を蹂躙しやすい構造かなと理解しています。そのため、可能であればinterfaceの方が平和なのかな? と理解してます。

※ 継承を使うなと言ってるわけではありません。僕も使います。でもGoは設計思想的に継承ないとかそういうのありましたよね?(これ以上は戦争)

感想

というわけで、依存性逆転の原則の説明でした。

ゲーム開発ではすっかりinterfaceとお友達ですが、本業のWeb系開発では、そもそもinterfaceを使っている現場をほとんど見たことがありません。特にpythonはinterfaceがなく、abstractクラスみたいなやつしか使えないので、もはや言語レベルでinterfaceの存在感が薄い場合もあります。まあabstractクラスでもいいけどさ。

また、DDDを実践しているWeb系の会社で、リポジトリ層の決まりだからinterfaceを使っているという方からお話を聞いたこともありますが、むしろちょっとめんどくさいっておっしゃってましたw すげーわかる。

ただ、こうしてinterfaceを使うことの意味を考えてみて、こういう素敵なこともあるよ? と引き出しを持てたのは収穫でした。

ちょうどWeb系開発でもいい感じに抽象化できる題材があるので、次はWeb系開発での依存性逆転の実践サンプルを書いてみようかな〜。

コメントを残す

メールアドレスが公開されることはありません。