こんにちは、hijoushikiです。先日traPのゲーム班内で開催された、新入生向けミニゲーム開発イベント「Made in traP」にて、リードプログラマとして全体の設計や実装を担当しました。この記事では、Made in traPを実現した技術的背景について紹介します。

要求仕様
Made in traPを実現するためには、最低限以下の問題を全てクリアする必要がありました。
ゲーム本体からミニゲームが呼び出せなければならない
Made in traPのキモは、ミニゲームを次々にプレイできることです。つまり、ゲーム本体から何かしらの手法でミニゲームを呼び出し、ミニゲームを動作させ、制限時間が過ぎればミニゲームを片付けてゲーム本体に復帰する必要があります。
簡単に思い付く方法は、ゲーム本体のSceneから SceneManager.LoadScene("MiniGameSceneName") を使用してミニゲームのSceneに遷移することでしょう。しかしその場合、ミニゲームのSceneに遷移したらゲーム本体のSceneは破棄されてしまいます。 進行中のゲームの状態はどう保存するか?ミニゲームの制御はどうやるか?どうやってミニゲームのSceneからゲーム本体のSceneに復帰するか?Loadに必要なミニゲームのSceneの名前はどう取得するか?そもそも、この手法ではSceneの切り替わりの関係で、原作のようにゲーム本体とミニゲームを滑らかに繋ぐことができないため、美しくありません。
普段の開発とほとんど同じようにコードが書けなければならない
Made in traPの目的は、新入生をその後の本格的なゲーム開発に繋げることです。そのため、コードの書き方や制限など、Made in traP特有の都合にミニゲーム側が過度に振り回されることはあってはなりません。
ただ、最低限ミニゲーム側とゲーム本体側は互いにやり取りできる必要があります。例えばミニゲームの成功/失敗の通知などです。その場合、using MadeInTrap.SDK.API として、MiniGameAPI.SendResult(bool isSuccess) というような形で、ゲーム本体にミニゲーム結果を簡単に送れるようにしなければなりません。
ゲーム本体とは独立したプロジェクトでミニゲームが作れなければならない
ミニゲームを作る時、ゲーム本体のUnityプロジェクトをCloneし、そのプロジェクト上でミニゲームを制作する形は美しくありません。ゲーム本体を意図せず変更してしまうかもしれませんし、ゲーム本体のGit履歴もミニゲームを作る上では不要です。新しくゲームを作るのですから、新しいプロジェクトでミニゲームを作れるようにするべきです。
ミニゲーム単体で再生できなければならない
ゲーム本体とは別のプロジェクトでミニゲームを作れるようにするのなら、ミニゲームはゲーム本体から呼び出して再生するだけでなく、それ単体でも再生できる必要があります。テストプレイのためにいちいちゲーム本体を通すのは開発体験が悪すぎます。
ミニゲームが提出できなければならない
ゲーム本体とは別のプロジェクトでミニゲームを作れるようにするのなら、ミニゲームをゲーム本体に提出する手段が必要になります。ミニゲームを .unitypackage として出力するのが順当でしょうが、単にそうするだけでは参照切れが発生するかもしれません。
提出するミニゲームがレギュレーションに従っているか判別できなければならない
使用可能なライブラリや、変更してはいけないPrefab、使ってはいけない文字、提出の形式など、ミニゲーム側が最低限守らなければいけないルールがあります。それが実際に守られているかを事前に判別する方法が無ければ、ミニゲーム制作者はミスに気付けず、運営側がゲームを個別に検証して修正する手間も膨大になります。
アーキテクチャ設計
アーキテクチャの説明をしてから、ミニゲームを制作して動かすまでの一連の技術を解説します。
まず、全体のアーキテクチャを以下のように設計しました.

技術スタックは以下の通りです。
- UniTask
- R3
- VContainer
- LitMotion
ポイントは、システム全体をHost, SDK, MiniGameの3つのサブシステムに分割していることです。
- Host: ミニゲームを呼び出すゲーム本体
- SDK: HostとMiniGameを繋ぐ部分。MiniGameには、ミニゲームの結果をHostに通知するAPIなど、ミニゲーム開発に必要な機能を提供し、Hostには、MiniGameの生成や結果の取得など、MiniGameへ干渉する窓口となる
- MiniGame: ミニゲーム
特に注目したいのが依存関係です。HostとMiniGameが互いに依存せず、どちらもSDKに依存しています。これにより、HostはMiniGameの内容を知る必要がなく、MiniGameもHostの実装を知る必要がありません。よって、HostとMiniGameが切り離し可能になり、SDKをPackage Managerで配布することによって、Hostとは独立したプロジェクトでMiniGameが作れるようになっています。この話はまた後ほど詳しく解説します。
Host

Hostは、ミニゲームを呼び出すMade in traPというゲーム本体部分です。Hostは以下の5つのレイヤーに分かれています。
- Model: MVRPパターンにおけるModelに相当する部分
- View: MVRPパターンにおけるViewに相当する部分。唯一
MonoBehaviourの継承を許可している - Director: ModelとViewの仲介役となり、両者をオーケストレーションする
- Infrastructure: 技術的関心 (ここでは主にSDK) を集約する
- CompositionRoot: VContainerを用いて依存の解決とEntryPointの起動を行う
これは私がUnity開発で個人的に使っているアーキテクチャです。複雑そうに見えますがやっていることはシンプルで、Pure C#でModelを書き、MonoBehaviour を継承したViewでそれを表示し、Directorで両者を仲介しているだけです。SDKなどの技術的関心はModelからInfrastructureに切り出し、CompositionRootでVContainerを使ってDIをしています。
とりあえず、InfrastructureがSDKを叩いてMiniGameの生成や結果の取得などを行っているということを押さえておけばOKです。その他の部分は今回の本質とは外れてしまうので,またいつかの機会に解説します.
SDK
SDKは、HostとMiniGameを繋ぐ部分です。MiniGameには、ミニゲームの結果をHostに通知するAPIなど、ミニゲーム開発に必要な機能を提供し、Hostには、MiniGameの生成や結果の取得など、MiniGameへ干渉する窓口となります。
SDKはAPIとInternalの2つのモジュールに分かれています。
- API: MiniGameが結果の通知をする際などに叩くAPI
- Internal: APIの内部実装や、Hostとの接続部分
APIは、using MadeInTrap.SDK.API として、MiniGameAPI から利用できます。APIには例えば以下のようなものがあります。
| API | 説明 |
|---|---|
MiniGameAPI.MiniGameStarted |
ミニゲームのプレイが開始されたときに発火します |
MiniGameAPI.MiniGameEnded |
ミニゲームのプレイが終了したときに発火します |
MiniGameAPI.RemainingTime |
現在のミニゲームの残り時間を取得します |
MiniGameAPI.CurrentLevel |
現在のレベルを取得します |
MiniGameAPI.SendResult(bool isSuccess) |
ミニゲームの結果をHostに通知します |
MiniGame

MiniGameは、ミニゲームのことです。SDKを利用してHostで動作するミニゲームを作ります。
例えば、UIのボタンを押したらクリアとなるシンプルなミニゲームの場合、このように記述します。
using UnityEngine;
using MadeInTrap.SDK.API;
using UnityEngine.UI;
namespace MadeInTrap.MiniGame.TestMiniGame_0
{
public class GameManager : MonoBehaviour
{
[SerializeField] Button clearButton;
void OnEnable()
{
clearButton.onClick.AddListener(Clear);
}
void OnDisable()
{
clearButton.onClick.RemoveListener(Clear);
}
void Clear()
{
MiniGameAPI.SendResult(true);
}
}
}
普通にゲームを作る場合とほとんど同じなのが分かると思います。ボタンが押されたら、APIを叩いて成功を通知するだけです。このように、最低限APIを利用するだけで、普段の開発とほとんど同じようにミニゲームを作ることができます。
ミニゲームの制作
ミニゲームの制作について詳しく見ていきます。ミニゲームは,個人がそれぞれのUnityプロジェクトで制作し,そのSceneをフォルダごとunitypackageにエクスポートして提出するようにしました.
SDKの配布
ミニゲームを作るにはまず、ミニゲームが叩くAPIや、ミニゲームのSceneのテンプレート、配布素材などが詰まったSDKを取得する必要があります。アーキテクチャ図のSDKの部分です。今回はこれをPackageManagerでGit URLからインストールするようにしました。こうすると、SDK開発側にとってもミニゲーム開発側にとっても、パッケージの管理や更新がとても楽になります。
口で言うのは簡単ですが、SDKを配布できるように作るのは結構難しいです。SDK単体で配布するためにはSDKがHostやMiniGameといった他のサブシステムに一切依存していない必要があります。なので設計段階から依存関係を適切に制御している必要があります。実はSDKを配布できるようにする作業に取り掛かったのは色々あってミニゲーム開発開始の1日前になってしまったのですが、最初から設計をちゃんと考えていたため、ほぼ何もすることなく一発でPackageへの切り出しに成功して感動しました。

制作
ミニゲームの制作は用意されているSceneテンプレートを用いて行います。といっても特別色々なセッティングがされているわけではありません。Made in traPのミニゲームとして動かすために必要な MiniGameController というPrefabと、Canvasが予め置かれている以外はデフォルトの新規シーンと変わりません。


MiniGameController は、ミニゲームに外部から干渉する際の窓口です。ミニゲームのセットアップ、開始、終了などのメソッドを公開しており、これを外から叩くことでミニゲームを制御できるようにしてあります。Prefabには、スクリプトの MiniGameController の他に、後述する単体テストや提出チェックのためのスクリプト、ミニゲーム中に表示するタイマーのUIと効果音のAudioSourceなどが含まれています。
Canvasは、どうせミニゲームを作るときに使うだろうということで最初から置いています。ただそれだけではなく、ミニゲームのSceneに存在する全てのCanvasのRender Modeが必ず Screen-Space Camera になるように最初からセットしておくためでもあります。これは後述するミニゲームの生成時に必要になってきます。
単体テスト
ミニゲームは普段通りSceneを再生することで、Hostを使わず単体テストできます。一見当たり前のように聞こえますが、再生時にタイマーのUIやAPIもちゃんと一緒に動作させる必要があるので一筋縄にはいきません。特に、ミニゲームの時間やレベルといった状態は本番再生時はHost側が保持しているので、単体テスト時にはHostの代わりとなるモックに切り替えられるようにする必要がありません。
この部分について詳しく説明します。 MiniGameAPI には、ミニゲームの残り時間 float RemainingTime や、ミニゲーム結果を通知する SendResult(bool) などのAPIがあります。これが叩かれると、MiniGameAPI はミニゲームの実行元を保持している MiniGameAPIRuntimeConnection を叩いて実行元を取得し、それに対して値の取得や結果の通知を行います。
本番再生時は、Hostの MiniGameRunner が実行元となり、MiniGameController を叩くなどして実際にミニゲームを動かしています。しかし、単体テスト時にはこれは存在しないため、APIの宛先や MiniGameController の叩き役がいなくなってしまいます。
そこで、単体テスト時に動作するモックとして UnitTestMiniGameRunner を用意しました。どちらとも IMiniGameAPIRuntime を実装し、本番再生時には MiniGameRunner を、単体テスト時には UnitTestMiniGameRunner を MiniGameAPIRuntimeConnection に登録することで、本番再生か単体テストかを気にすることなく同じようにミニゲームを動作させています。

提出準備
ミニゲームが完成したら提出の準備に入ります。提出は、アセットを全て1つのフォルダに収め、それをunitypackageとしてエクスポートすることで行います。なので、まずフォルダにSceneやスクリプト、スプライトなどを全て収めます。
次に、提出するフォルダの直下に MiniGameInfoSO というミニゲームの情報を記述するScriptableObjectを作成します。ここにSceneの名前や制作者名などの情報を記述します。Hostではこれを収集することでミニゲームの情報を管理します。

提出チェック
提出するにしても、MiniGameInfoSO が無かったり、必要なPrefabが適切に配置されていなかったり、フォルダ外のアセットを参照していたりしたら、Hostで正しく動作させることはできません。そのため、これらレギュレーションに違反していないかを検証する提出チェックを事前に走らせます。
MiniGameController Prefabに MiniGameSubmissionCheckTarget というスクリプトがアタッチされています。これに MiniGameInfoSO を割り当てることでそのフォルダを提出フォルダと認識し、チェックが走ります。
この機能が今回のMVPだと思っていて、この機能は元々追加する予定が無かったのを、私が「これあった方が良くね?」と思って独断で実装したものです。すると案の定本番では、Prefabの値が変更されている、使ってはいけない文字コードを使っている、使ってはいけない入力APIを使用している、といったレギュレーション違反が多発し、それらを事前にブロックすることができました。もしこれが無かったら、提出された全てのミニゲームをひとつひとつ検証して修正する必要があり、我々は発狂していたことでしょう…

エクスポート
提出チェックが通ればフォルダをunitypackageにエクスポートして提出します。

Hostの動作
次に、Hostが提出されたミニゲームを登録し、実際にゲーム上で再生するまでの流れを見ていきます。
ミニゲームの登録
提出されたミニゲームのunitypackageをインポートします。提出チェックに通っていれば、参照切れも発生せず問題なくミニゲームをインポート可能です。
インポートしたミニゲームのSceneを、Build ProfilesのScene Listに登録します。これをしないとSceneをLoadできませんからね。加えて、MiniGameInfoSO に MiniGameInfo のAddressableのラベルを付与します。これにより、Addressables.LoadAssetsAsync<MiniGameInfoSO>(config.MiniGameInfoLabel, null); でラベルを付与した全ての MiniGameInfoSO を取得することで存在するミニゲームの情報を全て取得することができます。

ミニゲームの生成
Hostからミニゲームを生成します。これには、MiniGameRunner が SceneManager.LoadSceneAsync("sceneName", LoadSceneMode.Additive) でミニゲームのSceneをAdditiveにロードし、SceneManager.SetActiveScene(scene) でアクティブSceneに切り替えています。つまり、Hostのシーンに重ねてミニゲームのシーンをロードしているということです。
ミニゲームの生成方法には3つの選択肢がありました。ミニゲームのSceneに直接遷移する方法、Additiveにロードする方法、ミニゲーム自体をPrefabとして提出してもらい、それを生成する方法です。
直接遷移する方法では、普通にやると遷移によりHostの状態が失われるため、状態の保存が問題になります。また、ミニゲームを表示するときのアニメーションがどうしても制限されてしまいます。
Prefabでやる方法はかなり不自然に感じます。提出や管理が難しいのもさることながら、ミニゲーム側で動的に生成されたゲームオブジェクトをどう管理するかが問題になります。
Additiveに読み込めば、Hostの状態は保存され、ミニゲームのカメラ映像をテクスチャに描画することで表示アニメーションもスムーズに行えます。また、動的に生成されるゲームオブジェクトは現在のアクティブシーン上に生成されるという仕様があるので、ミニゲーム中はミニゲームのSceneをアクティブにすることで、ゲームオブジェクトを確実にミニゲーム側で生成させることができます。そうすればScene破棄時に一緒に消えてくれるのでゴミは残りません。

ミニゲームの表示
単にAdditiveに読み込んだだけでは、HostのSceneとミニゲームのSceneが混ざり合ってしまいます。ミニゲームの映像を映した画面をフェードさせたりもしたいので、何らかの方法でミニゲームをHostのカメラに映らないようにし、ミニゲームのカメラ映像をテクスチャに描画してHostのUIに表示させるということが必要になります。
Hostのカメラからミニゲームを除外するには、ミニゲームのSceneに存在する全てのゲームオブジェクトのLayerを MiniGame に書き換え、HostのカメラのCulling Maskで MiniGame Layerを除外しています。Layerの書き換えは MiniGameController が行っています。
ミニゲームのSceneのカメラは camera.targetTexture = renderTexture; によって映像の描画先をテクスチャに変更し、そのテクスチャをHostのRawImageに表示させています。これによりミニゲームのカメラ映像をHostに表示することができ、RawImageをフェードさせたりすることで表示アニメーションもスムーズに実装できます。
ここで重要になるのが、ミニゲームのSceneに存在する全てのCanvasのRender Modeを Screen-Space Camera にすることです。これにより、Canvas上のUIもカメラの描画対象になり、他のゲームオブジェクトのようにテクスチャに描画することができます。Screen-Space Overlay の場合、Canvasはカメラを経由せず画面そのものに直接描画されます。そうなると、RawImageのフェードアニメーションが適用されず、表示が不自然になってしまいます。
入力のフォワーディング
次に問題になるのが、ミニゲーム側に入力をどう渡すかです。キー入力の場合、ミニゲーム側でInputSystemの設定をしていればキー入力がそのまま効きます。
問題なのはマウス入力です。マウス入力はそのままでは全く効きません。なぜなら、画面に映っているのはミニゲームのSceneのカメラ映像を投影したRawImageであり、そこに実際にミニゲームのオブジェクトが存在するわけではないからです。よって、RawImageに対するマウス入力を何らかの方法でミニゲーム側に送り、そこでイベントを手動発火させるという荒業が必要になってきます。
まず、RawImageにアタッチされた MiniGameScreenView が、マウスクリックやホバーといったマウス入力を受け取ります。その際、RectTransformUtility.ScreenPointToLocalPointInRectangle() を用いてRawImage内のマウス座標をUV座標に変換します。
次に、マウス入力の情報から MiniGamePointerInput を生成します。これは入力の内容やUVといった情報を保持することでマウス入力を表す構造体です。そしてこれを様々なクラスを経由して MiniGameController まで運びます。
入力を受け取った MiniGameController は、入力を元にミニゲームのSceneのEventSystemを直叩きし、eventSystem.RaycastAll(eventData, raycastResults); でミニゲーム内のオブジェクトをRaycastします。
最後に、Raycastしたオブジェクトに対して ExecuteEvents.Execute() などを叩くことで、マウスイベントを手動発火します。これにより、RawImageに対するマウス入力をミニゲームのSceneに手動で展開することができます。
ただし、この手動発火したマウスイベントをミニゲームのスクリプトで受け取るには、IPointerClickHandler といったEventSystemインターフェースを利用して実装する必要があります。逆に、それ以外の方法でマウスイベントを扱うことはできません。例えば、Mouse.current.position ではHostウィンドウのマウス座標をそのまま取得してしまい、ミニゲームのScene用に手動で補正された座標を使用できません。

感想
私が普段考えているような設計は、Unityで実際にゲームを作るうえではどうしても過剰になりやすく、私の設計力をフルで活かせる場面は今までありませんでした。しかし今回、設計をちゃんと考えたからこそこのゲームを完成させることができたと思う場面が何度もあり、作っていてとても楽しかったです。こんな素晴らしい機会をくれたYMAC君を始めとするゲーム班の皆様に圧倒的感謝!
