初めまして、フリーランスのゲームプログラマーとして活動しています福野 氷河です。
こういった技術記事への投稿は初めてになるので、おかしな点などありましたらすみません。
この度、.NET/Unity向けの非同期ベースステートマシンライブラリを公開しました。
この記事は本ライブラリの基本的な使い方と、設計思想について説明していきます。
※この記事にはAIを一切使用していません。
使い方
基本的にはREADMEを読んでもらえれば問題ないかと思いますが、簡単なコード例を提示します。
using AsyncStateMachine;
// StateFactoryの作成、セットアップ
var factory = new StateFactory<Foo>(); // State内で制御したいクラスをContextとして指定
factory.Register(() => new BarState()); // 稼働させるStateクラスの生成処理を登録
var stateMachine = StateMachine.Create(new Foo(), factory); // 使用するContextとFactoryを元にStateMachineを作成する
stateMachine.SetInitialState<BarState>(); // 最初に起動するStateクラスを指定
await stateMachine.RunAsync(cancellationToken); // 動作を開始する
本ライブラリでは、各ステートクラスで共有したい情報(プレイヤー情報やゲーム状態)をコンテキストとして登録させる方針をとっています。そのため、コンテキストなしのステート、ステートマシンは作成できません。
ここの仕様は他のステートマシンと大きく違うポイントかなと思っています。
また、StateFactoryが名前の通りステートマシンで動作させたいステートを生成するラムダを保持するクラスなので、他のDIコンテナなどと簡単に連携させることが可能です。
例として、VContainerを使用した場合は以下のように登録させることができます。
using AsyncStateMachine;
using VContainer;
IObjectResolver resolver; // コンストラクタ等から受け取るようにする
var factory = new StateFactory<Foo>();
factory.Register(resolver.Resolve<BarState>); // resolver.Resolve<>() をそのまま渡せる
特にステートクラスがMonoBehaviourなどを継承しないPure C#になるので、VContainerなどのDIコンテナによるコンストラクタインジェクションが非常に相性が良いです。
なぜ非同期なのか?
ここが一番重要なポイントだと思うので、なぜ非同期で設計したのかについて説明させてください。
特にUnityではステートマシンは以下のような設計が多いと思います。
class State
{
public void Start() {}
public void Update() {}
public void Exit() {}
}
class StateMachine
{
readonly Dictionary<Type, State> states;
State currentState;
public void Update()
{
currentState?.Update();
}
public void ChangeState<TState>()
{
var nextState = states[typeof(TState)];
currentState?.Exit();
currentState = nextState;
currentState.Enter();
}
}
※あくまで説明の為のコードなので非常に簡素なデザインにしています。
今回はわかりやすいように上記のステートマシンを同期ベースのステートマシンと呼称します。
この設計は実装も楽で依存が明確ですが、いくつかの問題点があります。
まずはステートマシンが動作してから終了するまでの寿命をステートが制御できない点です。
当たり前ですよね、この設計はステートマシンがステートを保持し、毎フレームの動作まで制御をしています。その為、いわゆる子であるステートは自分のUpdateがいつ呼ばれなくなるかを認識できません。
次に、ステートの動作中に発生した例外処理の対応です。各状態メソッド(Enter、Update、Exit)内で発生した例外をそのメソッド内で処理する場合は問題ないですが、Update内で例外が投げられた際にExitを呼んでほしいというケースが発生した場合、子であるステートは親に制御されて動作するという前提仕様がある以上、以下のようにステートマシン側で対応するしかありません。
public void Update()
{
try
{
currentState?.Update();
}
catch
{
currentState?.Exit();
}
}
また、このように対応しても次のフレームから普通にcurrentState.Updateが呼ばれます。
じゃあ例外が発生した時にはStateMachineを終了させる?それとも次のステートへ切り替えさせる?など対応はありますが、どれも親側で動作が決まってしまうため、ステート毎に挙動を変えるという事が難しくなります。
このような問題に対して、本ライブラリはステート主体の設計をベースにasync/awaitとtry-catch-finallyを用いて解決します。
AsyncStateMachineのRunAsyncは基本的には子であるステートのExecuteAsyncを呼び出すだけとなっています。ここは同期的ステートマシンとは変わりません。しかし、async/awaitによってRunAsyncの終了が子ステートのExecuteAsyncによって決まるというのが肝です。コードを見てみましょう。
class BarState : State<Foo>
{
protected override async ValueTask OnExecuteAsync(Foo context, CancellationToken ct)
{
// ステートマシンがいつまで稼働するかはOnExecuteAsyncの実装で決まる
await UniTask.WaitForSeconds(10, cancellationToken: ct);
}
}
// ステートマシン側のRunAsync
public async ValueTask RunAsync(CancellationToken ct)
{
await currentState.ExecuteAsync(context, ct);
}
具体的なコードはリポジトリを見ていただければと思いますが、このような実装になっています。
先程までの同期的ステートマシンと違い、async/awaitによって子が主導となっているのがわかるでしょうか?親であるステートマシンは子ステートがいつ終わるかを知りません。これが良いのです。子主導によって、ステートマシンがいつ終了するのか?ちゃんと呼ばれるのか?などを気にする必要もありません。また、Enter、Update、Exitのようなメソッドに分けていないので、毎フレーム何かを処理したければOnExecuteAsyncの中にwhileループ+UniTask.Yieldを書くことで対応することができます。UniTask.YieldのPlayerLoopTimingを指定することでUpdateベースやFixedUpdateベースなどにも対応させることができます。これは同期的ステートマシンだと親側にもUpdate、FixedUpdateとメソッドを別で定義しなければならないのでこういった親のルールに縛られないのは子主導の強みかなと思います。
子主導であれば、先ほどの例外処理に対しても以下のように書けます。
protected override async ValueTask OnExecuteAsync(Foo context, CancellationToken ct)
{
try
{
// 開始時にしたい処理はここ
while (!ct.IsCancellationRequested)
{
// 連続的にしたい処理はここ
await UniTask.Yield(ct);
}
// 例外が発生していない時の終了時の処理はここ
}
catch
{
// 例外対応はここ
}
finally
{
// 例外があってもなくてもしたい処理はここ
}
}
どうでしょうか?何がどのタイミングで処理されるかが一つにまとまっており、直感的に理解しやすいと思います。これはあくまで例なので、他のステートは違うように実装することも可能です。すべてのステートを同じように動作させることも、特定のステートだけ異なった動作にすることも可能なのがAsyncStateMachineの魅力だと思っています。
最後に
そんなAsyncStateMachineですが、現在は2.0.0をリリースしています。
以前までは1.0.2で止まっていたのですが、Fable 5やOpus 5をコードレビューに用いてバグ修正や破壊的変更を行ったため、メジャーバージョンとしてリリースしました。MITライセンスで公開していますので、よければ使ってみてください。IssueやPRも募集しております。
次回はこのメジャーアップデートに伴ってどのような問題が1.0.2にはあったのか?AIを使った開発体験の個人的な感想を伝えられたらなと思います。