【Unity】有限ステートマシンの基本的な実装

有限ステートマシンの基本的な実装です。

有限ステートマシン?

いま、自動販売機の挙動について考えてみます。

  • 自動販売機は「お金が未投入の状態」と「お金が投入された状態」の二つの状態を持つ
  • 「お金が未投入の状態」でお金を入れると「お金が投入された状態」に遷移する
  • 「お金が投入された状態」でボタンを押すと商品が排出されて「お金が未投入の状態」に遷移する

このように、いくつかの状態(お金投入・未投入)を持ち、あるトリガー(お金投入された・ボタン押された)
によって別の状態に遷移するようなものを有限ステートマシン(Finite State Machine / FSM)といいます。
またWikiによると、有限オートマトンも同義ということでいいようです。

以上が有限ステートマシンの説明となりますが、実装をする上での最低限の知識のみなので、
厳密な定義を知りたい方は専門的なサイトないしは書籍をご覧下さい。

本記事ではこの有限ステートマシンをプログラムで実装してみます。
ただ有限ステートマシンはあくまで上記の振る舞いのモデルを示す言葉であり、
プログラムとして決まった実装があるわけではないのでその点はご留意ください。

Switchによる実装

まずは簡単にSwitch分を使ってステートマシンを実装してみます。

using UnityEngine;

// 状態の定義
public enum StateType
{
    Idle,
    Run,
}

public class ExampleStateMachine : MonoBehaviour
{
    [SerializeField] // デバッグ用にシリアライズ
    private StateType _stateType;

    private void Update()
    {
        switch (_stateType)
        {
            case StateType.Idle:
                // Idle中にRキーが押されるとRunになる
                if (Input.GetKeyDown(KeyCode.R))
                {
                    _stateType = StateType.Run;
                }
                break;
            case StateType.Run:
                // Run中にIキーが押されるとIdleになる
                if (Input.GetKeyDown(KeyCode.I))
                {
                    _stateType = StateType.Idle;
                }
                break;
        }
    }
}

特に説明のいらないほど簡単なコードです。
Idle状態のときにRキーを押すとRunという状態に遷移し、Run状態のときにIキーを押すとIdleという状態に遷移します。

状態を持ち、トリガーに応じて状態が遷移するということで簡単ではありますがこれも有限ステートマシンです。

Stateパターンによる実装

前節の実装だと、例えばそれぞれのStateに切り替わった時の処理を追加し、さらにStateTypeも増やしていくと、
Update内の記述が増えて処理が煩雑になっていきます。

そこで、Stateパターンを使って実装を修正します。
StateMachineの説明の中でStateという言葉を使うとややこしくなりますが、
ここでいうStateパターンとはデザインパターンのStateパターンです。

19.State パターン | TECHSCORE(テックスコア)

それではソースコードです。

using UnityEngine;
using System.Collections.Generic;

// 状態定義
public enum StateType
{
    Idle,
    Run,
}

// 一つ一つの状態をStateパターンで実装するためのクラス
public abstract class State
{
    public abstract void Enter();
}
public class IdleState : State
{
    public override void Enter()
    {
        Debug.Log("Enter : Idle");
    }
}
public class RunState : State
{
    public override void Enter()
    {
        Debug.Log("Enter : Run");
    }
}

public class ExampleStateMachine : MonoBehaviour
{
    // 現在のStateType
    private StateType _stateType;
    // 現在のState
    private State _state;
    
    // Stateのインスタンスのキャッシュ
    private Dictionary<StateType, State> _states = new Dictionary<StateType, State>();

    private void Awake()
    {
        // Stateのインスタンスを登録しておく
        _states.Add(StateType.Idle, new IdleState());
        _states.Add(StateType.Run, new RunState());
    }

    private void Update()
    {
        switch (_stateType)
        {
            case StateType.Idle:
                if (Input.GetKeyDown(KeyCode.R))
                {
                    // StateTypeとStateを切り替え
                    _stateType = StateType.Run;
                    _state = _states[_stateType];
                    // Enterを呼ぶ
                    _state.Enter();
                }
                break;
            case StateType.Run:
                if (Input.GetKeyDown(KeyCode.I))
                {
                    // StateTypeとStateを切り替え
                    _stateType = StateType.Idle;
                    _state = _states[_stateType];
                    // Enterを呼ぶ
                    _state.Enter();
                }
                break;
        }
    }
}

Stateパターンを適用し、さらにStateTypeが切り替わった時の処理を各Stateに記述しました。

遷移の条件を登録できるようにする

定義する状態の数が増えると、状態間の遷移の管理が複雑になってきます。
これを管理しやすくするために遷移条件を最初に登録できるようにして整理します。

using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public enum StateType
{
    Idle,
    Run,
    Jump,
}

public abstract class State
{
    public abstract void Enter();
}
public class IdleState : State
{
    public override void Enter()
    {
        Debug.Log("Enter : Idle");
    }
}
public class RunState : State
{
    public override void Enter()
    {
        Debug.Log("Enter : Run");
    }
}
public class JumpState : State
{
    public override void Enter()
    {
        Debug.Log("Enter : Jump");
    }
}

public class Transition{
    public StateType To { get; set; }
    public KeyCode Trigger { get; set; }
}

public class ExampleStateMachine : MonoBehaviour
{
    private StateType _stateType;
    private State _state;
    
    private Dictionary<StateType, State> _stateTypes = new Dictionary<StateType, State>();
    // 遷移情報
    private Dictionary<StateType, List<Transition>> _transitionLists = new Dictionary<StateType, List<Transition>>();

    private void Awake()
    {
        _stateTypes.Add(StateType.Idle, new IdleState());
        _stateTypes.Add(StateType.Run, new RunState());
        _stateTypes.Add(StateType.Jump, new JumpState());

        // 遷移を登録する
        AddTransition(StateType.Idle, StateType.Run, KeyCode.R);
        AddTransition(StateType.Idle, StateType.Jump, KeyCode.J);
        AddTransition(StateType.Run, StateType.Idle, KeyCode.I);
        AddTransition(StateType.Jump, StateType.Idle, KeyCode.I);
    }

    private void Update()
    {
        var transitions = _transitionLists[_stateType];
        foreach (var transition in transitions)
        {
            if (Input.GetKeyDown(transition.Trigger))
            {
                // 登録されたトリガーが呼ばれたら遷移
                _stateType = transition.To;
                _state = _stateTypes[_stateType];
                _state.Enter();
                break;
            }
        }
    }
    
    /// <summary>
    /// 遷移情報を登録する
    /// </summary>
    /// <param name="from">遷移元のStateType</param>
    /// <param name="to">遷移先のStateType</param>
    /// <param name="trigger">トリガーとなるキー</param>
    private void AddTransition(StateType from, StateType to, KeyCode trigger)
    {
        if (!_transitionLists.ContainsKey(from))
        {
            _transitionLists.Add(from, new List<Transition>());
        }
        var transitions = _transitionLists[from];
        var transition = transitions.FirstOrDefault(x => x.To == to);
        if (transition == null)
        {
            // 新規登録
            transitions.Add(new Transition { To = to, Trigger = trigger });
        }
        else
        {
            // 更新
            transition.To = to;
            transition.Trigger = trigger;
        }
    }
}

Awake()で遷移の条件を登録することで管理しやすくなりました。

参考

有限オートマトン - Wikipedia

19.State パターン | TECHSCORE(テックスコア)

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