⏳

【Unity/C#】HybridでFluentなステートマシンを作った

に公開

はじめに

今回作ったものが以下になります。

Hybrid State Machine

https://github.com/FukaMiya/HybridStateMachine
HybridStateMachineはUnity向けステートマシンライブラリです。Unityを念頭に作りましたが、ピュアC#のためそれに限りません。ライセンスはMITライセンスです。

特徴

ハイブリッド駆動

Update()による条件監視と、Fire()によるイベント通知を1つのステートマシン内で扱うことができます。イベントは事前定義したEnumを使って型安全に管理可能です。

IPushAndPullStateMachine pushAndPullStateMachine = StateMachine.Create<TEvent>();

ステートマシンはファクトリメソッドから生成します。
仮にイベント駆動のPush型を使わない場合、

IPullStateMachine pullStateMachine = StateMachine.Create();

のようにTEventを省略することができます。

Fluent Interface

Fluentは「流暢な」という意味で、以下のようなメソッドチェーンを提供します。

stateMachine.At<IdleState>
    .To<JumpState>()
    .On(Event.Jump)
    .When(() => IsGrounded)
    .Build();

このように自然言語に近い形で遷移ロジックを記述可能です。また、AnyStateによる任意状態遷移や、一つ前のステートに戻ることも可能です。To<TState>()は同様にTo(State)にも対応しています。

複雑な条件を定義可能

stateMachine.At<StateA>
    .To<StateB>()
    .On(Event.SomethingHappened)
    .When(
        Condition.Any(
            () => condition1,
            () => condition2,
            () => condition3
        ))
    .Or(
        Condition.All(
            () => A == B,
            () => C > D
        ))
    .Build();

ConditionヘルパーやAnd(), Or()により複数の条件をまとめて設定することができます。
同一遷移についての条件を分けて書くこともできます。その場合、SetWeight(float)で重みを登録することができ、さらに等しい重み間では先に登録した条件が優先されます。

DI・Factory対応

依存関係を持つステートの生成を制御でき、VContainerなどのDIコンテナや手動DIとスムーズに連携できます。

var factory = new StateFactory();
factory.Register<InGameState>(() => new InGameState(player)); 
var stateMachine = StateMachine.Create(factory);

ステートマシン生成時に任意のファクトリメソッドを登録しておくことで、依存性を自由に注入可能です。もちろん、引数を取らないステートは省略可能です。
また、ステート遷移時に動的にコンテキストを共有することもできます(あまりお勧めはしませんが)

inGame.To<ResultState, int>(() => Score)
    .When(() => IsGameOver)
    .Build();

Mermaid可視化

構築したステートマシンをMermaid記法で出力し、遷移図として可視化できます。

stateDiagram-v2
    TitleState --> InGameState
    InGameState --> PauseState
    InGameState --> AttackState
    InGameState --> ResultState
    PauseState --> PreviousState
    AttackState --> InGameState
    ResultState --> TitleState

が出力され、以下のようにMermaidエディタで確認できます。

使い方

具体的な使い方は以下のコードを参照してください。
https://github.com/FukaMiya/HybridStateMachine/blob/main/Assets/GameEntryPoint.cs

考えてたこと

今回作る上で考えたこと、気をつけたことをまとめようと思います。

実装動機

今まで各プロジェクトごとに都度実装していたステートマシンを、使い回しの効く形でパッケージ化したいと思い作り始めました。
よくあるパターンの実装では、enumやクラスで状態を定義し、以下のいずれかで制御しがちです。

  1. Update内やif,switchを羅列して遷移
  2. 各ステート内でChangeState()を呼び出して遷移

1は小規模なら問題ありませんが、可読性の低下やボイラープレートの増加が気になります。2に関しては、ステート間の結合度が上がり、「どこからどこへ遷移するのか」という全体像(遷移図)がコードから読み取りにくくなるという問題があります。

上記を解決するためによく見かけるのが、ステートマシンクラスを用意した以下のようなイベント駆動(Push型)の実装です。

// イベントを受け取ったら遷移する(単純なPush型)
stateMachine.AddTransition<IdleState, WalkState>(GameEvent.StartWalk);

とてもスッキリして見えますし、多くの場合ではこれで事足りると思います。しかし、(「イベント」をどう定義するかに寄りますが)多くの場合「イベントが来た」だけで遷移することは稀で、「このイベントが来て、かつXXな状況なら」という条件判定が必要となります。単純なPush型ではこの条件判定をイベントを発火する側に書かざるを得ず、「イベント」がハイコンテキストなものとなってしまいます。結果として、「遷移条件 = イベント + ガード条件」という本来セットであるはずのロジックが分散し、ステートマシンの定義を見ただけでは「本当の遷移条件」が分からなくなってしまいます。

そこで実際に「イベント」をどのように定義するかは置いておいて、記法としては「イベント通知(Push型)」と「条件監視(Pull型)」を統合し、遷移条件の全てをステートマシンの定義内で完結できるようにしようと考えました。
ステートマシンを見れば遷移の全容を確認できる、という状態を目指したというわけです。

Fluent Interfaceについて

Push型とPull型を統合しようと考えると、問題なのはPull型です。例えば前述の例を拡張します。

// 遷移イベントとガード条件の両方を引数に取る
stateMachine.AddTransition<IdleState, WalkState>(
    GameEvent.StartWalk,
    () => IsGrounded
);

一見これでも問題なさそうです。ですが、「イベント通知ではなく条件監視のみで遷移したい」「イベント通知だけで、ガード条件いらない」といった際に柔軟性に欠けます。
さらに、遷移の重みなどのオプション、追加の条件などさらに遷移条件を細かく設定しようと思うとオーバーロードを大量に用意する必要性や可読性の低下を招きます。

そこで、BuilderパターンとFluent Interfaceを導入しました。
Fluent Interfaceはこのステートマシンの実装中に知った言葉ですが、いわば「自然言語っぽいメソッドチェーンを提供するインターフェース」です。単なるメソッドチェーンよりも可読性を意識した命名ということですね。この手法は今回のようなBuilderパターンと相性がよく、気に入っています。単にAddCondition()のように書いてもいいですが、書きごこちはこちらの方がいいと思いませんか?
また、遷移条件を記述するTransitionBuilder実装時に意識したこととして、TypeStateパターンがあります。オブジェクトの状態を型レベルで制御する手法です。これもBuilderパターンと相性がよく、パラメータの設定順序などの制御に有効です。

TypeStateパターンについてざっくり

例えば、ごはんの注文を組み立てるビルダーを考えます。

  • 各種1つまで
  • パンやご飯などの主食は絶対に含む
  • サラダ、デザート、飲み物は任意
  • サラダを含まないとデザートは頼めない
// 注文を完了できる型状態
public interface IOrderable
{
    void Order();
}

// 主食を選ぶ型状態(注文を完了できない)
public interface IMainDishSelector
{
    IBaseSelector AddRice();
    IBaseSelector AddBread();
}

// サラダか飲み物を選ぶ型状態(注文を完了できる)
public interface IBaseSelector : IOrderable
{
    IWithDrinkSelector AddDrink();
    IWithSaladSelector AddSalad();
}

// 飲み物あり・サラダなしの型状態(注文を完了できる)
public interface IWithDrinkSelector : IOrderable
{
    IWithSaladAndDrinkSelector AddSalad();
}

// 飲み物なし・サラダありの型状態(注文を完了できる)
public interface IWithSaladSelector : IOrderable
{
    IWithSaladAndDrinkSelector AddDrink();
    IWithDessertSelector AddDessert();
}

// 飲み物あり・サラダあり・デザートなしの型状態(注文を完了できる)
public interface IWithSaladAndDrinkSelector : IOrderable
{
    IFullCourseSelector AddDessert();
}

// デザートあり・飲み物なしの型状態(注文を完了できる)
public interface IWithDessertSelector : IOrderable
{
    IFullCourseSelector AddDrink();
}

// もうこれ以上追加で頼めない型状態(注文を完了できる)
public interface IFullCourseSelector : IOrderable
{
}

// 全てを継承したビルダー
public class DishBuilder :
    IMainDishSelector, 
    IBaseSelector, 
    IWithDrinkSelector, 
    IWithSaladSelector, 
    IWithDessertSelector,
    IWithSaladAndDrinkSelector, 
    IFullCourseSelector
{
    // 具体的な実装
}

肝となるのが、実装は1つのビルダークラスに統一するということです。同じインスタンスをパラメータ設定毎に違うインターフェースとして公開することで、設定済みパラメータを条件分岐せずとも制御することができます。

これにより、意図しない挙動を実行時エラーにしたりせず済みます。今回はこれを導入しなければ致命的に問題ということはありませんでしたが、Whenより先にOnを設定する、And,OrはWhenの後というような、よりFluentな構文を目指しました。条件設定の順序制限があることで統一感があり、可読性の向上に役立つと思っています。

Push型とPull型のステートマシンについて

ステートマシンの遷移条件設計において、「イベント」の取り扱いは非常に重要です。
ここで言う「取り扱い」とは、イベントの定義方法、すなわち「粒度」の問題を指します。
例えば、ジャンプステートへの遷移をイベント駆動(Push型)で考えてみます。

stateMachine.At<IdleState>
    .To<JumpState>()
    .On(Event.Jump)
    .Build();

このパターンは、よくあるAddTransition<IdleState, JumpState>(Event.Jump)と互換性があります。前述のように、この書き方の場合、Event.Jumpというイベントが単なる「ボタンを押した」という低レベルイベントなのか、あるいは「諸判定を含んだジャンプ開始命令」という高レベルイベントなのかを判断する方法はありません。
本来ステートマシンは遷移に関わるロジック(遷移条件の記述、その実行)の管理者であるべきですが、遷移条件の本質部分がステートマシンの外側(イベント発火元)に隠蔽されていることでその責務が果たせなくなります。

そこで、各イベントは低レベルなトリガーに留め、詳細な条件判定も含めてステートマシン側で処理すべきである、と考えることができます。

stateMachine.At<IdleState>()
    .To<JumpState>()
    .On(Event.InputJump)    // 低レベルなトリガー
    .When(() => IsGrounded) // ガード条件
    .Build();

このように、イベント駆動(Push型)に限らず条件監視型(Pull型)を組み合わせることで「低レベルトリガー」と「ガード条件」の両方をステートマシンに集約することができました。これにより「ステートマシン定義だけで全体像が把握しやすい」というコンセプトが達成できます。

とはいえこれらは理想論的で、実際のゲーム開発ではより複雑な条件分岐が発生するケースも少なくありません。適切に抽象化を施すことで、高レベルイベントとして扱うべきこともあるでしょう。
重要なのは、「低レベルトリガー + ガード条件」を徹底することでロジック全体像を把握しやすくするか、あるいは「抽象化されたイベント」でステート間の関係に注視するか、という両方の選択肢を選べるという点です。この強みを活かし、適切に使い分けるべきでしょう。

Pull型の注意点

Push型のステートマシンではFire()のタイミングで条件判定が行われますが、Pull型ではUpdate()による常時監視が前提となります。この呼び出しはUnityの場合MonoBehaviour.Update()から行われることが多いと思います。
そのため、多くの条件判定を毎フレーム行った場合ではゲーム全体のオーバーヘッドになり得ます(これは一般的な開発でも同じですが)。
可能であれば、Push型と組み合わせて使う。Pull型だけで使うにしても、判定は軽微なものにするといいと思います。GetComponentを含むなどはNGですね。

依存性注入まわり

HybridStateMachineでは依存性を各ステートにコンストラクタ注入できます。

// ゲーム情報を持つgameStateを注入する
factory.Register<IdleState>(() => new Idle(gameState)); 

現状、ステートマシンにStateFactory以外の依存性を注入する方法はサポートしていません。そのためステートマシンに遷移条件を集約しようと思うと以下のような記述が必要です。

IdleState idleState = stateMachine.At<IdleState>();
idleState.To<WalkState>()
    .On(InputEvent.MoveStart)
    .When(() => idleState.GameState.CanMove)
    .Build();

仮にステートマシンがゲーム情報を持つgameStateを知らずとも、IdleStateが知ってさえいればそのコンテキストを記述可能です。あるいは外部の共有コンテキストを作成することもできますね。基本的にはこれらのロジックで集約が可能ですが、この仕様に抵抗感がある場合には単にInputEvent.MoveStartの発火元に注入すれば良いでしょう。

比較的シンプルなファクトリを提供しているので、以下のようなDIコンテナとの連携も容易になっています。以下はVContainer向けの例です。

protected override void Configure(IContainerBuilder builder)
{
    // 各ステートを登録
    // 各ステートのコンストラクタはGameInfoを受け取る
    builder.Register<IdleState>(Lifetime.Transient);
    builder.Register<WalkState>(Lifetime.Transient);
    builder.Register<JumpState>(Lifetime.Transient);

    // 注入されるゲーム情報
    builder.Register<GameInfo>(Lifetime.Transient);

    // ステートマシンを登録
    builder.Register(container =>
    {
        var factory = new StateFactory();

        // Resolve時にGameInfoが注入されます
        factory.Register<IdleState>(() => container.Resolve<IdleState>());
        factory.Register<WalkState>(() => container.Resolve<WalkState>());
        factory.Register<JumpState>(() => container.Resolve<JumpState>());

        return StateMachine.Create(factory);
    }, Lifetime.Singleton);
}

また、ステート遷移時に動的にパラメータを渡すことも可能になっています。

inGame.To<ResultState, int>(() => Score)
    .When(() => IsGameOver)
    .Build();

Scoreというコンテキストを動的に渡すことで遅延評価が可能になります。これに関してもラムダを使うことのオーバーヘッドがあるのであまり推奨はしません。前述のコンストラクタ注入が利用できるなら利用すべきです。
とはいえ、Scoreのような参照型でないが動的変化するデータを渡したいというケースでは、限定的ですが役に立つかもしれません。しかしそれも、ScoreをラップするPlayerStatusクラスなりを作って共有する方がいいかと思います。

今後の展望

割と必要だと考えているのが、UniTaskなどでの非同期処理に対応することです。あるいはガードの仕組みを取り入れることで現状のままに非同期処理との連携が取れそうかなと考えています。
また、より高機能なステートマシンライブラリでは、HFSM(階層型状態機械)と呼ばれる共通部分を持つステートに紐付けて複数のステートを持つものや、プッシュダウンという内部にStackを持つものがあります。前者ではより柔軟な状態管理やビヘイビアツリー的挙動を、後者では例えば履歴管理や先行入力に使えるかもしれません。
さらにいえば、将来的には現在のMermaid出力ではなくエディタ拡張によるランタイムデバッグ機能とか作れたら面白そうですね。個人的にはこのステートマシンはコードベースで完結させていたいので、インスペクタから遷移条件変更とかは考えていないのですが。

終わりに

今回こういった記事を書くのは初めてなのですが、改めて言語化することで学べた感じがします。また何かネタがあれば書いてみたいです。
記事について、何か間違い等あれば教えていただければ幸いです。HybridStateMachineについても、ぜひ一度使ってもらえれば嬉しいです!

参考

https://en.wikipedia.org/wiki/Fluent_interface

Discussion