【Unity】StateMachineを使った画面遷移モデルの一考察

StateMachineを使って画面遷移を管理するモデルを作ってみたのでメモです。

はじめに

GUIの設計でViewとModelを疎結合にしていると画面遷移もModelにしていく必要が出てきます。
そして画面遷移のModelにはどうやらStateMachineを使うといい感じに設計できるようです。

tech.mercari.com

というわけで本記事ではStateMachineを使った画面遷移のModelを作ってみました。
StateMachineは下記の記事で作ったものを使用しています。

light11.hatenadiary.com

また今回はこんな感じの画面遷移を想定して実装しています。

f:id:halya_11:20190104004539p:plain

ソースコード

早速ですが画面遷移を管理するExampleNavigationクラスのソースコードです。

using System.Collections.Generic;
using System.Collections;
using UnityEngine;
using FiniteStateMachine;

/// <summary>
/// 画面の状態管理をする
/// トリガーは外部から受け取る
/// 戻る機能などもこのクラスの責務
/// </summary>
public class ExampleNavigation : MonoBehaviour {

    public enum State
    {
        Start, // 初期状態
        Title, // タイトル画面
        Register, // 登録画面
        Download, // ダウンロード画面
        Home, // ホーム画面
        End, // 終了状態
    }

    public enum Trigger
    {
        Start, // 起動
        PageBack, // 戻る
        TapTitle, // タイトルをタップ
        TapTitleAndUnRegistered, // タイトル画面をタップ(ユーザ未登録)
        TapTitleAndExistDLC, // タイトル画面をタップ(ユーザ登録済み、DLCあり)
        Registered, // ユーザ登録完了
        Downloaded, // コンテンツダウンロード完了
        End, // 終了
    }

    [SerializeField]
    private bool _autoStart = false;

    private StateMachine<State, Trigger> _stateMachine;
    private List<State> _history = new List<State>();
    private bool _popped = false;
    private bool _didInitialize = false;
    
    /// <summary>
    /// 初期化する
    /// </summary>
    public void Initialize()
    {
        if (_didInitialize) {
            return;
        }
        // StateMachineを生成
        _stateMachine = new StateMachine<State, Trigger>(this, State.Start);

        // 遷移情報を登録
        _stateMachine.AddTransition(State.Start, State.Title, Trigger.Start);
        _stateMachine.AddTransition(State.Title, State.Home, Trigger.TapTitle);
        _stateMachine.AddTransition(State.Title, State.Register, Trigger.TapTitleAndUnRegistered);
        _stateMachine.AddTransition(State.Title, State.Download, Trigger.TapTitleAndExistDLC);
        _stateMachine.AddTransition(State.Register, State.Title, Trigger.PageBack);
        _stateMachine.AddTransition(State.Register, State.Download, Trigger.Registered);
        _stateMachine.AddTransition(State.Download, State.Home, Trigger.Downloaded);
        _stateMachine.AddTransition(State.Home, State.End, Trigger.End);

        // 振る舞いを初期化
        foreach (State state in System.Enum.GetValues(typeof(State))) {
            SetupState(state);
        }
        _didInitialize = true;
    }

    /// <summary>
    /// Stateのふるまいを設定する
    /// </summary>
    public void SetupState(State state, System.Action<bool> onEnter = null, System.Func<bool, IEnumerator> enterRoutine = null
        , System.Action<bool> onExit = null, System.Func<bool, IEnumerator> exitRoutine = null, System.Action<bool, float> onUpdate = null)
    {
        // On Enter
        System.Action onEnterArg = () => 
        {
            var popped = _popped;
            OnEnter(state);
            if (onEnter != null)
                onEnter(popped);
        };

        // Enter Routine
        System.Func<IEnumerator> enterRoutineArg = null;
        if (enterRoutine != null)
            enterRoutineArg = () => enterRoutine(_popped);

        // On Exit
        System.Action onExitArg = null;
        if (onExit != null) 
            onExitArg = () => onExit(_popped);
        
        // Exit Routine
        System.Func<IEnumerator> exitRoutineArg = null;
        if (exitRoutine != null)
            exitRoutineArg = () => exitRoutine(_popped);

        // Update
        System.Action<float> onUpdateArg = null;
        if (onUpdate != null) 
            onUpdateArg = deltaTime => onUpdate(_popped, deltaTime);

        _stateMachine.SetupState(state, onEnterArg, enterRoutineArg, onExitArg, exitRoutineArg, onUpdateArg);
    }
    
    /// <summary>
    /// トリガーを実行する
    /// 各Viewから呼ぶ(ボタンが押されたときなど)
    /// </summary>
    public bool ExecuteTrigger(Trigger trigger)
    {
        if (trigger == Trigger.PageBack) {
            // 戻るトリガーだった場合は戻る処理をする
            return Pop();
        }
        return _stateMachine.ExecuteTrigger(trigger);
    }

    /// <summary>
    /// ページを戻る
    /// </summary>
    private bool Pop()
    {
        if (_stateMachine.ExecuteTrigger(Trigger.PageBack) && _history.Count >= 1) {
            // 戻ったフラグを立てておく
            _popped = true;
            return true;
        }
        return false;
    }

    private void Start () 
    {
        if (_autoStart) {
            Initialize();
        }
    }

    /// <summary>
    /// Stateに入った時の処理
    /// </summary>
    private void OnEnter(State state)
    {
        if (_popped) {
            // 戻ってきた場合は履歴から削除する
            _history.RemoveAt(_history.Count - 1);
            _popped = false;
        }
        else if (IsBackable(state)) {
            // 履歴に積んで戻れるようにする
            _history.Add(state);
        }
        if (ClearHistoryWhenEnter(state)) {
            // 履歴をクリアする
            _history.Clear();
        }
    }
    
    /// <summary>
    /// 履歴に積んで戻れるようにするか
    /// </summary>
    private bool IsBackable(State state)
    {
        switch (state) {
        case State.Start:
        case State.Title:
        case State.Download:
        case State.Home:
        case State.End:
            return false;
        case State.Register:
            return true;
        default:
            throw new System.Exception("not implemented.");
        }
    }

    /// <summary>
    /// このStateに入った時に履歴をクリアするか
    /// </summary>
    private bool ClearHistoryWhenEnter(State state)
    {
        switch (state) {
        case State.Title:
        case State.Download:
        case State.Register:
            return false;
        case State.Start:
        case State.Home:
        case State.End:
            return true;
        default:
            throw new System.Exception("not implemented.");
        }
    }

    private void Update()
    {
        // ステートマシンを更新
        _stateMachine.Update(Time.deltaTime);
    }
}

ソースコード自体の説明はコメントを見ればわかると思います。

クラスの責務としては下記のとおりです。

  • 各画面の遷移関係を構築
  • 履歴を保持
  • トリガーを受け取って画面の状態遷移

複数の画面遷移クラスが必要になってくるような大きいアプリケーションでは、
履歴周りは基底クラスに切り出したほうがいいかもしれません。

この辺りの機能をもったStateMachineを作ることも考えたのですが、
StateMachineの概念は標準化されていたほうが理解しやすいし差し替えやすいのでやめました。

デモ

使い方としては、各画面のViewを生成・破棄する責務を持つクラス(SceneManager的な)に
前節の画面遷移クラスを保持させて、状態遷移が行われたらViewを差し替えるイメージです。

遷移のトリガーはView側から前節のクラスのExecuteTrigger()を呼んだらいいかと思います。

そんな感じの使い方を想定したうえで、下記は動作確認用のソースコードです。

using System.Collections;
using UnityEngine;

public class NavigationDemo : MonoBehaviour {

    [SerializeField]
    private ExampleNavigation _navigation;
    
    private void Start()
    {
        // 初期化
        _navigation.Initialize();

        // 各状態毎のふるまいを定義
        SetupState(ExampleNavigation.State.Title);
        SetupState(ExampleNavigation.State.Register);
        SetupState(ExampleNavigation.State.Download);
        SetupState(ExampleNavigation.State.Home);
    }

    private void SetupState(ExampleNavigation.State state)
    {
        // 画面遷移時の処理
        // モデルであるNavigationから通知を受けて
        // Viewを更新する処理を書く
        _navigation.SetupState
        (
            state,
            popped => Debug.Log("OnEnter : " + state + (popped ? " (pop)" : "")),
            popped => EnterRoutine(state, popped),
            popped => Debug.Log("OnExit : " + state + (popped ? " (pop)" : "")),
            popped => ExitRoutine(state, popped)
        );
    }
    
    private IEnumerator EnterRoutine(ExampleNavigation.State state, bool popped)
    {
        Debug.Log(state + " : ロード処理など" + (popped ? " (pop)" : ""));
        yield return new WaitForSeconds(1.0f);
        Debug.Log(state + " : ページに入るアニメーションなど" + (popped ? " (pop)" : ""));
        yield return new WaitForSeconds(1.0f);
    }

    private IEnumerator ExitRoutine(ExampleNavigation.State state, bool popped)
    {
        Debug.Log(state + " : ページがはけるアニメーションなど" + (popped ? " (pop)" : ""));
        yield return new WaitForSeconds(1.0f);
    }

    private void Update()
    {
        // デバッグ用にトリガーを呼ぶ
        // 本来は各画面からNavigation.ExecuteTrigger()を直接呼ぶ
        if (Input.GetKeyDown(KeyCode.A))
            _navigation.ExecuteTrigger(ExampleNavigation.Trigger.Start);
        if (Input.GetKeyDown(KeyCode.B))
            _navigation.ExecuteTrigger(ExampleNavigation.Trigger.PageBack);
        if (Input.GetKeyDown(KeyCode.C))
            _navigation.ExecuteTrigger(ExampleNavigation.Trigger.TapTitle);
        if (Input.GetKeyDown(KeyCode.D))
            _navigation.ExecuteTrigger(ExampleNavigation.Trigger.TapTitleAndUnRegistered);
        if (Input.GetKeyDown(KeyCode.E))
            _navigation.ExecuteTrigger(ExampleNavigation.Trigger.TapTitleAndExistDLC);
        if (Input.GetKeyDown(KeyCode.F))
            _navigation.ExecuteTrigger(ExampleNavigation.Trigger.Registered);
        if (Input.GetKeyDown(KeyCode.G))
            _navigation.ExecuteTrigger(ExampleNavigation.Trigger.Downloaded);
        if (Input.GetKeyDown(KeyCode.H))
            _navigation.ExecuteTrigger(ExampleNavigation.Trigger.End);
    }
}

説明はコメントに書いた通りです。

まとめ

とりあえずこれでシンプルな画面遷移は実装できるかなと思います。

キャラ一覧からキャラ詳細への遷移など、画面間のデータの受け渡しなどはどうしようかなーという感じですが、
普通にNavigationクラスにキャラ情報プロパティを持たせて遷移前にView側からキャラ情報をセット > 遷移後の画面で情報を取得、でいい気がしています。

関連

light11.hatenadiary.com