FSM(ステートマシン)
自分でティックする、決定論的なオブジェクト単位のステートマシン — 名前付きステート、ガード付き遷移、グローバル割り込みを純粋な C# で。
すべての敵、メニュー、ゲームフェーズに名前付きステートの明快なセットを与え、切り替えは宣言的なガード付き遷移に任せましょう。
| インターフェース | IStateMachineService — IStateMachine<TContext> インスタンスを作るファクトリ |
| オフスイッチ | NullStateMachineService |
| アセンブリ | CommonGameSystem.Core |
| 起動 | 起動時に自動登録(23 サービスの 1 つ)— セットアップ不要 |
概要
有限ステートマシン(FSM)は、挙動を名前付きステート — 敵なら Idle、Chase、Dead など — と、その間を移動するルールに整理します。このサービスが提供するのはフラットでヘッドレスな FSM です:各ステートを小さなクラスとして定義し、宣言的なガード付き遷移(「敵がプレイヤーを見つけたら Idle から Chase へ」)または直接の ChangeState 呼び出しでマシンを駆動します。
クロックはゲーム側が所有します:毎フレーム各マシンをティックし、デルタタイムを自分で渡します。内部ティッカーも MonoBehaviour の荷物もありません。このマシンは敵 AI、メニューフロー、ゲームフェーズのループのために作られています — ティックあたりゼロアロケーション、決定論的な順序、純粋な C#、そして IL2CPP(プレイヤービルドに使われる Unity の事前コンパイラ)下でも安全です。
クイックスタート
ファクトリを一度だけ解決し、オブジェクトごとにマシンを作ります:
using CommonGameSystem.Core;
using UnityEngine;
[DefaultExecutionOrder(100)] // Run after the framework has started.
public class MyStateMachineUser : MonoBehaviour
{
private IStateMachine<MyStateMachineUser> _fsm;
private void Awake()
{
// Via the factory (recommended — you get diagnostics and the one-line off switch):
_fsm = ServiceLocator.Resolve<IStateMachineService>().Create<MyStateMachineUser>(this);
// Or construct one directly (works standalone, but is not counted in diagnostics):
// _fsm = new StateMachine<MyStateMachineUser>(this);
}
}
上のように、マシンはフィールドにキャッシュしてください。Update() の中でサービスを解決してはいけません。
API リファレンス
IStateMachine<TContext> — 毎フレームティックするインスタンス単位のマシン
TContext はステートが読み書きするオブジェクトです — 通常は所有側の MonoBehaviour やエンティティです。
| メンバー | シグネチャ | 説明 |
|---|---|---|
CurrentStateName | string(プロパティ) | 現在のステート名。Start の前は null。 |
IsRunning | bool(プロパティ) | Start の後は true、Dispose の後は false。 |
Add | void Add(string name, IState<TContext> state) | ステートを登録します。タイポをコンパイル時に捕まえるため、名前には public const string を使ってください。 |
AddTransition | void AddTransition(string from, string to, Func<TContext, bool> guard) | ガード付き遷移を登録します。グローバル割り込みには from に StateMachine<TContext>.AnyState を使います。これらは最初に評価されます。ガードは軽量で副作用なしに保ってください — レイキャストも書き換えも禁止です。 |
Start | void Start(string name) | 名前付きステートに入ります(OnEnter を呼びます)。Tick の前に一度呼ぶ必要があります。StateChanged は発火しません。 |
ChangeState | void ChangeState(string name) | 今すぐ遷移します:旧ステートで OnExit、新ステートで OnEnter、その後に StateChanged が発火します。未知のステート名なら安全な no-op です。 |
Tick | void Tick(float deltaTime) | 1 ステップ進めます:OnUpdate(deltaTime) を呼び、次に宣言的遷移を評価します。デルタは自分で渡すので、クロックも自分で選べます。 |
StateChanged | event Action<string, string> | 遷移時、OnEnter の後に (fromName, toName) で発火します。Start では発火しません。 |
Dispose | void Dispose() | クリーンなシャットダウン:現在のステートの OnExit を一度呼び、テーブルをクリアします。二度呼んでも安全です。 |
IState<TContext> — 実装するもの
このインターフェースを実装するか、StateBase<TContext> を継承して必要なものだけをオーバーライドします:
| メンバー | シグネチャ | 説明 |
|---|---|---|
OnEnter | void OnEnter(TContext ctx) | マシンがこのステートに入るときに一度呼ばれます。 |
OnUpdate | void OnUpdate(TContext ctx, float deltaTime) | このステートがカレントの間、毎ティック呼ばれます。ChangeState を呼んでも構いません。 |
OnExit | void OnExit(TContext ctx) | マシンがこのステートを離れるときに一度呼ばれます。 |
IStateMachineService — ファクトリ
| メンバー | シグネチャ | 説明 |
|---|---|---|
Create<TContext> | IStateMachine<TContext> Create<TContext>(TContext context, StateMachineOptions options = default) | 新しいインスタンス単位のマシンを返します。ファクトリで作られたマシンは ActiveMachineCount で追跡されます。 |
ActiveMachineCount | int(プロパティ) | まだ破棄されていないファクトリ製マシンの数。new で直接作られたマシンは追跡されません。 |
使用例
using CommonGameSystem.Core;
using UnityEngine;
// The context: the object your states read and modify.
public class Enemy : MonoBehaviour
{
public bool SeesPlayer;
public float Health = 100f;
}
// Define state names as public const strings to avoid typos.
public static class EnemyStates
{
public const string Idle = "Idle";
public const string Chase = "Chase";
public const string Dead = "Dead";
}
// Minimal states — extend StateBase and override only what you need.
public class IdleState : StateBase<Enemy> { }
public class ChaseState : StateBase<Enemy> { }
public class DeadState : StateBase<Enemy> { }
[DefaultExecutionOrder(100)] // Run after the framework has started.
public class EnemyController : MonoBehaviour
{
private IStateMachine<Enemy> _fsm;
private ITimeService _time;
private void Awake()
{
_time = ServiceLocator.Resolve<ITimeService>();
var service = ServiceLocator.Resolve<IStateMachineService>();
_fsm = service.Create<Enemy>(GetComponent<Enemy>());
_fsm.Add(EnemyStates.Idle, new IdleState());
_fsm.Add(EnemyStates.Chase, new ChaseState());
_fsm.Add(EnemyStates.Dead, new DeadState());
// Declarative transitions (preferred).
_fsm.AddTransition(EnemyStates.Idle, EnemyStates.Chase, enemy => enemy.SeesPlayer);
_fsm.AddTransition(EnemyStates.Chase, EnemyStates.Idle, enemy => !enemy.SeesPlayer);
// Global interrupt: any state can go to Dead.
_fsm.AddTransition(StateMachine<Enemy>.AnyState, EnemyStates.Dead, enemy => enemy.Health <= 0);
_fsm.Start(EnemyStates.Idle);
}
private void Update()
{
// The machine reads no clock — you pass the delta, so you pick the clock.
_fsm.Tick(_time.DeltaTime(Clock.Gameplay));
}
private void OnDestroy() => _fsm.Dispose();
}
無効化する
ServiceLocator.Replace<IStateMachineService>(new NullStateMachineService());
これでステートマシンがミュートされます:Tick、ChangeState、Start は no-op になり、CurrentStateName は常に null、StateChanged は発火せず、ActiveMachineCount は 0 です。呼び出しコードを変えずに、テスト中の AI やメニューフローを無効化するのに便利です。no-op サービスは構築時に 1 回だけ警告をログし、以後の各 Create<T> 呼び出しは無音です。
よくある落とし穴
- メインスレッド専用です。
Tick、ChangeState、Startはすべてメインスレッドで実行しなければなりません。ワーカースレッドからの呼び出しはエディタではInvalidOperationExceptionを投げます(このチェックは IL2CPP リリースビルドでは取り除かれます)。マシンはフレームループ上に置いてください。 - クロックを選ぶのはあなたです。 マシンはクロックを読みません —
deltaTimeをTickに渡します。ゲームと一緒にポーズする挙動にはtimeService.DeltaTime(Clock.Gameplay)を渡します。ポーズやスローモーション中もティックし続けるにはtimeService.UnscaledDeltaTime(Clock.Gameplay)を渡します。独立した「unscaled」クロックはありません:クロックはGameplay、UI、Backgroundで、それぞれがスケール済みと非スケールの両方の読み値を持ちます — Time を参照してください。 - ガードは軽く保ってください。 遷移ガードはすべてのマシンで毎ティック実行されます。ガード内の高コストな処理 — レイキャスト、経路探索、アロケーション — はマシン数 × 遷移数で膨れ上がります。そうした結果は
Tickを呼ぶ前にコンテキストオブジェクト上で計算してキャッシュしてください。 - struct コンテキストは変更を保持しません。
TContextがstructの場合、OnUpdate内での変更はローカルコピーにしか影響せず失われます。可変の共有コンテキストとして参照型 — 通常は所有側の MonoBehaviour やエンティティ — を使ってください。 - struct コンテキストと IL2CPP。
TContextが値型の場合、ジェネリックのインスタンス化StateMachine<YourStruct>が IL2CPP のコードストリッピングを生き残るよう、プロジェクトにlink.xmlエントリを追加してください。クラスのコンテキストにはエントリ不要です。
動作保証
- ステートフックの内側から要求された
ChangeStateは、現在のフックが終わった後に適用されます。複数要求された場合は最後のものが勝ちます。マシンはこれを再帰ではなくループで処理します — 遷移の連鎖でスタックがあふれることはありません。 AnyStateのグローバル割り込みは、常に現在ステート自身の遷移より先に評価されます。default(StateMachineOptions)が安全な構成です:警告オン、ステートの例外は封じ込め、現在のステートへの遷移は no-op。- このマシンはフラットな FSM です — 組み込みの階層やステートのスタックはありません。スタック可能で再開可能なゲームステートにはプッシュダウンスタックを参照してください。バックスタック式のメニューナビゲーションには、UI フレームワークの UI パネルスタックを使ってください。
関連ページ
- Time —
Tickを駆動するポーズ対応クロック - プッシュダウンスタック — スタック可能で再開可能なステート
- UI フレームワーク — メニュー用の UI パネルスタック
- はじめに — フレームワークのインストールと起動