Unity Pro Tips

DEVELOPER詳細

プロジェクトが大規模化してもコードを整然とした状態に保つコツ

<このページで学べる内容>

規模がどんどん拡大していくプロジェクトのコードを設計する効果的な戦略。整然とスケーリングでき、問題があまり発生しない方法を学びます。プロジェクトの規模が大きくなるにしたがって、設計を繰り返し変更し、クリーンアップする必要があります。変更を実施する前に常に一歩引いて、オブジェクトを整理するために小さな要素に分解し、再びそれらをまとめることが推奨されます。 この記事は Mikael Kalms 氏(スウェーデンのゲームスタジオ Fall Damage の CTO)によるものです。Mikael 氏には、20 年を超えるゲームの開発とリリースの経験があります。それでもなお、プロジェクトを安全、かつ効率的に拡大できるようなコードの作成方法に大きな関心を持っています。

インスタンス、プレハブおよび ScriptableObject

Unite Berlin 講演向けにチームが作成した、基本的な Pong スタイルゲームのコードサンプルを確認しましょう。

Unity pong スタイルゲーム

画像からわかるように、2 つのパドルと 4 つの壁(上下左右)、さらにゲームロジックとスコア UI があります。壁と同様に、パドルに対しても単純なスクリプトがあります。

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

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

これらの原則は、このような単純なプロジェクトでは有効ですが、大規模化させる場合は構造の変更が必要です。コードを整理するために使用できる戦略はどのようなものでしょうか。

まず、インスタンス、プレハブおよび ScriptableObject の違いについて理解しましょう。これはプレイヤー 1 の Paddle GameObject にある Paddle コンポーネントで、インスペクターで表示されます。

Unity パドルコンポーネント

そこに 3 つのパラメーターがあることがわかります。ただし、この表示ではパラメーターを使用するコードが何をするのかわかりません。

インスタンスで左パドルの入力軸を変更することは意味があるでしょうか。あるいは Prefab で実行すべきでしょうか。両方のプレイヤーで入力軸は異なると推定されます。したがって、おそらくインスタンスで変更する必要があります。「Movement Speed Scale(移動速度スケール)」はどうでしょう。インスタンスやプレハブで何か変更する必要があるでしょうか。

Paddle コンポーネントを表すコードを見ましょう。

Unity パドルコンポーネント

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

  • 1 回だけ使うオブジェクトについては、プレハブを作成し、インスタンスを作ればよいでしょう。
  • インスタンス固有の変更があるオブジェクトを複数回作成するのなら、プレハブを作成し、そのインスタンスを作り、いくつかの設定をオーバーライドしましょう。
  • 複数のインスタンスについて同じ設定をさせたい場合は、ScriptableObject を作成し、そこからソースデータを作成します。これは Paddle コンポーネントの例です。
Unity パドルコンポーネントの最終形態

これらの設定を PaddleData 型の ScriptableObject に移動したので、Paddle コンポーネントの PaddleData に対する参照があります。最終結果をインスペクターで見ると、2 つのアイテム(PaddleData と 2 つの Paddle インスタンス)があります。個別のパドルが指している共有設定のパケットの種類と軸名をまだ変更できます。新しい構造では、異なる設定の背後にある意図をさらに簡単に確認できます。

大規模 MonoBehaviour の分割

実際に開発中のゲームであった場合、個別の MonoBehaviour がどんどん拡大するのがわかると思います。各クラスは単一のオブジェクトを処理しなければならないと規定した、単一責任の原則に基づいて作業することで、それらを分割する方法を確認しましょう。適切に実行すると、「特定のクラスで何を実行するのか」、そして「何を実行しないのか」という問題の解答がすぐに得られます。これによりチームのすべての開発者が、個別のクラスが何を実行するのか簡単に理解できます。あらゆるサイズのコードベースに適用できる原則です。簡単な例を確認しましょう。

Unity パドル単一責任の原則

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

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

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

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

ここを利用して対象を小さな部分に分割できます。ゲームロジック、入力処理、物理シミュレーション、プレゼンテーションなどこれらのクラスには、多くの異なる種類の責任があります。

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

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

多くのゲームで、MonoBehaviour の外側にできるだけ多くのコードを追い出すことが重要だと考えられます。これを実行する 1 つの方法は ScriptableObject を使うことです。この方法の例が 便利なリソース にあります。

この記事では、MonoBehaviour から標準 C# クラスにコードを移動するという別の方法を確認します。この方法のメリットは何でしょう。

コードを小さな、コンポーネント化できるブロックに分割するために、一般の C# クラスには、Unity 独自のオブジェクトよりも優れた言語機能があります。さらに、標準 C# コードは Unity の外部でネイティブ .NET コードベースと共有できます。

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

この方法では責任の種類ごとにロジックを分割したくなります。ボールのサンプルに戻ると、単純な物理シミュレーションを C# クラス BallSimulation に移動しました。実行する唯一のジョブは、物理演算の統合とボールが物体に当たったときのリアクションの処理です。

ただし、ボールシミュレーションで実際に当たった物に基づいて決定することは意味があるでしょうか。ゲームロジックで処理することのように見えます。最終的には、なんらかの方法でシミュレーションをコントロールするロジック部分がボールにあります。そしてそのシミュレーションの結果が MonoBehaviour にフィードバックされます。

再構成バージョンを見ると、まず大きな変更として Destroy() 処理が深いレイヤ―に埋め込まれていないことに気づきます。この時点で、MonoBehaviour に残っている責任の明確なエリアは以下の通りです。

Unity パドルコンポーネントの再構成バージョン

まだ変更された点があります。FixedUpdate() で位置を更新するロジックを見ると、このコードは位置を送信し、そこから新しい位置が返ることがわかります。ボールシミュレーションではボールの位置を所有していません。提供されるボールの位置に基づいてその瞬間のシミュレーションを実行し、結果を返します。

インターフェースを使用する場合、そのボールの MonoBehaviour 部分、必要とするパーツだけをシミュレーションと共有できます。

Unity パドルコンポーネントの再構成バージョン

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

BallLogic は GameObject を破棄するタイミングを Ball に通知する必要もあります。フラグを返すのではなく、Ball は BallLogic のデリゲートを提供できます。これが、再構成バージョンで最後にマークされた行が実行する内容です。これでよりすっきりした設計になります。よくあるロジックがたくさんありますが、各クラスは目的が細かく限定されています。

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

ソフトウェアのアーキテクチャ

少し規模の大きいプロジェクトのソフトウェアアーキテクチャソリューションを確認しましょう。Ball ゲームのサンプルを使用する場合、BallLogic、BallSimulation など、コードにさらに特化したクラスの導入を開始すると、階層を構築できます。

Unity ソフトウェアのアーキテクチャ

MonoBehaviour は他のすべての対象を認識する必要があります。すべてのロジックを含むからです。ただし、ゲームのシミュレーション部分は必ずしもロジックのしくみを把握する必要はありません。シミュレーションを実行するだけです。ロジックはシミュレーションにシグナルを送り、それに応じてシミュレーションがリアクションすることもあります。

入力を処理するのは、分離された、自己完結した場所が便利です。これは入力イベントが生成され、ロジックに渡される場所です。次に起こることは、シミュレーションで決まります。

これは入力とシミュレーションでうまく機能します。ただし、プレゼンテーションに関連したところで問題が発生する可能性があります。例えば、特別なエフェクトをスポーンするロジック、スコアカウンターの更新などがあります。

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

プレゼンテーションでは他のシステムで発生していることを把握する必要がありますが、それらのシステムに完全にアクセスできる必要はありません。可能な場合は、ロジックとプレゼンテーションの分離を試します。2 つのモード、つまりロジックのみと、ロジックとプレゼンテーションでコードベースを実行できるように試します。

ときには、プレゼンテーションを適切な時点で更新できるようにロジックとプレゼンテーションを接続する必要があります。それでも、正確に表示するために必要なものだけをプレゼンテーションに提供することが目標です。それだけです。このようにして、作成しているゲームが全体的に複雑にならないように、2 つの部分に自然な境界を設けられます。

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

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

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

静的メソッドの作成

静的メソッドの優れた点は、グローバル変数にどれもアクセスしないと推定される場合に、メソッドに影響を与える可能性があるもののスコープを、メソッドを呼び出したときに渡される引数の内容で識別できることです。そのメソッドの実装を確認する必要はありません。

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

オブジェクトを分割する

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

ロジックが書き込み、プレゼンテーションが読み込む、ストレージエリアの提供だけを目的とするバッファーオブジェクトを作成できます。あるいは、それらの間に待ち行列を配置して、ロジックシステムが待ち行列にものを入れ、プレゼンテーションが待ち行列から読み込むことができるようにします。

ゲームが大きくなるにしたがって、メッセージバスを使うことが、ロジックとプレゼンテーションを切り離すよい方法になります。メッセージングのコアの原理は、送信者と受信者のいずれも相手の情報がなく、メッセージバス/システムを認識していることです。したがって、スコアをプレゼンテーションする部分は、スコアが変わったというイベントをメッセージシステムから知る必要があります。ポイントの変更をプレイヤーに示すイベントをゲームロジックがメッセージシステムにポストします。システムを切り離す場合には UnityEvent を基に開始すると便利です。あるいは、独自にコーディングすることもできます。別の目的で分離したバスを使用できます。

プロジェクトにロードされるシーンの整理

LoadSceneMode.Single の使用をやめて、代わりに LoadSceneMode.Additive を使用します。
シーンをアンロードしたいときに、明示的なアンロードを使用します。遅かれ早かれ、シーンの遷移中にいくつかのオブジェクトをライブ状態に維持する必要があります。

DontDestroyOnLoad の使用も止めましょう。オブジェクトの寿命の制御ができなくなってしまうためです。実際に LoadSceneMode.Additive でオブジェクトをロードすると、DontDestroyOnLoad を使用する必要がなくなります。寿命が長いオブジェクトを寿命が長いシーンに配置すればよいのです。

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

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

アプリケーションを終了する前に、実質上すべてのリソースをアプリケーションが解放できるようにします。可能なら、グローバル変数の割り当てや、DontDestroyOnLoad でマークされた GameObject も一切無くすべきです。

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

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

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

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

チームのメンバーが10 人を超えるころには、プロセスの自動化にある程度の労力を割く必要が出てくるでしょう。

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

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

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

テストするのは、コードだけではありません。コンテンツもテストが必要です。コンテンツのクリエイターがチームにいる場合、作成したコンテンツを標準化された方法ですぐに検証できるようにしておくと便利です。

プレハブの検証やカスタムエディターで入力した一部のデータの検証など、テストロジックはコンテンツクリエイターが簡単に利用できます。エディターでボタンをクリックするだけで、すばやい検証を実行できるようになれば、それにより時間を節約できることの価値がすぐにわかるでしょう。

これに続くステップは、定期的にオブジェクトを自動で再テストするために Unity Test Runner をセットアップすることです。 使用するビルドシステムの一部としてセットアップすることを考えると、通知 をセットアップするのが適切な方法です。問題が発生したとき、Slack またはメール通知をチームメンバーが受け取ります。

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

自動化プレイスルーには ゲームをプレイできる AI の作成とエラーのロギングが含まれます。つまり、AI がエラーを見つけることで、ユーザーが見つけるために費やす時間を節約します。

この場合、同じマシンで約 10 個のゲームクライアントをセットアップし、詳細設定を最小限に抑えて、すべてを実行します。クラッシュしたら、ログを確認します。これらのクライアントのいずれかがクラッシュするたびに、バグを見つけるために自分たちでゲームをプレイするか、あるいは他のユーザーに実行してもらう時間が節約されます。つまり、自分たちや他のユーザーが実際にゲームをプレイしてテストするとき、表示の問題がどこにあるのか、ゲームが面白いかなどに注力できます。

この記事はいかがでしたか?