Hero background image
Last updated May 2019, 10 min. read
プロジェクトの規模が拡大するのに合わせてコードを設計する方法

このページから得られるもの:成長中のプロジェクトのコードを設計するための効果的な戦略。これにより、問題が少なく、適切に拡張できます。プロジェクトの規模が大きくなるに従って、その設計は恒常的に調整し、クリーンアップしなければならなくなります。変更を実施する前には常に一歩引くようにして、思考の整理のために物事を小さいパーツへと分解していき、そしてそこからもう一度考えをまとめ上げるのが良いでしょう。

この記事は、スウェーデンのゲームスタジオ Fall Damage の CTO Mikael Kalms 氏によるものです。Kalms 氏には、20 年を越えるゲームの開発とリリースの経験があります。それでもなお、プロジェクトを安全かつ効率的に拡大できるようなコードの作成方法に大きな関心を持っています。

プロジェクトの規模が拡大するのに合わせてコードを設計する方法
「シンプル」から「複雑」へ

私のチームが Unite Berlin での講演用に作成した、非常に基本的な Pong スタイルのゲームのコード例をいくつか見てみましょう。上の画像からわかるように、2 つのパドルと 4 つの壁 (上下左右)、ゲーム ロジック、スコア UI があります。壁とパドルに対応する単純なスクリプトがあります。

このサンプルはいくつかの重要な原則に基づいています。

  • 1 つの「オブジェクト」 = 1 つの Prefab
  • 1 つの「オブジェクト」のカスタムロジック = 1 つの MonoBehaviour
  • 1 つのアプリケーション = 内部でリンクされたプレハブを含む 1 つのシーン

これらの原則は、このような相当にシンプルなプロジェクトでは有効ですが、より拡大した規模で臨むのであれば構造の変更をしていかなければなりません。では、コードを整理するために使用できる戦略はどのようなものでしょうか?

大規模化しつつあるプロジェクトのコードを設計するコツ_コンポーネントのパラメーター
インスタンス、プレハブ、ScriptableObject

まず、インスタンス、プレハブ、ScriptableObject の違いについて理解しましょう。上に示すのは、インスペクターに表示される Player 1 の Paddle ゲームオブジェクトの Paddle コンポーネントです。

3 つのパラメーターがあることがわかります。しかし、ここではベースのコードがどのように動作するのかわかりません。

左パドルの入力軸はインスタンスで変更したほうが納得いくものになるでしょうか。それとも、プレハブで実行すべきところでしょうか?プレイヤーごとに入力軸は異なるので、おそらくここはインスタンスで変更したほうがよいと思われます。「Movement Speed Scale」はどうでしょう?インスタンスかプレハブのどちらで変更すべきものでしょうか?

ここで、ちょっと Paddle コンポーネントを表すコードを見てみましょう。

大規模化しつつあるプロジェクトのコードを設計するコツ_コード内のパラメーター
単純なコード例のパラメーター

少し立ち止まって考えてみると、このプログラムではさまざまなパラメーターが異なる方法で使用されていることに気付きます。各プレイヤーごとに InputAxisName を個別に変更する必要があります。MovementSpeedScaleFactor と PositionScale は両方のプレイヤーで共有する必要があります。次にインスタンス、プレハブ、ScriptableObject を使用するときに指針となる戦略を示します。

  • 1 回だけ使用するオブジェクトについては、プレハブを作成し、それをインスタンス化しましょう。
  • インスタンス固有の変更があるオブジェクトなど、複数回必要になりそうなものについては、プレハブを作成し、それをインスタンス化して、いくつかの設定をオーバーライドします。
  • 複数のインスタンスにわたって確実に同じ設定が適用されるようにしたいのであれば、ScriptableObject を作成し、そこからソースデータを作成するようにしましょう。

Paddle コンポーネントで ScriptableObject をどのように使用しているかについては、次のコード例をご覧ください。

大規模化しつつあるプロジェクトのコードを設計するコツ_ScriptableObject の使用
ScriptableObject の使用

こういった設定は addleData 型の ScriptableObject に移動しているわけですから、この Paddle コンポーネントにはその PaddleData に対する参照のみが存在しています。どういう結果になっているのかをインスペクターで見てみれば、PaddleData と 2 つの Paddle インスタンスという、2 つのアイテムがありますね。軸名と個々のパドルがそれぞれ指している共有設定のパケットは引き続き変更できます。この新しい構造においては、こういった様々な設定の背後にある意図をより簡単に見ることができるようになるのです。

大規模化しつつあるプロジェクトのコードを設計するコツ_単一責任の原則
大型 MonoBehaviour の分割

これが実際の開発中のゲームであった場合、個々の MonoBehaviour がどんどん大きくなっていくのがわかると思います。いわゆる「単一責任の原則」に基づいて作業することで、これらをどう分割できるのかを見てみましょう。つまり、各クラスはそれぞれ単一のオブジェクトを処理していなければならないと規定されているということです。正しく適用すれば、「特定のクラスは何を行うのか」だけでなく「何を行わないのか」という質問にも簡潔に答えられるはずです。これにより、チーム内のすべての開発者が個々のクラスの機能を簡単に理解できるようになります。これはどのようなサイズのコードベースでも適用できる原則です。上の画像に示す簡単な例を見てみましょう。

ここに示すのはボールのコードです。あまりそのようには見えませんが、よく見てみると、ボールの初速度ベクトルを設定するためにデザイナーが使用する速度と、ボールのその時点の速度を管理するために自作した物理シミュレーションで使用する速度が存在していることがわかります。

わずかに異なる 2 つの目的のために同じ変数を再利用しているということです。ボールが動き始めれば、すぐ初速に関する情報は失われます。

自作の物理シミュレーションは FixedUpdate() での動きだけではなく、ボールが上下の壁に当たったときのリアクションにも対応します。

OnTriggerEnter() コールバックの奥には Destroy() 操作がありますね。ここで、トリガーロジックがゲームオブジェクト自体を削除します。規模の大きなコードベースでは、エンティティがそれ自体を削除することを許可するのは珍しく、多くの場合オーナーがその対象のオブジェクトを削除します。

ここが利用できるところです。対象を小さなパーツに分割できます。これらのクラスには、ゲームロジック、入力処理、物理シミュレーション、プレゼンテーションなど、多種多様な役割があります。

これらの小さなブロックを作成する方法を以下に示します。

  • 汎用ゲームロジック、入力処理、物理シミュレーション、プレゼンテーションは MonoBehaviour、ScriptableObject または生の C# クラス内に配置できます。
  • インスペクターでパラメーターを公開するためには、MonoBehaviour または ScriptableObject を使用できます。
  • エンジンイベントハンドラおよび GameObject の寿命の管理は、MonoBehaviour 内に配置する必要があります。

多くのゲームにとって、多くのコードを MonoBehaviour の外側に出すことにできるだけ労力や時間をかけることに、価値はあると思います。これを実行する 1 つの方法は、ScriptableObject を使用する方法です。この方法については、すでに有用なリソースがいくつか出回っています。

大規模化しつつあるプロジェクトのコードを設計するコツ_MonoBehaviour の C# クラスへの移行
MonoBehaviour から正規の C# クラスへ

MonoBehaviour から正規の C# クラスにコードを移動するというやり方もありますが、この方法のメリットは何でしょう?

正規の C# クラスには、コードを小さなコンポーネント化できるブロックに分割するという点においては Unity 独自のオブジェクトよりも有用な言語機能が備わっています。さらに、正規の C# コードは Unity の外部でネイティブ .NET コードベースと共有できます。

一方、正規の C# クラスを使用している場合、エディターはこのオブジェクトを認識せず、インスペクターにネイティブでは表示できません。

この方法では役割の種類ごとにロジックを分割する必要がありますが、ボールの例へ戻って考えてみると、ここでは単純な物理シミュレーションを BallSimulation という名前の C# クラスに移動してしまっています。このクラスが実行する必要があるジョブと言えば、物理演算の統合とボールが物体に当たったときのリアクションの処理くらいです。

しかし、ボールのシミュレーションにおいて、実際に当たった物体に基づいて判定を下すことは理にかなっているでしょうか?これはゲームロジックで処理することのように見えますね。つまるところ、ボールには何らかの形でシミュレーションを制御するロジック部分があり、そのシミュレーション結果が MonoBehaviour にフィードバックされているということになります。

上の再構成されたバージョンを見てみると、大きな変更点の 1 つとして、Destroy() 操作が深いレイヤーに埋め込まれなくなったことがわかります。この時点で、MonoBehaviour に残っている明確な役割領域はほんのわずかです。

これにできることは他にもあります。FixedUpdate() 内の位置更新ロジックを見てみると、そのコードは位置を送信する必要があり、そこから新しい位置が返されることがわかります。ボールのシミュレーションでは、ボールの位置自体は実際には保持していないのです。言うなれば、指定されたボールの位置に基づき、その瞬間のシミュレーションを実行して結果を返しています。

大規模化しつつあるプロジェクトのコードを設計するコツ_インターフェースの使用
インターフェースの使用

インターフェースを使用すれば、そのボールの MonoBehaviour 部分の、必要とされるパーツのみをシミュレーションと共有できることがあります(上の画像を参照してください)。

もう一度コードをよく見てみましょう。Ball クラスは単純なインターフェースを実装します。LocalPositionAdapter クラスを使用すると、Ball オブジェクトへの参照を別のクラスに渡すことができます。Ball オブジェクト全体ではなく、LocalPositionAdapter アスペクトだけを渡します。

また、BallLogic はゲームオブジェクトを破棄するタイミングを Ball に通知する必要があります。Ball はフラグを返す代わりに、BallLogic のデリゲートを提供することがあります。これが再構成されたバージョンで最後にマークされた行が実行する内容となります。これにより、すっきりとした設計が実現します。定型的なロジックは多数ありますが、各クラスには厳密に定義された目的があります。

これらの原則を使用することで、一人で作業しているプロジェクトの構造を整えることができます。

大規模化しつつあるプロジェクトのコードを設計するコツ_ソフトウェアアーキテクチャ
ソフトウェアのアーキテクチャ

少し規模の大きいプロジェクトのソフトウェアアーキテクチャソリューションを見てみましょう。Ball ゲームを例にとると、BallLogic やBallSimulation など、さらに特化したクラスをコードに導入すれば、階層構造を構築できるようになるはずです。

MonoBehaviour には他のすべてのロジックがまとめあげられているため、そのすべてを把握している必要がありますが、ゲームのシミュレーション部分は必ずしもロジックのしくみを把握している必要はありません。ただシミュレーションを実行するだけです。ときには、ロジックがシミュレーションに信号を送り、それに応じてシミュレーションが反応することもあります。

入力は、他と切り離された自己完結できる場所で処理させておくと便利です。ここで入力イベントが生成され、ロジックに渡されます。次に起こることがなんであれ、起点はシミュレーション次第です。

これは入力とシミュレーションではうまく機能してくれます。ただし、特殊効果を生成するロジックやスコアカウンターの更新など、プレゼンテーションに関係するものでは問題が発生する可能性があります。

ロジックとプレゼンテーション

プレゼンテーションでは他のシステムで何が発生しているかは把握していなければなりませんが、そういったシステムに完全にアクセスできるようになっている必要はありません。可能であれば、ロジックとプレゼンテーションは切り離すようにしてください。コードベースを、ロジックのみと、ロジックとプレゼンテーションの 2 つのモードで実行できるようになるところまでこぎつけるようにしてください。

ときには、プレゼンテーションを適切な時点で更新できるようにロジックとプレゼンテーションをつなぐ必要があります。それでも、プレゼンテーションを正確に表示するために必要なものだけを提供することが目標であるので、それ以外の理由はありません。こういったアプローチにより、構築しているゲームが全体的に複雑にならないように、2 つの部分を自然に区切れるようになります。

データのみのクラスとヘルパークラス

ときには、データに対するロジックと処理すべてを同じクラスに組み込んではいない、データのみを含むクラスも有効です。

データを所有せず、渡されたオブジェクトを処理する目的の関数を含むクラスを作成するのも、良い方法です。

静的メソッド

静的メソッドの優れた点は、どのグローバル変数にもアクセスしないと推定される場合であればメソッドに影響を与える可能性がある任意のスコープを、メソッドを呼び出したときに渡される引数の内容から一目で識別できることです。わざわざメソッドの実装から確認しなくてよいのです。

このアプローチは関数型プログラミングの分野が関わってきます。コアのビルディングブロックとしては、何かを関数へ送信すると、その関数が結果を返したり、出力パラメーターの 1 つを変更したりする、ということになります。このアプローチをお試しください。従来のオブジェクト指向プログラミングで実行するよりも、バグの数が少なくなる可能性があります。

オブジェクトの切り離し

オブジェクト間にグルーロジックを挿入することで、オブジェクトを切り離すこともできます。Pong スタイルのサンプルゲームを再び取り上げます。Ball ロジックと Score プレゼンテーションはどのようにして互いに通信するのでしょうか?ボールに何かが起きたときに Ball ロジックが Score プレゼンテーションに通知するにしても、Score ロジックが Ball ロジックに問い合わせるにしても、どうにかして互いに通信する必要があります。

この場合バッファーオブジェクトを作成する手段があります。ロジックが書き込み、プレゼンテーションが読み込む、ストレージエリアの提供だけを目的とするものです。または、それらの間にキューを配置して、ロジックシステムがキューにアイテムを入れ、プレゼンテーションがキューからそのアイテムを読み込めるように設定することもできます。

ゲームの規模が大きくなったときにプレゼンテーションからロジックを切り離す方法としてお勧めなのが、メッセージバスを使用する方法です。メッセージングのコアの原理は、送信者と受信者のいずれも相手の情報がなく、メッセージバス/システムを認識していることです。さて、Score プレゼンテーションは、スコアが変わったというイベントをメッセージシステムを通じて受け取る必要があります。その後、ゲームロジックがポイントの変更をプレイヤーに示すイベントをメッセージシステムにポストします。システムを切り離す必要がある場合は、UnityEvents を使用すると便利ですが、独自にコーディングすることもできます。これにより、用途に合わせて別個のバスを作成できます。

シーンのロード

LoadSceneMode.Single の使用をやめて、代わりに LoadSceneMode.Additive を使用します。

シーンをアンロードする必要があるときは、明示的なアンロードを使用します。なんにしても、シーンの遷移中にはいくつかのオブジェクトをライブ状態へ維持しておかなければいけないのですから。

DontDestroyOnLoad の使用もやめてください。オブジェクトの生存期間を制御できなくなります。実際、LoadSceneMode.Additive でオブジェクトをロードすれば、DontDestroyOnLoad を使用する必要がなくなります。生存期間が長いオブジェクトを、生存期間が長いシーンへ変えてください。

クリーンで制御されたシャットダウン

私はこれまでいろいろなゲームを作ってきましたが、どんなゲームを作るときにも役立った習慣は、クリーンで制御されたシャットダウンをサポートすることでした。

アプリケーションを終了する前に、ほぼすべてのリソースをアプリケーションが解放できるようするのです。可能であれば、グローバル変数は引き続き割り当てず、ゲームオブジェクトを DontDestroyOnLoad でマークしないようにしてください。

オブジェクトをシャットダウンする方法に特定の順番があれば、エラーを見つけ、リソースのリークを特定するのが簡単になります。また、これにより、再生モードを終了したときに Unity エディターが不安定な状態になりません。再生モードを終了するとき、Unity はドメインを完全には再ロードしません。クリーンシャットダウンを実行できれば、エディターでゲームを実行した後に、エディターや編集モードのスクリプティングが不安定な動作を起こす可能性が低くなります。

シーンファイルのマージ負担の軽減

これは、Git、Perforce、Plastic などバージョン管理システムを使用して実現できます。すべてのアセットをテキストとして保存し、オブジェクトをプレハブ化してシーンファイルから切り離します。最後に、シーンファイルを複数の小さなシーンに分割します。ただし、これには他にツールが必要になる場合があることに注意してください。

コードテストプロセスの自動化

近いうちに 10 人以上のチームになることが見込まれている場合は、プロセス自動化のために何か措置を講じなければならなくなると思われます。

クリエイティブなプログラマーであれば、ユニークな部分により時間をかけるため、できるだけ多くの繰り返し部分を自動化に回したいと考えるものでです。

自作のコードのテストを作成することから始めましょう。具体的に言えば、オブジェクトを MonoBehaviour から正規のクラスに移動しようとする場合、ロジックとシミュレーション向けユニットテストを構築するためにユニットテストフレームワークを使用するのが簡単です。すべての場合に有効とは限りませんが、後で他のプログラマーがコードにアクセスしやすくなります。

コンテンツテストプロセスの自動化

テストするのは、コードだけではありません。コンテンツもテストする必要があります。チーム内にコンテンツ作成者がいる場合は、作成したコンテンツを迅速に検証するための標準化された方法があれば、チーム全員にとってより良い結果が得られます。

プレハブを検証したり、カスタムエディターで入力した一部のデータを検証したりするテストロジックは、コンテンツクリエイターが簡単に利用できるようにする必要があります。エディター内のボタンをクリックするだけですぐに検証できる場合、ユーザーはすぐにこれが時間の節約になることを理解するようになります。

次の段階としては、Unity Test Runner をセットアップすることがあります。これでオブジェクトを定期的かつ自動的に再テストできるようになります。指定したすべてのテストも実行されるように、これをビルドシステムの一部としてセットアップすることをお勧めします。お勧めの方法は、通知をセットアップしておくことです。そうすれば問題が発生したときにチームメンバーが Slack またはメールで通知を受け取れるようになります。

自動化プレイスルーの作成

自動化プレイスルーには、制作したゲームをプレイできる AI の作成と、エラーのログ記録が関与します。簡単に言えば、AI がエラーを 1 つ見つけるごとに、ユーザーがエラーを見つけるために費やす必要がある時間が減るということです。

このケースでは、同じマシンに約 10 個のゲームクライアントをセットアップし、詳細設定を最小限に抑えて、すべてを実行します。クライアントがクラッシュしたら、ログを確認します。こういったクライアントの 1 つがクラッシュするまでにかかった時間のひとつひとつが、言ってしまえばバグを見つけるために自分たちでゲームをプレイしたり、他のユーザーにプレイしてもらったりする必要をなくして節約できた時間だということです。つまり、実際に自分たちや他のプレイヤーと共にテストプレイをしたときには、もうゲームが面白かったかどうか、どこに表示の問題があったかなどにフォーカスできたということです。

このコンテンツにご満足いただけましたか?