🍓

[Unity]状態制御のためにImtStateMachineを使ってみた

2024/04/05に公開

はじめに

ステートマシンに関してあれこれ調べていたらImtStateMachineについての記事を見つけ、「なるほど、これは便利そうだ。」と思ったので実際に使ってみます。
導入方法やステートマシンとは何かという説明については、リンクの記事を見てみてください。

https://qiita.com/BelColo/items/a94c9ccc2d5174dc29a3
https://elekibear.com/post/20211230_01

やること


capsuleがプレイヤー、cubeがエネミー

上のGifのcapsuleがプレイヤー、cubeがエネミーと思ってください。
プレイヤーとエネミーは下記の仕様のもと動いています。

  • プレイヤー

    • WSADキーで移動する
  • エネミー

    • 通常は待機モード
    • 待機モード時には必ず索敵範囲の中心にいるようにする
    • 索敵範囲内(青い円上)にプレイヤーが侵入したら戦闘モードに
    • 戦闘モードではプレイヤーを追跡する
    • プレイヤーが索敵範囲外に出たら待機モードに戻る

上記のエネミーの挙動をImtStateMachineを用いて実装していきます。
(今回プレイヤーは移動しか行わないので、プレイヤーのコードは割愛します。)

実装

今回はImtStateMachineを触ることが目的なので、あまり凝った設計にはせず、下記のようなシンプルな実装にします。
エネミーのステートは、待機(Idle)と戦闘(Battle)の2つとしておきます。

役割 クラス名
プレイヤー プレイヤー制御 PlayerController
エネミー エネミー制御 EnemyController
エネミー 待機状態制御 EnemyState_Idle
エネミー 戦闘状態制御 EnemyState_Battle
ステートマシン 状態制御管理 ImtStateMachine

ひとまず、EnemyController、EnemyState_Idle、EnemyState_Battleのコードを連続して貼り付けておきます。

EnemyController
EnemyController
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using IceMilkTea.Core;

/*
 * 
 * @ エネミー制御クラス
 * 
 */

public class EnemyController : MonoBehaviour
{
    //! ステート名定義
    public enum States
    {
        Idle,  // 待機
        Battle // 戦闘
    }

    //! 索敵範囲半径
    [SerializeField]
    protected float _searchRange = 10f;
    public float SearchRange => _searchRange;


    //! 攻撃範囲半径
    [SerializeField]
    protected float _attackRange = 2f;
    public float AttackRange => _attackRange;

    //! 移動速度
    [SerializeField]
    protected float _moveSpeed = 1f;
    public float MoveSpeed => _moveSpeed;


    //! ステートマシン
    private ImtStateMachine<EnemyController> _stateMachine;

    //! 現在のステート
    protected States _nowStates = new States();

    //! プレイヤー制御クラス
    protected PlayerController _player = null;
    public PlayerController Player => _player;

    //! 初期位置
    protected Vector3 _rootPos = new Vector3();
    public Vector3 RootPos => _rootPos;


    /*
     * @ 開始時処理
     */
    private void Awake()
    {
        // 初期位置保存
        _rootPos = this.transform.position;

        // プレイヤー
        _player = FindObjectOfType<PlayerController>();

        // ステートマシンセットアップ
        _stateMachine = new ImtStateMachine<EnemyController>(this);
        _stateMachine.AddTransition<EnemyState_Idle, EnemyState_Battle>((int)States.Battle);
        _stateMachine.AddTransition<EnemyState_Battle, EnemyState_Idle>((int)States.Idle);

        // 起動ステートを設定
        _stateMachine.SetStartState<EnemyState_Idle>();
    }

    /*
     * @ 更新処理
     */
    private void Update()
    {
        _stateMachine.Update();
    }

    /*
     * @ ステートの変更処理
     */
    public void ChangeState(States nextStates)
    {
        _stateMachine.SendEvent((int)nextStates);

        _nowStates = nextStates;
    }

    /*
     * @ プレイヤーが指定範囲内に存在するか判定
     */
    public bool IsNearPlayer(Vector3 basePos, float range)
    {
        var distance = Vector3.SqrMagnitude(basePos - _player.transform.position);

        return distance <= Mathf.Pow(range, 2);
    }

    /*
     * @ 指定した対象へ近寄る
     */
    public void ApproachTarget(Vector3 target)
    {
        this.transform.position = Vector3.MoveTowards(this.transform.position, target, _moveSpeed * Time.deltaTime);
    }
}

EnemyState_Idle
EnemyState_Idle
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using IceMilkTea.Core;

/*
 * 
 * @ エネミー 待機状態制御クラス
 * 
 */
public class EnemyState_Idle : ImtStateMachine<EnemyController>.State
{
    /*
     * @ ステート開始時処理
     */
    protected internal override void Enter()
    {
        Debug.Log("Enter Idle");
    }

    /*
     * @ ステート更新処理
     */
    protected internal override void Update()
    {
        // 索敵
        if (Context.IsNearPlayer(Context.RootPos, Context.SearchRange))
        {
            // 索敵範囲内にプレイヤーが入ったのでBattleへ
            Context.ChangeState(EnemyController.States.Battle);
        }
        else
        {
            Context.ApproachTarget(Context.RootPos);
        }
    }

    /*
     * @ ステート終了処理
     */
    protected internal override void Exit()
    {
        Debug.Log("Exit Idle");
    }
}
EnemyState_Battle
EnemyState_Battle
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using IceMilkTea.Core;

/*
 * 
 * @ エネミー 戦闘状態制御クラス
 * 
 */
public class EnemyState_Battle : ImtStateMachine<EnemyController>.State
{
    /*
     * @ ステート開始時処理
     */
    protected internal override void Enter()
    {
        Debug.Log("Enter Battle");
    }

    /*
     * @ ステート更新処理
     */
    protected internal override void Update()
    {
        // プレイヤーが索敵範囲内に存在するか判定
        if (Context.IsNearPlayer(Context.RootPos, Context.SearchRange))
        {
            // 攻撃可能範囲にプレイヤーが存在するか判別
            if (Context.IsNearPlayer(Context.transform.position, Context.AttackRange))
            {
                // いるので攻撃
            }
            else
            {
                // いないので接近
                Context.ApproachTarget(Context.Player.transform.position);
            }
        }
        else
        {
            // プレイヤーが索敵範囲外になったのでIdleへ
            Context.ChangeState(EnemyController.States.Idle);
        }
    }

    /*
     * @ ステート終了処理
     */
    protected internal override void Exit()
    {
        Debug.Log("Exit Battle");
    }
}

説明

using IceMilkTea.Core;

まず、ImtStateMachineを使用するにはusingに上記を追加する必要があります。

敵制御クラス EnemyController

敵のパラメータの保持や変更、各ステートで共通して使う判定や移動などを行うクラスです。

    
    //! ステートマシン
    private ImtStateMachine<EnemyController> _stateMachine;

    private void Awake()
    {
        // ステートマシンセットアップ
        _stateMachine = new ImtStateMachine<EnemyController>(this);
    }

EnemyControllerクラスのステートマシンを変数として定義します。
Awake内で初期化する際に(this)で自分自身を渡します。
これで、このEnemyController内でステートマシンを扱えるようになります。


    //! ステート名定義
    public enum States
    {
        Idle,  // 待機
        Battle // 戦闘
    }

    private void Awake()
    {
        // ステートマシンセットアップ
        _stateMachine.AddTransition<EnemyState_Idle, EnemyState_Battle>((int)States.Battle);
        _stateMachine.AddTransition<EnemyState_Battle, EnemyState_Idle>((int)States.Idle);
    }

実際にステートマシンの遷移を組んでいきます。

遷移はAddTransitionで追加することができます。
<EnemyState_Idle, EnemyState_Battle>というのが2つの状態制御クラス間の遷移を表してます。
今回で言えば、EnemyState_Idle(待機状態)からEnemyState_Battle(戦闘状態)へ遷移可能、という意味です。
そのあとに続いている((int)States.Battle)という部分で、遷移するためのトリガーをintで設定しています。
Animatorを想像してもらえるとわかりやすいでしょうか?
下図のような感じです。

同様にして逆方向の遷移、EnemyState_Battle(戦闘状態)からEnemyState_Idle(待機状態)の遷移も定義しています。

ちなみに、トリガーの設定をわざわざenumからintにキャストしているのは単純にわかりやすくするためです。
今回は2ステートしかないので0,1で直接指定しても問題ないのですが、後々ステートの数が5個とかになるともう制御しきれなくなると思います。
おそらくどこかにメモしないと忘れる。
それよりも、最初から「((int)States.Battle)を渡したら戦闘状態に遷移する。」となっていた方がわかりやすいと思います。

また、現在のステート状況(_nowStates)をenumで定義できるのも便利です。
_stateMachine.CurrentStateNameでも現在のステートにアクセスできるようImtStateMachine側で用意してくれていますが、クラス名をstringに変換したもので少し使いにくいなと。

    /*
     * @ 更新処理
     */
    private void Update()
    {
        _stateMachine.Update();
    }

続いて大事なのが、上記の_stateMachine.Update()。
これを呼び出さないとステートマシン側の更新処理が走らず、ステート遷移も内部処理も行ってくれません。
逆に言えば、任意のタイミングでステートマシン側の更新処理を行えるということでもありますね。

敵の状態制御クラス EnemyState_○○

public class EnemyState_Idle : ImtStateMachine<EnemyController>.State

状態制御クラス側では、上記のようにImtStateMachine<T>.Stateを継承します。
わざわざ別クラスとして実装せず、EnemyController内に実装しても大丈夫です。(クラスが長くなるという点はpartialで補える。)
自分は一つのクラスが大きくなりすぎるのがあまり好きではないのと、このEnemyState_○○が基底クラスになる可能性もあるので、今回は別クラスとしました。

    /*
     * @ ステート開始時処理
     */
    protected internal override void Enter()
    {
        Debug.Log("Enter Idle");
    }

    /*
     * @ ステート更新処理
     */
    protected internal override void Update()
    {
        // 索敵
        if (Context.IsNearPlayer(Context.RootPos, Context.SearchRange))
        {
            // 索敵範囲内にプレイヤーが入ったのでBattleへ
            Context.ChangeState(EnemyController.States.Battle);
        }
        else
        {
            Context.ApproachTarget(Context.RootPos);
        }
    }

Enter()がそのステートの開始時処理、Update()が更新処理、Exit()が終了処理です。
待機の状態では常にプレイヤーを探し続けてほしいので、Update内に索敵処理を実装しています。
その他にもいろいろと関数が実装されているので、冒頭に張り付けた記事を見てみてください。
(関数を辿れば自分自身でもいろいろとImtStateMachineの中身を見ることができます。)

なお、元のクラス(EnemyController)の変数や関数にはContext.~でアクセスできます。
これのおかげで、複数の状態制御クラスで共通して使いたい変数や関数をControllerのクラスにまとめておくことができます。
処理やパラメータが重複したり、逐一パラメータを渡したりしなくて良いので非常に助かります。
その代わり、元のクラスのほうでアクセス修飾士をpublicにしておく必要があります。

わざわざ別クラスとして実装せず、EnemyController内に実装しても大丈夫です。(クラスが長くなるという点はpartialで補える。)

一点補足ですが、上記のような実装にすると状態制御クラスからEnemyControllerの変数や関数へContext.~を経由しなくてもアクセスできるようになります。
publicにする必要もないので、コードはすっきりします。
ただし、アクセスし放題ということではあるので、クラス内にまとめるか別クラスにするかはプロジェクトと要相談だと思いました。

おわりに、感想

いや、便利ですね。
導入がとても簡単なうえに一通りの機能が揃っているので、自分でなんちゃってステートマシンを実装する必要がないというのはかなり手間が省けます。
割り込みや例外処理を自分で実装するとなるとかなり骨が折れるので、それが最初から揃っているのはありがたい限りです。
今回は簡単なEnemyの動きを実装したにすぎませんが、大抵のゲーム開発はImtStateMachineで事足りるのではないかと思いました。
ステートマシンを実装した元クラス(今回で言えばEnemyController)へのアクセスが簡単なのも最高です。

という感じで、結局まとまりのない記事になってしまいましたが、何かのお役に立てば幸いです。

Discussion