Unidux でマルチシーン画面遷移制御を行う【Unity Advent Calender 2021】

本記事は Unity Advent Calender 2021 その1 の 12日目の記事になります.
また本記事に関しては【年末だよ】Unity お・と・なのLT大会 2021に LT 発表を行っていますので,
そちらも参考にいただければと思います.

LT 資料

まず初めに LT 発表資料を掲載します.ダイジェクトに内容を知りたい方はスライドを見るとよいかもしません.

speakerdeck.com

Unity Learning Matarials

(2022.1.12 追記)
Unity Learning Materials に動画が掲載されたので載せておきます.

www.youtube.com

learning.unity3d.jp

はじめに

ゲームにはほぼ画面遷移があり各々オレオレ画面遷移制御を実装していると思われます.
加えてチーム開発になると単一シーン構成だとコンフリクトが発生しやすく,
マルチシーンでシーンごとに役割や担当を分けることもあります.

また,諸々の事情でスタック履歴があるとよかったりシーン遷移時に何らかのデータの
受け渡しの仕組みがあると嬉しいこともあります.

これらの要件を満たすマルチシーン×画面遷移機構の仕組みをフルスクラッチで作るのは大変です.

Unidux

そこで Unidux を用いてマルチシーンと画面遷移制御をいい感じに作ってみました.
Unidux は Flux Architecture for Unity となります.
Flux は Meta が開発したアーキテクチャで,React の Redux を知っているとピンとくるかもしれません.

github.com

マルチシーン構成の関連事例に CEDEC 2018 にてポケラボ様のシノアリスの講演があるので参考にするとよいでしょう.

cedil.cesa.or.jp

サンプルプロジェクト

リポジトリ

github.com

検証環境

  • Unity 2021.2.6f1
  • Unidux 0.4.2
  • UniRx 7.1.0
  • UniTask 2.2.5
  • UniDi 0.1.0-preview.1

(UniDi は Zenject をリファクタリングしたリポジトリで,実質 Zenject と同じです.)

github.com

Unidux の概念説明

Flux Architecture

facebook.github.io

画面遷移が多く,情報のリアルタイムアップデートが必要なサービスの場合,MVC,MVPといったアーキテクチャでは,
Model,Viewの数が膨大になったり,ModelやViewの間での双方向のやり取りがあるなどから,
システム構造が非常に複雑化してしまう問題があります.

この問題を解決するために Meta が提案したアーキテクチャが Flux アーキテクチャになります.
最大の特徴は Model と View の間の双方向のデータフローではなく,全て単方向のデータフローということです.

f:id:xrdnk:20211211154912p:plain

引用: 「Flux」アーキテクチャとは? MVCやMVPとの違いとテスタビリティー - キーマンズネット

Unidux Data-flow

f:id:xrdnk:20211211155105g:plain

Unidux のデータフローを説明します.

まずユーザがいて何らかのビューがあります.
このビューに対してユーザは何らかのインタラクションを行い,イベントを発火します.
ここを起点に Unidux のサービスを説明します.

受け取った Event を基に Action Creator は Action を生成します.
このアクションは抽象的ではありますが,何らかの状態を更新するアクションだと思ってください.

次に Action Creator が生成した Action を Dispatcher が受け取り,ディスパッチします.
ディスパッチは急送とか発送とかの意味を持ちます.
そして,どこへディスパッチするかという話になりますが,Store にある Reducer へ送られます.
Store は Reducer と State を持っており,Store は意味そのままで格納庫・ストアを意味しております.
Reducer はここでは意味が難しいですが,変更を加えるものだと思っていただければと思います.State は状態です.
つまり Store は状態と状態を変更するものが同居している感じです.
Reducer によって State が更新された時,ここで Watcher という状態を監視するものが,
更新された状態をビューに通知する役目を果たします.大方 Unidux の流れとしてはこのような感じになります.

Unidux 登場人物

Uniduxの機能について一つずつ説明していきます.

Page

f:id:xrdnk:20211211155423p:plain

Unidux では1画面のことをページと表現します.ページは1個以上のシーンファイルを持ちます.
たとえばタイトル画面・リザルト画面にはUIシーンとロジックシーンがあり,
メインゲーム画面にはその2つに加えてレベルデザインシーンがあったりします.

Scene Category

Unidux では Unity のシーンファイルに対して3種類のシーンカテゴリーで分類されています.

  • Permanent :永続的に存在するシーンです.どのシーンにも共通として使われるシステム部分などが該当します.
  • Page:加減算処理される画面として使われるシーンです.
  • Modal:モーダルはほぼページと変わらないです.あんまり使われていないのでここでは説明を割愛します.

Scene Config

「シーンファイルとカテゴリーの紐づけ」や「ページとシーンファイル群の紐づけ」の設定を行う必要があります.

using System.Collections.Generic;
using Unidux.SceneTransition;

namespace Deniverse.UniduxSTSample.Domain.Unidux
{
    /// <summary>
    /// シーン設定を行うクラス
    /// </summary>
    public class SceneConfig : ISceneConfig<SceneName, PageName>
    {
        /// <summary>
        /// カテゴリーマップの設定
        /// <remarks>ここにシーンファイルとカテゴリーの紐づけを行う</remarks>
        /// </summary>
        public IDictionary<SceneName, int> CategoryMap { get; } =
            new Dictionary<SceneName, int>
        {
            // Permanent Scene 設定
            {SceneName.EntryPoint, SceneCategory.Permanent},
            {SceneName.CommonSystem, SceneCategory.Permanent},
            {SceneName.UniduxService, SceneCategory.Permanent},

            // Title Page Scene 設定
            {SceneName.Title_UI, SceneCategory.Page},
            {SceneName.Title_Logic, SceneCategory.Page},

            // Main Page Scene 設定
            {SceneName.Main_UI, SceneCategory.Page},
            {SceneName.Main_Logic, SceneCategory.Page},
            {SceneName.Main_Level, SceneCategory.Page},

            // Result Page Scene 設定
            {SceneName.Result_UI, SceneCategory.Page},
            {SceneName.Result_Logic, SceneCategory.Page}
        };

        /// <summary>
        /// ページマップの設定
        /// <remarks>ページとシーンファイル群の紐づけを行う</remarks>
        /// </summary>
        public IDictionary<PageName, SceneName[]> PageMap { get; } =
            new Dictionary<PageName, SceneName[]>
        {
            // タイトルページのシーン設定
            {PageName.Title, new[]
            {
                SceneName.Title_UI,
                SceneName.Title_Logic,
            }},

            // メインゲームページのシーン設定
            {PageName.Main, new []
            {
                SceneName.Main_UI,
                SceneName.Main_Logic,
                SceneName.Main_Level,
            }},

            // リザルトページのシーン設定
            {PageName.Result, new []
            {
                SceneName.Result_UI,
                SceneName.Result_Logic
            }}
        };
    }
}

マルチシーン構成例

f:id:xrdnk:20211211160202p:plain

皆さんのマルチシーン構成はどのような感じでしょうか.私の場合を紹介します.

まず先にエントリーポイントのシーンがあります.
ここでは後で説明するパーマネントドメインシーンの順番やプロジェクト全体設定を行います.

その下に Domain Layer Scenes があり,これらは永続的に残るシーン群です.
主にゲームシステムやドメインロジックの塊のシーンです.
例えばリソース管理のロジック,マルチプレイ・ボイチャのロジックシーンなどなどがあります.
そして一番最後には Unidux Service シーンがあります.ここではページの順番や設定を行います.
タイトル画面・メインゲーム画面・リザルト画面といった感じですね.

Unidux Service シーンの下にページシーン群があって,これらのシーンはロード・アンロード処理が走ります.
一応,一番下には Don’t Destroy On Load Scene が存在します.

PageData

ページデータは画面に関するデータです.必ずしも作成する必要はありませんが,画面遷移時のデータ受け渡しに活用できます.
例として,メインゲーム画面からリザルト画面に遷移するときに
スコアデータや倒した数のデータを渡してリーダボードに結果を表示するなどに活用できます.

IPageData を実装します.

    /// <summary>
    /// リザルト画面用のページデータ
    /// </summary>
    [Serializable]
    public class ResultPageData : IPageData
    {
        /// <summary>
        /// ダメージ量
        /// </summary>
        public double DamageAmount { get; set; }

        ResultPageData() { }

        public ResultPageData(double damageAmount) =>
            DamageAmount = damageAmount > 0 ? damageAmount : 0;
    }

Action Creator

ステートを変更させるアクションを作る役です.
Unidux では Push,Pop,Replace,Reset,SetData,Adjust のアクションがデフォルトで提供されています.
勿論自分でカスタムなアクションを作ることはできます.

        public static class ActionCreator
        {
            public static PushAction Push(TPage page, IPageData data = null)
            {
                return new PushAction(page, data);
            }

            public static PopAction Pop()
            {
                return new PopAction();
            }

            public static ReplaceAction Replace(TPage page, IPageData data = null)
            {
                return new ReplaceAction(page, data);
            }

            public static ResetAction Reset()
            {
                return new ResetAction();
            }

            public static SetDataAction SetData(IPageData data)
            {
                return new SetDataAction(data);
            }

            public static AdjustAction Adjust()
            {
                return new AdjustAction();
            }
        }

Dispatcher

Dispatcher は Action Creator から受け取ったアクションを Reducer に渡す役です.
MVP や MVC では Presenter / Controller の役割に近い感じがあります.

Store

Store は State と Reducer を格納しており,状態管理とデータ処理を担っています.
MVP や MVC では Model の役割に近いです.

Reducer

Dispatcher から届いた Action を受け取ってステートを更新する役です.

State

状態を持つクラス群を纏めるドメインオブジェクトです.
Unidux Scene Transition ではシーンとページを状態を持つものとして管理しています.

    /// <summary>
    /// ステートエンティティ
    /// <para>状態を持つ項目を設定する</para>
    /// <para>今回はシーンとページを設定している</para>
    /// </summary>
    [Serializable]
    public class State : StateBase
    {
        public PageState<PageName> Page { get; set; } = new();
        public SceneState<SceneName> Scene { get; set; } = new();
    }

Watcher

ステートの変更を監視する役です.
ステートに変更が発生したのを感知した後,更新されたステートをビュー側に通知します.

PageWatcher の例です.監視には UniRx の Observer Pattern を利用しています.

    /// <summary>
    /// ページのステートを監視するクラス
    /// </summary>
    public sealed class PageWatcher : IInitializable, IDisposable
    {
        readonly ISceneConfig<SceneName, PageName> _config = new SceneConfig();
        CompositeDisposable _disposable;

        void IInitializable.Initialize()
        {
            _disposable = new CompositeDisposable();

            // 何らかのステートが変更された時,ページの更新処理を走らせる
            UniduxService.OnStateChangedAsObservable()
                .Where(state => state.Page.IsReady)
                .Subscribe(UpdatePage)
                .AddTo(_disposable);
        }

        void IDisposable.Dispose() => _disposable?.Dispose();

        /// <summary>
        /// 更新されたステートの情報を基にページ情報を更新する
        /// </summary>
        /// <param name="state">更新されたステート</param>
        void UpdatePage(State state)
        {
            // 現状のページステートが古い場合,調整アクションを実行する
            if (state.Scene.NeedsAdjust(_config.GetPageScenes(), _config.PageMap[state.Page.Current.Page]))
            {
                UniduxService.Dispatch(PageDuck<PageName, SceneName>.ActionCreator.Adjust());
            }
        }
    }

Unidux の主な使い方

新しい画面に遷移したい:Push Action

新しい画面に遷移したい場合ですが,起動時に最初にタイトル画面を表示する処理を例として挙げますと,
まずはタイトル画面へ遷移するプッシュアクションをアクションクリエータから生成した後,それをディスパッチします.

        void Awake()
        {
            // 起動時,最初にタイトル画面を表示する
            // タイトル画面へ遷移するプッシュアクションを生成
            var pushTitlePageAction = PageDuck<PageName, SceneName>.ActionCreator.Push(PageName.Title);
            // プッシュアクションのディスパッチ
            UniduxService.Dispatch(pushTitlePageAction);
        }

新しい画面にデータを渡しながら遷移したい:Push Action

新しい画面にデータを渡しながら遷移したい場合,プッシュアクションを作る時に第二引数にページデータを渡して,
先ほどと同様にプッシュアクションのディスパッチを行うことでできます.

            _buttonEnterResultPage
                .OnClickAsObservable()
                .Subscribe(_ =>
                {
                    // 現状のメインページデータの状態を取得する
                    var mainPageData = UniduxService.State.Page.GetData<MainPageData>();
                    // 取得したデータからダメージ量のデータをリザルトページデータに渡す
                    var resultPageData = new ResultPageData(mainPageData.DamageAmount);
                    // 初期リザルトページデータを持ちつつ,リザルト画面へ遷移するプッシュアクションを生成
                    var pushResultPageAction = PageDuck<PageName, SceneName>.ActionCreator.Push(PageName.Result, resultPageData);
                    // プッシュアクションのディスパッチ
                    UniduxService.Dispatch(pushResultPageAction);
                })
                .AddTo(this);

前の画面に遷移したい:Pop Action

前の画面に遷移したい場合は,アクションクリエータからポップアクションを生成して,ディスパッチを行います.

            _buttonReturnTitlePage
                .OnClickAsObservable()
                .Subscribe(_ =>
                {
                    // 前の画面(タイトル画面)に遷移するためにポップアクションの生成
                    var popTitlePageAction = PageDuck<PageName, SceneName>.ActionCreator.Pop();
                    // ポップアクションのディスパッチ
                    UniduxService.Dispatch(popTitlePageAction);
                })
                .AddTo(this);

これまでの遷移履歴をリセットして最初の画面に遷移したい:Reset Action + Push Action

リザルト画面からタイトル画面へ遷移するような感じで,これまでの遷移履歴をリセットして最初の画面に遷移したい場合は,
まず最初にアクションクリエータからリセットアクションを生成してディスパッチした後に,
タイトル画面へ遷移するプッシュアクションを生成してディスパッチすればよいです.

            _buttonGoToTitle
                .OnClickAsObservable()
                .Subscribe(_ =>
                {
                    // これまでのスタック履歴をリセット(クリア)する
                    var resetAction = PageDuck<PageName, SceneName>.ActionCreator.Reset();
                    // リセットアクションのディスパッチ
                    UniduxService.Dispatch(resetAction);
                    // タイトル画面へプッシュするアクションを作る
                    var pushTitlePageAction = PageDuck<PageName, SceneName>.ActionCreator.Push(PageName.Title);
                    // プッシュアクションのディスパッチ
                    UniduxService.Dispatch(pushTitlePageAction);
                })
                .AddTo(this);

ページデータの取得をしたい:GetData

ページデータの取得を行いたい場合は,取得を行うだけなので,
状態を更新するアクションを生成するのではなく,現状のステートからゲットデータを行う処理を行えば取得できます.

        void IInitializable.Initialize()
        {
            // Uniduxから現状のページデータであるMainGameDataを取得する
            var mainPageData = UniduxService.State.Page.GetData<MainPageData>() ?? new MainPageData();
            _godHp = mainPageData.GodHp;
            _damageDone = mainPageData.DamageAmount;
            _mainPageData = mainPageData;
            _godHpRp = new DoubleReactiveProperty(_godHp);
        }

ページデータの更新をしたい:SetData Action

ページデータの更新を行いたい場合は,アクションクリエータからデータ更新のアクションを生成してディスパッチをします.

            // メインゲームページデータ の更新処理
            _mainPageData.GodHp = _godHp;
            _mainPageData.DamageAmount = _damageDone;

            // ページデータの更新
            var setDataAction = PageDuck<PageName, SceneName>.ActionCreator.SetData(_mainPageData);
            // データ設定アクションでディスパッチ
            UniduxService.Dispatch(setDataAction);

異なるシーンに存在するオブジェクトを参照したい:DI コンテナを利用する

マルチシーンの問題点として異なるシーンに存在するオブジェクトをどのように参照するか考える必要があります.
ここでは,Zenject を利用して依存関係の解決を行います.

Zenject は Scene Context の親子関係の設定と Multi-Parenting の設定が Inspector で比較的容易に出来ます.
ここで注意なのはシーンの読込順番と Scene Context 親子関係は連動します.

f:id:xrdnk:20211211161413p:plain

最初に見せたマルチシーン構成を例にとりますと,
これらは Scene Context の親子関係を考慮した上でこのようなシーン読込順番になっています.
たとえばロジックページシーンはUIページシーンにあるオブジェクトを参照するために,
UIページシーンの子になる必要があったりします.

親は1つとは限らず,メインページ画面のロジックページシーンのように,
マルチプレイサービスシーンやリソースサービスシーンを親として複数持つことも考えられます.

終わりに

マルチシーンにおける画面遷移を紹介しましたが,単一シーンの画面遷移も軽く紹介します.
前回の記事で Unity Screen Navigator (USN) を取り上げています.

xrdnk.hateblo.jp

個人的には Unidux と USN は親和性があると考えています.
Unidux をシーンファイルをスコープとした遷移制御をするならば,
USN はプレハブをスコープとして遷移制御ができます.
これらを組み合わせることで快適な画面遷移制御ができるのはないでしょうか.

f:id:xrdnk:20211211161557p:plain

一応,本記事では Unidux をマルチシーン画面遷移制御を行うために利用していますが,
勿論 State Machine の用途として利用することもできます.

qiita.com

参考資料

baba-s.hatenablog.com

qiita.com

tech.mobilefactory.jp

github.com

cedil.cesa.or.jp

facebook.github.io

「Flux」アーキテクチャとは? MVCやMVPとの違いとテスタビリティー - キーマンズネット