Stateパターンを使ってみよう!

こんにちは。
株式会社アドグローブ ゲーム事業部 エンジニアの田邊です。
今回はデザインパターンの1つである《Stateパターン》について、例を使いながら説明していきたいと思います。

もくじ

Stateパターンとは

オブジェクトの状態が変わった時に、その振る舞いをclassとして実装します。
そのclassを切り替えることで、オブジェクトの振る舞いを変えることができます。

何をするか

例として、「メロンを食べた時の状態ごとにテキストを表示する」をやってみたい思います。

ここでいう、状態と振る舞いとは

  • 状態
    • メロンの熟し具合
  • 振る舞い
    • メロンの熟し具合の確認
    • 状態ごとのテキスト

のことです。

つまり、「メロンの熟し具合」によって「メロンの熟し具合の確認」や「状態ごとのテキスト」を変えれたらいいわけです。

作ってみた

実際に作ってみたclassやinterfaceを紹介していきます。

IState : 状態ごとの振る舞いを定義するためのInterface

public interface IState
{
    /// <summary>
    /// 更新
    /// </summary>
    /// <param name="melon"></param>
    void Execute(Melon melon);

    /// <summary>
    /// テキストを取得
    /// </summary>
    /// <returns></returns>
    string GetText();
}
名前 説明
Execute() メロンの熟し具合を確認する(毎フレーム処理される想定)
GetText() 状態ごとのテキストを取得する

UnripeState : メロンが熟していない状態

public class UnripeState : IState
{
    public void Execute(Melon melon)
    {
        if (melon.RipeTime >= Melon.UnripeStateTime)
        {
            // 未熟状態の時間を超えたのでRipeStateに遷移する
            melon.ChangeState(Melon.StateId.Ripe);
        }
    }

    public string GetText() => "まだ、熟していないようだ";
}
振る舞い 説明
Execute() メロンの熟し具合を確認して、未熟状態の時間を超えたら完熟の状態に切り替える
GetText() メロンが熟していない状態のときに表示するテキスト

RipeState :メロンが完熟の状態

public class RipeState : IState
{
    public void Execute(Melon melon)
    {
        if (melon.RipeTime >= Melon.RipeStateTime)
        {
            // 完熟状態の時間を超えたのでOverripeStateに遷移する
            melon.ChangeState(Melon.StateId.Overripe);
        }
    }

    public string GetText() => "おいしい!甘い!いい香り!";
}
振る舞い 説明
Execute() メロンの熟し具合を確認して、完熟状態の時間を超えたら過熟の状態に切り替える
GetText() メロンが熟した状態のときに表示するテキスト

OverripeState : メロンが過熟の状態

public class OverripeState : IState
{
    public void Execute(Melon melon) { }

    public string GetText() => "熟しすぎたようだ";
}
振る舞い 説明
Execute() 確認しない(えっ)*1
GetText() メロンを熟しすぎた状態のときに表示するテキスト

Melon : メロン自身 状態と熟した時間を管理している

public class Melon : MonoBehaviour
{
    /// <summary> 未熟状態の時間(秒) </summary>
    public static readonly float UnripeStateTime = 5f;
    /// <summary> 完熟状態の時間(秒) </summary>
    public static readonly float RipeStateTime = 10f;
    /// <summary> 過熟状態の時間(秒) </summary>
    public static readonly float OverripeStateTime = 20f;

    public enum StateId
    {
        /// <summary> 未熟(熟していない) </summary>
        Unripe,
        /// <summary> 完熟(熟した) </summary>
        Ripe,
        /// <summary> 過熟(熟しすぎ) </summary>
        Overripe,
    }

    /// <summary> 熟した時間 </summary>
    public float RipeTime { get; private set; }

    /// <summary> 現在のステート(状態)のId </summary>
    private StateId CurrentStateId { get; set; }

    /// <summary> ステートを保持するDictionary </summary>
    private Dictionary<StateId, IState> _states = new Dictionary<StateId, IState>();

    /// <summary> 現在のステート(状態) </summary>
    private IState CurrentState => _states[CurrentStateId];

    void Start()
    {
        RipeTime = 0f;

        _states.Add(StateId.Unripe, new UnripeState());
        _states.Add(StateId.Ripe, new RipeState());
        _states.Add(StateId.Overripe, new OverripeState());

        CurrentStateId = StateId.Unripe;
    }

    void Update()
    {
        // 熟した時間を加算
        RipeTime += Time.deltaTime;

        // ステートの更新
        CurrentState.Execute(this);
    }

    /// <summary>
    /// テキスト取得
    /// </summary>
    /// <returns></returns>
    public string GetText() => CurrentState.GetText();

    /// <summary>
    /// 食べた
    /// </summary>
    public void Eaten() => Destroy(gameObject);

    /// <summary>
    /// ステートを変更
    /// </summary>
    /// <param name="nextStateId"></param>
    public void ChangeState(StateId nextStateId)
    {
        if (CurrentStateId == nextStateId)
            return;

        CurrentStateId = nextStateId;
    }
}

使ってみた

今回は、シンプルにスペースキーを押したらテキストを表示するとします。

    [SerializeField] private Melon _melon = null;
    [SerializeField] private Text _melonText = null;
    [SerializeField] private Text _timeText = null;
    
    void Update()
    {
        _timeText.text = $"経過時間:{_melon.RipeTime}秒";

        if (Input.GetKey(KeyCode.Space))
        {
            _melonText.text = _melon.GetText();
            _melon.Eaten();
        }
    }

まとめ

Stateパターンを使用してメロンの状態ごとに振る舞いを変えてみました。

ほかにも考えられる振る舞いとして

  • 状態の開始時
    • メロンの見た目を変える
    • 状態が変わったことの通知(エフェクトや音を再生する)
  • 状態の終了時
    • 開始時と同様
  • メロンを叩いた時
    • 状態ごとの演出(エフェクトや音を再生する)

などなど、いろいろあると思います。
Stateパターンを使うことで、クラスや処理が分割されソースコードがシンプルになります。
もちろん、Stateパターンにも使いどころというものはあるのでそれはしっかり見極めましょう!

また、メロンで処理している状態の管理には StateMachine を使うのが一般的です。
StateMachineについては、また機会があれば紹介したいと思います!

最後まで読んでいただきありがとうございました。

ちなみに、私が食べたメロンは
「メロンのつるが完全に枯れ」「弾力が出たころ」に冷やして食べたところ、とても甘くておいしかったです!

*1:今回の仕様だと必要がないというだけで一定時間たったらDestoryとかしてもいいかもしれません