【Unity】asyncを使ったシンプルなステートマシン

ステートマシンの実装は色々ありますが、asyncを用いたシンプルなものを紹介します。
と言いつつも実際はasyncを使う必要もなくて、単純にステート遷移時に実行する関数をasyncにしてるだけです。
ただ、世に出回っているステートマシンがOnEnter,OnUpdate,OnExitみたいな実装が多いので、asyncを使うならOnEnterだけで十分だろうという思想です。

使い方

記事の下の方にステートマシンの実装は載せるので、先に使用例を。

private enum GameState
{
    Morning,
    Night,
    Result
}

private QStateMachine<GameState> stateMachine = null;

private void Awake()
{
    // StateMachineを作成
    this.stateMachine = new QStateMachine<GameState>().AddTo(this);
    
    // 各State遷移時に実行する関数を登録
    this.stateMachine.SetStateAction(GameState.Morning, MorningAsync);
    this.stateMachine.SetStateAction(GameState.Night, NightAsync);
    this.stateMachine.SetStateAction(GameState.Result, ResultAsync);

    // Stateの遷移を登録(ここで登録した遷移のみ出来る。この制約がある方が便利な場合が多い)
    this.stateMachine.SetTransition(GameState.Morning, GameState.Night);
    this.stateMachine.SetTransition(GameState.Morning, GameState.Result);
    this.stateMachine.SetTransition(GameState.Night, GameState.Morning);
    this.stateMachine.SetTransition(GameState.Night, GameState.Result);

    // StateMachineを開始
    this.stateMachine.Start(GameState.Morning);

    // Resultステートへの遷移チェック
    ResultCheck();
}

// ステートマシンとは別の場所でリザルトに遷移するチェックを回せる
private async void ResultCheck()
{
    // 30秒後にResultステートに遷移
    await UniTask.WaitForSeconds(30);
}

// Morningステート遷移時に実行される
private async void MorningAsync(CancellationToken ct)
{
    try{
        // 10秒後にNightステートに移行
        await UniTask.WaitForSeconds(10, cancellationToken: ct);
        TrySetState(GameState.Night);
    }
    catch (OperationCanceledException)
    {
         // この関数実行中に外部によってステート遷移した場合の処理
    }
}

// Nightステート遷移時に実行される
private async void NightAsync(CancellationToken ct)
{
    // 5秒後にMorningステートに移行
    await UniTask.WaitForSeconds(5, cancellationToken: ct);
    TrySetState(GameState.Morning);
}

// Resultステート遷移時に実行される
private void ResultAsync(CancellationToken ct)
{
    // asyncじゃなくてもok
    Debug.Log("Result");
}

IDisposableの実装になっているので、AddToやDisposeをしてもらえると。
また、SetTransitionには任意のconditionを引数に与えられ、遷移に条件を付与できます。
TrySetStateになっているのは、ステート遷移が登録されていなかったりconditionを満たさない場合にfalseとするためです。

ソースコード

R3を使っていますが、必要に応じてUniRxや通常のTask等にしてもらえれば。
(そんなに変える部分は無いと思います。)

using System;
using System.Collections.Generic;
using System.Threading;
using R3;

namespace QuestBase
{
    public class QStateMachine<T> : IDisposable where T : Enum
    {
        private Dictionary<T, List<(T state, Func<bool> condition)>> transitions = new Dictionary<T, List<(T, Func<bool>)>>();
        private Dictionary<T, Action<CancellationToken>> stateActions = new Dictionary<T, Action<CancellationToken>>();
        private T currentState;
        private CancellationTokenSource stateCts;
        private Subject<T> onStateChanged = new Subject<T>();

        public T CurrentState => this.currentState;
        public Observable<T> OnStateChanged => this.onStateChanged;

        public void Dispose()
        {
            this.onStateChanged.Dispose();
            AsyncUtil.CancelAndDisposeCTS(ref stateCts);
        }

        public void Start(T startState)
        {
            SetState(startState);
        }

        public void SetTransition(T from, T to, Func<bool> condition = null)
        {
            if (this.transitions.TryGetValue(from, out var list))
            {
                list.Add((to, condition));
            }
            else
            {
                this.transitions[from] = new List<(T, Func<bool>)> { (to, condition) };
            }
        }

        public void SetStateAction(T state, Action<CancellationToken> action)
        {
            this.stateActions[state] = action;
        }

        public bool TrySetState(T state)
        {
            if (this.transitions.TryGetValue(this.currentState, out var stateList))
            {
                foreach (var toState in stateList)
                {
                    if (toState.state.Equals(state) && (toState.condition?.Invoke() ?? true))
                    {
                        SetState(state);
                        return true;
                    }
                }
            }
            return false;
        }

        private void SetState(T state)
        {
            this.currentState = state;
            if (this.stateActions.TryGetValue(this.currentState, out var func))
            {
                AsyncUtil.CancelAndCreateCTS(ref stateCts);
                func.Invoke(this.stateCts.Token);
            }
            else
            {
                AsyncUtil.CancelAndDisposeCTS(ref stateCts);
            }
            this.onStateChanged.OnNext(state);
        }
    }
}