はじめに
サムザップ #2 Advent Calendar 2019 の12/3の記事です。
株式会社サムザップの尾崎です。Unityエンジニアです。
内容
リンクスリングスのアウトゲームの設計について紹介したいと思います。
また扱いやすいAPI(プログラムインターフェース)を目指しているのでそのコードを紹介します。
※ アウトゲームとはキャラクター選択画面など4v4バトルのゲーム本体以外の機能を指します
※ 紹介するコードはエラー処理を省いて記載してきます
リンクスリングスについて
画面イメージ
ゲーム画面動画
設計方針
- 分かりやすいシンプルな構成
- 使いやすいAPI
- メンテナンスしやすい
- 簡単に動作確認できる
主な採用技術
async/await、 UniTask
async/awaitはC#標準の非同期処理のための機能です。
コルーチンの代わりとして使っていて、画面遷移や通信やアニメーションなどの非同期系処理はasync/awaitに統一しています。
コールバックがなく読みやすいコードになっています。
Zenject
オブジェクト同士を参照させるのにZenjectを採用しています。
staticやシングルトンがなくなり、整理されたクラス関係を構築できました。
Pusher
アウトゲームでのリアルタイム通信のために採用しています。
マッチング、チャット、ゲーム内通知などに使用しています。
HTTPポーリングに比べて高速なレスポンスが得られています。
ちなみにインゲームのリアルタイム通信にはPhotonを採用しています。
シーン構成
Unityのマルチーシーン機能を活用して1画面1シーンの構成にしています。
この構成にすることで作業分担しやすくなっています。
またシーンを開いて再生することで編集中画面の動作を素早く確認することもできます。
この画面はホーム画面でミッション画面を開きアイテム詳細ダイアログを開いた状態です。
- OutGameMain: アウトゲームのメイン部分。アウトゲーム自体の起動を担当。グローバルフッターなどのUI要素も持ちます。
- MyPage3D: ホーム画面の背景3D表示を担当。背景3Dは様々な機能の後ろに表示されるので生存期間の長いシーンです。
- MyPage: ホーム画面の2D表示を担当。
- ChallengeDialog: チャレンジ機能のダイアログ画面です。
- ChallengeDialogDetail: チャレンジ機能の詳細表示のダイアログ画面です。
- ItemDetailDialog: 上のスクリーンショットの最前面に表示されているアイテム詳細ダイアログ画面です。
- DontDestroyOnLoad: 全シーンで常に存在するオブジェクトが配置されるUnityデフォルトシーンです。画面遷移システムやアセットバンドルシステムはこの領域に配置されています。
プログラム構成
MVC(Model-View-Controller)パターンです。
MVVM、MVPと比較検証した結果、シンプルなMVCを採用しました。
Model
Model=データはxxxDataというクラスに定義しています。
データそのものとそれを扱うメソッドを持ちます。
サーバーから受け取ったjsonをC#オブジェクトにする役割もあります。
public partial class SomeData : ISerializationCallbackReceiver
{
// サーバーから受け取ったintへのプロパティ。読み取り専用
public int SomeCount => someCount;
public enum SomeTypes
{
None,
Type1,
Type2
}
// サーバーから受け取ったstringをenumに変換
public SomeTypes SomeType;
// データを元に判定を行ったりするプロパティ
public bool SomeUsefulProperty
{
get
{
...
}
}
// データ検索などを行うメソッド
public int SomeUsefulMethod(SomeTypes type)
{
...
...
}
public void OnAfterDeserialize()
{
// 文字列をenumに変換
Enum.TryParse(someType, out SomeType);
}
public void OnBeforeSerialize() { }
}
// サーバーから受け取るjsonをデシリアライズするためのクラス
// 半自動生成
[Serializable]
public partial class SomeData
{
[SerializeField]
private int someCount;
[SerializeField]
private string someType;
}
Controller
画面を制御する部分です。
ModelとViewの橋渡しをします。
1画面につき1つのメインコントローラーを用意します。
複雑な画面ではメインコントローラー1つだとクラスが大きくなるので画面内の一部分を制御するサブコントローラーを作成します。
// 画面のメインコントローラー
public class SomeScene : MonoBehaviour, IAdditiveSceneTask
{
// 画面遷移システム
[Inject]
private SceneLoader _sceneLoader;
// View
[SerializeField]
private Text _text;
// サブコントローラー
[SerializeField]
private SomeSubController _subController;
// 画面遷移トゥイーン
// インスペクタでリストにトゥイーンを登録するコンポーネントです
[SerializeField]
private Tweens _tweens;
// 初期化
private void Start()
{
_text.text = "";
}
// 画面遷移システムから画面開始時に呼び出される独自のコールバックです
// IAdditiveSceneTaskを実装すると呼ばれます
public async Task Activate()
{
/* 画面開始時の処理 */
// 通信
var someData = await WebRequest.Factory.SomeInfo(param).Send();
// データをUIにセット
_text.text = someData.name;
// サブコントローラーの実行
_subController.Execute();
// UI出現アニメーション
await _tweens.PlayInAnimations();
}
public async Task Inactivate()
{
/* 画面終了時の処理 */
// UIを消すアニメーション
await _tweens.PlayOutAnimations();
// 各種アンロード
}
private void OnDestroy()
{
// 後処理
}
// ボタンが押されたときの処理
// インスペクタでButtonコンポーネントから呼び出すように設定します
public void OnClickButton()
{
// 例でバトルトップ画面に遷移
// 画面はシーンをAdditiveロードする仕組み
// 次シーンをロードしてActivate()を呼び出し、現在シーンのInactivateを呼び出します
_sceneLoader.LoadSceneAdditive(ScenesEnum.BattleTop, false);
}
}
コンポーネント指向の考えで基底クラスが単純なMonoBehaviourになっているのが特徴です。
各画面で共通の機能はコンポーネントを設置してSerializeFieldで参照して使用します。
例えば、画面遷移時のイベント(画面開始、画面終了など)は AdditiveSceneEventTrigger
、AdditiveSceneTaskTrigger
(非同期イベント)というコンポーネントを設置して処理します。
これによって基底クラスが肥大化しないようにしています。
View
Unity UIのCanvasやImage、ScrollRect、LayoutGroupなど見た目を制御するコンポーネントをViewコンポーネントと位置付けています。
それら見た目を制御するコンポーネントを組み合わせてHierarchyを構築してファイル化したSceneやPrefabがViewの扱いです。
基本的にはUnity UI標準コンポーネントを利用して、独自のViewコンポーネントを組み合わせています。
独自コンポーネントにはタブ、トゥイーン、スプライトアニメなど多数あります。
WebでいうHTMLのイメージです。
アウトゲームの主要機能
画面遷移
// 画面遷移のためのクラス
[Inject]
private SceneLoader _sceneLoader;
// シーンをAdditiveロード
_sceneLoader.LoadSceneAdditive(
Scenes.SomeFunc,
new SomeFuncScene.Arguments
{
TargetId = 1001
}
);
ダイアログ (ポップアップウインドウ)
// ダイアログ開く
var dialog = await DialogLoader.Load<SomeDialog>();
dialog.Execute(param);
// ボタンが押されて閉じられるまで待つ
bool isOk = await dialog.WaitClose();
if (isOk)
{
// OKが押されたときの処理
}
public class SomeDialog : MonoBehaviour
{
// ダイアログ共通処理コンポーネント
[SerializeField]
private DialogCommon _common;
// OKボタンを押した?
private bool _isOk = false;
private void Awake()
{
// 初期化
// 開く処理はDialogCommonによって自動的に行われます
}
public void Execute(int param)
{
// 引数を使った処理
}
// OKボタンを押した
public void OnClickOkButton()
{
_isOk = true;
_common.Close();
}
// キャンセルボタンを押した
public void OnClickCancelButton()
{
_common.Close();
}
// ボタンが押されてダイアログが閉じるまで待つ
// 選択結果を返す
public async Task<bool> WaitClose()
{
await Common.WaitClose();
return _isOk;
}
}
通信
try
{
var webRequest = new WebRequest<SomeData>(APIType.SomeInfo, param);
var responseData = await webRequest.Send();
}
catch (WebRequestException e)
{
// 通信エラー時
}
リアルタイム通信
[Inject]
private IPusher _pusher;
await _pusher.Subscribe("channel_name",);
_pusher.Bind<SomeRealtimeData>("channel_name", "event_name", (someData) => {
// サーバーからデータ受信したときの処理
// 例. マッチングしたプレイヤーの情報を表示、チャットメッセージを表示
});
アセットバンドル
アセットバンドルシステムはIAssetBundleLoader
として抽象化してサーバーからロードするクラスとローカルファイルからロードするクラスを切り替えられるようにしています。
Loadメソッドの第三引数ownerはGameObject型の引数でownerがDestroyされるとアセットバンドルもアンロードされる仕組みにしています。
// アセットバンドルロードシステム
[Inject]
private IAssetBundleLoader _assetBundleLoader;
var prefab = await _assetBundleLoader.Load<GameObject>(assetBundleName, assetName, owner);
設計で気をつけていること
コンポーネント指向
Unityの設計に習いコンポーネント指向で開発しています。
小さい機能を実現するコンポーネントを組み合わせて大きな機能を作ります。
コンポーネントが充実してくると組み合わせて新しい機能を効率よく作れます。
コードを書く必要がなく、非エンジニアにも優しいです。
各コンポーネントの役割です
- xxxButton: 独自ボタン制御(小さなコントローラー。クリック時の画面遷移などを行う)
- Image: 見た目
- Button: ボタン
- CanvasGroup: 透明度とクリック可否
- SwitchSprite: ボタンON/OFF時のImageに割り当てるスプライトの切り替え
- ScaleInTween: UI出現時のトゥイーンアニメ
- ScaleOutTween: UI消失時のトゥイーンアニメ
- ClickTween: クリック時のトゥイーンアニメ
- Se: クリック時のSE再生
各種TweenやSeはボタン以外でも利用する汎用的なコンポーネントです。
また、長押しが必要なボタンには以前紹介したLongPressTriggerを付けます。
コンポーネント指向の逆はオブジェクト指向の継承だと考えています。
継承で上記ボタンを作ると標準Button継承したCustomButtonクラスを作成しその中でトゥイーンやSE再生、長押しなどを作り込むことになり、それらは再利用しにくいものになります。
別コンポーネントとして用意することで、ボタン以外からも利用することができます。
また大規模プログラムで継承を多用するとC#が単一継承であるため基底クラスに不必要な機能が入って肥大化することが多いです。コンポーネントの組み合わせで作ることでコード重複が少なく、再利用性の高いプログラムになります。
リンクスでは継承の使い所を見極めて継承階層が深くならないようにしています。
依存性の注入
Zenjectを利用して、1つの実装に依存しない柔軟性のあるプログラムにしています。
この依存性注入によってシーンの単体実行、通信のモック化、ローカルアセットをアセットバンドルとしてロードなどを実現しています。
複数の実装が必要のないものはinterfaceを定義せずにクラス1つにしています。
依存性が高まるシングルトンは禁止しています。
テストしやすい環境
シーンやコンポーネントをテストしやすくしています。
例えばシーンではバトル後の結果画面は正規フローだとログイン、マッチング、バトルを経るため動作確認までにとても手間がかかります。
バトル結果画面のシーンを開いた状態でUnity再生するとダミーデータで動作させて素早く確認できるようにしています。
コンポーネントはインスペクタにデバッグボタンを用意して確認しやすくしています。
シングルトンを使用しない
シングルトンをアンチパターンと捉えて使用しないようにしています。
シングルトンはグローバル変数と同じ性質があります。様々なところからアクセスされると分かりにくいバグが発生しやすいコードになってしまうためです。
各クラス間の依存性も高くなります。例えばキャラクターの体力が変化したときに体力UIにシングルトンでアクセスしてしまうとキャラクターを動作させるのにUIも必要になるといった具合です。
また1つの実装に依存することになり、モック実装に差し替えられずテストしやすい環境の妨げになります。
シングルトンのデメリットについてはこちらの記事が参考になります。
プログラマが知るべき97のこと「シングルトンパターンの誘惑に負けない」
合わせて、クラス名に◯◯Managerと付くものは慣例的にシングルトンになりがちなので、◯◯Managerというクラス名は禁止にしました。
◯◯Managerというクラス名は役割があいまいで、肥大化しやすい問題もあります。
シングルトンの代替策
- 他のオブジェクトをお世話するシングルトンクラス(◯◯Manager)をそもそも作らない
できるだけインスタンス自身が自分で処理する - 呼び出されるクラスにイベントを用意して、呼び出す側でイベントを受けて処理を実行する (オブザーバーパターン)
- クラスの実行に必要なインスタンスを外から渡す
- コンストラクタやメソッドに手動で渡す
- 依存性注入 (DI)
- サービスロケーターから取得する
通信のモック化
通信はサーバー通信する実装と、通信せずにローカルファイルを使用する実装の2パターンを用意しています。
メリット
- クライアントとサーバーの並行開発ができます
- モック化機能がないとサーバー実装完了後にクライアント実装開始になり開発期間が長くなります
- 通信で受け取るデータを自由に作れます
- 例えばプレゼント機能でデータ空パターンとデータ大量パターンを容易にテストできます
- 通信エラーのシミュレーションができます
この構成のためローカルサーバーは使っていません。
通信モック化のための設定ファイルの画面です。
- 時間の偽装
- 通信エラーのシミュレーション
- 通信レスポンスとして使うJsonファイルの指定
- これを使って様々なパターンのデータで動作テストします
UniRxオペレーターを多用しない
UniRxには多数のオペレーターが用意されていますが習得コストが高いと判断し、Whereなど基本的なもののみを使うようにしています。
UniRxで使用しているのはSubject、ReactiveProperty、MicroCoroutineです。
- Subject
- C#標準eventの代わりに使用。解放が楽です。
- ReactiveProperty
- 値の変化を購読するときに使用しています。
- MicroCoroutine
- 高速なUpdate、コルーチンとして使用しています。
通信などの非同期処理にもRxを使わずasync/awaitかコルーチンを使っています。
手続き型で記述することで分かりやすくしています。
最後に
明日は @tomeitou さんの記事です。