土屋つかさの技術ブログは今か無しか

土屋つかさが主にプログラミングについて語るブログです。

#unity ツカサ式スタック型ステートマシンの紹介(ゲーム向きのステートマシン実装)

 現在、テキストウィンドウ描画ライブラリを作ってまして*1、その過程でスタック型のステートマシンを実装しました。このステートマシンは土屋が頻繁に使うロジックで、仲間内では「ツカサ式(つかさしき)」と呼んでいます。今回はこのツカサ式ステートマシンを紹介します。

 ステートマシン自体はごく短いコードなので、興味があればコピペして使ってください。なお、今回はミニマムな実装にしたかったので、必須ロジックのみに絞っています。その為、実用面では幾つか機能が足りていないかもです(これについては後述)。
 GitHubに下記サンプルコード込みで上げておきました。
github.com

 ちなみに、「ツカサ式」は単なる呼称です。昔、このスタイルのステートマシンを使っていたのが、仲間内で土屋(=土屋"つかさ")だけだったので「ツカサ式」と呼んでいた事に由来します。分類としては「スタック型ステートマシン」あるいは「プッシュダウンオートマトン」と呼ばれる物です*2

ソースコード

 今回組んだ実装は以下の通りです。

//T2sFSM
//Copyright (c) 2022 tsukasa TUTIYA
//This software is released under the MIT License.
//http://opensource.org/licenses/mit-license.php

using System.Collections.Generic;

namespace info.someiyoshino.Tsukasa
{
    //ツカサ式スタック型ステートマシン(Tsukasa system of Stack-Based FSM)
    public class T2sFSM<TContext>
    {
        public interface IState
        {
            public bool Update(TContext context);
        }

        private readonly Stack<IState> StateStack = new();

        public void Update(TContext context)
        {
            while (StateStack.Count > 0)
            {
                if (StateStack.Pop().Update(context)) return;
            }
        }

        public void PushState(IState state)
        {
            StateStack.Push(state);
        }
    }
}

 T2sFSM<TContext>クラスがステートマシン、T2sFSM<TContext>.IStateインターフェイスがステートマシンによって実行されるステートになります。

 T2sFSM<TContext>.PushState()メソッドではステート(IState内部インターフェイスを継承したクラスのインスタンス)を受け取り、内部で保持しているスタック(StateStack)にPushします。

 T2sFSM<TContext>.Update()メソッドでは、スタックの先頭からステートをPopし、IState.Update()メソッドを実行します。戻り値がtrueならスタックの巡回を終了してメソッドを終了し、falseならスタック上の次のステートを評価します。

 「たったこれだけでステートマシンとして機能するの?」と思うかもですが、(多くの処理を外部に投げているので)上手く動いてくれます。

使い方とサンプルコード

 Unity上で動作確認するサンプルコードを組んでみました。

using UnityEngine;
using info.someiyoshino.Tsukasa;

public class Sample : MonoBehaviour
{
    private readonly T2sFSM<Sample> StateMachine = new();

    void Start()
    {
        StateMachine.PushState(new Wait());
    }

    void Update()
    {
        StateMachine.Update(this);
    }

    public class Wait : T2sFSM<Sample>.IState
    {
        public float ElapsedTime = 0.0f;

        public bool Update(Sample context)
        {
            ElapsedTime += Time.deltaTime;

            if (ElapsedTime >= 2.0f)
            {
                context.StateMachine.PushState(new Wait());
                context.StateMachine.PushState(new Output("Output:2"));
                context.StateMachine.PushState(new Output("Output:1"));
                return false;
            }
            context.StateMachine.PushState(this);
            return true;
        }
    }

    public class Output : T2sFSM<Sample>.IState
    {
        string Text;
        public Output(string text)
        {
            Text = text;
        }

        public bool Update(Sample context)
        {
            Debug.Log(Text);
            return false;
        }
    }
}

 適当なオブジェクトにアタッチさせて実行すると、「コンソールに"Output:1"と出力」→「同"Output:2"と出力」→「2秒間停止」を無限に繰り返します。実装ではWaitとOutputの2種類のステートを、「Output→Output→Wait」と実行されるようにスタックに3個積んで実行します。そしてWaitステートの終了時に再度スタックに3個のステートを積む事で、無限ループにしています。

 通常のステートマシン(FSM)で同じ挙動をさせる場合、Outputステートが「自分は1回目と2回目のどっちのOutputなのか」を状態として持つ必要があります*3が、ツカサ式(スタック型FSM)ではその役割をスタックが担います。

 サンプルコードを動作順に見て行きましょう。

    private readonly T2sFSM<Sample> StateMachine = new();
  //private readonly T2sFSM<Sample> StateMachine = new T2sFSM<Sample>();

 T2sFSM<TContext>を初期化しておきます。ステートマシンを使用するクラスをジェネリクスで指定します。"new()"はC#9.0からの省略記法です。旧verのC#の場合は下行のコメントのように書く必要があります。ちょっと長くて冗長になってしまいますね。C#9.0素晴らしい。

    void Start()
    {
        StateMachine.PushState(new Wait());
    }

 Start()タイミングで、最初のステートWaitをスタックにpushします。プッシュ型ステートマシンでは、いわゆる状態遷移シーケンスは用意せず、スタックにステートをpushして遷移先のステートを指定します。

    void Update()
    {
        StateMachine.Update(this);
    }

 Update()タイミングで、ステートマシンを実行します。thisを渡すことで、各ステートが呼び出し元のクラス(ここではSample)にアクセスできるようにしています。

Waitステート

 次はpushされたwaitステートを見ていきます。

    public class Wait : T2sFSM<Sample>.IState
    {
        public float ElapsedTime = 0.0f;

 IStateインターフェイスを継承したクラスがステートになります。実行するクラスの内部クラスとして実装する必要があります。

        public bool Update(Sample context)
        {
            ElapsedTime += Time.deltaTime;

 毎フレーム実行されるUpdate()では、Time.deltaTimeを加算して経過時刻を保持します。

            if (ElapsedTime >= 2.0f)
            {
                context.StateMachine.PushState(new Wait());
                context.StateMachine.PushState(new Output("Output:2"));
                context.StateMachine.PushState(new Output("Output:1"));
                return false;
            }

 指定秒数(ここでは2.0秒)を過ぎているならステートを新たに3個スタックします。PushされたステートはLIFOで実行されるので、ここでは「Output」「Output」「Wait」の順に実行する想定です。falseを返し、スタック内の次のステートを実行するようにステートマシンに通知します。

 IState継承クラスは自身を実行しているT2sFSMを直接参照する方法を持たないので、Sampleを経由してPushStateしています。StateMachineはprivateメンバですが、WaitはSampleの内部クラスなのでアクセスが許されています。

            context.StateMachine.PushState(this);
            return true;
        }
    }

 指定期間内であれば自分自身をスタックし、次のループで引き続きWaitステートを実行します。trueを返し、ステートマシンに現フレームでの動作終了を通知します。

 ちなみに、PopしたステートをPushし直すのがちょっと間抜けにも感じますが、こういうロジックになるのはウェイト処理だけなので、許容範囲かな……?

Outputステート

 最後はOutputステートです。

    public class Output : T2sFSM<Sample>.IState
    {
        string Text;
        public Output(string text)
        {
            Text = text;
        }
        public bool Update(Sample context)
        {
            Debug.Log(Text);
            return false;
        }
    }

 生成時に文字列を保存し、Update()ではその文字列をログに出力しています。falseを返し、次のステートを実行するように通知しています。

 余談ですが、コンストラクタで各ステートの初期値を設定できるのが何気に便利です(状態遷移シーケンスを構築するタイプでは難しいと思う)。毎回ステートをnewしなければならないのでトレードオフではありますが。あとステートが状態を持っていいのかというツッコミはある。

余談1:クラス名について

 "T2sFSM"というクラス名は"Tsuchiya Tsukasa Stack-based Finite State Machine"の頭文字を並べています。

 名前はいつも悩む所で、通常の有限ステートマシン("StateMachne"とか)と区別出来るようにしたいのですが、"StackStateMachne"だとやたらと長くなってしまいます。今回は実装をミニマムにしたので、名前も出来るだけ短くしてみました。 

余談2:改修案

 今回、できるだけミニマムな実装を目指しましたが、実務で使う場合は機能が足りないかもしれません。特にnull判定と例外処理はちゃんとやった方が良いです。GitHub版の方は更新していくつもりです。

 個人的には、他にも気になっている、かつ、どれがベストなのか判断出来ていない課題が幾つかあります。

1:Contextを経由しないPushState()が必要かどうか

 現在の実装では、IState派生クラスから自身を実行しているステートマシンを直接参照できないため、Contextを経由しないとPushState()が出来ません。

 これに対応するには、IState.Update()の引数にステートマシンへの参照を足すか、IStateにメンバ変数を持ってステートマシンへの参照を足す必要があります(後者の場合はIStateはインターフェイスではなくなる)

2:Contextへの参照をステートマシンが保持すべきかどうか

 現在の実装では、Contextクラスへの参照をこれはT2sFSM.Update()の引数で受け取っていますが、コンストラクタで受け取ってメンバに保持した方が良いでしょうか?

 毎フレームthisを渡すのが間抜けに思えるのですが、Contextという"状態"を保持していいのかという疑問もあって良く分かりません(今回はよりミニマムな実装を選びました)。

3:IStateインターフェイスはT2sFSMクラスの外に実装するべきか?

 ステートクラスを実装する時に継承元として": T2sFSM.IState"としなければならないのが、文字数が多すぎる気がして気になっています。外に出せば"IState"と書けるのですが、なんか(『なんか』ってなんだよ)バラバラになった気がしてしっくりきません。

4:無限ループに陥る欠点は対応可能か?

 ステートの積み方を間違えると簡単に無限ループを構築してしまい、その対応が面倒だと感じています。ただ、これって原理的な対応って可能なのかなあ……?

 今書いているコードでは(今の所)これらは問題になってないのでこのままで行くつもりです。途中で問題に直面したらしれっと改修していると思います。

おわり

 当初はスタック型ステートマシン(と、ツカサ式)のメリット/デメリットについても検証するつもりだったんですが、既に結構な長さになってしまったので今回はここまで。いつか続きを書きたいと思います。

参考リンク

JRPGの作り方:ゲーム開発者向けの手引き(2013)

gamedevelopment.tutsplus.com
 スタック型ステートマシンがゲームアーキテクチャと相性が良いという話がされています。著者のDan Schuller氏は様々なゲーム会社を経て、現在はUnity社のエンジニアをされているようです。

プッシュダウンステートマシンのサンプル実装(階層構造を扱える状態遷移機械の話)(2020)

someiyoshino.info
 2年以上前に書いた自分の記事です。当時の自分は階層型FSMとスタック型FSMを混同していたようです(すみません)。当時はC#にそこまで詳しく無いのもあって、コードも分かりにくいです。今回の記事をアップデート版とさせてください。

*1:現在というか、常時作っているというか……

*2:「スタック型ステートマシン」も「プッシュダウンオートマトン」も文字にするのも言葉にするのも長くなってしまうので、「ツカサ式」で伝えらえると楽なのです

*3:「あります」と書いたけど、本来ステート自身が状態を持つ事はNGな気もする(そんな純粋なステートマシン書いた事は無いけれども)