コンテンツへスキップ

QualiArtsengineer blog

Web出身のUnityエンジニアによる大規模ゲームの基盤設計

Web出身のUnityエンジニアによる大規模ゲームの基盤設計

35 min read

みなさま、こんにちは。
2013年度新卒入社の吉成(@y_yoshinari)と申します。
現在私は『ボーイフレンド(仮)きらめき☆ノート』(以下、ボイきら)というサービスのUnityの実装を担当しています。

私は現在はUnityエンジニアですが、入社時は『なぞってピグキッチン』というスマートフォン向けブラウザゲームのフロントエンドの実装を担当していました。
2年前にWebフロントエンジニアからUnityエンジニアへ職種転向をし、ボイきらでは新規立ち上げから、ゲーム全体の設計方針の決定や、フレームワークの作成などに携わりました。

今回は、Webフロント出身のUnityエンジニアが大規模ゲームの基盤設計を行ったらこんな感じになりましたという話をしたいと思います。
「Unityって結構自由にコードを書けるからチームで何かしらの指針が欲しい」的なことを考えている方のお役に立てれば嬉しいです。

Unityで基盤設計をする際に考えたこと

JavaScriptはAngularJSやBackbone.jsなどの登場によってMV◯フレームワークの元で簡単に開発を行うことができるようになりました。
これらを用いないと、ModelとViewを綺麗に分けづらく、昔はModelとViewが分かれていない設計のサービスも多かったと思います。

そしてModelやViewを意識しないと、個性豊かなコードが書かれやすくなります。

Unityもオブジェクト指向言語だったり、StartメソッドやUpdateメソッドなどが用意されていたりするものの、それだけでは実際にどういう書き方が正解なのかも分からず、やはり個性豊かなコードが書かれやすくなってしまうと思います。

また、ModelやViewを分けたとしても、大規模ゲーム開発では、ページ遷移周りの仕組みや共通パーツの仕組みなども整備しないと、やはりコードにブレが生じてしまいます。

そこで今回は大きく下記の点を意識し、Unityにおける大規模ゲームの基盤設計を行ないました。

  • ModelやViewをきっちり分けたい
  • ページ遷移周りを整備したい
  • 誰が書いても同じコードになるようにしたい
  • 共通パーツも整理して使いまわして効率化したい

以降では、各々の項目に関して順に説明をしていきます。

これからする話のザックリ概要

長くなりそうなので、先にザックリ概要を書いておきます。
ここで気になった部分だけ読んでいただければと思います。

MVPパターン

ボイきらでは、ModelやViewを分けるためにMVPパターンを導入しました。
ここでは、MVPパターン及び、MVPパターンを実現するために必要な要素を説明します。

MVPパターンの実装例

実際に、MVPパターンによる機能実装の例を紹介します。

WebとUnityのページ遷移

Webのページ遷移手法、Unityのページ遷移手法を紹介し、最終的にUnityのページ遷移ってWebのSPAと同じ考え方ができそうな気がするという話に着地します。

ページ遷移周りの設計

SPAゲームを制作していた際に行っていたページの生成や破棄の手法をUnityに適用してみた話をします。最後に、実際にページ遷移時にページがどのように生成され、破棄されるのかについての例を紹介します。

Triggerメソッド

MVPによって細かな単位でのコードの統一化は行えたので、ページというもう少し大きな単位でのコードの統一化を行うための話をします。
「全員が適切なタイミングに適切に処理を挟み込めれば、大抵の実装は同じになるだろう」というザックリした思想の元、ページ遷移周りの適切そうなタイミングに定義したメソッド群を紹介していきます。

コンポーネント

コンポーネント(部品)は最初に一気に作っちゃうと楽ですよという話をしますが、各々のコンポーネントの紹介まで入れると記事が終わらなくなってしまうので、軽く触れる程度にしておきます。

まとめ

結果どうだったのか的な話をします。

MVPパターン

ボイきらでは、ModelViewを分けるためにMVPパターンを導入しました。
ここでは、ボイきらの中で導入しているMVPパターンを紹介したいと思います。
あくまで、ボイきらではこういう設計にしましたという話なので、厳密には一般的なMVPパターンとは少し異なるかもしれないです。

M、V、Pの説明

MVPのMはModel、VはView、PはPresenterをそれぞれ表します。
各々、下記の図のような役割を持ちます。

ちなみにMVPの図での表現方法ですが、下記のスライドが凄く分かりやすかったので参考にさせていただきました。
MVPパターンによる設計アプローチ「あなたのアプリ報連相できてますか」

この設計では、PresenterModelViewを所持しています。
そのため、PresenterModelViewを知っているのですが、相互参照を避けるためにModelViewPresenterを知りません。

それではこれら3つを用いてMVPパターンを実現していきましょう。

MVPパターンの実現に必要な要素

MVPパターンを実現するためには、大きく下記の2点が必要となります。

  1. PresenterModelの値の変更を検知して、Viewを操作すること
  2. PresenterViewのユーザー入力イベントを検知して、Modelを操作すること

図にすると下記のようになります。

PresenterModelViewを所持しているので、直接メソッドを叩くことにより各々を操作することができます。
しかし、ModelViewPresenterを知らないので、直接メソッドを叩いて値の変更を伝えたり、ユーザー入力のイベントを伝えたりすることはできません。

以上より、MVPパターンを実現するためには下記の2点を解決する必要があります。

  1. 値の変更の監視(PresenterModelの値の変更を監視する仕組み)
  2. 子要素から親要素へのイベント伝達(ViewからPresenterへのイベント伝達)

値の変更の監視

ボイきらでは、UniRxを用いて値の監視を行っています。

UniRxとはUnityでReactive Extensions(Rx)を実現するためのライブラリです。
UniRxを用いると、イベントや値の変化を自動で伝播させることができます。

ちなみにUniRxはRxを実現できる強力なライブラリなのですが、ボイきらではガンガンRxを使ったりはしておらず、値の変化の伝播とかごく一部の仕組みを実現するためにしかUniRxを使っていません。

それでは実際にUniRxを用いた「値の変更の監視」を見ていきましょう。

ReactiveProperty

まず、監視される変数のクラスとしてReactiveProperty<T>があります。
このクラスを用いることで、T型の値を監視可能なインスタンスとして生成することができます。
監視したい値自体はValueプロパティに格納します。

// ReactivePropertyの宣言(引数は初期値、省略可)
ReactiveProperty<int> count = new ReactiveProperty<int>(10);

// 値の代入
count.Value = 100;

// 値の取得
Debug.Log(count.Value);

Subscribe

ReactivePropertyのSubscribeメソッドによって、ReactivePropertyのValue値が変更された際に、現在のValue値を引数としたActionを実行することができます。

// ReactivePropertyをSubscribeすることで、値の変更を監視することができる
count
  .Subscribe((count) => {
    // count.Valueの値が変更される度に呼ばれる
    Debug.Log(count)
  })
  .AddTo(gameObject)

下記のような書き方も可能です。

// ReactivePropertyをSubscribeすることで、値の変更を監視することができる
count
    .Subscribe(OnCountChanged)
    .AddTo(gameObject);

// countの値が変更された際に呼ばれる
void OnCountChanged(int count)
{
    Debug.Log(count);
}

AddTo(gameObject)によって、gamaObjectが破棄された時に監視を自動で解除することができます。

MVPパターンへの適用

MVPパターンでの値の変更の監視にこの仕組みを適用すると下記のようになります。後ほど具体的な実装例を別途紹介します。

  • ModelがReactivePropertyを持つ
  • PresenterがSubscribeでModelのReactivePropertyを監視する

子要素から親要素へのイベント伝達

「値の変更の監視」の説明をしたので、次は「子要素から親要素へのイベント伝達」の説明をします。
子要素から親要素へのイベント伝達は、子要素にイベント発火時に呼ばれるListener(Action)を用意し、親要素から子要素のListenerにイベント発火時の行いたい処理を紐付けることによって実現をします。

// 子供のクラス
public class ChildClass : MonoBehavior
{
    // クリック時のイベントリスナー
    public Action OnClickedListener;

    // クリック時に呼ばれる
    public void OnClicked()
    {
        if (OnClickedListener != null)
        {
            OnClickedListener();
        }
    }
}

// 親のクラス
public class ParentClass : MonoBehavior
{
    // 子供
    public ChildClass Child;

    // イベントの設定
    public void SetEvent()
    {
        Child.OnClickedListener = OnChildClicked;
    }

    // 子供のクリック時に呼ばれる
    public void OnChildClicked()
    {
        // クリック時の処理
    }
}

MVPパターンへの適用

MVPパターンでの子要素から親要素へのイベント伝達にこの仕組みを適用すると下記のようになります。こちらも後ほど具体的な実装例を別途紹介します。

  • PresenterViewを持つ
  • Viewが子要素、Presenterが親要素になる

MVPパターンの実装例

実際に具体的な実装例を見ていきましょう。 今回は下記のタイミング調整を実装するとします。

それでは、ModelViewPresenterの各々の実装を紹介します。 今回は説明のため、外部メソッド化してる部分もあえてベタ書きしているので無駄に同じ記述が多かったりします。

Modelの実装

using UniRx;

public class SettingModel
{
    // タイミング
    private ReactiveProperty<int> _timing;
    public IReadOnlyReactiveProperty<int> Timing
    {
        get { return _timing; }
    }

    // コンストラクタ
    public SettingModel()
    {
        _timing = ReactiveProperty<int>(0);
    }

    // Timingの値を設定する
    public void SetTiming(int timing)
    {
        _timing.Value = timing;
    }
}

ボイきらではReactivePropertyの変数自体はprivateで保持し、外部へはIReadOnlyReactivePropertyのインターフェイスを公開するようにしています。 これによって、ReactivePropertyで管理している値をModelの外部からは変更できないようにしています。

// 値の取得はできる
Debug.Log(_model.Timing.Value)

// 値の設定はエラー
_model.Timing.Value = 10

IReadOnlyReactivePropertyは個人的にModelの値をどこからでも変更できるのは怖いなと思って導入しましたが、メソッドを叩けばどこからでも値を変更できるのは同じなので、別にここまでやらなくても良いとは思います。
ただ、Setメソッドを用意したことで、どこから値を変更されているかをすぐに追えるようになったり、この変数は絶対にこの変数と一緒に変更をするなどが保証できるようにはなりました。

Viewの実装

using UnityEngine;
using System.Collections;

public class SettingView : MonoBehavior
{
    // タイミングの値のラベル
    [SerializeField]
    private UILabel _timingLabel;

    // 「+」ボタンが押された時のイベントリスナー
    public Action OnPlusButtonClickedListener;

    // 「ー」ボタンが押された時のイベントリスナー
    public Action OnMinusButtonClickedListener;

    // 「自動調整」ボタンが押された時のイベントリスナー
    public Action OnAutoButtonClickedListener;

    // 「初期設定に戻す」ボタンが押された時のイベントリスナー
    public Action OnResetButtonClickedListener;

    // Timingの値が変更された時に呼ばれます
    public void OnTimingChanged(int timing)
    {
        _timingLabel.text = timing.ToString();
    }

    // 「+」ボタンが押された時に呼ばれるメソッド
    public void OnPlusButtonClicked()
    {
        if (OnPlusButtonClickedListener != null)
        {
            OnPlusButtonClickedListener();
        }
    }

    // 「ー」ボタンが押された時に呼ばれるメソッド
    public void OnMinusButtonClicked()
    {
        if (OnMinusButtonClickedListener != null)
        {
            OnMinusButtonClickedListener();
        }
    }

    // 「自動調整」ボタンが押された時に呼ばれるメソッド
    public void OnAutoButtonClicked()
    {
        if (OnAutoButtonClickedListener != null)
        {
            OnAutoButtonClickedListener();
        }
    }

    // 「初期設定に戻す」ボタンが押された時に呼ばれるメソッド
    public void OnResetButtonClicked()
    {
        if (OnResetButtonClickedListener != null)
        {
            OnResetButtonClickedListener();
        }
    }
}

ボイきらはNGUIを用いて実装が行われているので、NGUIベースで話をします。あとでuGUIの場合についても少し触れます。

ボタンがいっぱいあるので、ここではPlusButtonで説明を行ないます。

NGUIのUIButtonはInspector上でクリック時に呼ばれるメソッドとの紐付けを行うので、PlusButtonのクリックイベントをInspector上でOnPlusButtonClickedメソッドに紐付けます。 そしてOnPlusButtonClickedメソッドでは、自身のListenerに処理が設定されているときだけ、その処理を実行します。

// 「+」ボタンが押された時に呼ばれるメソッド
public void OnPlusButtonClicked()
{
    if (OnPlusButtonClickedListener != null)
    {
        OnPlusButtonClickedListener();
    }
}

PresenterからViewのListenerにクリック時の処理を設定しておけば、クリック時にその処理を実行することができます。

ちなみに、ボイきらでは毎回nullチェックを書くのが面倒なので、実際はnullチェックしてからメソッドを叩く処理は外部メソッド化して、もう少し簡潔に書くことができるようにはしています。

// 「+」ボタンが押された時に呼ばれるメソッド
public void OnPlusButtonClicked()
{
    SystemUtility.SafeCall(OnPlusButtonClickedListener);
}

また、OnTimingChangedメソッドはタイミングの値が変わった時に、画面のラベルを更新するためのメソッドです。Modelで管理しているTimingの値が変更された際にPresenterから叩かれます。

// Timingの値が変更された時に呼ばれます
public void OnTimingChanged(int timing)
{
    _timingLabel.text = timing.ToString();
}

以上のように、Viewはユーザーの入力イベントを検知したり、画面を更新したりする役割をします。
また、今回は書いておりませんが画面のアニメーションもViewが担当します。

Presenterの実装

using UnityEngine;
using System.Collections;
using UniRx;

public class SettingPresenter : MonoBehavior
{
    // View
    [SerializeField]
    private SettingView _view;

    // Model
    private SettingModel _model;

    // 初期化
    public void Initialize()
    {
        // Modelの生成
        _model = new SettingModel();

        Bind();
        SetEvents();
    }

    // Modelの値の変更を監視する
    private void Bind()
    {
        // ModelのTimingの値が変わった際に、Viewを更新する
        _model.Timing
            .Subscribe(_view.OnTimingChanged)
            .AddTo(gameObject);
    }

    // Viewのイベントの設定を行う
    private void SetEvents()
    {
        _view.OnPlusButtonClickedListener = OnPlusButtonClicked;
        _view.OnMinusButtonClickedListener = OnMinusButtonClicked;
        _view.OnAutoButtonClickedListener = OnAutoButtonClicked;
        _view.OnResetButtonClickedListener = OnResetButtonClicked;
    }

    // 「+」ボタンが押された時に呼ばれるメソッド
    private void OnPlusButtonClicked()
    {
        if (_model.Timing.Value < MAX_TIMING)
        {
            _model.SetTiming(_model.Timing.Value + 1);
        }
    }

    // 「ー」ボタンが押された時に呼ばれるメソッド
    private void OnMinusButtonClicked()
    {
        if (MIN_TIMING < _model.Timing.Value)
        {
            _model.SetTiming(_model.Timing.Value - 1);
        }
    }

    // 「自動調整」ボタンが押された時に呼ばれるメソッド
    private void OnAutoButtonClicked()
    {
        // 自動調整ページへ遷移処理
    }

    // 「初期設定に戻す」ボタンが押された時に呼ばれるメソッド
    private void OnResetButtonClicked()
    {
        _model.SetTiming(0);
    }
}

ViewはInspectorから設定をしており、Modelは内部で生成しています。
基本的に、PresenterViewは同じオブジェクトにアタッチされている想定です。

BindメソッドではModelの値の変更を検知して、Viewを操作しています。

// Modelの値の変更を監視する
private void Bind()
{
    // ModelのTimingの値が変わった際に、Viewを更新する
    _model.Timing
        .Subscribe(_view.OnTimingChanged)
        .AddTo(gameObject);
}

SetEventsメソッドではViewのユーザー入力イベントを検知して、Modelを操作したり、別途その際に行いたい処理を実行したりしています。

// Viewのイベントの設定を行う
private void SetEvents()
{
    _view.OnPlusButtonClickedListener = OnPlusButtonClicked;
    _view.OnMinusButtonClickedListener = OnMinusButtonClicked;
    _view.OnAutoButtonClickedListener = OnAutoButtonClicked;
    _view.OnResetButtonClickedListener = OnResetButtonClicked;
}

また、ボイきらでは、一番大元のエントリーポイントでしかAwakeやStartの使用を許可しておらず、初期化などは全て明示的にInitializeメソッドを定義して叩くようにしました。
たしかに、Unityのレンダリングフローなどちゃんと理解すればメソッドの叩かれる順番などは分かるかもしれないです。しかし、チーム全員がそこを理解し切るのは厳しいかなという点と、バグが発生しやすそうなポイントな上に、実際にバグが発生したら調査もキツそうだなという点から、今回は明示的にコードの記述だけでメソッドの呼ばれる順番を保証するようにしました。
Unityの設計思想に反しているかもしれないのですが、着地すごくスッキリしましたし、制御フローもコードを見ればすぐに分かるようになったので、やって良かったと思っています。

MVPおまけ①

オブジェクトを監視する時。

// プレイヤー情報クラス
public class PlayerData
{
    public string Nickname { get; set; }
    public int Level { get; set; }
}

// Modelクラス
public class Model
{
    // プレイヤー情報
    private ReactiveProperty<PlayerData> _player;
    public IReadOnlyReactiveProperty<PlayerData> Player
    {
        get { return _player; }
    }

    // コンストラクタ
    public Model()
    {
        _player = new ReactiveProperty<PlayerData>();
    }
}

// Viewクラス
public class View : MonoBehavior
{
    // プレイヤー情報が変更された際に呼ばれます
    public void OnPlayerChanged(PlayerData player)
    {
        // nullなら何もしない
        if (player == null)
        {
            return;
        }
        // プレイヤー情報を画面に適用(UILabelに表示)
        _nicknameLabel.value = player.Nickname;
        _levelLabel.value = player.Level.ToString();
    }
}

// Presenterクラス
public class Presenter : MonoBehavior
{
    // View
    [SerializeField]
    private View _view;

    // Model
    private Model _model;

    // 初期化
    public void Initialize()
    {
        _model = new Model();

        Bind();
    }

    // Modelの値の変更を監視する
    public void Bind()
    {
        _model.Player
            .Subscribe(_view.OnPlayerChanged)
            .AddTo(gameObject);
    }
}

色々省略していますが、ざっくりプレイヤー情報を監視するMVPを書いてみました。

ここではPlayerDataという型のオブジェクトをReactivePropertyにしています。
値型とは違い、オブジェクトは値がnullになることがあります。
実際、下記のように初期値を与えずにオブジェクトのReactivePropertyを作成するとnullが初期値として設定されます。

_player = new ReactiveProperty<PlayerData>();

そして、初期値にnullが設定されている場合には、nullを引数としてSubscribeの監視処理(_view.OnPlayerChanged)が叩かれてしまいます。

_model.Player.Subscribe(_view.OnPlayerChanged).AddTo(gameObject)

nullのオブジェクトのプロパティにアクセスをするとエラーが出てしまうので、Subscrubeによって叩かれるメソッドには下記のようにnullチェックを挟む必要があります。

// プレイヤー情報が変更された際に呼ばれます
public void OnPlayerChanged(PlayerData player)
{
    // nullなら何もしない
    if (player == null)
    {
        return;
    }
    // プレイヤー情報を画面に適用(UILabelに表示)
    _nicknameLabel.value = player.Nickname;
    _levelLabel.value = player.Level.ToString();
}

毎回、View側にnullチェックしてreturnするコードを書くのは避けたいです。
こんな時はSubscribeの監視処理を下記のようにすることで簡潔に処理を書くことができます。

_model.Player.Where((player) => player != null)
  .Subscribe(_view.OnPlayerChanged)
  .AddTo(gameObject)

Whereを挟むことによって、Whereの条件を通過した時しかSubscribeで設定されている処理が走らなくなります。

長々とコードを書いたりしましたが、ここではこのWhereの紹介がしたかっただけです。

MVPおまけ②

uGUIのボタンのクリックイベントについて触れておきます。

// Viewクラス
public class View
{
    // 「+」ボタン
    [SerializeField]
    private Button _plusButton;

    public Button PlusButton
    {
        get { return _plusButton; }
    }
}

// Presenterクラス
public class Presenter
{
    // View
    [SerializeField]
    private SettingView _view;

    // 初期化
    public void Initialize()
    {
        SetEvents();
    }

    // Viewのイベントの設定を行う
    private void SetEvents()
    {
        _view.PlusButton.onClick.AddListener(OnPlusButtonClicked);
    }

    // 「+」ボタンが押された時に呼ばれるメソッド
    private void OnPlusButtonClicked()
    {
        if (_model.Timing.Value < MAX_TIMING)
        {
            _model.SetTiming(_model.Timing.Value + 1);
        }
    }
}

uGUIのButtonではInspector上でイベントを紐付けなくても、クリック時に呼ばれるメソッドを簡単にコード上から定義することができます。
なので、uGUIのButtonに関してはViewから公開してしまって、下記のようにPresenterでイベントを設定してしまったほうが簡潔かもしれないです。

_view.PlusButton.onClick.AddListener(OnPlusButtonClicked)

正直、Inspectorからイベントを設定するのは、イベントが剥がれたり、設定の漏れやミスが発生しそうで怖いです。
そこでボイきらでもNGUIを用いてはいますが、ボタン用の基盤クラスを作成してuGUIの場合と似たような実装を行えるようにし、Inspectorからイベントを設定しなくて済むようにしています。

_view.PlusButton.onClickListener = OnPlusButtonClicked

WebとUnityのページ遷移

ここまでがMVPパターンに関する話でした。ここからはUnityのページ遷移周りをWebと比較して整理し、基盤のフレームワークを作成した話をしていきます。
まずは、Webのページ遷移手法と、Unityのページ遷移手法を紹介します。

Webのページ遷移

まずはWebのページ遷移手法を紹介します。
Webのページ遷移には大きく2つの手法が存在します。

  1. URL変更で0から画面を読み込む手法
  2. SPA(Single Page Application)

URL変更で0から画面を読み込む手法

この手法では、URLが切り替わるたびに毎回新しくページを取得し直します。

この手法には下記のメリットとデメリットがあります。

メリットデメリット
・ページ毎にHTMLを組めば良くシンプル ・ページ遷移時にメモリが解放される・毎回0から読み込み直すので、遷移が重い ・同じパーツを毎回読み込み直す ・遷移時にアニメーションをさせづらい ・前のページの状態を保持しづらい

SPA(Single Page Application)

この手法では、画面の更新に必要な情報をサーバーとやり取りしつつ、画面を切り替えずに、要素を動的に生成したり破棄したりしてページ遷移を実現します。

この手法には下記のメリットとデメリットがあります。

メリットデメリット
・遷移が速い ・同じパーツを置きっぱなしで流用できる ・遷移時にアニメーションさせやすい ・前のページの状態を保持しやすい・作りが複雑になる ・メモリ管理をしっかりと考える必要がある

Webのページ遷移の2つの手法を比較してみて

対象をゲームに絞って考えてみると、画面を読み込み直す手法でゲームを作るとユーザビリティは悪くなると思います。なのでユーザを第一に考えると、ゲームはSPAの方が向いていると私は考えています。

Unityのページ遷移

それでは、次にUnityのページ遷移手法を紹介します。
Unityのページ遷移も大きく2つの手法が存在します。

  1. Scene切り替え遷移
  2. 1-Scene内遷移

Scene切り替え遷移

この手法では、ページ毎にSceneを作成してScene自体を切り替えることでページ遷移を実現します。

この手法には下記のメリットとデメリットがあります。

メリットデメリット
・ページ毎にSceneを作れば良くシンプル ・遷移時にメモリが解放される・毎回0からSceneを読み込み直すので、遷移が重い ・同じパーツを毎回読み込み直す ・遷移時にアニメーションをさせづらい ・前のページの状態を保持しづらい

1-Scene内遷移

この手法では、1つのScene内で要素をPrefabから動的に生成したり、破棄したりしてページ遷移を実現します。

この手法には下記のメリットとデメリットがあります。

メリットデメリット
・遷移が速い ・同じパーツを置きっぱなしで流用できる ・遷移時にアニメーションさせやすい ・前のページの状態を保持しやすい・作りが複雑になる ・メモリ管理をしっかりと考える必要がある

Unityのページ遷移の2つの手法を比較してみて

1-Scene内遷移は、先程紹介したWebのSPAと似ていると思います。
なので、Webと同様にユーザーを第一に考えるとゲームは1-Scene内遷移が向いていそうだと感じました。

正直、Scene切り替え遷移のデメリットは、DontDestroyOnLoadを用いることである程度解決することも可能です。ただ、それはそれで残すObjectなどを管理する仕組みを別途考える必要が出てきます。そしてその管理方法で参考になりそうなものも見つからず、Scene切り替え遷移の綺麗な設計方法も思い付きませんでした。

そこで今回は、SPAを参考にできそうな1-Scene内遷移を最終的に採用しました。

ページ遷移周りの設計

ここからはSPAゲームの知識を元にUnityのページ遷移周りの設計をどのようにしていったかを紹介していきます。

まず今回参考にしたSPAの設計に関してです。世の中に色々とフレームワークはあるのですが、今回は自分がWebフロントだった頃に実装を担当した『なぞってピグキッチン』(以下、ピグキッチン)の設計を最終的に参考にしました。

下記がピグキッチンを最終的に参考にした理由です。

  • 大規模のSPAゲームをリリースして安定運用できた実績がある
  • 他の技術と比べて、自分が一番しっかりと設計を理解しきれている
  • この設計だと作りたいものが実現できなくて困るという経験がほぼ無かった
  • Unityでも問題無く使える見通しがついていた
  • 誰が書いても同じコードになるくらい、キッチリしていた

ピグキッチン自体の設計に関しては、私が1年目でまだ初々しかった頃に書いた記事がありますので、気になる方はこちらを読んでみてください。
近日公開予定 JSフレームワークBeez

それではここからは具体的なUnityの基盤設計の話をしていきます。
以降の設計は最終的に全てフレームワークに落とし込んでいます。

まずはページの生成と破棄周りの管理手法を説明します。

SceneとWindowとScreen

ページの生成と破棄を段階的に管理するために、SceneとWindowとScreenという3つの概念を用意します。

SceneはUnityのSceneを表します。そして、ガチャやミュージックといった、大きな単位でのページの塊をWindowと呼び、楽曲選択ページやゲスト選択ページのような細かな単位のページをScreenと呼ぶこととします。

そして、SceneとWindowとScreenは下記のような特徴を持っています。

  • SceneはWindowを管理、WindowはScreenを管理します
  • WindowとScreenは各々スタックで管理されます
  • 管理している親が消えたら、その親が管理している要素は全て破棄されます

例えば、ミュージックWindowが消えたら、楽曲選択、ゲスト選択、ユニット選択のScreenは全て破棄されます。

具体的なページ遷移フロー

それでは実際にページ遷移時にページがどのように生成され、破棄されるのかについての例を見ていきましょう。

実際のサービスでは有り得ないページ遷移なのですが、下記のようなページ遷移が行われたとします。

  1. ガチャのトップページを開いて
  2. ガチャ結果ページに行って
  3. そこから楽曲選択ページに行って
  4. ゲスト選択ページに行って
  5. ユニット選択ページに行って
  6. ユニット選択ページからゲスト選択ページに戻って
  7. ミュージック自体を閉じてガチャ結果ページに戻って
  8. ガチャトップページに戻って
  9. ガチャ自体を閉じる

この時、各Windowと各Screenがどういう管理をされるのかを見ていきましょう。

1. ガチャのトップページへ遷移

まずまっさらなSceneがあります。

Scene

ガチャのトップページを開くために、まずはSceneの管理下にガチャ全体を管理するガチャWindowが作られます

Scene
└ GachaWindow

そして、ガチャWindowの管理下に、ガチャトップScreenが作られます

Scene
└ GachaWindow   
    └GachaTopScreen

2. ガチャ結果ページへ遷移

ガチャWindowの管理下に、ガチャ結果Screenが作られます。

Scene
└ GachaWindow
    └GachaTopScreen
    └GachaResultScreen

3. 楽曲選択へ遷移

新しくミュージックに遷移するので、Sceneの管理下にミュージックWindowが作られます。そして、その管理下に、楽曲選択Screenが作られます。

Scene
└ GachaWindow
  └GachaTopScreen
  └GachaResultScreen
└ MusicWindow
  └MusicSelectScreen

このタイミングで、ガチャ自体を表示する必要がなくなったので、ガチャWindowが隠されます。

Scene
└ GachaWindow
  └GachaTopScreen
  └GachaResultScreen
└ MusicWindow
  └MusicSelectScreen

4. 5. ゲスト選択ページへ遷移し、ユニット選択ページへ遷移

今までと同様の遷移をします。
楽曲選択Screenとゲスト選択Screenは隠されます。(が残しておきます)

Scene
└ GachaWindow
  └GachaTopScreen
  └GachaResultScreen
└ MusicWindow
  └MusicSelectScreen
  └GuestSelectScreen
  └UnitSelectScreen

6. ユニット選択ページからゲスト選択ページへ戻る

ゲスト選択ページに戻るので、ゲスト選択Screenが再度表示されます。

Scene
└ GachaWindow
  └GachaTopScreen
  └GachaResultScreen
└ MusicWindow
  └MusicSelectScreen
  └GuestSelectScreen
  └UnitSelectScreen

そして戻ったことで、ユニット選択Screenは不要になったので破棄されます。

Scene
└ GachaWindow
  └GachaTopScreen
  └GachaResultScreen
└ MusicWindow
  └MusicSelectScreen
  └GuestSelectScreen
  └UnitSelectScreen ← Destroy!

この時、ゲスト選択Screenは隠していただけなので、遷移前の状態を保持したまま再度表示することができます。

Scene
└ GachaWindow
  └GachaTopScreen
  └GachaResultScreen
└ MusicWindow
  └MusicSelectScreen
  └GuestSelectScreen

7. ミュージック自体を閉じて、ガチャ結果ページへ戻る

ガチャに戻るので、ガチャWindowが再度表示され、戻り先のScreenであるガチャ結果Screenが再度表示されます。

Scene
└ GachaWindow
  └GachaTopScreen
  └GachaResultScreen
└ MusicWindow
  └MusicSelectScreen
  └GuestSelectScreen

ミュージックを閉じることで、ミュージックWindowは不要になったので破棄されます。それによって、管理下に置かれていた楽曲選択Screen、ゲスト選択Screenも同時に破棄されます。

Scene
└ GachaWindow
  └GachaTopScreen
  └GachaResultScreen
<span class="has-vivid-red-color">└ MusicWindow ← Destroy!
  └MusicSelectScreen
  └GuestSelectScreen</span>

この時、ガチャ結果Screenも隠していただけなので、遷移前の状態を保持したまま再度表示することができます。

Scene
└ GachaWindow
  └GachaTopScreen
  └GachaResultScreen

8. ガチャトップページへ戻る

ガチャトップScreenが再度表示され、戻ったことでガチャ結果Screenは不要になったので破棄されます。

Scene
└ GachaWindow
  └GachaTopScreen
  <span class="has-vivid-red-color">└GachaResultScreen ← Destroy!</span>

この時、ガチャトップScreenも遷移前の状態を保持したまま再度表示することができます。

Scene
└ GachaWindow
  └GachaTopScreen

9. ガチャ自体を閉じる

最後に、ガチャを閉じることでガチャWindowも不要になったので破棄されます。それによって、管理下に置かれていたガチャトップScreenも同時に破棄されます。

Scene
<span class="has-vivid-red-color">└ GachaWindow ← Destroy!
  └GachaTopScreen</span>

そしてSceneだけが残っている最初と同じ状態になりました。

Scene

このページ管理手法の2つのポイント

1. 進んでいる間は、前のWindowやScreenは残しておく

これは、ページ遷移では前の状態を保持したまま、ページを戻りたいケースが多いからです。これを破棄してしまうと、戻るたびに遷移前と同じ状態を0から作り直さなくてはならなくなります。

2. 戻った後は、もう必要ないので破棄してしまって良い

これは、再度遷移してくる時は、前の状態は必要なく、新しく生成したページを見せれば良いからです。

Triggerメソッド

ここまでで、MVPパターンとページ遷移の際のWindowやScreenの生成と破棄のフローの説明を行ないました。
MVPによって細かな単位でのコードの統一化は行えたので、ページというもう少し大きな単位でのコードの統一化を行ないたいと思います。

ピグキッチンでは、SceneとWindowとScreenの「ページ遷移で処理を挟みたいであろう箇所」にメソッドを仕込んでいました。

そして、用意されたメソッドで処理を挟みこむページ遷移実装を行う中で、適切なタイミングで適切に処理を挟み込めれば、大抵の実装は何とかなる。そして、全員が適切なタイミングに適切に処理を挟み込めれば、大抵の実装は同じになるということを感じました。

そこで、ボイきらでもSceneとWindowとScreenの「ページ遷移で処理を挟みたいであろう箇所」にメソッドを仕込みました。以降ではこのメソッドをTriggerメソッドと呼びます。

他で例えると、Reactのコンポーネントのライフサイクルで定義されているcomponentWillMount、componentDidMountなどのメソッド群と近いイメージです。

また、Webではないのですが、Objective-CやSwift(というかiOSのUIKitフレームワーク)のViewControllerのライフサイクルで呼ばれる、viewDidLoad、viewWillAppear、viewDidAppear、viewWillDisappear、viewDidDisappearなどのメソッド群とも同じようなイメージです。

Triggerメソッドの概要

WindowはWindowPresenterBaseを、ScreenはScreenPresenterBaseを継承して実装します。そして、WindowPresenterBaseとScreenPresenterBaseの処理を挟みたくなる箇所に、overrideできるTriggerメソッドを予め用意しておきます。
実装者はTriggerメソッドを実装するだけで、ゲームの大まかな流れを作れるようになっています。

Windowに用意してあるTriggerメソッド

Windowを開くことをOpen、閉じることをCloseと定義しています。
その下で、Windowには主に下記のTriggerメソッドを用意しています。

// 初期化時に呼ばれます
IEnumerator Initialize();
// OnOpenの直前に呼ばれます
IEnumerator OnBeforeOpen();
// 自身が開かれる際に呼ばれます
IEnumerator OnOpen();
// 他のWindowが開かれることによって、自身が画面から消える時に呼ばれます
IEnumerator OnOpenOut();
// OnCloseInの直前に呼ばれます
IEnumerator OnBeforeCloseIn();
// 他のWindowが閉じることによって、自身が画面に表示される時に呼ばれます
IEnumerator OnCloseIn();
// 自身を閉じることによって、自身が画面から消える時に呼ばれます
IEnumerator OnCloseOut();
// 自身が管理しているスクリーンが遷移し始めた時に呼ばれます
void OnScreenWillChange();
// 自身が管理しているスクリーンが遷移し終わった時に呼ばれます
void OnScreenDidChanged();

また、どのWindowから遷移してきたかを知りたい時のためのメソッドなども用意してあります。

// Open時に呼ばれるメソッドです(前のWindowのStateが必要な場合はこっちを使う)
IEnumerator OnOpen(WindowState prevState);

Screenに用意してあるTriggerメソッド

Screenを進むことをMove、戻ることをBackと定義しています。 その下で、Screenには主に下記のTriggerメソッドを用意しています。

// 初期化時に呼ばれます
IEnumerator Initialize();
// OnMoveInの直前に呼ばれます
IEnumerator OnBeforeMoveIn();
// 自身に遷移してくる際に呼ばれます
IEnumerator OnMoveIn();
// OnMoveInの直後に呼ばれます
IEnumerator OnEndMoveIn();
// 次のScreenに遷移する際に呼ばれます
IEnumerator OnMoveOut();
// OnBackInの直前に呼ばれます
IEnumerator OnBeforeBackIn();
// 戻るによって自身に遷移してくる際に呼ばれます
IEnumerator OnBackIn();
// 前のScreenに戻る際に読まれます
IEnumerator OnBackOut();
// 自身を管理しているWindowが閉じる際に呼ばれます
IEnumerator OnClose();
// Androidバックキーが押された際に呼ばれます
void AndroidBack();

実装する処理の例

各々のTriggerメソッドで、こんな処理をやってもらいたいという例をいくつか挙げたいと思います。

Initialize

  • Modelの生成
  • Viewの初期化(UIをアニメーション前の状態にする)
  • 追加で生成するべきUIパーツの生成と配置
  • Bind処理(Modelの値の変更の監視設定)
  • SetEvents処理(Viewのイベントの監視設定)
  • 画面の描画に必要な追加通信

OnBeforeMoveIn

InitializeとOnBeforeMoveInの間で、前のScreenからパラメータが必要に応じて渡されてきています。 ここでは、初期化後に渡されたパラメータに応じて、画面が表示される前に済ませておきたい処理を行う

  • 渡されたパラメータをModelに適応
  • 渡されたパラメータに依存した追加UI生成
  • 渡されたパラメータに依存した追加通信

OnMoveIn、OnBackIn

  • UIを表示するアニメーションの再生

OnMoveOut、OnBackOut

  • UIを非表示にするアニメーションの再生

具体的なTriggerメソッドの叩かれるフロー

実際にページ遷移時にTriggerメソッドがどのように叩かれるのかを見ていきましょう。

下記のようなページ遷移が行われたとします。

  1. 楽曲選択ページへ遷移し
  2. ゲスト選択ページへ遷移し
  3. 楽曲選択ページへ戻ってくる

1. 楽曲選択ページへ遷移

最初に、MusicWindowへ遷移するために、ScenePresenterで下記の処理を叩きます。

// ミュージックWindowへ遷移
StartCoroutine(OpenEnumerator(WindowState.Music))

今回はパラメータは渡しませんが、パラメータを渡すことも可能です。

// パラメータを渡してミュージックWindowへ遷移
StartCoroutine(OpenEnumerator(WindowState.Music, (MusicWindowPresenter targetWindowPresenter) =>
{
    // targetWindowPresenterにパラメータを渡す
}));

最初にミュージック全体を管理するMusicWindowが生成されます。

その際に、下記のTriggerメソッドが順に実行されます。

// ミュージックWindowの初期化
MusicWindowPresenter.Initialize()
// 特に今回はやること無し
MusicWindowPresenter.OnBeforeOpen()
// MusicWindowで管理しているUIの表示アニメーション
// ここで、1ページ目の楽曲選択ページへの遷移処理を叩く
MusicWindowPresenter.OnOpen()

まず、MusicWindowがPrefabからロードされて配置されます。 その際、MusicWindowのInitializeが叩かれるので、初期化処理を行ないます。 次に、MusicWindowのOnBeforeOpenが叩かれるのですが、今回は遷移時に何もパラメータを渡していないので何も処理を行ないません。 そして、MusicWindowPresenterのOnOpenが叩かれるので、MusicWindowのUIの表示アニメーションを再生します。今回はWindow全体で使うUI(Window単位で管理した方が良いUI)は無く、何もUIを管理していないのでアニメーションの再生も行ないません。

MusicWindowが表示され終わったので、MusicWindowのOnOpenの終わりで、1ページ目の楽曲選択ページへの遷移処理を叩きます。

// 楽曲選択Screenへ遷移
StartCoroutine(MoveEnumerator(ScreenState.MusicSelect))

そして、MusicSelectScreenが生成されます。

その際に、下記のTriggerメソッドが順に実行されます。

// ミュージックWindowの初期化
MusicWindowPresenter.Initialize()
// 特に今回はやること無し
MusicWindowPresenter.OnBeforeOpen()
// MusicWindowで管理しているUIの表示アニメーション
// ここで、1ページ目の楽曲選択ページへの遷移処理を叩く
MusicWindowPresenter.OnOpen()

まず、MusicWindowがPrefabからロードされて配置されます。 その際、MusicWindowのInitializeが叩かれるので、初期化処理を行ないます。 次に、MusicWindowのOnBeforeOpenが叩かれるのですが、今回は遷移時に何もパラメータを渡していないので何も処理を行ないません。 そして、MusicWindowPresenterのOnOpenが叩かれるので、MusicWindowのUIの表示アニメーションを再生します。今回はWindow全体で使うUI(Window単位で管理した方が良いUI)は無く、何もUIを管理していないのでアニメーションの再生も行ないません。

MusicWindowが表示され終わったので、MusicWindowのOnOpenの終わりで、1ページ目の楽曲選択ページへの遷移処理を叩きます。

// 楽曲選択Screenへ遷移
StartCoroutine(MoveEnumerator(ScreenState.MusicSelect))

そして、MusicSelectScreenが生成されます。

その際に、下記のTriggerメソッドが順に実行されます。

// 楽曲選択スクリーンの初期化
MusicSelectScreenPresenter.Initialize()
// 特に今回はやること無し
MusicSelectScreenPresenter.OnBeforeMoveIn()
// 楽曲選択スクリーンのUIの入ってくるアニメーション
MusicSelectScreenPresenter.OnMoveIn()
// 特に今回はやること無し
MusicSelectScreenPresenter.OnEndMoveIn()

まず、MusicSelectScreenがPrefabからロードされて配置されます。
その際、MusicSelectScreenのInitializeが叩かれるので、初期化処理を行ないます。
そして、MusicSelectScreenのOnBeforeMoveInが叩かれるのですが、今回も遷移時に何もパラメータを渡していないので何も処理を行ないません。
次に、MusicSelectScreenのOnMoveInが叩かれるので、MusicSelectScreenのUIの表示アニメーションを再生します。
表示が終わったタイミングで、MusicSelectScreenのOnEndMoveInが叩かれますが、今回は特に何もしません。

2. ゲスト選択ページへ遷移し

ゲスト選択ページで表示されるリストは選んだ楽曲に依存して選ばれるので、楽曲選択ページから遷移する際は、選んだ楽曲の情報をパラメータとして送ります。

// 選んだ楽曲情報を渡してゲスト選択Screenへ遷移
StartCoroutine(MoveEnumerator(ScreenwState.GuestSelect, (GuestScreenPresenter targetScreenPresenter) =>
{
    // ゲスト選択スクリーンに選んだ楽曲情報を渡す
    targetScreenPresenter.SetSelectedMusic(_model.MusicId);
}));

そして、GuestSelectScreenが生成され、遷移を行ないます。

その際に、下記のTriggerメソッドが順に実行されます。

// ゲスト選択スクリーンの初期化
GuestSelectScreenPresenter.Initialize()
// 楽曲選択スクリーンのUIの捌けるアニメーション
MusicSelectScreenPresenter.OnMoveOut()
// 前のスクリーンで選んだ曲情報を元に、ゲスト取得の通信、ゲストのリストの生成
GuestSelectScreenPresenter.OnBeforeMoveIn()
// ゲスト選択スクリーンのUIの入ってくるアニメーション
GuestSelectScreenPresenter.OnMoveIn()
// 特に今回はやること無し
GuestSelectScreenPresenter.OnEndMoveIn()

まず、GuestSelectScreenがPrefabからロードされて配置されます。 その際、GuestSelectScreenのInitializeが叩かれるので、初期化処理を行ないます。 初期化後に、楽曲選択ページで選んだ楽曲情報がパラメータとして送られてきます。 そして、GuestSelectScreenが表示されることによってMusicSelectScreenが隠されるので、MusicSelectScreenのOnMoveOutが叩かれます。 ここでは、MusicSelectScreenのUIの捌けるアニメーションを再生します。 そして、GuestSelectScreenのOnBeforeMoveInが叩かれるので、送られてきたパラメータを元にゲスト取得の通信をして、ゲストのリストを生成します。 次に、GuestSelectScreenのOnMoveInが叩かれるので、GuestSelectScreenのUIの表示アニメーションを再生します。 表示が終わったタイミングで、GuestSelectScreenのOnEndMoveInが叩かれますが、今回は特に何もしません。

3. 楽曲選択ページへ戻ってくる

戻る際は戻り先を指定しなくて良いので、下記の処理で戻ることができます。戻る際にパラメータを渡したい場合は今までと同様の渡し方をBackEnumeratorの第一引数で行うことができます。

// Screenを戻る
StartCoroutine(BackEnumerator())

楽曲選択Screenに戻り、ゲスト選択Screenが破棄されます。

その際に、下記のTriggerメソッドが順に実行されます。

// ゲスト選択スクリーンのUIの捌けるアニメーション
GuestSelectScreenPresenter.OnBackOut()
// 開放処理(破棄時に呼ばれるデフォルト関数)
GuestSelectScreenPresenter.OnDestroy()
// 特に今回はやること無し
MusicSelectScreenPresenter.OnBeforeBackIn()
// 楽曲選択スクリーンのUIの入ってくるアニメーション
MusicSelectScreenPresenter.OnBackIn()

まず、GuestSelectScreenが戻ることによって非表示になるので、GuestSelectScreenのOnBackOutが叩かれます。 ここで。GuestSelectScreenのUIの捌けるアニメーションを再生します。 そして、戻ったことでGuestSelectScreenは不要になったので破棄され、デフォルト関数のOnDestroyが叩かれます。 ここでは、GuestSelectScreen関連で開放するべきものの開放処理を行ないます。 戻ることによってMusicSelectScreenが表示されるので、MusicSelectScreenのOnBeforeBackInが叩かれ、OnBackInが叩かれます。 OnBeforeBackInではGuestSelectScreenからパラメータを渡されていないので何も処理は行わず、OnBackInでMusicSelectScreenのUIを表示するアニメーションを再生します。

コンポーネント

最後にちょっとだけコンポーネントの話をします。 ボイきらではコンポーネント(部品)を最初に一気に作って、全員がそれを利用して開発できる状態にしました。

  • ボタン
  • サムネイル
  • 背景
  • ブロッキング
  • ダイアログ
  • ローディング
  • メニュー
  • ラベル
  • ヘッダー
  • タブ
  • トースト
  • リスト
  • スライダー
  • ページング
  • etc…

あと、UIアニメーションもルール化して、最初に一気に作りました。

これを最初にやっておくだけで、開発効率もクオリティも大きく変わります。 また、共通化しておくと、デザイン変更があったときに凄く楽です。

本当は各コンポーネントをどういう設計思想で作ったかなども説明していきたいのですが、1個1個説明してると長くなりすぎて収集がつかなくなる(というかもう既に収集付かない感じになっている)のでやめておきます。

まとめ

長くなってしまいましたが、Webフロント出身のUnityエンジニアが大規模ゲームの基盤設計を行ったらこんな感じになりましたという話でした。

今回Unityの基盤設計をやってみた感想はこんな感じです。

  • 設計を始めた際に課題に感じてたことは概ね解決できた気がする
  • 人によるコードのブレが減って、他の人が書いたコードもパッと見で何をやってるか分かるようになった
  • それに伴いプルリクに取られる時間が減った
  • 特に大きな問題なくリリースができた
  • リリース後も、全員が自分が作っていない箇所の改修や改善でも負荷なく行えている

この設計で、ボイきらのような大規模ゲームでも問題なく開発は行えました。

ただこの設計が正解というわけではないです。Unityの強みを活かしきれているのかも分かりません。あくまで設計の1つの案としてこういう考え方もあるんだなと思っていただけるとありがたいです。

個人的にWebフロントはフレームワークも成熟してきていて設計の宝庫だと思うので、今はもうWebから離れてはしまいましたが、今後もWebのいろいろなフレームワークから設計を学んだりしてUnityなどのフレームワークがまだ確立されていない領域に還元できたらいいなと考えています。

この記事が、自分と同様に「Unityって結構自由にコードを書けるからチームで何かしらの指針が欲しい」的なことを考えている方のお役に立てれば嬉しいです。MVPパターンとかはすぐに使ってみることができると思います。

ここまでお付き合いいただきありがとうございました。

おまけ(という名の宣伝)

『ボーイフレンド(仮)きらめき☆ノート』はクオリティも高く、楽しいサービスに仕上げられたと思っています。よろしかったら遊んでみていただけると嬉しいです。

https://youtu.be/WEvf9Y-5Gis

2013年にサイバーエージェントに新卒入社。QualiArtsにてUnityエンジニアとして複数のゲームの開発・運用に携わる。現在は、ゲーム・エンターテイメント事業部(SGE)のエンジニアボードとして、事業部全体のエンジニアの新卒採用と若手育成および組織作りも行っている。著書:『ステップアップUnity プロが教える現場の教科書』