こんにちは。ミラティブUnityエンジニアの菅谷(tetsujp84)です。
今回はミラティブのライブゲーム開発で行ったアウトゲームの設計について紹介します。
以前アウトゲーム設計に関してXでポストしたらレスポンスをいただけたのでできるだけ丁寧に解説してみました。こんな話も聞きたいよというのがあったら是非教えてください。
よくありそうなソシャゲアウトゲームの設計について今更記事化してるんだけどどれだけ需要あるんだろう。MVPの概念とかクリーンアーキテクチャライクな知識って業界的な浸透率どんなもんなんだ。
— 鉄 -TETSU- (@tetsujp84) 2023年8月28日
アウトゲームについて
ゲーム開発者にとっては馴染み深いと思いますが、ゲームにはインゲームと呼ばれる部分とアウトゲームと呼ばれる部分に別れます。インゲームはゲーム体験のコアでキャラクターを操作したり、アクションがあったりといった部分です。アウトゲームは多数の画面と遷移から構成されることが多く、キャラクターの強化・ガチャ・ショップなどの機能が挙げられます。 主にボタンなどのUIを操作し、サーバーと通信を行い、画面を遷移させることが主な機能となります。
例えば以下はキャラクター一覧画面→キャラクター詳細画面→キャラクター強化画面とボタンを押して遷移します。
ミラティブでのアウトゲームの設計について
アウトゲーム制作の際はクラス設計やルール決めが非常に重要です。特に複数人での開発はこれらが人によって異なってしまうとプロジェクト構造が複雑化し、いびつなコードが増えていってしまいます。また、新しいメンバーが開発を始める際に指針があることで既存のコードが把握しやすくなり、スムーズに開発が始められます。 ミラティブのライブゲーム作成時はアウトゲーム開発前にメンバー間でどういった設計を採用するかを議論してから開発にとりかかりました。
実際には一般的なModel-View-Presenter(MVP)をベースとし、VContainerやUnityScreenNavigatorといったOSSとどう連携するかの取り決めがメインであったため、そこまでおかしな構成にはしないようにしています。また、全体の方針として共通化や分割化をしすぎないように注意しています。以前私がゲーム開発において、無理に共通化をしすぎてパラメータをたくさん持った関数が生まれたり、分割をしすぎてシンプルな処理なのにどこに何があるかわからなくなってしまった経験があったためです。
使用したOSS
- VContainer
- DIフレームワークでクラスやオブジェクトの依存関係を管理します
- プロジェクトがDIを使用していたため、アウトゲームでも採用しています
- UnityScreenNavigator
- 画面をページとモジュールの単位で分割し切り替えることで画面遷移を行います
- ScreenSystem
- UnityScreenNavigatorとの連携に特化したライブラリを作成しました
- 型付きでパラメータを渡しつつ画面遷移をできるようにしたり、DI Containerに対応させています
- UniTask
- Taskを簡単に扱うためのOSSで通信や画面遷移など幅広く使用しました
- UniRx
- 主にViewのイベントをPresenterに通知し、MVPを実現するために使います
- アウトゲームではUniTaskが正しく扱えればUniRxは不要に感じましたが、UniRxのほうがUniTaskより使い慣れていたことから採用しています
- MessagePipe
- メッセージングライブラリで、同時に存在する別の画面に更新内容を届けたい場合に使用しました
UnityScreenNavigatorを使った画面の構成
UnityScreenNavigatorはUnityで画面遷移、画面遷移アニメーション、遷移履歴のスタック、画面のライフサイクルマネジメントを行うためのライブラリです。
普通、アウトゲームの開発はインゲーム開発の後になりやすく、またデザインに引きづられやすかったり微妙に異なった機能を要求されることもあるため、多くのゲームで似たような構造になるにも関わらずアウトゲーム画面の遷移系を各プロジェクトで自作するという状況に陥りやすいです。 ミラティブでのライブゲーム作成時にも自作を検討しましたが、プロジェクト全体として見たときにシンプルでスケーラブルな構造であるほうが良いという判断を今回は行いました。その際にUnityScreenNavigatorには以下の特徴があったためこちらのOSSを活用することにしました。
- ビューとロジックが分離しやすい作りになっている
- 画面を入れ替えたりスタックしたりといった画面操作が簡単に行える
- トランジション演出が変更しやすく、デザイナー作業だけで完結できる
- Lifecycleの形で各画面の作りが統一されている
UnityScreenNavigatorにはPage、Modal、Sheetがありますが、PageとModalのみを利用しています。 Pageは画面の基本となり常に表示されるもので、イメージとしてはメイン画面・1シーンとなります。 Pageが更新されると画面全体が更新されます。画面の遷移はPrefabの切り替えで実現しています。 ModalはPageの上に表示されるダイアログとして利用しています。キャラクター一覧ダイヤログ→キャラクター詳細ダイヤログ→キャラクター強化ダイヤログ...など複数のModalが積み重なることもあります。同時に操作できるダイヤログは1つだけにするようにしています。
UnityScreenNavigatorをよりプロジェクトで使いやすくするため、VContainerとの連携やラッパー機能を実装したOSSのScreenSystemを利用しています。PageとModalの利用に特化させ、VContainer経由での生成管理を行うように最適化しています。また、次の画面構築の際のパラメータ渡しの仕組みも整えたり、不要な記述は減らせるようになっていたりとプロジェクトでの使いやすさを向上させました。
サンプルプロジェクトを公開しています
ミラティブで実際に作成し利用している方法をもとにした、アウトゲーム設計のサンプルプロジェクトを公開しています。下記のクラス設計のルールをもとにライブラリを活用した作りとなっているため、合わせて動かしていただけると理解しやすいと思います。今後のゲーム作成の参考になるとうれしいです。
クラス設計
MVPをベースとし、通信や画面遷移などの共通処理や肥大化しやすい処理は適時別のクラスで行うようにしています。 Modelの寿命はその画面内でのみとし、永続データはRepositoryに持たせるようにしています。 Presenterは各画面に1つとしています。画面内での要素が多くなるとPresenterも肥大化する傾向にありますが、上述のように大きな処理は別のクラスに分けることでPresenterが直接行う処理を減らすようにしました。また、ModelとViewも基本1画面に1つですが、画面が肥大化してきた場合や画面内の要素が独立する場合は分割するようにしています。そもそも1画面での機能がそこまで多くなく、細かく分割しすぎると取り回しがしにくいことから必要になったら分割する方針を取りました。
UseCaseやRepositoryといった名前はクリーンアーキテクチャなどの他のアーキテクチャで使われる用語を採用していますが、クリーンアーキテクチャと一致した使い方ではありません。クリーンアーキテクチャや他のアーキテクチャを参考にしつつ、使いやすさと複雑さを減らすことを目指して設計しました。
主要なクラスについて説明していきます。
LifetimeScope
LifetimeScopeはVContainer特有の単位です。1画面につき一つ作成し、画面におけるクラスの依存関係を指定したり、ViewをPresenter(Lifecycle)に受け渡すために使用します。 また、画面内で行われる通信のMock情報を追加します。
public class TestLifetimeScope : LifetimeScope { [SerializeField] private TestView _view; protected override void Configure(IContainerBuilder builder) { base.Configure(builder); builder.RegisterComponent(_view); // Viewの登録 builder.Register<TestPageLifecycle>(Lifetime.Singleton); // 画面ごとのLifecycleの登録 builder.Register<TestUseCase>(Lifetime.Singleton); // 通信のUseCaseの登録 AddMockInDebug(builder); // 通信のMock差し込み } }
Lifecycle
Lifecycleは画面の実質的なエントリーポイントとして動作しながら、MVPにおけるPresenterの役割も担います。画面単位で作成し、画面の寿命に紐づくことからLifecycleと名付けています。 以下の方針で設計しています
- コンストラクタはLifetimeScopeから取得した各クラスの参照を取得するに留め、複雑な処理は行わない
- 画面遷移前にWillPushEnterAsync内で以下を行う
- ParameterからModelを生成する
- Viewを初期化する
- 画面遷移後はDidPushEnter内でViewのボタン処理を購読する
- 画面遷移後にボタン処理を有効にすることで、遷移中にはボタン入力のイベントが行われない
また、ScreenSystemの仕様上、クラスのアトリビュートにAssetName(プレハブ名)を指定しています。
[AssetName("TestPage")] public class TestPageLifecycle : LifecyclePageBase { // コンストラクタで受け取るものを宣言 private readonly TestPageView _view; private readonly PageEventPublisher _publisher; private readonly ModalManager _modalManager; // コンストラクタインジェクションを利用する [Inject] public TestPageLifecycle(TestPageView view, PageEventPublisher publisher, ModalManager modalManager) : base(view) { _view = view; _publisher = publisher; _modalManager = modalManager; } // 画面遷移前のタイミングでViewの初期化を行う protected override UniTask WillPushEnterAsync(CancellationToken cancellationToken) { var testModel = new TestPageModel(); _view.SetView(testModel); return UniTask.CompletedTask; } // 画面遷移後にボタンのイベントを登録する public override void DidPushEnter() { base.DidPushEnter(); // Pageを表示する _view.OnClickPage.Subscribe(_ => { _publisher.SendPushEvent(new NextPageBuilder()); }); // Modalを表示する _view.OnClickModal.Subscribe(_ => { _modalManager.Push(new NextModalBuilder()).Forget(); }); } }
PageEventPublisherとModalManagerはScreenSystemの機能で、それぞれPageやModalの切り替えを行います。次に開く画面をBuilderを通して構築します。
Model
Lifecycleで作られ画面内の実データを持ちます。多くは通信結果やマスタデータを元にプロパティとして保持することになります。また、Model内の更新処理は全てLifecycleから呼び出されます。1画面で1Modelが基本ですが、通信の内容や複雑度に応じてModelは分割します。ただし、細かく分割しすぎるとModel間での情報の受け渡しにLifecycleとの連携が必須になってしまうことから、まずは1Modelで作っておいて、独立できるな、肥大化してきたなと思ったら分割するといったルールにしています。リスト情報などの複数の要素を管理したい場合はModel内Modelとして作成することもあります。
public class TestPageModel { public readonly string TestMessage; public TestPageModel() { // 通信結果やマスタデータを元にModelを構築することもある TestMessage = "Test Page"; } }
View
LifecycleからModelを受け取り画面を構築します。各ボタンなどのUIはSerializeFieldで参照を取得しておきます。また、各ボタンのイベントはIObservableで公開します。リストなど、動的に増えるようなViewのパーツはこのView内で生成します。すなわちViewがViewを持つ構造となり、イベントも橋渡しのようになってしまいますがイベント管理がしやすいため許容しています。Modelと同じく基本は1画面につき1Viewにしています。Viewはアニメーションなども実装するため、各クラスの中で最も肥大化しやすいように思います。ただし、Viewの役割を、「Modelを受け取って画面UIを構築すること」、「イベントを発火させること」の2つだけの役割のみに留めることで各処理間での複雑さは回避できます。また、アニメーション自体もAnimatorやTimelineなどの非スクリプトデータに本体を逃がすことで、スクリプトの記述量は減ります。更にはアニメーションが終わるまでUniTaskを用いて待つ、と記述することでアニメーションによる非同期処理も同期処理のように記述することができ理解もしやすいです。そのためViewの肥大化そのものはそこまで問題ではないため1画面で1Viewのルールを基本にしています。また、プロジェクトによってはModelを直接Viewに渡さずにインターフェイスを介すことも考えられます。今回はViewもModelも複数の画面で再利用されることが少ないこと、役割が明確なのでルール上で縛れることからインターフェイス化はしていません。運用していって、ViewがModelのメソッドを呼んでしまう等のルール違反が起こったらインターフェイス化しようと考えています。
ScreenSystemを使用しているため、PageはPageViewBase、ModalはModalViewBaseを継承して作ります。
public class TestPageView : PageViewBase { [SerializeField] private TextMeshProUGUI _messageText; [SerializeField] private Button _nextPageButton; [SerializeField] private Button _nextModalButton; // ボタンはクリックのイベントのみ公開する public IObservable<Unit> OnClickPage => _nextPageButton.OnClickAsObservable(); public IObservable<Unit> OnClickModal => _nextModalButton.OnClickAsObservable(); // Modelを受け取って画面を構築する public void SetView(TestPageModel model) { _messageText.SetText(model.TestMessage); } }
ボタンのイベント処理はUniRxではなく、UniTaskのIUniTaskAsyncEnumerableとForEachAwaitAsyncを使用して実装することもできます。UniTaskで実装するとボタンを押した際に通信処理などの非同期処理は処理が終わるまで次の処理を行わない、といったブロッキング相当の処理が実現しやすくなっています。開発初期ではチームメンバーはUniRxに馴染みがあったため、MVPの仕組みはUniRxで実現していました。その後UniTaskの勉強会やコードレビューを通してUniTaskを習得していくことで、UniTaskだけでも実装できるようになりました。また、UniTaskのほうがUniTask Trackerでのデバッグがしやすかったり、UniRxで書くかUniTaskで書くかが混在してむしろ見にくくなってしまったりとUniRxとUniTaskを併用する課題も見つかったため、今後はUniTaskをメインで使っていくつもりです。UniRx/UniTaskをどうを使うかは開発者のスキルや知識に大きく依存してしまうため、実装しやすいほうを選択するとよいでしょう。参考までに以下はUniTaskを使ったボタン処理のサンプルです。
// View public IUniTaskAsyncEnumerable<AsyncUnit> OnClickAsync => _button.OnClickAsAsyncEnumerable(); // Presenter _view.OnClickAsync.ForEachAwaitAsync(async _ => { // 非同期処理、特に通信処理や画面遷移処理 // await ~~ // 関数内の処理が完了するまで次の処理は実行されない });
Builder
PageやModalの生成を行います。BuilderBaseはScreenSystemの機能の一つで、DI Containerと連携して画面を生成します。また、次の画面で使う要素はParameterとして定義して渡せます。例えば選択したアイテムのIDや前の画面での通信結果を渡しています。
public class TestPageBuilder : PageBuilderBase<TestLifecycle, TestPageView> { public TestBuilder(bool playAnimation = true, bool stack = true) : base(playAnimation, stack) { } } // 次の画面にパラメータを渡す場合 public class TestPageBuilder : PageBuilderBase<TestLifecycle, TestView, TestPageParameter> { public TestBuilder(TestParameter parameter, bool playAnimation = true, bool stack = true) : base(parameter, playAnimation, stack) { } }
UseCase
主にコンストラクタと実行関数を1つを持ち、受け取ったデータに対し副作用を与えないようなクラスです。静的関数として作成することも可能ですが、DIとの相性の良さからクラス化しています。 主に以下の基準でUseCaseを作成しています。
- 通信処理
- 共通処理として複数の画面で使われる処理
- Lifecycleに記述すると肥大化して可読性が落ちる場合
特に通信処理は複数の画面から呼ばれることが多く、処理内容も肥大化しやすいためUseCase化しています。例えばキャラクター情報取得通信はインゲーム遷移時や編成画面、強化画面など複数の画面で呼ばれます。同じ処理を行うことになるのでUseCaseとしてまとめておいて1個のクラスとして管理するようにしています。 開発当初はLifecycleが直接通信を行うようにしていましたが、通信のフロー内にUIの更新が入ったり、リトライ処理が入ったりと様々な処理を行う必要が出たためUseCase化するようにしました。 また、通信以外にも、すべての処理をUseCase化することも考えられますが、細かくしすぎると1回だけしか使わないUseCaseが増え続けてしまい可読性が落ちて開発効率も落ちると考えたため、他の共通処理では2,3回使うことがわかったら都度UseCase化してまとめるようにしています。
以下は通信のUseCaseのサンプルです。通信して結果を受け取るだけでなく、通信中のUI表示やエラーハンドリングなども含めて行います。使用者(Lifecycleなど)はDoConnectを呼び出すだけでエラーハンドリングまで含めた通信処理を行うことができます。
public class TestUseCase { private readonly IHttpClient _httpClient; // 通信の実行クラス private readonly NetworkErrorUseCase _networkErrorUseCase; // 通信エラーに伴う画面遷移などのエラー対策 private readonly NetworkLoadingController _loadingController; // 通信中の表示 [Inject] public HomePageUseCase(IHttpClient httpClient, NetworkErrorUseCase networkErrorUseCase, NetworkLoadingController loadingController) { _httpClient = httpClient; _networkErrorUseCase = networkErrorUseCase; _loadingController = loadingController; } // DoConnectを呼んで実行する public async UniTask<TestPageLifecycle.NetworkParameter> DoConnect(CancellationToken cancellationToken) => await DoConnectInternal(cancellationToken, 0); // DoConnectの内部処理 // エラー時はそれまでのリトライ回数を受け取って再帰処理をする private async UniTask<TestPageLifecycle.NetworkParameter> DoConnectInternal(CancellationToken cancellationToken, int retryCount) { // 通信中...のUIを表示して通信を開始する _loadingController.Activate(); var connection = await _httpClient.Call<TestConnectResponse>(new TestConnectRequest(), cancellationToken); _loadingController.InActivate(); // 失敗時処理 if (!connection.result.IsSuccess()) { // 例えばメンテナンス中はメンテナンス表示をしてタイトルに戻す if (connection.response?.status.IsInMaintenanceMode ?? false) { await _networkErrorUseCase.ShowMaintenanceToTitle(connection.response?.status.error); return null; } // エラーの場合はリトライするか選択させる // リトライには最大回数が設定されており、それを上回ったら強制的にタイトルに戻させる var isRetry = await _networkErrorUseCase.ShowErrorWithRetry(retryCount); if (isRetry) { retryCount++; return await DoConnectInternal(cancellationToken, retryCount); } return null; } // 通信に成功したらレスポンスを返す return new TestPageLifecycle.NetworkParameter() { TestConnectResponse = result.response }; } }
画面遷移を伴う通信処理のタイミングについて
アウトゲームでは画面を表示する際に通信結果を利用して画面を構築することが多々あります。例えばキャラクターデータを取得してからキャラクター画面を表示するなどです。その際に、次の画面で通信のレスポンスが必要になる場合は、一つ前の画面で通信を行い、通信のレスポンスをパラメータとして次の画面に渡しています。本来は前の画面から独立して、次の画面のLifecycle内で通信を行い、そのままレスポンスを使って構築したいのですが、UnityScreenNavigatorの仕様上、通信エラー時に画面遷移を取り消して前の画面に戻る(そもそも次の画面を出さないようにする)といった処理がうまく行えませんでした。そのため、通信エラーが発生した際に、初期化されていない画面が表示されてしまう問題が起こってしまいました。そのため前の画面で通信を正常に行った後で画面の遷移処理を行うようにしています。
Repository
Repositoryは主に永続化するデータを管理します。複数の画面にまたがって取得、更新されます。例えばユーザー名や配信に関する情報などはRepositioryに含めて処理します。多くはログイン時にサーバーから受け取りそのタイミングでRepositoryを生成します。シングルトンでの管理も可能ですが、タイトルに戻ってログイン情報を破棄したい場合など、Repositoryが破棄・再生成されることがあるので適切なLifetimeScopeで管理するようにしています。
public class UserRepository { public string Id { get; private set; } // ユーザーID public string Name { get; private set; } // ユーザー名 // ログイン時に生成してユーザー情報を初期化する public void SetUser(string id, string name) { Id = id; Name = name; } }
MessagePipeでの画面間でのメッセージング
画面Aでの更新結果を、同時に開いている画面Bにも反映したい、といったニーズがあります。 たとえばキャラクター強化を行った際、一つ前のステータス画面も更新を反映させたい、という場合です。 これはMessagePipeを用いたメッセージングで実現しています。 元となる画面では更新の通知を飛ばし、反映したい画面で通知をそれぞれ受け取り、通知の内容を元に各自で画面に反映します。
以下はメッセージング実現のための一連のクラスと流れです。
メッセージとして通知する内容をクラス化しておきます。
public class MessagePipeTestMessage { // 変更内容を定義しておきます public readonly int Count; public MessagePipeTestMessage(int count) { Count = count; } }
RootLifetimeScope等の上流のLifetimeScopeで通知処理を登録します。
protected override void Configure(IContainerBuilder builder) { var options = builder.RegisterMessagePipe(); builder.RegisterMessageBroker<MessagePipeTestMessage>(options); }
通知を発行する側は更新時にPublisherを用いて通知を飛ばします。
private readonly IPublisher<MessagePipeTestMessage> _testMessagePublisher; _testMessagePublisher.Publish(new MessagePipeTestMessage(_parameter.ModalCount));
通知を購読する側ではSubscribeを用いてメッセージを受け取った際の更新処理を記述します。
private ISubscriber<MessagePipeTestMessage> _testMessageSubscriber; _testMessageSubscriber.Subscribe(m => { // 受け取った更新内容を元にModelやViewに反映します _view.UpdateModalCount(m.Count); }).AddTo(_disposeCancellationTokenSource.Token);
上記のようにLifetimeScopeへの登録、発行側の処理、購読側の処理を作成することでメッセージングが行われ、複数画面の更新を行っています。 MessagePipeはインゲームに関わらずメッセージングが実現できる有用なライブラリのため積極的に活用しています。
設計における考え
今回はミラティブでのアウトゲーム設計に関して紹介しました。MVPをベースに各種OSSを活かしてシンプルで細かくしすぎない程度の設計を目指しています。この設計は最初から決まっていたわけでも、一人が独断で設計したわけではありません。チームメンバーとともに議論を行い、運用を見据えながら都度更新して作っていきました。
また、この設計が完璧であるということもなく、プロジェクトの規模や開発期間によっても変わり、別のプロジェクトでは反省を活かして別の形に設計し直す可能性もあります。例えば現在でも1クラスの責務が複数あったり、UseCaseの有無から各クラスの単体テストが行いにくくなっています。テストをしっかり導入したい場合はより細く分割する必要があります。
設計において大事なのはメンバーとの間で合意ができており、ルールとして整理されていることだと考えています。また、ルールの変更自体が悪いとも考えていません。プロジェクトの状況に合わせて分割したり共通化したりと変化させることも必要です。この際には理想だけを追い求めて自分勝手な改修や運用を行うことが一番よくありません。お互いがリスペクトしながらプロジェクトを改善していけると良いですね。
We are hiring!
設計に興味があり、お互いの議論を通して効率的な開発を進めていけるメンバーを募集しています!!