【Unity/C#】Stateパターンで状態を管理する

今回は State パターンの説明と Unity/C# で使用するときの簡単な実装例の紹介です。

Stateパターンの説明

State パターンは「GoF の 23のデザインパターン」という設計集に収録されている設計の 1つです。特に Unity 固有の考え方ではありませんがシステム開発やゲーム作成をしているときに色々と「状態」が出てきら使える(かもしれない)設計方法です。

例えば、プレイヤーが(待機|走る|攻撃する|ダメージを受ける)などはそれぞれが「状態」となり、この各々の状態に専用の処理を書く必要があります。この一つ一つの状態を State というクラスにして状況に応じて切り替え使い分ける事で状態を表現する事が State の基本です。

【前提】パターンを使わない場合

まず State パターンを使わない場合を考えます。一般的にパターンを使わない場合、switch 文 (または条件判定) で状態を分岐して実装します。

// switch を使った状態管理の例

// プレイヤーの状態
public enum PlayerState { Idle, Walk, Run, Attack }

// プレイヤーを表すスクリプト
public class Player : MonoBehaviour
{
    PlayerState _state = PlayerState.Idle;
    public void Update()
    {
        switch (_state)
        {
            case PlayerState.Idle:
                // 待機中の処理を書く
                break;
            case PlayerState.Walk:
                // 歩く処理を書く
                break;
            case PlayerState.Run:
                // 走る処理を書く
                break;
            case PlayerState.Attack:
                // 攻撃の処理を書く
                break;
            default:
                break;
        }
    }

    // 指定した状態に変更する
    public void ChangeState(PlayerState next) => _state = next;
}

各 case の実装はメソッド化してコードを整理することが多いと思います。特に規模が小さい時は問題ありません。一目で確認できて分りやすいかもしれません。

しかし状態が増えるごとに switch 文を追加するとコード量と変数が徐々に増加するため、追加するたびに全体をチェックして問題が無いかのチェックしますが、これは状態量が増えると急激に問題になる可能性があります。

例えば、特定の状態専用の変数を書き換えている、ある状態を抜ける時に特定の変数を更新し忘れる、ある状態からある状態に移動すると想定しない動作になる etc...

Stateパターン

次に State パターンを見てみましょう。State パターン全体を図示(UML 表記)すると以下のようになります。

各クラスの役割は以下の通りです。

Stateインターフェース

状態を表す基底クラスです。ここにどういう操作があるのかを定義します。基本的にインターフェース(interface)を使用しますが、このクラスを通じてサブクラスをどれも同じように扱うことが重要なので抽象クラス(abstract class)で実装しても問題ありません。

StateImplement1, 2

State クラスを継承し具体的な状態を実装します。歩く、走るなど各状態をここに実装します。

Context

現在の State を持って利用者と具体的な State の間を仲介するクラスです。利用者はこのクラスを通して State の機能を実行したり状態を変更したりします。

メリットとデメリット

このように各状態は State を継承したサブクラスがそれぞれの状態を表し、Context が State クラスを通して各状態を統一的に操作することが State パターンです。

State パターンを使用するメリットですが、ある状態の変更しても別の状態に影響しない事です。クラスごとに状態が分かれているため、特定の状態の更新は別の状態に影響しないので変更の影響が最小化できます。また状態ごとの専用の変数がある場合、クラス内に閉じているので他の状態から操作されず安全です。

このパターンのデメリットですが、State クラス自身にメソッドを追加すると State クラスを継承している全てのサブクラスを修正する必要があるため、初期に検討や設計がある程度必要です。また、ある状態の時だけ特別なメソッドを作って利用者が呼び出したいという事が基本的にできない(しないほうがいい)ためこちらも事前に検討が必要です。また状態が少ないとき(数個程度)でそれ以上増えないようなケースでは過剰設計で逆に扱いにくい場合があります。

C#/Unityで実装する

Unity 向けにプレイヤーが操作するキャラクターを想定して State パターンを適用してみたいと思います。プレイヤーは(待機|歩く|攻撃)の 3つの状態を持つとします。

【事前準備】Playerクラス

あらかじめ Player が操作するプレイアブルなスクリプトを以下のように作成しておきます。

// Player.cs

public class Player : MonoBehaviour
{
    // まずは空で作成する
}

Stateインターフェース

各状態の IPlayerState インターフェースとして実装します。

// IPlayerState.cs

public interface IPlayerState
{
    // このクラスの状態を取得する
    PlayerState State { get; }

    // 状態開始時に最初に実行される
    void Entry();

    // フレームごとに実行される
    void Update();

    // 状態終了時に実行される
    void Exit();
}

// プレイヤーの状態
public enum PlayerState { Idle, Walk, Attack }

状態に入る前に、Enter() を実行して、フレームごとに Update() が呼び出され、状態を抜ける時に Exit() が呼び出される想定でメソッドを宣言しています。この 3つのメソッドを作成する実装は割と一般的です。

StateImplement1, 2

各状態を表すクラスを実装します。 IPlayerState を継承して、待機、歩く、攻撃のクラスをそれぞれ作成します。

// PlayerStateIdel.cs

public class PlayerStateIdel : IPlayerState
{
    Player _player;
    public PlayerState State => PlayerState.Idle;
    public PlayerStateIdel(Player plaeyr) => _player = plaeyr;
    public void Entry() { /*...*/ }
    public void Update() { /*...*/ }
    public void Exit() { /*...*/ }
}

// - - - - - - - - - - - - - - - - - - - - 
// PlayerStateWalk.cs

public class PlayerStateWalk : IPlayerState
{
    Player _player;
    public PlayerState State => PlayerState.Walk;
    public PlayerStateWalk(Player plaeyr) => _player = plaeyr;
    public void Entry() { /*...*/ }
    public void Update() { /*...*/ }
    public void Exit() { /*...*/ }
}

// - - - - - - - - - - - - - - - - - - - - 
// PlayerStateAttack.cs

public class PlayerStateAttack : IPlayerState
{
    Player _player;
    public PlayerState State => PlayerState.Attack;
    public PlayerStateAttack(Player plaeyr) => _player = plaeyr;
    public void Entry() { /*...*/ }
    public void Update() { /*...*/ }
    public void Exit() { /*...*/ }
}

Contextクラス

次に状態を保持して利用者との間を取り持つための PlayerStateContext クラスを作成します。

このクラスは以下の機能を持ちます

  • 現在の状態保持して State を利用者が触らないように分離する
  • 状態の変更を PlayerState で変更できる
  • 状態変更時の Enter, Update, Exit を制御する
// PlayerStateContext.cs

public class PlayerStateContext
{
    IPlayerState _currentState;    // 現在の状態
    IPlayerState _previousState;   // 直前の状態

    // 状態のテーブル
    Dictionary<PlayerState, IPlayerState> _stateTable;

    public void Init(Player player, PlayerState initState)
    {
        if (_stateTable != null) return; // 何度も初期化しない

        // 各状態選クラスの初期化
        Dictionary<PlayerState, IPlayerState> table = new()
        {
            { PlayerState.Idle, new PlayerStateIdel(player) },
            { PlayerState.Walk, new PlayerStateWalk(player) },
            { PlayerState.Attack, new PlayerStateAttack(player) },
        };
        _stateTable = table;
        ChangeState(initState);
    }

    // 別の状態に変更する
    public void ChangeState(PlayerState next)
    {
        if (_stateTable == null) return; // 未初期化の時は無視
        if (_currentState == null || _currentState.State == next)
        {
            return; // 同じ状態には遷移しない
        }
        // 退場 → 現在状態変更 → 入場
        var nextState = _stateTable[next];
        _previousState = _currentState;
        _previousState?.Exit();
        _currentState = nextState;
        _currentState.Entry();
    }

    // 現在の状態をUpdateする
    public void Update() => _currentState?.Update();
}

Playerクラスと各Stateの修正

次に Player クラスに Context の保持と各状態への変更を追加します。

// Player.cs

public class Player : MonoBehaviour
{
    // StateパターンのContext
    PlayerStateContext _context;

    private void Awake()
    {
        // Stateを初期化
        _context = new PlayerStateContext();
        _context.Init(this, PlayerState.Idle);
    }

    private void Update() => _context.Update();

    public void Idle() => _context.ChangeState(PlayerState.Idle);
    public void Walk() => _context.ChangeState(PlayerState.Walk);
    public void Attack() => _context.ChangeState(PlayerState.Attack);
}

そして例えば待機状態の内から(歩く|攻撃)に状態遷移をするために PlayerStateIdel.Update メソッドを以下のように修正します。

public class PlayerStateIdel : IPlayerState
{
    Player _player;
    public PlayerState State => PlayerState.Idle;
    public PlayerStateIdel(Player plaeyr) => _player = plaeyr;
    public void Entry() { /*...*/ }
    public void Update()
    {
        if (/*特定の条件だったら*/)
        {
            _player.Walk();
        }
        else if (/*特定の条件だったら*/)
        {
            _player.Attack();
        }
    }
    public void Exit() { /*...*/ }
}

これで状態内から別の状態に遷移できるようになりました。

まとめ

State パターン自体は各状態をクラス化して State として共通化して Context に持たせましょうまでしか言っていないため、最後の状態内からの遷移などには特に言及がありません。また、Exit と Enter を実装しましょうなども特に指定はありません。

このため、Exit や Enter、Update メソッドとか、ある状態内から別の状態に遷移する実装などは State パターンを踏まえて独自に実装したものとなります。したがって State パターンで State や Context を作成した後どうするかは状況に応じて必要な実装を自分で考える必要があります

状況に応じて Context のインスタンスはだれが持つべきなのか、各自で状態遷移をどう扱うか、だれが状態遷移するのか、次の状態に遷移するための条件判定、Context は誰が持つのか etc... は、状況に応じて自由に実装ができます。

例えば、tranform を参照して前のフレームから一定距離移動していたら移動状態、ユーザーのキー入力があったら攻撃状態、攻撃動作が完了したら待機状態など Unity 向けには対応が必要な個所もあると思います。これを基本の形として自立行動する AI を実装したりも可能です。

以上「状態」をクラス化して扱う State パターンの紹介でした。

関連リンク

takap-tech.com