加筆 or 修正
- 2021-02-09
- ImtStateMachine.cs のリンクを修正
- 2021-11-30
- 続編記事の執筆準備開始
- 2022-01-11
- ImtStateMachine.cs のリンクを修正
- 2022-02-21
- 続編執筆進捗100%(投稿しました。)
- https://qiita.com/BelColo/items/a27b66b18794b33943f9
- 2022-06-15
- ImtStateMachine.cs(StateMachine.cs)のリンクを修正
- 2023-10-23
- ImtStateMachineリポジトリが独立したので前置きとリンクを修正
前置き
ステートマシンよりも前に、状態制御とはなにかを書いていたのですが、思った以上に長くなりそうだったので、状態制御については別記事にします。
今回は、シノア氏が開発しているOSSである IceMilkTea という、Unityゲームエンジン向け開発サポートライブラリの「サブセット」である ImtStateMachine ImtStateMachine の中核コードStateMachine.csを紹介(語り尽く)したいと思います。
Unityで状態制御といえば
殆どのUnityゲームクリエイターがステートマシンと聞いたら次のアセットを思い浮かべるのではないのでしょうか
-
Playmaker
- Unityのステートマシンの老舗と言えばこのアセットですよね、Unity3.2の頃ではお世話になっていました。
- いまでもメンテナンスは続いており、Playmaker以外の人もPlaymaker用追加モジュールを製作されているほどの人気です。
-
Behaviour Machine
- 非常に軽快でシンプルでありながら、強力なビジュアルエディタが付属しており使い勝手が抜群です。
- また、有料版になりますが、ソースコードもフルオープンのためブラックボックスがなく、問題が起きた時の調査もしやすいです。
- Unity4.6~5.xではお世話になっていました。
- しかも、ステートマシンだけに限らずビヘイビアツリーと言った状態制御方法もサポートしています。(むしろこっちが本命でステートマシンがおまけ感)
-
Arbor 3: FSM & BT Graph Editor
- 知る人ぞ知る、日本国産のステートマシンアセットです。
- そのため、日本語マニュアルも完備されており、ビジュアルエディタも日本語サポートされています。
- こちらもビヘイビアツリーを実現する機能が提供されています。
ステートマシンとは少し違いますが
なにも、状態制御はステートマシンだけが実装方法ではありません。解決方法は違えど状態制御が可能なものもあります。
Unityにおいてもステートマシンを使わずとも状態制御は可能です。本題と少しずれてしまいますが、他のアセットも見てみましょう。
-
UniRx
- C#界隈で有名な neuecc氏が開発しているUnity向けにチューニングされた ReactiveExtensions と言えばこれですね
- RxライクでUnity向けのオペレータも豊富です。
- ほぼシーケンシャルな状態遷移及び状態の振る舞いがシンプルなら非常に強力に働くでしょう。
-
Behavior Designer
- Unityのビヘイビアツリーの老舗と言えばこのアセットですね、Unity 4.6世代あたりではお世話になっていました。
- ビジュアルエディタも非常にわかりやすく、デバッグ機能も非常に強力でツリーの構築も非常にやりやすいです。
-
NodeCanvas
- ビヘイビアツリー界隈で知らない人は居ないのではないのでしょうか、これも非常に有名ですね。
- やや独特なビジュアルエディタでありながら、非常にハイパフォーマンスでかつメモリ効率も良いアセットです。
- またPlaymakerと同様、様々なサブシステムへのインテグレーションも行われており、非常にサードパーティが多いのも一つの魅力です。
これだけのオススメのアセットがあるのに何故 ImtStateMachine?
殆どのインディーズ系Unityゲーム開発であれば、上記で紹介したアセットで事足りますし
実際、非常にエディタとしても強力でそれなりのパフォーマンスが得られます。
では、それでもその恩恵を捨てながらも ImtStateMachine を使う理由とは何でしょうか。
サーバーコードも、Unity側実装と同じようにかつハイパフォーマンスで使いたい!
近年では、スマートフォンゲームでも珍しくなくなってきた、リアルタイムオンライン通信によるマルチプレイゲームが流行ってきています。
そのため、サーバーサイド側の開発事情も変わってきています。殆どの通信は1度のリクエストで1度のレスポンスで済むものです、それも今も変わりません。
しかし、リアルタイム通信で同期型のオンライン通信となると、ガラッと変わりサーバーが今までステートレスな処理からステートフルな処理へ大変貌を遂げました。
それを解決するには、ステートマシンによる状態制御が必要です。
そこで、可能ならばUnityで実装しているような状態制御をサーバーサイド側のプログラムでもやりたい!
しかも、サーバー側コードなので、ただ状態制御が出来るだけでなく、パフォーマンスも保証し安定して動作しなければなりません。
そんな理由もあり、それを実現出来るのが ImtStateMachine でした。
ImtStateMachine の特徴
では、サーバーサイドでもUnityでも使えるステートマシンといわれる ImtStateMachine はどんなステートマシンなのか箇条書きで簡単ですが、以下にまとめました。
-
本体がピュアC#で実装されている
- 本体コードを見るとわかりますが、本体コードの全てがC#の標準クラスでのみ実装されています。
- そのため、C#の実装環境なら殆どの場所で再利用が可能です。
- サーバーコードでもUnityコードでも使える理由はこれのおかげです。
-
ソースコードファイルがたった1つ
- なんと、ステートマシンとして実現する実装コードはたったの1ファイルのみです。
- フルセットである IceMilkTea をまるごと導入する必要はありません。
- そのため、非常にポータビリティが高いので再利用性がより一層上がります。
-
ハイパフォーマンスでメモリ効率もよい
- 非常にコンパクトな実装のため、ナノ秒オーダーでの動作が可能です。
- 一度動作を開始したら、ステートマシンによるGCAllocは一切ありません。
-
安全に動作する(例外耐性が高い)
- ImtStateMachineが、ステートで発生した例外を適切に拾い上げて制御する方法を提供しています(UnhandledException機構)。
- ステートで例外が発生してもステートマシンが不正終了する事が無いため、そのまま復帰することが可能です。
-
状態の遷移が決められていて入力と紐付けられる(有限状態機械)
- 有限状態機械として実装するための遷移テーブルを構築する手段が提供されています。
- 遷移テーブルも非常に見やすい書き方で実現されています。
-
任意状態遷移を構築できる(Any遷移)
- 有限状態機械のみならず、ステートパターンとして組むための任意遷移が提供されています。
-
どの型(クラス)に対してもステートマシンとして実装出来る(コンテキストフリー)
- ステートマシン自体にコンテキストは存在せず、コンテキストはユーザー定義型で実装することが可能です。
-
状態の遷移条件を状態毎に実装が可能(遷移ガード)
- ステートマシンの入力に対して、ステートが遷移をコントロールすることが可能です。
-
状態のスタックが可能(状態割り込み)
- 状態を維持したまま状態を遷移して、状態を元に戻すといった事が可能です。
-
ライセンスがzlib/libpng(MITライセンスよりもっと緩い)
- 最近の OSS では MIT ライセンスが多いですが IceMilkTea は zlib/libpng ライセンスという形を取っています。
- すごくざっくりいうと(as-is(現状の))まま使われることを想定しているけど、改変は自由にOK(その際は改変されたということがわかるようにすること)
- 改変したとしても、開発者を偽らなければそのまま使っていいよ(ソースコードに書かれている作者情報をそのままにすること)
- 特に、権利者表記を義務付けないよ、書いてもいいけど作者から書くことは強要しないよ
大まかな使い方
状態の書き方
ステートマシンと言うからには状態の定義が必要です。
ImtStateMachine は、どのステートマシンの状態なのかという実装の仕方になります。
ImtStateMachine<コンテキストになるクラスの型>.State
using System;
using IceMilkTea.Core;
using UnityEngine;
// 状態を定義しているだけの何もしないクラス
public class Hoge : MonoBehaviour
{
// この Hoge クラスのステートマシン
private ImtStateMachine<Hoge> stateMachine;
// この Hoge クラスのアイドリング状態クラス
private class IdleState : ImtStateMachine<Hoge>.State
{
// 何もしない状態クラスなら何も書かなくても良い(むしろ無駄なoverrideは避ける)
}
// この Hoge クラスのなにかを処理している状態クラス
private class ProcessState : ImtStateMachine<Hoge>.State
{
// 状態へ突入時の処理はこのEnterで行う
protected internal override void Enter()
{
}
// 状態の更新はこのUpdateで行う
protected internal override void Update()
{
}
// 状態から脱出する時の処理はこのExitで行う
protected internal override void Exit()
{
}
// 状態で発生した未処理の例外がキャッチされた時の処理はこのErrorで行う
protected internal override bool Error(Exception exception)
{
// 未処理の例外をハンドリングしたのなら true を返すことで、ステートマシンはエラーから復帰します
return true;
}
// ステートマシンが状態の遷移をする前にステートマシンのイベント入力を処理するならこのGuardEventで行う
protected internal override bool GuardEvent(int eventId)
{
// 特定のタイミングで遷移を拒否(ガード)するなら true を返せばステートマシンは遷移を諦めます
if (!Context.isActiveAndEnabled)
{
return true;
}
// 遷移を許可するなら false を返せばステートマシンは状態の遷移をします
return false;
}
// ステートマシンが前回のプッシュした状態に復帰する時の処理をするならこのGuardPopで行う
protected internal override bool GuardPop()
{
// 復帰を拒否(ガード)するなら true を返せばステートマシンは復帰を諦めます
if (!Context.isActiveAndEnabled)
{
return true;
}
// 復帰を許可するなら false を返せばステートマシンは復帰します
return false;
}
}
}
この例では、1ソースコードで状態を実装していますが、C#にはpartialという型の分割定義が可能なので、メンテナンス性を考慮し状態クラスごとにソースコードを分けると良いです。
partial 型 (C# リファレンス)
もちろん、コード規模によっては1ファイルで書いたほうが良い場面もあるので、適切な分量で書き分けましょう。
using IceMilkTea.Core;
using UnityEngine;
public partial class Hoge : MonoBehaviour
{
// この Hoge クラスのステートマシン
private ImtStateMachine<Hoge> stateMachine;
}
using IceMilkTea.Core;
public partial class Hoge
{
// この Hoge クラスのアイドリング状態クラス
private class IdleState : ImtStateMachine<Hoge>.State
{
}
}
using IceMilkTea.Core;
public partial class Hoge
{
// この Hoge クラスのなにかを処理している状態クラス
private class ProcessState : ImtStateMachine<Hoge>.State
{
}
}
状態遷移テーブルの作り方
殆どのケースでは有限状態機械として実装することが多いので、その遷移テーブルを記述してみます。
ImtStateMachine では AddTransition, AddAnyTransition, AddTransitionRange の3つの関数でテーブルを構築し、SetStartState で開始状態を定義する事ができます。
遷移テーブルは状態クラスの型を使って from -> to : input といった形で関数を呼びます。
// この関数を呼ぶと(遷移元状態クラスの状態で、入力値の入力をされた時に、遷移先状態クラスの状態へ遷移する)という定義になります。
stateMachine.AddTransition<遷移元の状態クラスの型, 遷移先の状態クラスの型>(入力値);
using IceMilkTea.Core;
using UnityEngine;
public partial class Hoge : MonoBehaviour
{
// ステートマシンの入力(イベント)を判り易くするために列挙型で定義
public enum StateEventId
{
Finish,
Reset,
}
// この Hoge クラスのステートマシン
private ImtStateMachine<Hoge> stateMachine;
private void Awake()
{
// ステートマシンのインスタンスを生成して遷移テーブルを構築
stateMachine = new ImtStateMachine<Hoge>(this); // 自身がコンテキストになるので自身のインスタンスを渡す
stateMachine.AddTransition<IdleState, ProcessState>((int)StateEventId.Finish);
stateMachine.AddTransition<ProcessState, IdleState>((int)StateEventId.Reset);
// 起動ステートを設定(起動ステートは IdleState)
stateMachine.SetStartState<IdleState>();
}
}
上記の呼び出しを行うと、次のような状態遷移テーブルが構築されます
ステートマシンの更新ループ
状態クラスの定義、遷移テーブルの構築、をここまでやったら後はステートマシンを動かすだけです。
ImtStateMachine では Update 関数がステートマシンの起動と更新を担います。
通常、Unityでは、コンポーネントの更新ループ関数で状態の更新を行うので、コンポーネントの Start 関数で起動し Update 関数で更新するのが常套手段になります。
また、サンプルコードを見ていただけたら分かりますが ImtStateMachine は Update 関数を呼び出すだけで状態更新を行えます。
業務アプリケーションなどのフォームアプリケーション、コンソールアプリケーションなどで動かす場合は、OSのメッセージループ内で呼び出したり、タイマやボタンのクリック等の特定イベントなどでのタイミングでの更新も可能です。
ご自身の希望するタイミングで、更新タイミングを実行することが可能です。
using IceMilkTea.Core;
using UnityEngine;
public partial class Hoge : MonoBehaviour
{
/*
中略...
*/
private void Start()
{
// ステートマシンを起動
stateMachine.Update();
}
private void Update()
{
// ひたすら更新
stateMachine.Update();
}
}
ステートマシンへのイベント入力
ステートマシンへ状態の遷移をするための指示をするには、入力が必要です。
ImtStateMachine では SendEvent 関数で状態の遷移をする事が出来ます。
入力する値は、遷移テーブルを構築する時に Add~Transition 関数に渡した引数の値になります。
using IceMilkTea.Core;
using UnityEngine;
public partial class Hoge : MonoBehaviour
{
/*
中略...
*/
// 何かがぶつかった!
private void OnCollisionEnter(Collision collision)
{
// ステートマシンにイベントを送る
// この例では IdleState の状態時は ProcessState へ遷移するが
// ProcessState の状態時は、遷移する先が無いので何も起きない
stateMachine.SendEvent((int)StateEventId.Finish);
}
}
試しに使ってみる
本当は、もっともっと ImtStateMachine を使ったテクニックを紹介したいのですが、今回はカレンダー記事ということで基本中の基本という「語り尽くす」とはと何だと思ってしまいます。
代わりと言ってはなんですが、たった一つの ImtStateMachine のソースコードファイルをインポートしたUnityプロジェクトで簡単なサンプルを作ってみたので、ご参照下さい。
ImtStateMachine を使ったテクニック集なども、いずれ記事にしたいと思います。
では、早速 ImtStateMachine を使ってUnityで簡単なサンプルを作ってみます。
今回作るゲームは、ブロック崩しを作りたいと思います。
要件は次の通りです。
- スペースキーでボールを発射
- 全てのブロックが破壊されれば終了
- プレイヤーがボールを受け取れずミスを3回したらゲーム終了
新規Unityプロジェクトを作ってソースコードをインポート
今回の使用するUnityエンジンバージョンは 2018.3.0f2 です。
早速Unityプロジェクトを新規作成し、シノア氏の IceMilkTea のリポジトリに含まれる ImtStateMachine のソースコードだけ、コピペするかリポジトリをチェックアウトしてきた所から頂戴します。
https://github.com/Sinoa/IceMilkTea/blob/develop/Program/Runtime/Core/UnitCode/PureCsharp/StateMachine.cs
このように、ポンっとPluginsディレクトリに置くだけの簡単な作業です。
物理エンジンのバウンド閾値を調整
今回作るゲームはブロック崩しの為ボールが確実にバウンドするように、物理エンジンのバウンドするかどうかの閾値を0にします。
ステージをパパパっと作る
全てプリミティブなオブジェクトで組んでしまいます。もちろん物理エンジンのコンポーネントも付けていきます。
いい感じです。
プレイヤークラスを書く
それでは、まずプレイヤーとして存在するバーの操作クラスを作ってみましょう。
プレイヤーが行える状態は次の構造になりますね。
では、この構造をソースコードに落とし込んでいきます。
using IceMilkTea.Core;
using UnityEngine;
[RequireComponent(typeof(Rigidbody))]
[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(Transform))]
public class Player : MonoBehaviour
{
// ステートマシンのイベントID列挙型
private enum StateEventId
{
Enable,
Disable,
}
// 中略...
// ステートマシン変数の定義、もちろんコンテキストは Player クラス
private ImtStateMachine<Player> stateMachine;
// 中略...
// コンポーネントの初期化
private void Awake()
{
// 中略...
// ステートマシンの遷移テーブルを構築(コンテキストのインスタンスはもちろん自分自身)
stateMachine = new ImtStateMachine<Player>(this);
stateMachine.AddTransition<DisabledState, EnabledState>((int)StateEventId.Enable);
stateMachine.AddTransition<EnabledState, DisabledState>((int)StateEventId.Disable);
// 起動状態はDisabled
stateMachine.SetStartState<EnabledState>();
}
private void Start()
{
// 中略...
// ステートマシンを起動
stateMachine.Update();
}
// Playerクラスと言っておきながら移動コンポーネントなのでFixedUpdateでステートマシンを回す
private void FixedUpdate()
{
// ステートマシンの更新
stateMachine.Update();
}
// プレイヤーの操作を有効にします
public void EnableMove()
{
// ステートマシンに有効イベントを叩きつける
stateMachine.SendEvent((int)StateEventId.Enable);
}
// プレイヤーの操作を無効にします
public void DisableMove()
{
// ステートマシンに無効イベントを叩きつける
stateMachine.SendEvent((int)StateEventId.Disable);
}
// プレイヤーの移動も何も出来ない哀れな状態クラス
private class DisabledState : ImtStateMachine<Player>.State
{
}
// プレイヤーの移動が許された状態クラス
private class EnabledState : ImtStateMachine<Player>.State
{
// 状態の更新を行います
protected override void Update()
{
// 中略...
}
}
}
ブロッククラスを書く
次にブロックの状態をコントロールするクラスを作ってみましょう。
ブロックが起こりうる状態は次の構造になりますね。
では、この構造をソースコードに落とし込んでいきます。
using IceMilkTea.Core;
using UnityEngine;
public class Block : MonoBehaviour
{
// 状態イベントの定義
private enum StateEventId
{
Dead,
Revive,
}
// 現在の状態が生存状態なら生存していることを返すプロパティ
public bool IsAlive => stateMachine.IsCurrentState<AliveState>();
private ImtStateMachine<Block> stateMachine;
private void Awake()
{
stateMachine = new ImtStateMachine<Block>(this);
stateMachine.AddTransition<AliveState, DeadState>((int)StateEventId.Dead);
stateMachine.AddTransition<DeadState, AliveState>((int)StateEventId.Revive);
stateMachine.SetStartState<AliveState>();
}
private void Start()
{
stateMachine.Update();
}
private void Update()
{
stateMachine.Update();
}
private void OnCollisionEnter(Collision collision)
{
// 衝突した相手がボールなら
if (collision.gameObject.name == "Ball")
{
// 死亡イベントを送る
stateMachine.SendEvent((int)StateEventId.Dead);
}
}
public void Revive()
{
// ステートマシンに復活イベントを送る
stateMachine.SendEvent((int)StateEventId.Revive);
}
private class AliveState : ImtStateMachine<Block>.State
{
// 中略...
}
private class DeadState : ImtStateMachine<Block>.State
{
// 中略...
}
}
シーンクラスを書く
次にゲームの流れをコントロールするシーンのクラスを作ってみましょう。
シーンが起こりうる状態は次の構造になりますね。
では、この構造をソースコードに落とし込んでいきます。
using IceMilkTea.Core;
using UnityEngine;
public class MainGameScene : MonoBehaviour
{
// ステートマシンのイベントID列挙型
private enum StateEventId
{
Play,
Miss,
Retry,
Exit,
AllBlockBloken,
Finish,
}
// 中略...
// ステートマシン変数の定義、もちろんコンテキストは MainGameScene クラス
private ImtStateMachine<MainGameScene> stateMachine;
private int missCount;
// コンポーネントの初期化
private void Awake()
{
// ステートマシンの遷移テーブルを構築(コンテキストのインスタンスはもちろん自分自身)
stateMachine = new ImtStateMachine<MainGameScene>(this);
stateMachine.AddTransition<ResetState, StandbyState>((int)StateEventId.Finish);
stateMachine.AddTransition<StandbyState, PlayingState>((int)StateEventId.Play);
stateMachine.AddTransition<PlayingState, MissState>((int)StateEventId.Miss);
stateMachine.AddTransition<PlayingState, GameClearState>((int)StateEventId.AllBlockBloken);
stateMachine.AddTransition<MissState, StandbyState>((int)StateEventId.Retry);
stateMachine.AddTransition<MissState, GameOverState>((int)StateEventId.Exit);
stateMachine.AddTransition<GameClearState, ResetState>((int)StateEventId.Finish);
stateMachine.AddTransition<GameOverState, ResetState>((int)StateEventId.Finish);
// 起動状態はReset
stateMachine.SetStartState<ResetState>();
}
private void Start()
{
// ステートマシンを起動
stateMachine.Update();
}
private void Update()
{
// ステートマシンの更新
stateMachine.Update();
}
public void MissSignal()
{
// ステートマシンにミスイベントを送る
stateMachine.SendEvent((int)StateEventId.Miss);
}
private class ResetState : ImtStateMachine<MainGameScene>.State
{
protected override void Enter()
{
foreach (var block in Context.blocks)
{
block.Revive();
}
Context.player.GetComponent<Transform>().position = Context.playerStartTransform.position;
Context.player.DisableMove();
Context.ball.GetComponent<Transform>().position = Context.ballStartTransform.position;
Context.ball.GetComponent<Rigidbody>().velocity = Vector3.zero;
StateMachine.SendEvent((int)StateEventId.Finish);
}
}
private class StandbyState : ImtStateMachine<MainGameScene>.State
{
protected override void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
StateMachine.SendEvent((int)StateEventId.Play);
}
}
}
private class PlayingState : ImtStateMachine<MainGameScene>.State
{
protected override void Enter()
{
var xDirection = Random.Range(-1.0f, 1.0f);
var zDirection = Random.Range(0.5f, 1.0f);
Context.ball.GetComponent<Rigidbody>().velocity = new Vector3(xDirection, 0.0f, zDirection).normalized * Context.ballSpeed;
Context.player.EnableMove();
}
protected override void Update()
{
var blockAllDead = true;
foreach (var block in Context.blocks)
{
if (block.IsAlive)
{
blockAllDead = false;
break;
}
}
if (blockAllDead)
{
StateMachine.SendEvent((int)StateEventId.AllBlockBloken);
}
}
}
private class MissState : ImtStateMachine<MainGameScene>.State
{
protected override void Enter()
{
Context.player.DisableMove();
Context.ball.GetComponent<Transform>().position = Context.ballStartTransform.position;
Context.ball.GetComponent<Rigidbody>().velocity = Vector3.zero;
Context.missCount += 1;
if (Context.missCount == Context.availablePlayCount)
{
StateMachine.SendEvent((int)StateEventId.Exit);
return;
}
StateMachine.SendEvent((int)StateEventId.Retry);
}
}
private class GameClearState : ImtStateMachine<MainGameScene>.State
{
protected override void Enter()
{
Debug.Log("GameClear!!!");
StateMachine.SendEvent((int)StateEventId.Finish);
}
}
private class GameOverState : ImtStateMachine<MainGameScene>.State
{
protected override void Enter()
{
Debug.Log("GameOver...");
StateMachine.SendEvent((int)StateEventId.Finish);
}
}
}
完成
完成プロジェクトに関しては、GitHubにて公開しました。
https://github.com/BelColo/ImtStateMachineUseSample
当初より予定していた、記事の長さを遥かに超える事になってしまいましたが、いかがでしたでしょうか。
サンプルコードはパフォーマンスや実装方法について、ステートマシンの動かし方を重点的に説明するため、犠牲になっていますが、それでもステートマシンを使ったことによって遥かに実装が楽になっていることがわかるはずです。
今回使ったImtStateMachineは、Unityだけに留まらずサーバーサイドのプログラムにも使われており、実際使わせてもらっています。
ImtStateMachineが流行ることを願いつつ今回は、これでしめさせて頂きます。
ImtStateMachineを使ったテクニック集などもいつの日か、書ける日が来たら書こうと思います。