【Unity】Scriptable Objectを利用して修正容易なステートマシンを実装してみた

今回はUnityで使用可能な汎用的なステートマシンを実装しました。

ステートマシンの実装

思い至った理由

UnityのScriptable Objectはシーン間をまたいでも大丈夫なため、データの共有使用が得意です。しかし、変数だけでなくメソッドも記述できるため、より柔軟な使い方ができるのでは?と思っていました。

そんな時に、以下の記事を見つけました。

ゲーム構築を劇的にスマートにする Scriptable Objectの 3つの活用方法 – Unity for Pro
様々な手段を駆使して膨大なデータを管理する必要はありません。Scriptable Objectという Unity独自のクラスを使えば、Unityがシリアライズしたデータをアセットとして格納できるので、よりシンプルに、より手軽に、ゲーム全体の管理や変更を行えるようになります。 これらのヒントは、Schell Games ...

これを見終わって私は、「ステートマシンも同様に実装できるのでは?」と思ったのです。

Stateパターンの是非

Stateパターンを使用すると、継承を利用して各ステートを記述することになります。しかし、新たなステートを作るたびに特化したコードを記述する必要があり、その分追加修正が必要となります。

これの問題は

  • プログラマーが挙動実装とレベルデザインを同時にしてしまう点
  • 追加修正がプログラマーしかできない点

にあります。

デザイナーが編集しづらくなるため、小規模の場合は何とかなっても、大規模で複雑になってくると修正に手間がかかってしまいます。

そのため、以下の構造が望まれます。

  • ScriptableObjectを利用し、コードを書かずにステートを複製・削除できる
  • 各ステートの挙動に合わせて自由にUnityEventにメソッドを登録・解除できる

そうすれば、クラス間が疎結合になるため修正も容易になり、プログラマー以外の方もステートの挙動を把握しながら作成できます。

ソースコード

必要なコードは4つあり、コードの大まかな構成は

  • ステートや本体となる2つのScriptable Object
  • Scriptable Objectの動作を可能にする2つ のMonoBehaviour

となっております。

それぞれUnityスクリプトを作成し、コードをコピペして使用してください。

MyState.cs

//MyState.cs

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

[CreateAssetMenu(menuName = "ステートマシン/ステート")]
public class MyState : ScriptableObject
{
    private readonly List<StateListen> listeners = new List<StateListen>();
    private bool isCurrent;

    public bool IsCurrent()
    {
        return isCurrent;
    }

    public void Enter()
    {
        listeners.ForEach(x => x.StateEnter());
        isCurrent = true;
    }

    public void Update()
    {
        if (isCurrent) listeners.ForEach(x => x.StateUpdate());
    }

    public void Exit()
    {
        isCurrent = false;
        listeners.ForEach(x => x.StateExit());
    }

    public void RegisterListener(StateListen listener)
    {
        listeners.Add(listener);
    }

    public void UnregisterListener(StateListen listener)
    {
        listeners.Remove(listener);
    }
}

上記のMyState.csは各ステートを表すScriptable Objectとなります。RegisterListener、UnRegisterListenerでヒエラルキーにあるStateListenクラスを外部から自動的に登録・解除し、Enter、Update、Exitで登録されたStateListenクラスのステートの挙動を実行しています。

ちなみに、ForEachはLINQを使用して実装しています。スッキリかけるのでおすすめです。

MyStateLayer.cs

//MyStateLayer.cs

using System;
using UnityEngine;

[CreateAssetMenu(menuName = "ステートマシン/レイヤー")]
public class MyStateLayer : ScriptableObject
{ 
    [Header("実行中の変更非推奨")]
    [SerializeField]
    private MyState start;

    [NonSerialized]
    private MyState current;

    [NonSerialized]
    private bool isEnable;

    public void Start()
    {
        if (!isEnable)
        {
            current = start;
            current.Enter();
            isEnable = true;
        }
    }

    public void Resume()
    {
        if (!isEnable)
        {
            if (current == null) current = start;
            current.Enter();
            isEnable = true;
        }
    }

    public void Stop()
    {
        if (isEnable) {
            current.Exit();
            isEnable = false;
        }
    }

    public void Transition(MyState target)
    {
        if (isEnable)
        {
            current.Exit();
            current = target;
            current.Enter();
        }
    }

    public MyState GetState() {
        return current;
    }

    public bool IsState(MyState target)
    {
        if(target == current)return true;
        return false;
    }

    public bool IsEnable() {
        return isEnable;
    }

    public MyState GetInitState()
    {
        return start;
    }
}

MyStateLayer.csは各ステートを取りまとめるマシンの役割を果たします。初期ステートを始めに登録して使用します。

ここで重要なメソッドには

Transition(MyState target)遷移先にステートを変更する
Start()ステートマシンを開始する
Stop()ステートマシンを終了する
Resume()ステートマシンを再開する

があり、外部のスクリプトから使用して制御できる構造になっております。

StateListen.cs

//StateListen.cs

using UnityEngine;
using UnityEngine.Events;

public class StateListen : MonoBehaviour
{
    [SerializeField]
    private MyState state;
    [SerializeField]
    private UnityEvent OnEnter;
    [SerializeField]
    private UnityEvent OnUpdate;
    [SerializeField]
    private UnityEvent OnExit;

    private void OnEnable()
    {
        state.RegisterListener(this);
    }

    private void OnDisable()
    {
        state.UnregisterListener(this);
    }

    public void StateEnter()
    {
        OnEnter.Invoke();
    }

    public void StateExit()
    {
        OnExit.Invoke();
    }

    public void StateUpdate()
    {
        OnUpdate.Invoke();
    }
}

StateListen.csにはUnityEventクラスがついており、「状態に入ったとき」「状態にいる時」「状態から出たとき」の3つの場合に関してメソッドを外部から指定することができます。

MyFSM.cs

//MyFSM.cs

using UnityEngine;

public class MyFSM : MonoBehaviour
{
    [Header("初期ステートはScriptableObject側で設定してください")]
    public MyStateLayer machine;

    void Update()
    {
        if (machine.IsEnable())
        {
            machine.GetState().Update();
        }
    }
}

MyFSM.csは実行したいステートマシン本体を登録して使用します。これのおかげで、各ステートのUpdate部分が実現できています。

使い方

ステートの作成

ステートの作成は「Assets>Create>ステートマシン>ステート」と辿り、作成できます。

例として、今回は「Title」「Play」「Menu」の3つの状態を作成してみました。

各ステートの挙動登録

次に空のゲームオブジェクトを作成し、そこにStateListen.csをアタッチします。

今回は3つの状態があるため、StateListenクラスも3つアタッチします。

図はTitleの場合の例です。ゲームオブジェクトには、先ほど作成したTitleという名のScriptableObjectを指定します。

ここに見えている「OnEnter」と「OnUpdate」と「OnExit」にメソッドを追加する形になります。

ステートマシンの作成

今度はステートマシン本体を作成したいので、「Assets>Create>ステートマシン>レイヤー」と辿り、ステート取りまとめるScriptable Objectを作成します。

例として、今回は「MainFSM」という名前で作成してみました。開始ステートはここで予め指定する必要があるので、忘れずに指定します。

ステートマシンの登録

最後に、MainFSMを動くようにするために、空のゲームオブジェクトを作成し、MyFSM.csを追加します。

そして、動かしたいマシン本体のScriptable Objectを指定します。

あるステートから別のステートへ遷移させる方法

キーを押すと遷移

例えば、「Aボタンを押したら、Title状態からPlay状態に遷移する」という挙動を実装してみます。

その場合、例えば「StateAction.cs」などという名前のスクリプトを作成し、以下のように記述すれば実現できます。

//StateAction.cs

using UnityEngine;

public class StateAction : MonoBehaviour
{
    MyStateLayer machine;   //ステートマシン
    MyState nextState;      //遷移先にしたいステート

    public void MoveToPlay() {
        if (Input.GetKeyDown(KeyCode.A)) {
            machine.Transition(nextState);
        }
    }
}

ここで重要なのが、「machine.Transition(nextState); 」の部分です。これは、指定したマシンの元のステートをnextStateに遷移させる役割を持っています。

次に、空のゲームオブジェクトを作成→StateAction.csをアタッチして、図のようにMachineとNext Stateを設定します。

そして、作成した挙動をTitleステートのUpdateに登録します。

しかし、ステートマシンの開始を指定していないので、このままでは動作しません

ステートマシンの起動

ですので、先ほどのStateActionのスクリプトを以下のように追加変更し、例として開始時にステートマシンを起動するように変更します。

using UnityEngine;

public class StateAction : MonoBehaviour
{
    public MyStateLayer machine;   //ステートマシン
    public MyState nextState;      //遷移先にしたいステート

    public void Start()
    {
        Debug.Log("ステートマシンを開始");
        machine.Start();
    }

    public void MoveToPlay() {
        if (Input.GetKeyDown(KeyCode.A)) {
            machine.Transition(nextState);
            Debug.Log("遷移しました!");
	    }
    }
}

変更後、Playボタンを押し、実行後Aボタンを押して遷移が確認できたら成功です。

ステートマシンの終了・再開

ステートマシンを終了・再開するにはMyStateLayerクラスのメソッド「Stop」や「Resume」を使用します。

例えば「Bを押すとステートマシンが終了」「Cを押すとステートマシンが再開」を実現するには、以下のUpdateメソッドをMonoBehaviourを継承した適当なクラスに記述すれば実現可能です。

private void Update()
{
    if (Input.GetKeyDown(KeyCode.B))
    {
        machine.Stop();
    }
    if (Input.GetKeyDown(KeyCode.C))
    {
        machine.Resume();
    }
}

終わりに

今回は疎結合で修正容易なステートマシンをScriptable Objectを使用することで実装しました。小規模な場合ではSwitch文やステートパターンを使用すれば十分な場合も多いですが、大規模になるほど修正が困難になるため、今回実装したステートマシンが役に立つと思います。

ぜひ取り入れてみてください。

タイトルとURLをコピーしました