はじめに
サムザップ #2 Advent Calendar 2019の12/3の記事です。
株式会社サムザップの尾崎です。Unityエンジニアです。
内容
リンクスリングスのアウトゲームの設計について紹介したいと思います。
また扱いやすいAPI(プログラムインターフェース)を目指しているのでそのコードを紹介します。
※ アウトゲームとはキャラクター選択画面など4v4バトルのゲーム本体以外の機能を指します
※ 紹介するコードはエラー処理を省いて記載してきます
リンクスリングスについて
公式サイト
画面イメージ
![]() | ![]() | ![]() |
---|
ゲーム画面動画
https://www.youtube.com/watch?v=XFfSejixKfE
設計方針
- 分かりやすいシンプルな構成
- 使いやすいAPI
- メンテナンスしやすい
- 簡単に動作確認できる
主な採用技術
async/await、 UniTask
async/awaitはC#標準の非同期処理のための機能です。
コルーチンの代わりとして使っていて、画面遷移や通信やアニメーションなどの非同期系処理はasync/awaitに統一しています。
コールバックがなく読みやすいコードになっています。
Zenject
オブジェクト同士を参照させるのにZenjectを採用しています。
staticやシングルトンがなくなり、整理されたクラス関係を構築できました。
Pusher
アウトゲームでのリアルタイム通信のために採用しています。
マッチング、チャット、ゲーム内通知などに使用しています。
HTTPポーリングに比べて高速なレスポンスが得られています。
ちなみにインゲームではPhotonを採用しています。
プログラム構成
MVC(Model-View-Controller)パターンです。
MVVM、MVPと比較検証した結果、シンプルなMVCを採用しました。
Model
Model=データはxxxDataというクラスに定義しています。
データそのものとそれを扱うメソッドを持ちます。
サーバーから受け取ったjsonをC#オブジェクトにする役割もあります。
publicpartialclassSomeData:ISerializationCallbackReceiver{// サーバーから受け取ったintへのプロパティ。読み取り専用publicintSomeCount=>someCount;publicenumSomeTypes{None,Type1,Type2}// サーバーから受け取ったstringをenumに変換publicSomeTypesSomeType;// データを元に判定を行ったりするプロパティpublicboolSomeUsefulProperty{get{...}}// データ検索などを行うメソッドpublicintSomeUsefulMethod(SomeTypestype){......}publicvoidOnAfterDeserialize(){// 文字列をenumに変換Enum.TryParse(someType,outSomeType);}publicvoidOnBeforeSerialize(){}}// サーバーから受け取るjsonをデシリアライズするためのクラス// 半自動生成[Serializable]publicpartialclassSomeData{[SerializeField]privateintsomeCount;[SerializeField]privatestringsomeType;}
Controller
画面を制御する部分です。
ModelとViewの橋渡しをします。
1画面につき1つのメインコントローラーを用意します。
複雑な画面ではメインコントローラー1つだとクラスが大きくなるので画面内の一部分を制御するサブコントローラーを作成します。
// 画面のメインコントローラーpublicclassSomeScene:MonoBehaviour,IAdditiveSceneTask{// 画面遷移システム[Inject]privateSceneLoader_sceneLoader;// View[SerializeField]privateText_text;// サブコントローラー[SerializeField]privateSomeSubController_subController;// 画面遷移トゥイーン// インスペクタでリストにトゥイーンを登録するコンポーネントです[SerializeField]privateTweens_tweens;// 初期化privatevoidStart(){_text.text="";}// 画面遷移システムから画面開始時に呼び出される独自のコールバックです// IAdditiveSceneTaskを実装すると呼ばれますpublicasyncTaskActivate(){/* 画面開始時の処理 */// 通信varsomeData=awaitWebRequest.Factory.SomeInfo(param).Send();// データをUIにセット_text.text=someData.name;// サブコントローラーの実行_subController.Execute();// UI出現アニメーションawait_tweens.PlayInAnimations();}publicasyncTaskInactivate(){/* 画面終了時の処理 */// UIを消すアニメーションawait_tweens.PlayOutAnimations();// 各種アンロード}privatevoidOnDestroy(){// 後処理}// ボタンが押されたときの処理// インスペクタでButtonコンポーネントから呼び出すように設定しますpublicvoidOnClickButton(){// 例でバトルトップ画面に遷移// 画面はシーンをAdditiveロードする仕組み// 次シーンをロードしてActivate()を呼び出し、現在シーンのInactivateを呼び出します_sceneLoader.LoadSceneAdditive(ScenesEnum.BattleTop,false);}}
View
Unity UIのCanvasやImage、ScrollRect、LayoutGroupなど見た目を制御するコンポーネントをViewコンポーネントと位置付けています。
それら見た目を制御するコンポーネントを組み合わせてHierarchyを構築してファイル化したSceneやPrefabがViewの扱いです。
基本的にはUnity UI標準コンポーネントを利用して、独自のViewコンポーネントを組み合わせています。
独自コンポーネントにはタブ、トゥイーン、スプライトアニメなど多数あります。
WebでいうHTMLのイメージです。
アウトゲームの機能
画面遷移
// 画面遷移のためのクラス[Inject]privateSceneLoader_sceneLoader;// シーンをAdditiveロード_sceneLoader.LoadSceneAdditive(Scenes.SomeFunc,newSomeFuncScene.Arguments{TargetId=1001});
ダイアログ (ポップアップウインドウ)
// ダイアログ開くvardialog=awaitDialogLoader.Load<SomeDialog>();dialog.Execute(param);// ボタンが押されて閉じられるまで待つboolisOk=dialog.WaitClose();if(isOk){// OKが押されたときの処理}
publicclassSomeDialog:MonoBehaviour{// ダイアログ共通処理コンポーネント[SerializeField]privateDialogCommon_common;// OKボタンを押した?privatebool_isOk=false;privatevoidAwake(){// 初期化// 開く処理はDialogCommonによって自動的に行われます}publicvoidExecute(intparam){// 引数を使った処理 }// OKボタンを押したpublicvoidOnClickOkButton(){_isOk=true;_common.Close();}// キャンセルボタンを押したpublicvoidOnClickCancelButton(){_common.Close();}// ボタンが押されてダイアログが閉じるまで待つ// 選択結果を返すpublicasyncTask<bool>WaitClose(){awaitCommon.WaitClose();return_isOk;}}
通信
try{varwebRequest=newWebRequest<SomeData>(APIType.SomeInfo,param);varresponseData=awaitwebRequest.Send();}catch(WebRequestExceptione){// 通信エラー時}
リアルタイム通信
[Inject]privateIPusher_pusher;await_pusher.Subscribe("channel_name",);_pusher.Bind<SomeRealtimeData>("channel_name","event_name",(someData)=>{// サーバーからデータ受信したときの処理// 例. マッチングしたプレイヤーの情報を表示、チャットメッセージを表示});
アセットバンドル
アセットバンドルシステムはIAssetBundleLoader
として抽象化してサーバーからロードするクラスとローカルファイルからロードするクラスを切り替えられるようにしています。
Loadメソッドの第三引数ownerはGameObject型の引数でownerがDestroyされるとアセットバンドルもアンロードされる仕組みにしています。
// アセットバンドルロードシステム[Inject]privateIAssetBundleLoader_assetBundleLoader;varprefab=await_assetBundleLoader.Load<GameObject>(assetBundleName,assetName,owner);
設計で気をつけていること
コンポーネント指向
Unityの設計に習いコンポーネント指向で開発しています。
小さい機能を実現するコンポーネントを組み合わせて大きな機能を作ります。
コンポーネントが充実してくると組み合わせて新しい機能を効率よく作れます。
コードを書く必要がなく、非エンジニアにも優しいです。
各コンポーネントの役割です
- xxxButton: 独自ボタン制御(小さなコントローラー。クリック時の画面遷移などを行う)
- Image: 見た目
- Button: ボタン
- CanvasGroup: 透明度とクリック可否
- SwitchSprite: Imageに割り当てるスプライトの切り替え
- ScaleInTween: UI出現時のトゥイーンアニメ
- ScaleOutTween: UI消失時のトゥイーンアニメ
- ClickTween: クリック時のトゥイーンアニメ
- Se: クリック時のSE再生
各種TweenやSeはボタン以外でも利用しています。
コンポーネント指向の逆はオブジェクト指向の継承だと思います。
継承で上記ボタンを作ると標準Button継承したCustomButtonを作成しその中でトゥイーンやSe再生を作り込むことになり、それらは再利用しにくいものになります。
また大規模プログラムで継承を多用すると基底クラスに不必要な機能が入って肥大化することが多いです。コンポーネントの組み合わせで作ることでコード重複が少なく、再利用性の高いプログラムになります。
リンクスでは使い所をわきまえて継承階層が深くならないようにしています。
依存性の注入
Zenjectを利用しています。
1つの実装に依存しない柔軟性のあるプログラムにしています。
複数の実装が必要のないものはinterfaceを定義せずにクラス1つにしています。
シングルトンは禁止しています。
テストしやすい環境
シーンやコンポーネントをテストしやすくしています。
例えばシーンではバトル後の結果画面は正規フローだとログイン、マッチング、バトルを経るため動作確認までにとても手間がかかります。
バトル結果画面のシーンを開いた状態でUnity再生するとダミーデータで動作させて素早く確認できるようにしています。
コンポーネントはインスペクタにデバッグボタンを用意して確認しやすくしています。
UniRxオペレーターを多用しない
UniRxには多数のオペレーターが用意されていますが習得コストが高いと判断し、Whereなど超基本的なもののみを使うようにしています。
UniRxで使用しているのはSubject、ReactiveProperty、MicroCoroutineです。
- Subject
- C#標準eventの代わりに使用。解放が楽です。
- ReactiveProperty
- 値の変化を購読するときに使用しています。
- MicroCoroutine
- 高速なUpdate、コルーチンとして使用しています。
通信などの非同期処理にもRxを使わずasync/awaitかコルーチンを使っています。
手続き型で記述することで分かりやすくしています。
シングルトンを使用しない
シングルトンをアンチパターンと捉えて使用しないようにしています。
1つの実装に依存することになるのと、グローバル変数と同じく様々なところからアクセスされると分かりにくいコードになってしまうためです。
シーン構成
Unityのマルチーシーン機能を活用して1画面1シーンの構成にしています。
この構成にすることで作業分担しやすくなっています。
またシーンを開いて再生することで編集中画面の動作を素早く確認することもできます。
この画面はホーム画面でミッション画面を開きアイテム詳細ダイアログを開いた状態です。
このときのHierarchyはこのようになっています。
最後に
明日は @tomeitouさんの記事です。