Loading...

【Unity】カニの動きで学ぶ有限ステートマシン(FSM)【ゲームAI】

Unity
マイケル
マイケル
みなさんこんにちは!
マイケルです!
エレキベア
エレキベア
こんにちクマ〜〜〜
マイケル
マイケル
今日はUnityで 有限ステートマシン(FSM)
について実装していくよ!
エレキベア
エレキベア
ステートマシンって何クマ?
マイケル
マイケル
有限ステートマシンはゲームAIでよく使われていて、
下記のように ステートを遷移させることでふるまいを表現するモデル のことなんだ!
有限ステートマシン(Finite State Machine)とは
  • 有限個のステートを持ち、入力を処理することで別のステートへ遷移する。
  • 同時にとることが出来るステートは1つだけ。
  • 有限オートマトン有限状態機械とも呼ばれる。
Image 20211230 015537↑ステート遷移例
マイケル
マイケル
例えばUnityのAnimatorもステートマシンに含まれるよ!
ScreenShot 2021 11 23 13 03 29↑Animatorもステートマシンである
エレキベア
エレキベア
なるほどクマ
ステートを制御するためのマシンなのクマね(?)
マイケル
マイケル
今回はこれをスクリプトで実装した例を紹介します!
実装したサンプルはGitHubにあげているので、よければ参考にお使いください!

GitHub – unity-state-machine-sample

マイケル
マイケル
それでは早速やっていこう!
スポンサーリンク

参考書籍

マイケル
マイケル
書籍は主に下記を参考にさせていただきました!
ステートマシンは序章にすぎず、実例を交えて様々なゲームAIの実装が紹介されているので、
気になった方は読んでみてください!

実例で学ぶゲームAIプログラミング

マイケル
マイケル
そしてステート遷移に関する実装はゲーム開発の様々な書籍で紹介されていて、
例えば下記の書籍内でも紹介されていました。
気になった方は是非いろいろ漁ってみてください!

Game Programming Patterns ソフトウェア開発の問題解決メニュー impress top gearシリーズ

ゲームプログラミングC++

マイケル
マイケル
どれも参考になりますが、全てC++で実装されている点には注意です。
エレキベア
エレキベア
ステート遷移は定番なのクマね

有限ステートマシンの実装

マイケル
マイケル
それでは実装に移りたいと思います。
今回は下記のようなカニの動きを実現させます!
Image 20211230 012308↑実現するカニの動き
01 kani state↑実行すると動く
エレキベア
エレキベア
なんか見てると面白いクマね
マイケル
マイケル
常に働き続ける社畜カニになります・・・

プロジェクト構成

マイケル
マイケル
ゲームオブジェクトの構成は下記の通り!
各サンプルごとにシーンを用意していますが、ステージはどのシーンも共通で使用していて、
オブジェクトはStageManagerクラスで管理しています。
ScreenShot 2021 12 30 11 12 58↑Stageは共通で使用
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// ステージ管理クラス
/// </summary>
public class StageManager : MonoBehaviour
{
    [SerializeField] public Transform homeTransform; // 家の位置情報
    [SerializeField] public Transform seaTransform;  // 海の位置情報
    [SerializeField] public GameObject fishPrefab;   // 魚Prefab
}
↑オブジェクトはStageManager内で定義
マイケル
マイケル
サンプルは下記4つを用意していますが、実現する動きは全て同じになります。
また、凝れば凝るほど複雑さも増すため、どの方法がいいとも一概には言えません。
そのためケースバイケースで選ぶようにしてください。


[実装方法サンプル]
Sample01 enumを使用した実装
Sample02 classを使用した実装
Sample03 Sample02をステートマシンとして切り出したもの
Sample04 ステートマシンにステート状態の遷移情報を定義したもの

エレキベア
エレキベア
どれも一長一短あるクマね
マイケル
マイケル
基本的には、規模が大きくなったり複雑なステート遷移をする場合には
ステートマシンとしてクラスを切り出す
のがいいと思います。

Sample01: enumを使用した実装

マイケル
マイケル
サンプル1つ目は、最もシンプルなステート遷移の実装です。
各ステートをenum型で定義してswitch文で切り替えるといった方法になります。

        /// <summary>
        /// ステート
        /// </summary>
        private enum StateType
        {
            MoveSea,  // 海へ移動
            Hunting,  // 魚採取
            MoveHome, // 家へ移動
            Eating,   // 食事
        }
        private StateType _state = StateType.MoveSea;     // 現在のステート
        private StateType _nextState = StateType.MoveSea; // 次のステート

        private void Start()
        {
            // 最初のステートを開始する
            MoveSeaStart();
        }

        private void Update()
        {
            // 現在のステートのUpdateを呼び出す
            switch (_state)
            {
                case StateType.MoveSea:
                    MoveSeaUpdate();
                    break;
                case StateType.Hunting:
                    HuntingUpdate();
                    break;
                case StateType.MoveHome:
                    MoveHomeUpdate();
                    break;
                case StateType.Eating:
                    EatingUpdate();
                    break;
            }

            // ステートが切り替わったら
            if (_state != _nextState)
            {
                // 終了処理を呼び出して
                switch (_state)
                {
                    case StateType.MoveSea:
                        MoveSeaEnd();
                        break;
                    case StateType.Hunting:
                        HuntingEnd();
                        break;
                    case StateType.MoveHome:
                        MoveHomeEnd();
                        break;
                    case StateType.Eating:
                        EatingEnd();
                        break;
                }

                // 次のステートに遷移する
                _state = _nextState;
                switch (_state)
                {
                    case StateType.MoveSea:
                        MoveSeaStart();
                        break;
                    case StateType.Hunting:
                        HuntingStart();
                        break;
                    case StateType.MoveHome:
                        MoveHomeStart();
                        break;
                    case StateType.Eating:
                        EatingStart();
                        break;
                }
            }
        }

        /// <summary>
        /// 遷移先のステート設定
        /// </summary>
        /// <param name="nextState">次のステート</param>
        private void ChangeState(StateType nextState)
        {
            _nextState = nextState;
        }
↑enum型からswitch文で切り替える方法
マイケル
マイケル
各ステートの処理については
以下のように関数ごとに分けたものになります。

        // 各ステート処理
        // ----- move sea -----
        private void MoveSeaStart()
        {
            Debug.Log("start move sea");
        }

        private void MoveSeaUpdate()
        {
            var enemyPosition = transform.position;
            var targetPosition = stageManager.seaTransform.position;
            // 海へ到着したら次のステートへ
            if (Vector3.Distance(enemyPosition, targetPosition) < 0.5f)
            {
                ChangeState(StateType.Hunting);
                return;
            }
            // 海へ向かう
            transform.position = Vector3.MoveTowards(
                enemyPosition,
                targetPosition, 
                5.0f * Time.deltaTime);
            transform.LookAt(targetPosition);
        }

        private void MoveSeaEnd()
        {
            Debug.Log("end move sea");
        }
        
        // ----- hunting -----
        private void HuntingStart()
        {
            Debug.Log("start hunting");
            // 採取スタート
            _isFinishHunting = false;
            StartCoroutine(HuntCoroutine());
        }

        private void HuntingUpdate()
        {
            // 採取が完了したら次のステートへ
            if (_isFinishHunting)
            {
                ChangeState(StateType.MoveHome);
            }
        }

        private void HuntingEnd()
        {
            Debug.Log("end hunting");
        }
        
        // 採取が完了しているか?
        private bool _isFinishHunting;
        
        // 採取コルーチン
        private IEnumerator HuntCoroutine()
        {
            // 狩猟中、数秒待機
            yield return new WaitForSeconds(2.0f);

            // 魚取得
            Instantiate(stageManager.fishPrefab, transform);
            
            // 狩猟完了
            _isFinishHunting = true;
        }
        
        // ----- move home -----
        private void MoveHomeStart()
        {
            Debug.Log("start move home");
        }

        private void MoveHomeUpdate()
        {
            var enemyPosition = transform.position;
            var targetPosition = stageManager.homeTransform.position;
            // 家へ到着したら次のステートへ
            if (Vector3.Distance(enemyPosition, targetPosition) < 0.5f)
            {
                ChangeState(StateType.Eating);
                return;
            }
            // 家へ向かう
            transform.position = Vector3.MoveTowards(
                enemyPosition,
                targetPosition, 
                5.0f * Time.deltaTime);
            transform.LookAt(targetPosition);
        }

        private void MoveHomeEnd()
        {
            Debug.Log("end move home");
        }
        
        // ----- eating -----
        private void EatingStart()
        {
            Debug.Log("start eating");
            // 食事開始
            _isFinishEating = false;
            StartCoroutine(EatCoroutine());
        }

        private void EatingUpdate()
        {
            // くるくる周る
            transform.Rotate(Vector3.up * 500.0f * Time.deltaTime);
                
            // 食事が完了したら次のステートへ
            if (_isFinishEating)
            {
                ChangeState(StateType.MoveSea);
            }
        }

        private void EatingEnd()
        {
            Debug.Log("end eating");
        }
        
        // 食事が完了しているか?
        private bool _isFinishEating;
            
        // 食事コルーチン
        private IEnumerator EatCoroutine()
        {
            // 食事中、数秒待機
            yield return new WaitForSeconds(5.0f);
                
            // 子オブジェクト(魚)を破棄
            foreach (Transform child in transform)
            {
                Destroy(child.gameObject);
            }
            
            // 食事完了
            _isFinishEating = true;
        }
↑各ステートの処理
マイケル
マイケル
簡単な実装できるのが魅力ですが、ステートや条件が追加されるほどあっという間にスパゲティ状態になってしまうというデメリットがあります。
エレキベア
エレキベア
今回の例でも読むのが少し辛いクマね・・・

Sample02: classを使用した実装

マイケル
マイケル
そこで各ステート処理をクラスごとに分けた場合が次の実装になります。
ステートの基底クラスを継承して定義するようにしています。

        // 各ステート処理
        // --- state基底クラス -----
        private class StateBase
        {
            public virtual void OnStart(Enemy owner) { }

            public virtual void OnUpdate(Enemy owner) { }

            public virtual void OnEnd(Enemy owner) { }
        }
        
        // ----- move sea -----
        private class StateMoveSea : StateBase
        {
            public override void OnStart(Enemy owner)
            {
                Debug.Log("start move sea");
            }

            public override void OnUpdate(Enemy owner)
            {
                var enemyPosition = owner.transform.position;
                var targetPosition = owner.stageManager.seaTransform.position;
                // 海へ到着したら次のステートへ
                if (Vector3.Distance(enemyPosition, targetPosition) < 0.5f)
                {
                    owner.ChangeState(StateType.Hunting);
                    return;
                }
                // 海へ向かう
                owner.transform.position = Vector3.MoveTowards(
                    enemyPosition,
                    targetPosition, 
                    5.0f * Time.deltaTime);
                owner.transform.LookAt(targetPosition);
            }

            public override void OnEnd(Enemy owner)
            {
                Debug.Log("end move sea");
            }
        }

        // ----- hunting -----
        private class StateHunting : StateBase
        {
            public override void OnStart(Enemy owner)
            {
                Debug.Log("start hunting");
                // 採取スタート
                _isFinishHunting = false;
                MonoBehaviorHandler.StartStaticCoroutine(HuntCoroutine(owner));
            }

            public override void OnUpdate(Enemy owner)
            {
                // 採取が完了したら次のステートへ
                if (_isFinishHunting)
                {
                    owner.ChangeState(StateType.MoveHome);
                }
            }

            public override void OnEnd(Enemy owner)
            {
                Debug.Log("end hunting");
            }
            
            // 採取が完了しているか?
            private bool _isFinishHunting;
            
            // 採取コルーチン
            private IEnumerator HuntCoroutine(Enemy owner)
            {
                // 狩猟中、数秒待機
                yield return new WaitForSeconds(2.0f);

                // 魚取得
                Instantiate(owner.stageManager.fishPrefab, owner.transform);
            
                // 狩猟完了
                _isFinishHunting = true;
            }
        }

        // ----- move home -----
        private class StateMoveHome : StateBase
        {
            public override void OnStart(Enemy owner)
            {
                Debug.Log("start move home");
            }

            public override void OnUpdate(Enemy owner)
            {
                var enemyPosition = owner.transform.position;
                var targetPosition = owner.stageManager.homeTransform.position;
                // 家へ到着したら次のステートへ
                if (Vector3.Distance(enemyPosition, targetPosition) < 0.5f)
                {
                    owner.ChangeState(StateType.Eating);
                    return;
                }
                // 家へ向かう
                owner.transform.position = Vector3.MoveTowards(
                    enemyPosition,
                    targetPosition, 
                    5.0f * Time.deltaTime);
                owner.transform.LookAt(targetPosition);
            }

            public override void OnEnd(Enemy owner)
            {
                Debug.Log("end move home");
            }
        }

        // ----- eating -----
        private class StateEating : StateBase
        {
            public override void OnStart(Enemy owner)
            {
                Debug.Log("start eating");
                // 食事開始
                _isFinishEating = false;
                MonoBehaviorHandler.StartStaticCoroutine(EatCoroutine(owner));
            }

            public override void OnUpdate(Enemy owner)
            {
                // くるくる周る
                owner.transform.Rotate(Vector3.up * 500.0f * Time.deltaTime);
                
                // 食事が完了したら次のステートへ
                if (_isFinishEating)
                {
                    owner.ChangeState(StateType.MoveSea);
                }
            }

            public override void OnEnd(Enemy owner)
            {
                Debug.Log("end eating");
            }
            
            // 食事が完了しているか?
            private bool _isFinishEating;
            
            // 食事コルーチン
            private IEnumerator EatCoroutine(Enemy owner)
            {
                // 食事中、数秒待機
                yield return new WaitForSeconds(5.0f);
                
                // 子オブジェクト(魚)を破棄
                foreach (Transform child in owner.transform)
                {
                    Destroy(child.gameObject);
                }
            
                // 食事完了
                _isFinishEating = true;
            }
        }
↑各ステートをクラスに分ける
マイケル
マイケル
クラスを分けることによってまとまりが分かりやすくなり、
下記のように呼び出し側もシンプルにすることができます。

        /// <summary>
        /// ステート
        /// </summary>
        private readonly StateMoveSea _stateMoveSea = new StateMoveSea();
        private readonly StateHunting _stateHunting = new StateHunting();
        private readonly StateMoveHome _stateMoveHome = new StateMoveHome();
        private readonly StateEating _stateEating = new StateEating();
        private enum StateType
        {
            MoveSea,  // 海へ移動
            Hunting,  // 魚採取
            MoveHome, // 家へ移動
            Eating,   // 食事
        }
        private StateBase GetState(StateType state)
        {
            switch (state)
            {
                case StateType.MoveSea:
                    return _stateMoveSea;
                case StateType.Hunting:
                    return _stateHunting;
                case StateType.MoveHome:
                    return _stateMoveHome;
                case StateType.Eating:
                    return _stateEating;
                default:
                    Debug.Log("not state!!");
                    return null;
            }
        }
        private StateBase _state; // 現在のステート

        private void Start()
        {
            _state = _stateMoveSea;
            _state.OnStart(this);
        }
        
        private void Update()
        {
            _state.OnUpdate(this);
        }

        /// <summary>
        /// ステート切替処理
        /// </summary>
        /// <param name="nextState"></param>
        private void ChangeState(StateType nextState)
        {
            _state.OnEnd(this);
            _state = GetState(nextState);
            _state.OnStart(this);
        }
↑呼び出し元もシンプルになる
エレキベア
エレキベア
だいぶ見やすくなったクマね

Sample03: ステートマシンとして切り出す

マイケル
マイケル
このステート基底クラスと関連処理を別クラス(StateMachine)として切り出したのがSample03になります。
下記のようにジェネリック型で定義することで他のクラスからも利用できるようになっています。
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

namespace Sample03
{
    /// <summary>
    /// ステートマシンクラス
    /// class定義の基底クラス切り出し版
    /// </summary>
    public class StateMachine<TOwner>
    {
        /// <summary>
        /// ステート基底クラス
        /// 各ステートクラスはこのクラスを継承する
        /// </summary>
        public abstract class StateBase
        {
            public StateMachine<TOwner> StateMachine;
            protected TOwner Owner => StateMachine.Owner;

            public virtual void OnStart() { }
            public virtual void OnUpdate() { }
            public virtual void OnEnd() { }
        }
        private TOwner Owner { get; }
        private StateBase _currentState; // 現在のステート
        private StateBase _prevState;    // 前のステート
        private readonly Dictionary<int, StateBase> _states = new Dictionary<int, StateBase>(); // 全てのステート定義

        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="owner">StateMachineを使用するOwner</param>
        public StateMachine(TOwner owner)
        {
            Owner = owner;
        }

        /// <summary>
        /// ステート定義登録
        /// ステートマシン初期化後にこのメソッドを呼ぶ
        /// </summary>
        /// <param name="stateId">ステートID</param>
        /// <typeparam name="T">ステート型</typeparam>
        public void Add<T>(int stateId) where T : StateBase, new()
        {
            if (_states.ContainsKey(stateId))
            {
                Debug.LogError("already register stateId!! : " + stateId);
                return;
            }
            // ステート定義を登録
            var newState = new T
            {
                StateMachine = this
            };
            _states.Add(stateId, newState);
        }
        
        /// <summary>
        /// ステート開始処理
        /// </summary>
        /// <param name="stateId">ステートID</param>
        public void OnStart(int stateId)
        {
            if (!_states.TryGetValue(stateId, out var nextState))
            {
                Debug.LogError("not set stateId!! : " + stateId);
                return;
            }
            // 現在のステートに設定して処理を開始
            _currentState = nextState;
            _currentState.OnStart();
        }

        /// <summary>
        /// ステート更新処理
        /// </summary>
        public void OnUpdate()
        {
            _currentState.OnUpdate();
        }
        
        /// <summary>
        /// 次のステートに切り替える
        /// </summary>
        /// <param name="stateId">切り替えるステートID</param>
        public void ChangeState(int stateId)
        {
            if (!_states.TryGetValue(stateId, out var nextState))
            {
                Debug.LogError("not set stateId!! : " + stateId);
                return;
            }
            // 前のステートを保持
            _prevState = _currentState;
            // ステートを切り替える
            _currentState.OnEnd();
            _currentState = nextState;
            _currentState.OnStart();
        }

        /// <summary>
        /// 前回のステートに切り替える
        /// </summary>
        public void ChangePrevState()
        {
            if (_prevState == null)
            {
                Debug.LogError("prevState is null!!");
                return;
            }
            // 前のステートと現在のステートを入れ替える
            (_prevState, _currentState) = (_currentState, _prevState);
        }
    }
}
↑StateMachineクラスの実装
エレキベア
エレキベア
ついにステートマシンのクラスが出来たクマね
マイケル
マイケル
呼び出し側では下記のようにこのクラスを変数として持つようにします。
初期化の際にStateを登録する必要があることには注意が必要です。

        /// <summary>
        /// ステート定義
        /// </summary>
        private enum StateType
        {
            MoveSea,  // 海へ移動
            Hunting,  // 魚採取
            MoveHome, // 家へ移動
            Eating,   // 食事
        }
        private StateMachine<Enemy> _stateMachine;
        
        private void Start()
        {
            // ステートマシン定義
            _stateMachine = new StateMachine<Enemy>(this);
            _stateMachine.Add<StateMoveSea>((int) StateType.MoveSea);
            _stateMachine.Add<StateHunting>((int) StateType.Hunting);
            _stateMachine.Add<StateMoveHome>((int) StateType.MoveHome);
            _stateMachine.Add<StateEating>((int) StateType.Eating);
            // ステート開始
            _stateMachine.OnStart((int) StateType.MoveSea);
        }

        private void Update()
        {
            // ステート更新
            _stateMachine.OnUpdate();
        }
↑呼び出し側の実装
エレキベア
エレキベア
めちゃくちゃシンプルになったクマね
マイケル
マイケル
各ステート処理については下記のようになります。
ここはSample02とほとんど変わらないですね。

        // 各ステート処理
        // ----- move sea -----
        private class StateMoveSea : StateBase
        {
            public override void OnStart()
            {
                Debug.Log("start move sea");
            }

            public override void OnUpdate()
            {
                var enemyPosition = Owner.transform.position;
                var targetPosition = Owner.stageManager.seaTransform.position;
                // 海へ到着したら次のステートへ
                if (Vector3.Distance(enemyPosition, targetPosition) < 0.5f)
                {
                    StateMachine.ChangeState((int) StateType.Hunting);
                    return;
                }
                // 海へ向かう
                Owner.transform.position = Vector3.MoveTowards(
                    enemyPosition,
                    targetPosition, 
                    5.0f * Time.deltaTime);
                Owner.transform.LookAt(targetPosition);
            }

            public override void OnEnd()
            {
                Debug.Log("end move sea");
            }
        }

        // ----- hunting -----
        private class StateHunting : StateBase
        {
            public override void OnStart()
            {
                Debug.Log("start hunting");
                // 採取スタート
                _isFinishHunting = false;
                MonoBehaviorHandler.StartStaticCoroutine(HuntCoroutine());
            }

            public override void OnUpdate()
            {
                // 採取が完了したら次のステートへ
                if (_isFinishHunting)
                {
                    StateMachine.ChangeState((int) StateType.MoveHome);
                }
            }

            public override void OnEnd()
            {
                Debug.Log("end hunting");
            }
            
            // 採取が完了しているか?
            private bool _isFinishHunting;
            
            // 採取コルーチン
            private IEnumerator HuntCoroutine()
            {
                // 狩猟中、数秒待機
                yield return new WaitForSeconds(2.0f);

                // 魚取得
                Instantiate(Owner.stageManager.fishPrefab, Owner.transform);
            
                // 狩猟完了
                _isFinishHunting = true;
            }
        }

        // ----- move home -----
        private class StateMoveHome : StateBase
        {
            public override void OnStart()
            {
                Debug.Log("start move home");
            }

            public override void OnUpdate()
            {
                var enemyPosition = Owner.transform.position;
                var targetPosition = Owner.stageManager.homeTransform.position;
                // 家へ到着したら次のステートへ
                if (Vector3.Distance(enemyPosition, targetPosition) < 0.5f)
                {
                    StateMachine.ChangeState((int) StateType.Eating);
                    return;
                }
                // 家へ向かう
                Owner.transform.position = Vector3.MoveTowards(
                    enemyPosition,
                    targetPosition, 
                    5.0f * Time.deltaTime);
                Owner.transform.LookAt(targetPosition);
            }

            public override void OnEnd()
            {
                Debug.Log("end move home");
            }
        }

        // ----- eating -----
        private class StateEating : StateBase
        {
            public override void OnStart()
            {
                Debug.Log("start eating");
                // 食事開始
                _isFinishEating = false;
                MonoBehaviorHandler.StartStaticCoroutine(EatCoroutine());
            }

            public override void OnUpdate()
            {
                // くるくる周る
                Owner.transform.Rotate(Vector3.up * 500.0f * Time.deltaTime);
                
                // 食事が完了したら次のステートへ
                if (_isFinishEating)
                {
                    StateMachine.ChangeState((int) StateType.MoveSea);
                }
            }

            public override void OnEnd()
            {
                Debug.Log("end eating");
            }
            
            // 食事が完了しているか?
            private bool _isFinishEating;
            
            // 食事コルーチン
            private IEnumerator EatCoroutine()
            {
                // 食事中、数秒待機
                yield return new WaitForSeconds(5.0f);
                
                // 子オブジェクト(魚)を破棄
                foreach (Transform child in Owner.transform)
                {
                    Destroy(child.gameObject);
                }
            
                // 食事完了
                _isFinishEating = true;
            }
        }
↑各ステート処理

Sample04: 遷移情報を定義する場合

マイケル
マイケル
最後はステート状態の遷移情報を定義したステートマシンクラスの実装例です。
こちらは下記動画と記事を参考にさせていただきました!
天神いなさんの動画ではライブラリ化まで説明してくださってるので、是非みてみてください。

FSMの実装方法とより良い使い方

マイケル
マイケル
この実装はステートの状態遷移図を元に定義する方法で、
下記のようにイベントごとに遷移情報を持たせる考え方(イベント駆動型)になっています。
イベント遷移元State遷移先State
食事終了EatingMoveSea
海に到着MoveSeaHunting
採取完了HuntingMoveHome
家に到着MoveHomeEating
マイケル
マイケル
StateMachineクラスの実装は以下のようになります。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace Sample04
{
    /// <summary>
    /// ステートマシンクラス
    /// 状態遷移の定義版
    /// </summary>
    public class StateMachine<TOwner>
    {
        /// <summary>
        /// ステート基底クラス
        /// 各ステートクラスはこのクラスを継承する
        /// </summary>
        public abstract class StateBase
        {
            public StateMachine<TOwner> StateMachine;
            protected TOwner Owner => StateMachine.Owner;
            public readonly Dictionary<int, StateBase> Transitions = new Dictionary<int, StateBase>(); // ステート遷移情報
            
            public virtual void OnStart() { }
            public virtual void OnUpdate() { }
            public virtual void OnEnd() { }
        }
        private TOwner Owner { get; }
        private StateBase _currentState; // 現在のステート
        private readonly LinkedList<StateBase> _states = new LinkedList<StateBase>(); // 全てのステート定義

        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="owner">StateMachineを使用するOwner</param>
        public StateMachine(TOwner owner)
        {
            Owner = owner;
        }

        /// <summary>
        /// ステート追加
        /// </summary>
        private T Add<T>() where T : StateBase, new()
        {
            // ステートを追加
            var newState = new T
            {
                StateMachine = this
            };
            _states.AddLast(newState);
            return newState;
        }

        /// <summary>
        /// ステート取得、無ければ追加
        /// </summary>
        private T GetOrAdd<T>() where T : StateBase, new()
        {
            // 追加されていれば返却
            foreach (var state in _states)
            {
                if (state is T result)
                {
                    return result;
                }
            }
            // 無ければ追加
            return Add<T>();
        }

        /// <summary>
        /// イベントIDに対応した遷移情報を登録
        /// </summary>
        /// <param name="eventId">イベントID</param>
        /// <typeparam name="TFrom">遷移元ステート</typeparam>
        /// <typeparam name="TTo">遷移先ステート</typeparam>
        public void AddTransition<TFrom, TTo>(int eventId)
            where TFrom : StateBase, new()
            where TTo : StateBase, new()
        {
            // 既にイベントIDが登録済ならエラー
            var from = GetOrAdd<TFrom>();
            if (from.Transitions.ContainsKey(eventId))
            {
                Debug.LogError("already register eventId!! : " + eventId);
                return;
            }
            // 指定のイベントIDで追加する
            var to = GetOrAdd<TTo>();
            from.Transitions.Add(eventId, to);
        }

        /// <summary>
        /// ステート開始処理
        /// </summary>
        /// <typeparam name="T">開始するステート</typeparam>
        public void OnStart<T>() where T : StateBase, new()
        {
            _currentState = GetOrAdd<T>();
            _currentState.OnStart();
        }

        /// <summary>
        /// ステート更新処理
        /// </summary>
        public void OnUpdate()
        {
            _currentState.OnUpdate();
        }

        /// <summary>
        /// イベント発行
        /// 指定されたIDのステートに切り替える
        /// </summary>
        /// <param name="eventId">イベントID</param>
        public void DispatchEvent(int eventId)
        {
            // イベントIDからステート取得
            if (!_currentState.Transitions.TryGetValue(eventId, out var nextState))
            {
                Debug.LogError("not found eventId!! : " + eventId);
                return;
            }
            // ステートを切り替える
            _currentState.OnEnd();
            nextState.OnStart();
            _currentState = nextState;
        }
    }
}
↑遷移情報も持たせたStateMachineクラス
マイケル
マイケル
呼び出し側では以下のようにイベントを定義して、初期化時に遷移情報を登録するようにしています。
今回の場合数に変わりはありませんが、ステートごとではなく遷移の数だけイベントを定義する必要があることには注意が必要です。

        /// <summary>
        /// ステージ管理クラス
        /// </summary>
        [SerializeField] private StageManager stageManager;

        /// <summary>
        /// イベント定義
        /// </summary>
        private enum EventType
        {
            EatFinish,  // 食事終了
            ArriveSea,  // 海に到着
            HuntFinish, // 採取完了
            ArriveHome, // 家に到着
        }
        private StateMachine<Enemy> _stateMachine;

        private void Start()
        {
            // ステートマシン定義
            _stateMachine = new StateMachine<Enemy>(this);
            _stateMachine.AddTransition<StateEating, StateMoveSea>((int) EventType.EatFinish);
            _stateMachine.AddTransition<StateMoveSea, StateHunting>((int) EventType.ArriveSea);
            _stateMachine.AddTransition<StateHunting, StateMoveHome>((int) EventType.HuntFinish);
            _stateMachine.AddTransition<StateMoveHome, StateEating>((int) EventType.ArriveHome);
            // ステート開始
            _stateMachine.OnStart<StateMoveSea>();
        }

        private void Update()
        {
            // ステート更新
            _stateMachine.OnUpdate();
        }
マイケル
マイケル
各ステート定義は下記のようになります。
これまでとさほど変わりはありませんが、イベント発行によりステート切り替えを行うようにしています。

        // 各ステート処理
        // ----- move sea -----
        private class StateMoveSea : StateBase
        {
            public override void OnStart()
            {
                Debug.Log("start move sea");
            }

            public override void OnUpdate()
            {
                var enemyPosition = Owner.transform.position;
                var targetPosition = Owner.stageManager.seaTransform.position;
                // 海へ到着したら次のステートへ
                if (Vector3.Distance(enemyPosition, targetPosition) < 0.5f)
                {
                    StateMachine.DispatchEvent((int) EventType.ArriveSea);
                    return;
                }
                // 海へ向かう
                Owner.transform.position = Vector3.MoveTowards(
                    enemyPosition,
                    targetPosition, 
                    5.0f * Time.deltaTime);
                Owner.transform.LookAt(targetPosition);
            }

            public override void OnEnd()
            {
                Debug.Log("end move sea");
            }
        }

        // ----- hunting -----
        private class StateHunting : StateBase
        {
            public override void OnStart()
            {
                Debug.Log("start hunting");
                // 採取スタート
                _isFinishHunting = false;
                MonoBehaviorHandler.StartStaticCoroutine(HuntCoroutine());
            }

            public override void OnUpdate()
            {
                // 採取が完了したら次のステートへ
                if (_isFinishHunting)
                {
                    StateMachine.DispatchEvent((int) EventType.HuntFinish);
                }
            }

            public override void OnEnd()
            {
                Debug.Log("end hunting");
            }
            
            // 採取が完了しているか?
            private bool _isFinishHunting;
            
            // 採取コルーチン
            private IEnumerator HuntCoroutine()
            {
                // 狩猟中、数秒待機
                yield return new WaitForSeconds(2.0f);

                // 魚取得
                Instantiate(Owner.stageManager.fishPrefab, Owner.transform);
            
                // 狩猟完了
                _isFinishHunting = true;
            }
        }

        // ----- move home -----
        private class StateMoveHome : StateBase
        {
            public override void OnStart()
            {
                Debug.Log("start move home");
            }

            public override void OnUpdate()
            {
                var enemyPosition = Owner.transform.position;
                var targetPosition = Owner.stageManager.homeTransform.position;
                // 家へ到着したら次のステートへ
                if (Vector3.Distance(enemyPosition, targetPosition) < 0.5f)
                {
                    StateMachine.DispatchEvent((int) EventType.ArriveHome);
                    return;
                }
                // 家へ向かう
                Owner.transform.position = Vector3.MoveTowards(
                    enemyPosition,
                    targetPosition, 
                    5.0f * Time.deltaTime);
                Owner.transform.LookAt(targetPosition);
            }

            public override void OnEnd()
            {
                Debug.Log("end move home");
            }
        }

        // ----- eating -----
        private class StateEating : StateBase
        {
            public override void OnStart()
            {
                Debug.Log("start eating");
                // 食事開始
                _isFinishEating = false;
                MonoBehaviorHandler.StartStaticCoroutine(EatCoroutine());
            }

            public override void OnUpdate()
            {
                // くるくる周る
                Owner.transform.Rotate(Vector3.up * 500.0f * Time.deltaTime);
                
                // 食事が完了したら次のステートへ
                if (_isFinishEating)
                {
                    StateMachine.DispatchEvent((int) EventType.EatFinish);
                }
            }

            public override void OnEnd()
            {
                Debug.Log("end eating");
            }
            
            // 食事が完了しているか?
            private bool _isFinishEating;
            
            // 食事コルーチン
            private IEnumerator EatCoroutine()
            {
                // 食事中、数秒待機
                yield return new WaitForSeconds(5.0f);
                
                // 子オブジェクト(魚)を破棄
                foreach (Transform child in Owner.transform)
                {
                    Destroy(child.gameObject);
                }
            
                // 食事完了
                _isFinishEating = true;
            }
        }
↑各ステートの処理
エレキベア
エレキベア
だいぶ完成されてきたクマね〜〜

アクタ間でのやり取り

マイケル
マイケル
以上で実装4パターンの紹介は終わりになります。
しかしせっかくステートマシンを作ったので、最後にステートを持ったアクタ間のやり取りについても実装してみようと思います。

Sample05: カニ夫婦の生活

マイケル
マイケル
実装するのはカニ夫婦の生活になります。
下記のように、魚を採って帰った時に妻がいたらプレゼントする動きを作ってみます!
Image 20211230 012931↑ダーリンとハニーのやり取り
02 kani honey story↑働く夫とのんびり暮らす妻
03 kani honey story 2↑ログ
エレキベア
エレキベア
なんか見てるとおもろいクマね〜〜
でも複雑で難しそうクマ
マイケル
マイケル
ダーリン、ハニーでそれぞれステートマシンを定義して
図の通りに条件を定義
してあげれば案外簡単に作れるよ!
とりあえず、StageManagerには追加で必要なオブジェクトを定義しておこう。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// ステージ管理クラス
/// </summary>
public class StageManager : MonoBehaviour
{
    [SerializeField] public Transform homeTransform; // 家の位置情報
    [SerializeField] public Transform seaTransform;  // 海の位置情報
    [SerializeField] public GameObject fishPrefab;   // 魚Prefab
    
    // *以下はSample05のみ使用
    [SerializeField] public Transform homeHoneyTransform; // 家ハニーの位置情報
    [SerializeField] public Transform walkTransform;      // 散歩ポイントの位置情報
    [SerializeField] public Sample05.Honey honey; // めんどくさいからハニーもいれちゃおう・・・
}
↑StageManagerへのオブジェクト追加
マイケル
マイケル
基本的にはSample03を改変して実装します。
ダーリン(Enemyクラス)には新たなステートと家に到着した時の判定を追加してあげましょう!

        /// <summary>
        /// ステート定義
        /// </summary>
        private enum StateType
        {
            MoveSea,  // 海へ移動
            Hunting,  // 魚採取
            MoveHome, // 家へ移動
            Eating,   // 食事
            Looking,  // 眺める ←★追加
        }
        private StateMachine<Enemy> _stateMachine;
        
        private void Start()
        {
            // ステートマシン定義
            _stateMachine = new StateMachine<Enemy>(this);
            _stateMachine.Add<StateMoveSea>((int) StateType.MoveSea);
            _stateMachine.Add<StateHunting>((int) StateType.Hunting);
            _stateMachine.Add<StateMoveHome>((int) StateType.MoveHome);
            _stateMachine.Add<StateEating>((int) StateType.Eating);
            _stateMachine.Add<StateLooking>((int) StateType.Looking); // ←★追加
            // ステート開始
            _stateMachine.OnStart((int) StateType.MoveSea);
        }

        private void Update()
        {
            // ステート更新
            _stateMachine.OnUpdate();
        }

        // 各ステート処理

・・・略・・・

        // ----- move home -----
        private class StateMoveHome : StateBase
        {
            public override void OnStart()
            {
                Debug.Log("<color=aqua>**kanio** 魚採れたので帰ります</color>");
            }

            public override void OnUpdate()
            {
                var enemyPosition = Owner.transform.position;
                var targetPosition = Owner.stageManager.homeTransform.position;
                // 家へ到着したら次のステートへ
                if (Vector3.Distance(enemyPosition, targetPosition) < 0.5f)
                {
                    // ★↓この辺を追加
                    // ハニーが家にいた場合
                    if (Owner.stageManager.honey.IsWaitingHome())
                    {
                        // 子オブジェクト(魚)をプレゼントしてまた海へ出かける
                        Debug.Log("<color=yellow>**kanio** あげるよハニー</color>");
                        foreach (Transform child in Owner.transform)
                        {
                            Owner.stageManager.honey.ReceiveFish(child.gameObject);
                        }
                        StateMachine.ChangeState((int) StateType.Looking);
                        return;
                    }
                    // いなかったら自分で食べる
                    StateMachine.ChangeState((int) StateType.Eating);
                    return;
                }
                // 家へ向かう
                Owner.transform.position = Vector3.MoveTowards(
                    enemyPosition,
                    targetPosition, 
                    5.0f * Time.deltaTime);
                Owner.transform.LookAt(targetPosition);
            }

            public override void OnEnd()
            {
            }
        }

・・・略・・・

        // ↓★追加
        // ----- looking -----
        private class StateLooking : StateBase
        {
            public override void OnStart()
            {
                Debug.Log("<color=aqua>**kanio** 眺めます</color>");
                // 眺める開始
                _isFinishLooking = false;
                MonoBehaviorHandler.StartStaticCoroutine(LookCoroutine());
            }

            public override void OnUpdate()
            {
                // 眺めるのが完了したら次のステートへ
                if (_isFinishLooking)
                {
                    StateMachine.ChangeState((int) StateType.MoveSea);
                }
            }

            public override void OnEnd()
            {
            }
            
            // 眺めるのが完了しているか?
            private bool _isFinishLooking;
            
            // 眺めるコルーチン
            private IEnumerator LookCoroutine()
            {
                // 数秒待機
                yield return new WaitForSeconds(2.0f);
                // 眺める完了
                _isFinishLooking = true;
            }
        }
↑ダーリンの動き
マイケル
マイケル
そしてハニー(Honeyクラス)では、魚を受け取ったら食べるようにします!
それ以外はたまに散歩しています。
エレキベア
エレキベア
(なまけものクマ・・・)

    /// <summary>
    /// Honeyクラス
    /// 魚を受け取って食べるだけの生活
    /// </summary>
    public class Honey : MonoBehaviour
    {
        /// <summary>
        /// ステージ管理クラス
        /// </summary>
        [SerializeField] public StageManager stageManager;

        private enum StateType
        {
            WaitHome, // 待機
            Walking,  // 散歩
            Eating,   // 食事
        }
        private StateMachine<Honey> _stateMachine;

        private void Start()
        {
            // ステートマシン定義
            _stateMachine = new StateMachine<Honey>(this);
            _stateMachine.Add<StateWaitHome>((int) StateType.WaitHome);
            _stateMachine.Add<StateWalking>((int) StateType.Walking);
            _stateMachine.Add<StateEating>((int) StateType.Eating);
            // ステート開始
            _stateMachine.OnStart((int) StateType.WaitHome);
        }

        private void Update()
        {
            // ステート更新
            _stateMachine.OnUpdate();
        }

        /// <summary>
        /// 家で待機中か?
        /// </summary>
        public bool IsWaitingHome()
        {
            return _stateMachine.IsCurrentState((int) StateType.WaitHome);
        }

        /// <summary>
        /// 魚を受け取る
        /// </summary>
        /// <param name="fish"></param>
        public void ReceiveFish(GameObject fish)
        {
            // 魚を受け取る
            Debug.Log("<color=yellow>**honey** うれしいわダーリン</color>");
            fish.transform.parent = transform;
            var position = transform.position;
            position.y = fish.transform.position.y;
            fish.transform.position = position;
            // 食事ステートに変更
            _stateMachine.ChangeState((int) StateType.Eating);
        }

        // 各ステート処理
        // ----- wait home -----
        private class StateWaitHome : StateBase
        {
            public override void OnStart()
            {
                Debug.Log("<color=red>**honey** 家で待ちます</color>");
                // 待機スタート
                _isFinishWaiting = false;
                MonoBehaviorHandler.StartStaticCoroutine(WaitCoroutine());
            }

            public override void OnUpdate()
            {
                // 待機が完了したら次のステートへ
                if (_isFinishWaiting)
                {
                    StateMachine.ChangeState((int) StateType.Walking);
                }
            }

            public override void OnEnd()
            {
            }
            
            // 待機が完了しているか?
            private bool _isFinishWaiting;
            
            // 待機コルーチン
            private IEnumerator WaitCoroutine()
            {
                // 数秒待機
                yield return new WaitForSeconds(5.0f);
            
                // 待機完了
                _isFinishWaiting = true;
            }
        }

        // ----- walking -----
        private class StateWalking : StateBase
        {
            public override void OnStart()
            {
                Debug.Log("<color=red>**honey** 散歩してきます</color>");
                // 帰宅中フラグをfalseに設定
                _isGoingHome = false;
            }

            public override void OnUpdate()
            {
                var enemyPosition = Owner.transform.position;
                
                // 目的地の設定
                var targetPosition = _isGoingHome
                    ? Owner.stageManager.homeHoneyTransform.position // 帰宅中の場合、家の位置
                    : Owner.stageManager.walkTransform.position;     // 帰宅中でない場合、散歩する位置
                
                // 海へ到着したら次のステートへ
                if (Vector3.Distance(enemyPosition, targetPosition) < 0.5f)
                {
                    // 帰宅中フラグをONにしてreturn
                    if (!_isGoingHome)
                    {
                        _isGoingHome = true;
                        return;
                    }
                    // 次のステートへ遷移する
                    StateMachine.ChangeState((int) StateType.WaitHome);
                    return;
                }
                // 目的地へ向かう
                Owner.transform.position = Vector3.MoveTowards(
                    enemyPosition,
                    targetPosition, 
                    3.5f * Time.deltaTime);
                Owner.transform.LookAt(targetPosition);
            }

            public override void OnEnd()
            {
            }

            private bool _isGoingHome; // 帰宅中か?
        }

        // ----- eating -----
        private class StateEating : StateBase
        {
            public override void OnStart()
            {
                Debug.Log("<color=red>**honey** 魚食べます</color>");
                // 食事開始
                _isFinishEating = false;
                MonoBehaviorHandler.StartStaticCoroutine(EatCoroutine());
            }

            public override void OnUpdate()
            {
                // くるくる周る
                Owner.transform.Rotate(Vector3.up * 500.0f * Time.deltaTime);
                
                // 食事が完了したら次のステートへ
                if (_isFinishEating)
                {
                    StateMachine.ChangeState((int) StateType.WaitHome);
                }
            }

            public override void OnEnd()
            {
            }
            
            // 食事が完了しているか?
            private bool _isFinishEating;
            
            // 食事コルーチン
            private IEnumerator EatCoroutine()
            {
                // 食事中、数秒待機
                yield return new WaitForSeconds(3.0f);
                
                // 子オブジェクト(魚)を破棄
                foreach (Transform child in Owner.transform)
                {
                    Destroy(child.gameObject);
                }
            
                // 食事完了
                _isFinishEating = true;
            }
        }
    }
↑ハニークラスの実装
マイケル
マイケル
以上で実装は完了です!
エレキベア
エレキベア
なんとか動いたクマ〜〜〜

おわりに

マイケル
マイケル
というわけで今回は有限ステートマシンの実装でした!
どうだったかな?
エレキベア
エレキベア
動きを作るのはおもしろいクマね
生きているみたいで感動したクマ
マイケル
マイケル
命を吹き込む作業になるから楽しいね!
ゲームAIには他にもいろいろな方法があるから
どんどん試していこう!
マイケル
マイケル
それでは今日はこの辺で!
アデュー!!
エレキベア
エレキベア
クマ〜〜〜〜〜

【Unity】カニの動きで学ぶ有限ステートマシン(FSM)【ゲームAI】 〜完〜

コメント