はじめに
OX ENGINEER STUDIO所属クライアントエンジニアの新山と申します。
ゲーム開発において、キャラクターや敵の行動制御は非常に重要な要素です。
本記事では、Unityを使用してステートマシン実装を行い、2回に分けて解説していきます。
今回は状態のクラス化についてです。
前編をまだご覧いただけていない方は、先に「UnityでStateMachineを作ってみる 前編」からご覧ください。
これからステートマシンを導入したい方や、既存の設計を見直したい方にとって参考になれば幸いです。
クラス化
ステートのクラス化
- 一つのクラスで一つのステートのふるまいを表現します。
- 各ステートお互いに疎結合になるように設計します。
- 基本的にステートは自分のふるまいにだけ責任を持ち、他ステートの実装に依存しないようにします。
- ステートの切り替えはステートマシンが行います。
- ステート側はあくまで変更先のリクエストをするだけです。
- 各ステートは各種Componentを直接操作しません。
- Monobehaviorを継承したクラスの参照を渡すので、そこに機能を実装してそれを呼び出します。
- 共有できる機能を実装し、ステートはそれを必要に応じて呼び出すだけです。
- どうしても他ステートと情報のやり取りが必要な場合はコンテキストクラスを使用します。
ステートの基礎となるinterface
全ステートで実装したいものは以下の関数です。
// ステートのひな形
public interface IState
{
// ステートに入った時に実行
public void Enter();
// ステートが有効時毎フレーム実行
public void Update();
// ステートから出るときに実行
public void Exit();
}
今回はこれを継承した抽象クラスを作成し、それをベースとして実装していきます。
また、ステートに「次殿ステートに遷移したいか」を持たせたかったので、ジェネリック型で渡すようにしています。
ステートマシンは実行中のステートの遷移リクエストト遷移先を見て、ステートを切り替えるようにします。
ステートの基底クラス
基底クラス
public abstract class StateBase<T>:IState
{
// ステート変更のリクエストフラグ
public bool _isRequestTransrate = false;
// リクエスト用に遷移先のステートのEnumを保持する。
public T _transrateState;
// IState用
public abstract void Enter();
public abstract void Update();
public abstract void Exit();
// ステートの変更リクエスト。設定すればステートマシン側で変更してくれる
protected void ChangeStateRequest(T nextState)
{
// リクエストフラグを立てて
_isRequestTransrate = true;
// 遷移先を更新する
_transrateState = nextState;
}
}
abstractにすることで各種ステートに以下のメソッドの実装を強制しています。
〇Enter
・ステート開始時に呼ばれる
〇Update
・ステート有効時、毎フレーム呼ばれる
〇Exit
・他ステートに遷移する前に実行される
なぜMonoBehaviourを継承しないか
ステートは振る舞いの塊であり、コンポーネントではない
MonoBehaviourはゲームオブジェクトにアタッチされるコンポーネントとしての責務を持つが、ステートはあくまで「データと処理のセット」であり、単体でシーン上に存在して欲しくない。
インスタンス生成の柔軟性
MonoBehaviourはAddComponentに依存するため、ステートの切り替えや一時的生成が煩雑になる。
ステートはComponentではない。- プレハブやシーン構造に依存せず、コード上で軽量に生成・破棄できる設計とするため、通常のクラスとして実装している。
ステートマシン本体
さて、ステートの基礎となる抽象クラスを作ったところで、一旦ステートマシン本体を実装していきましょう。
ステートマシン
public class StateMachine<T>
{
// ステート辞書 Enumとステートの紐づけをする
private readonly Dictionary<T, StateBase<T>> _states = new();
// 今のステート
private StateBase<T> _current;
// 辞書に登録
public void Register(T key, StateBase<T> state)
{
// 追加出来ればtrue、既にキーが存在していたらfalseを返す
if (_states.TryAdd(key, state))
return;
// 重複はできないのでエラー表示をする
Debug.LogError($”StateMachine: Key {key} is already registered.”);
}
// ステート変更
public void ChangeState(T key)
{
// 無いなら警告出して処理しない
if (!_states.ContainsKey(key))
{
Debug.LogWarning($”State {key} is not registered.”);
return;
}
// 今のステートの終了時処理の呼び出し
_current?.Exit();
// ステートの変更
_current = _states[key];
// 変更したステートの開始処理の呼び出し
_current?.Enter();
}
// ステートが遷移をリクエストしていないか確認する。
private void CheckTransrate()
{
// ステートの変更リクエストの確認
if (_current._isRequestTransrate)
{
// 次のステートに変更
ChangeState(_current._transrateState);
// フラグを戻す
_current._isRequestTransrate = false;
}
}
// 更新処理
public void Update()
{
if(_current==null)
return;
// 更新
_current.Update();
// 遷移リクエストの確認
CheckTransrate();
}
}
ステートの更新
ステート変更時、各ステートからステートマシンに変更の命令をさせたくなかったので、ステート側の変更リクエストフラグ(_isRequestTransrate)がtrueだった場合、遷移先(_transrateState)に変更するようにしています。
各ステートは以下のような形で変更をリクエストします。
変更リクエスト
// 追跡に変更のリクエスト
ChangeStateRequest(EnemyStateID.Chase);
なぜ辞書を持っているか?
ステートの変更時、定義したEnumを渡すようにしたかったからです。
Enumをキーとして、各ステートを紐づけています。
こうすることで呼び出す側は
ステートの変更
_stateMachine.ChangeState(EnemyStateID.Chase);
このように、ステートのIDを渡すだけでステートの変更ができるようになります。
ステートの追加手順
ステートの追加手順は以下のようになります。
1. StateBaseを継承した新しいステートクラスを作成します。
2. Enter, Update, Exit の各関数を実装します。
3. ステートマシンを使用するクラスの Start 関数内で
ステートマシンのRegister関数を使ってステートクラスをステートマシンに登録します。
4. 必要に応じて、他ステートから ChangeStateRequest関数を呼び出して遷移させます。
ステートをクラス化し、ステートマシンで管理することで、各ステートの責務が明確になり、保守性・拡張性が大幅に向上しました。
今回の設計は敵AIに限らず、プレイヤー操作やUI制御など、さまざまな場面で応用できます。
ぜひ皆さんのプロジェクトに合わせてカスタマイズしてみてください。
最後に、簡単な敵AIのステートの実装例を紹介します。
実装例
敵ステートの基底クラス
敵のステートを例として実装してみましょう。まずは基底クラスから
敵ステート基底クラス
public abstract class EnemyStateBase : StateBase<EnemyStateID>
{
protected Enemy _owner = null;
public abstract override void Enter();
public abstract override void Update();
public abstract override void Exit();
}
本体の参照を_ownerとして持つことで、移動・ターゲット取得・捜索など、本体依存の処理を状態ごとに記述することができます。
ステート例
例としての待機ステートです。
※細かい処理は仮であり、一部省略しています。
ステート
public class EnemyIdleState : EnemyStateBase
{
public EnemyIdleState(Enemy owner)
{
_owner = owner;
}
public override void Enter()
{
if (!owner)
return;
// #MEMO ここに開始時処理を書く
// アニメーションの制御
_owner.PlayAnimation(AnimationID.EnemyIdle);
// idleステートは移動しない
_owner.SetMoveSpeed(0.0f);
}
public override void Update()
{
if (!owner)
return;
// プレイヤーの捜索(視界内にいるかの判定)
if (_owner.Search(GameManager.Instance().player))
{
// 次のステートに変更する
ChangeStateRequest(EnemyStateID.Chase);
return;
}
}
public override void Exit()
{
if (!owner)
return;
// #MEMO ここに終了時処理を書く
}
}
敵クラス
敵クラス
public class Enemy : MonoBehaviour
{
// ステートマシン本体
private StateMachine<EnemyStateID> _stateMachine;
void Start()
{
_stateMachine = new StateMachine<EnemyStateID>();
// IDとステートを紐づけて登録する。
_stateMachine.Register(EnemyStateID.Idle,new EnemyIdleState(this));
_stateMachine.Register(EnemyStateID.Chase,new EnemyMoveState(this));
// ステートはIdelで初期化する
_stateMachine.ChangeState(EnemyStateID.Idle);
}
// Update is called once per frame
void Update()
{
// 現在のステートの更新処理
_stateMachine.Update();
}
}
最後に
いかがだったでしょうか。
私自身、学生時代はUnityでゲーム制作をしており、その際にステートマシンを取り入れていました。
ひな形を作り、ルールを作ることで変更や拡張が容易になったかと思います。
デザインパターンを取り入れる最初の一歩として皆さまのお力になれれば幸いです。
ここまでご覧いただき、ありがとうございました!

