💨

Unity用の激薄ステートマシンライブラリを作りました

2024/11/01に公開

はじめに

Unity用のステートマシンライブラリを作りました。名前はPureFsmです。

PureFsm

https://github.com/qemel/PureFsm

PureFsmはUnity用のステートマシンライブラリです。

特徴

  • PureC#で実装できる
  • UniTask必須
    • その分EnterとかExitとかをawaitでき、内部に非同期処理を書きやすい
  • DIフレンドリーで、ステートマシンや各ステートを簡単にDIできる
    • VContainerの使用を推奨します
  • 激薄
    • 200行くらいのコード

使い方

上記リンクのREADMEに書いているので、コード例だけ載せます。

ステートマシンの作成

using PureFsm;

public class SampleFsm : Fsm<SampleFsm>
{
    public SampleFsm(IEnumerable<IState<SampleFsm>> states) : base(states)
    {
        AddTransition<IdleState, WalkState>((int)Transition.Walk);
        AddTransition<WalkState, IdleState>((int)Transition.Idle);
        
        _ = Run<IdleState>();
    }
}

public enum Transition
{
    Idle,
    Walk,
}

ステートの作成

public class IdleState : IState<SampleFsm>
{
    readonly Foo _foo; // 各ステートにDIできる

    public IdleState(Foo foo)
    {
        _foo = foo;
    }

    public async UniTask<int> EnterAsync(CancellationToken token)
    {
        await UniTask.Delay(1000, cancellationToken: token);
        await _foo.DoSomethingAsync();
        return (int)Transition.Walk;
    }
    
    public async UniTask ExitAsync(CancellationToken token)
    {
        Debug.Log("Exit IdleState");        
    }
}

public class WalkState : IState<SampleFsm>
{
    public async UniTask<int> EnterAsync(CancellationToken token)
    {
        Debug.Log("Enter WalkState");

        while (!token.IsCancellationRequested) // Update()の代わり
        {
            await UniTask.Yield();

            if (Input.GetKeyDown(KeyCode.Space))
            {
                return (int)Transition.Idle;
            }
        }
    }
    
    public async UniTask ExitAsync(CancellationToken token)
    {
        await UniTask.Delay(1000, cancellationToken: token);
        Debug.Log("Exit WalkState");
    }
}

Update()に相当するものはないので、EnterAsync()の中でwhileループを使う感じになります。

DI(VContainer)

using PureFsm;
using VContainer;

public class GameLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        builder.Register<SampleFsm>(Lifetime.Singleton);
        builder.Register<IdleState>(Lifetime.Singleton).As<IState<SampleFsm>>();
        builder.Register<WalkState>(Lifetime.Singleton).As<IState<SampleFsm>>();

        builder.Register<Foo>(Lifetime.Singleton);
    }
}

作った動機・気を付けたこと

あんまり大袈裟なステートマシンを使う気が無かった

Unityのステートマシンライブラリはいくつかあるのですが、あんまり大袈裟なものを使う気が無かったので、最小限のもので作ってみました。

クラスが入れ子になるタイプのものをあまり使いたくなかった

ステートマシンの中にクラスを定義してステートを表現するタイプのものは、ファイルを分けづらい他、ステートマシン自体の依存クラスが多くなりがちだったので、そこを分けたいという気持ちがありました。

できるだけpublicな部分を少なくしたかった

個人的に、ステートマシンはほとんどエントリポイントとして機能して、外部から触れる部分はなるべく少なくするべきと思っていたので、そのようにしてみました。

現在のステートも公開していないため、基本的にはステート内部でやりたい処理は全部書いておこうというスタンスです。

DIしやすいものが欲しかった

コンストラクタで依存性が解決できると非常に便利なので、そういう方針で作りました。

最初はステート遷移に相当するものまでコンストラクタ注入しようと思っていたのですが、そこそこ登録が面倒になりそうでやめました。

overrideを使いたくなかった

ほんとうに個人の好みですが、public override void Enter()の記述量があまり好きではなく、出来ればIStateを実装するだけで済ませたかったので、そうしました。

できるだけ記述量を減らしたかった

最初は状態の遷移をenum型で管理して厳密にいこうと思っていたのですが、ジェネリックの見た目がきつめになったので、記述量を減らすべくint型で管理することにしました。

とはいえ普通にenum型をつくり、それを適宜intにキャストすればいいだけなのでそこまで使用感に問題は無いと捉えています。

遷移自体をちゃんとした型として扱うことに対してそこまでのメリットを感じなかったのも、このような作り方にした理由の一つです。

ただ、ステートマシン自体をloggerに出すときなどはint型のままだと分かりづらいなーという悩みが少々あります。

おわりに

バグ報告などあれば是非お願いします...!

Discussion