Flat Leon Works

アプリやゲームを作ってます。

【C#】コルーチン(イテレータブロック)で状態遷移

C#によるゲームプログラミングで、コルーチンを使った状態遷移をやってみたら便利だったのでその方法を紹介します。

状態遷移とは

プログラミングにおける状態遷移とは、オブジェクトが持つ状態を変化させ、それによりそのオブジェクトが行う処理内容を切り替えることです。例えば、ゲームのキャラクターは待機状態から歩行状態、攻撃状態に変わり、それぞれの状態で動作が変わります。他にも、ゲームでのシーン切り替えや画面切り替えも状態遷移とみなすことができます。

状態遷移をenumとswitch文で実装する

状態遷移をプログラムで実装する場合、一般的にはenumとswitch文を使うか、あるいは状態をクラスのようなオブジェクトにしてそのオブジェクトを差し替えることで実現します。

例えば、待機、歩行、攻撃の状態を持つゲームキャラクターを状態遷移を、enumとswitch文で実装すると以下のような感じでしょうか。

public enum CharaState
{
      Invalid
    , Idle
    , Walk
    , Attack
    , Wait
}

class PlayerCharacter
{
    CharaState m_State;
    CharaState m_NextState;
    float      m_WaitTime;
    public void Update()
    {
        switch( m_Scene )
        {
            case CharaState.Idle:
            {
                if ( IsStickInput() )
                {
                    ChangeState( State.Walk );
                    ChangeAnimation_Walk();
                }
                else if ( IsPushButton( A ) )
                {
                    ChangeState( State.Attack );
                    ChangeAnimation_Attack();
                }
                break;
            }
            case CharaState.Walk:
            {
                if ( !IsStickInput() )
                {
                    ChangeState( State.Idle, 0.1f );
                    ChangeAnimation_Idle();
                }
                break;
            }
            case CharaState.Attack:
            {
                if ( IsAttackFinish() )
                {
                    ChangeState( State.Idle, 0.5f );
                    ChangeAnimation_Idle();
                }
                break;
            }
            case CharaState.Wait:
            {
                if ( m_WaitTime > 0f )
                {
                    m_WaitTime -= Time.deltaTime;
                    if ( m_WaitTime <= 0 )
                    {
                        m_State = m_NextState;
                        m_WaitTime = 0;
                    }
                }
                break;
            }
        }
    }
    void ChangeState( CharaStete state, float waitTime = 0f )
    {
        m_WaitTime = waitTime;
        if ( m_WaitTime > 0f )
        {
            m_NextState = state;
            m_State = CharaState.Wait;
        }
        else
        {
            m_State = state;
        }

    }
}

状態遷移のためのChangeState関数に待ち時間の機能を持たせていたりしますが、それ以外はよくある普通のコードだと思います。しかし、このような状態遷移の処理はコルーチン(イテレータブロック)を使うことでもっとシンプルかつ柔軟に記述することができるようになります。

コルーチンによる状態遷移の実装

コルーチン(イテレータブロック)で状態遷移を実現するには、なんとgoto文を使います。

class PlayerCharacter
{
    public IEnumerator<System.Func<bool>> Update()
    {
        IDLE:;
        {
            ChangeAnimation_Idle();

            while ( true )
            {
                yield return null;

                if ( IsStickInput() )
                {
                    goto WALK;
                }
                else if ( IsPushButton( A ) )
                {
                    goto ATTACK;
                }
            }
        }

        WALK:;
        {
            ChangeAnimation_Walk();

            while ( true )
            {
                yield return null;

                if ( !IsStickInput() )
                {
                    yield return WaitForSeconds( 0.1f );
                    goto IDLE;
                }
            }
        }

        ATTACK:;
        {
            ChangeAnimation_Attack();

            while ( true )
            {
                yield return null;

                yield return WaitForAnimationEnd();
                yield return WaitForSeconds( 0.5f );
                goto IDLE;
            }
        }
    }
}

このように、ラベルを状態として扱い、goto文で状態遷移を行います。上記コードに表れる、yield return WaitForSeconds( 0.5f );などがどのように機能しているのかは後述する「コルーチンによる実装の詳細」で解説します。

goto文をどうしても使いたくない場合は、enumとwhile文とswitch文で代替できますが、ごちゃごちゃするだけなのでおすすめしません。goto文を使わない方法はこの記事の末尾で紹介しています。

コルーチンによる実装のメリット

コルーチンによる実装には以下のようなメリットがあります。

  • 見通しがよくなる
  • 一連の流れをシンプルに記述できる
  • 柔軟性が高くなる
  • 状態間での値のやり取りが簡単になる

見通しがよくなる

コルーチンとgoto文による実装だと、enumを定義する必要がなくswitch文による分岐も記述する必要がなくなるので、すっきりした見た目になります。また、後述しますがコルーチンだと一連の流れをシンプルに記述することができるので、これも見通しがよくなることに繋がります。

各状態の初期化処理(最初に行う処理の記述)も単純に各ラベル文の直後に記述するだけでよくなり、とても見やすくなります。

一連の流れを簡単に記述できるようになる

「アニメーションの再生が終わったら1秒待ってから状態遷移を行う」というような一連の流れを実装するには、enumとswitch文だと、新たにメンバ変数を用意するなど工夫して実装する必要がありますが、コルーチンによる実装の場合はyieldを使ってシンプルに記述することができます。

柔軟性が高くなる

簡単に「状態」を追加したり、各状態の中での一連の流れを簡単に記述できるようになるので、仕様変更にも柔軟に対応できるようになります。

状態間での値のやり取りが簡単になる

enumとswitch文による実装の場合は、各状態間で値をやりとりするにはメンバ変数を追加する必要がありました。しかし、コルーチンによる実装だと、Update関数内にローカル変数を追加するだけで各状態間で値のやり取りができるようになります。

コルーチンによる実装のデメリット

コルーチンによる実装にはデメリットもあります。

  • 外部から状態の変更がしづらい
  • 処理の共通化が難しい

外部から状態の変更がしづらい

コルーチンによる実装ではコードの実行位置で「状態」を表すことになります。中断されたコルーチンの実行位置を外部から変更することはできないため、外部から状態の変更を直接行うことができません。状態の変更を外部から行うには、メンバ変数などを経由して間接的に行う必要があります。例えば、状態変更リクエストを文字列として受け取れるようにしておき、コルーチンの再開時に状態変更リクエストがあるか確認して、あった場合にgoto文で状態遷移を行うなどの方法を取る必要があります。

class PlayerCharacter
{
    public string ChangeStateRequest { get; set; }
    public IEnumerator<System.Func<bool>> Update()
    {
        IDLE:;
        {
            while ( true )
            {
                yield return null;

                if ( ChangeStateRequest == "WALK" )
                {
                    ChangeStateRequest = "";
                    goto WALK;
                }
            }
        }

        WALK:;
        {
            while ( true )
            {
                yield return null;
            }
        }

        ATTACK:;
        {
            while ( true )
            {
                yield return null;
            }
        }
    }
}

このやり方にはメリットもあります。それは状態遷移が自然に制限されることです。上記コードでは、IDLE状態からのみWALK状態に遷移できるようになっています。また、状態遷移のタイミングがかならずUpdate時になります。想定していない状態から状態への遷移と、想定外のタイミングでの状態遷移が自然と制限される形になるので便利な場合もあります。

処理の共通化が難しい

yield文やgoto文を含む処理は別の関数に分けることができないので、処理を共通化することが難しくなっています。例えば先程のChangeStateRequest変数をチェックして状態遷移を行う処理は、別の関数に分けることができません。

class PlayerCharacter
{
    public string ChangeStateRequest { get; set; }
    public void CheckChangeStateRequest()
    {
        if ( ChangeStateRequest == "WALK" )
        {
            ChangeStateRequest = "";
            goto WALK; // このgoto文は機能しない。というかコンパイルエラーになるはず。
        }
    }
    public IEnumerator<System.Func<bool>> Update()
    {
        IDLE:;
        {
            while ( true )
            {
                yield return null;
                CheckChangeStateRequest();
            }
        }

        WALK:;
        {
            while ( true )
            {
                yield return null;
                CheckChangeStateRequest();
            }
        }

        ATTACK:;
        {
            while ( true )
            {
                yield return null;
                CheckChangeStateRequest();
            }
        }
    }
}

このように、状態遷移をどの状態からでも行えるようにするには各状態でChangeStateRequest変数をチェックする処理を記述する必要があります。

ただし、goto文ではなくyield文を含む処理の関数分けは、コルーチンを入れ子にすることで実現することができます。詳細は後述の「yieldを含む処理を関数として分ける」をご覧ください。

状態遷移をコルーチンで実装しない方がよい場合

状態遷移をコルーチンで実装することにはデメリットもあるので、素直にenumとswitch文などで実装した方がよい場合もあります。例えば以下のような場合です。

  • 外部から状態を切り替えることがメインの場合
  • 状態の切り替えを即座に行う必要がある場合

ゲームのプレイヤーキャラはまさにこれに当てはまるので、状態遷移をコルーチンで実装するのはあまり向いていないかもしれません。そもそもゲームのプレイヤーキャラはさまざまな状態が重ね合わさった状態になるので状態遷移という概念で実装するのはよくないですね。例えば、走りながらジャンプしてさらに攻撃してしかも無敵状態ということが普通に発生します。こういう場合は、状態をフラグで持たせましょう。記事中にゲームのプレイヤーキャラをコード例として使っているのは説明のためということで…。

では、どういう場面で状態遷移をコルーチンで利用するのがいいのかというと、全体の流れを制御する場面です。ゲーム全体を制御する処理、シーン全体を制御する処理などです*1。あと個人的な経験ですが、マウスによるドラッグ処理を実装するのにも便利でした。ドラッグの処理は、ドラッグ前、ドラッグ中、ドラッグ後の処理があり、それぞれで情報を共有必要があるので、コルーチンによる実装が合っていました。

コルーチンによる実装の詳細

コルーチンによる状態遷移を行うことに関してのアイデアとしては、コルーチン内でラベルとgoto文を使うというだけのことです。ただし、これだけだとまだ不便なのでいくつかのテクニックがあります。

「待ち」ができるようにする

「待ち」というのは、3秒待つとか、○○が終わるまで待つといった処理です。これは言い換えると、条件が満たされたら自動で再開させるということです。

これを実現するには、コルーチン(イテレータブロック)の戻り値をIEnumerator<System.Func<bool>>型にします。IEnumeratorの部分はイテレータブロックのために必要な部分なので無視するとして、ポイントはSystem.Func<bool>です。System.Func<bool>が何を表すのかというと、再開の条件です。

コルーチンを呼び出す側はコルーチンが中断された場合、System.Func<bool>を受け取ります。そして、任意のタイミングでSystem.Func<bool>を呼び出し、その結果がtrueならコルーチンを再開させます。

コルーチンを呼び出し、自動で再開させる者をCoroutineControllerクラスとして実装した例が以下のコードです。

public class CoroutineController
{
    IEnumerator<Func<bool>> m_Coroutine; // コルーチン
    Func<bool>              m_ResumeCondition; // 中断したコルーチンから受け取った再開条件
    public CoroutineController( IEnumerator<Func<bool>> coroutine )
    {
        m_Coroutine = coroutine;
    }

    public bool Update()
    {
        if ( m_Coroutine == null ){ return true; }
        bool needResume = m_ResumeCondition == null || m_ResumeCondition(); // 再開すべきかどうかチェック
        if ( needResume )
        {
            // コルーチン再開
            if ( m_Coroutine.MoveNext() )
            {
                // コルーチン中断
                m_ResumeCondition = m_Coroutine.Current; // 再開条件を受け取る
            }
            // コルーチン終了
            else
            {
                m_Coroutine = null;
                return true;
            }
        }
        return false;
    }

    public System.Func<bool> GetUpdateFunc()
    {
        return () => return Update();
    }
}

以下のように使います。

class MainGameScene
{
    PlayerCharacter m_Player;
    CoroutineController m_PlayerCoroutine;
    public MainGameScene()
    {
        m_Player = new PlayerCharacter();
        m_PlayerCoroutine = new CoroutineController( m_Player.Update() );
    }
    public void Update()
    {
        m_PlayerCoroutine.Update();
    }
}

これでコルーチン内部でyield return () => IsFinishedHoge();というような記述で待ちができるようになりました。yield returnで返す値はSystem.Func<bool>であればいいので、例えば指定秒待つというような処理を関数化しておくこともできます。

    public static Func<bool> WaitForSeconds( float time )
    {
        float remainTime = time;
        return () => { remainTime -= Time.deltaTime; return remainTime <= 0f; };
    }

ちなみに、このような待ちの仕組みはUnityのコルーチンにも実装されています。ただし、Unityのコルーチンの戻り値型はIEnumerator<Func<bool>>型ではなくちょっと癖があるので注意が必要です。

状態の初期化処理を記述する

ラベルの直後からreturn yieldを行う部分までが、その状態の初期化処理になります。

    public IEnumerator<System.Func<bool>> Update()
    {
        IDLE:;
        {
            // ここに初期化処理を記述する

            while ( true )
            {
                yield return null;
                CheckChangeStateRequest();
            }
        }
    }

状態の終了時処理を記述する

その状態の処理全体をtryブロックで囲うことで、その状態を抜け出すとき(つまりtryブロックを抜けるとき)にfinallyブロックが必ず実行されるようになります。これを利用することで状態の終了時の処理を記述することができます。

    public IEnumerator<System.Func<bool>> Update()
    {
        IDLE:;
        {
            try
            {
                // ここに初期化処理を記述する

                while ( true )
                {
                    yield return null;
                    if ( IsStickInput() )
                    {
                        goto WALK;
                    }
                }
            }
            finally
            {
                // ここに終了時の処理を記述する
            }
        }
    }

毎回呼ばれるようにする(毎回再開する)

while(true)yield return null;を使うことで、毎回再開させることができます。毎回が何なのかはコルーチン呼び出し側の実装によりますが、ゲームプログラミングでのオブジェクトなら毎フレームなのが一般的だと思います。

    public IEnumerator<System.Func<bool>> Update()
    {
        IDLE:;
        {
            while ( true )
            {
                yield return null;

                // 毎回呼ばれる
            }
        }
    }

現在の状態を取得できるようにする

状態を表す値をラベル文の直後に代入しておくことで、外部から現在の状態を取得できるようになります。状態を表す値は文字列にするのが楽だと思います。

class PlayerCharacter
{
    string m_CurrentState;
    public string CurrentState { get { return m_CurrentState; } }
    public IEnumerator<System.Func<bool>> Update()
    {
        IDLE: m_CurrentState = "IDLE"; // 状態を表す値を代入
        {
            while ( true )
            {
                yield return null;
            }
        }

        WALK: m_CurrentState = "WALK"; // 状態を表す値を代入
        {
            while ( true )
            {
                yield return null;
            }
        }
    }
}

yieldを含む処理を関数として分ける

yieldを含む処理はそのままでは別の関数として分けることができません。しかし、その処理をコルーチンにすれば処理を分けることができます。つまりコルーチンを入れ子にするわけです。

以下のようにすることでコルーチンを入れ子にすることができます。

class PlayerCharacter
{
    bool m_Muteki;

    // 1秒だけ無敵にする
    public IEnumerator<System.Func<bool>> Muteki()
    {
        m_Muteki = true;

        yield return WaitForSeconds( 1.0f );

        m_Muteki = false;
    }

    public IEnumerator<System.Func<bool>> Update()
    {
        IDLE: m_CurrentState = "IDLE"; // 状態を表す値を代入
        {
            // コルーチンを入れ子にする(つまり、このコルーチンが終わるまで待つ)
            var co = new CoroutineController( Muteki() )
            yield return () => co.Update();

            while ( true )
            {
                yield return null;
            }
        }

        WALK: m_CurrentState = "WALK"; // 状態を表す値を代入
        {
            // GetUpdateFunc()を使えば1行で記述可能
            yield return new CoroutineController( Muteki() ).GetUpdateFunc();

            while ( true )
            {
                yield return null;
            }
        }
    }
}

(おまけ)Unityのコルーチンについて

Unityのコルーチンは作成すると自動で処理を毎フレーム呼んでくれるのですが、この挙動が個人的に好きではないので、Unityのコルーチンを使わずに上の方で紹介したCoroutineControllerのようなものを自作しています。ですが、この記事で紹介している手法はすべてUnityのコルーチンでも実現可能なので、Unityのコルーチンを使うかどうかは好みに合わせるのがいいと思います。

ちなみに、手動で処理を呼び出すメリットとしては、処理のタイミングや停止を簡単に制御できるようになるという点があります。これの何が嬉しいのかというと、例えばゲームをポーズ(一時停止)する処理をシンプルに記述することができるようになります。

class MainGameScene
{
    public IEnumerator<System.Func<bool>> Update()
    {
        MAINGAME:
            while ( true )
            {
                yield return null;
                GameUpdate(); // 手動でUpdateを呼び出す

                if ( IsPauseButtonClicked() )
                {
                    goto PAUSE;
                }
            }
        PAUSE:
            yield return ShowPauseWindow();
            while ( true )
            {
                yield return null;

                // ポーズ中はUpdateしない
                // GameUpdate();

                if ( IsPauseWindowClosed() )
                {
                    goto MAINGAME;
                }
            }
    }
}

こうすることで、GameUpdate内ではポーズについての処理を一切行わずに、ポーズを実現できます。Unityのコルーチンには停止機能がありますが、個々のオブジェクトについて停止操作をするのではなく、大元のUpdateを呼び出す/呼び出さないという形にしたほうがわかりやすいと思います。

(おまけ)goto文を使わないバージョン

どうしてもgoto文を使いたくないという場合は、enumとwhile文とswitch文を使うことで代替できますが、ごちゃごちゃするだけなので素直にgoto文使った方がいいと思います。

public enum CharaState
{
      IDLE
    , WALK
    , ATTACK
}

class PlayerCharacter
{
    public string ChangeStateRequest { get; set; }
    public IEnumerator<System.Func<bool>> Update()
    {
        CharaState state;
        while ( true )
        {
            switch( state )
            {
                case CharaState.IDLE:
                {
                    ChangeAnimation_Idle();

                    while ( true )
                    {
                        yield return null;

                        if ( IsStickInput() )
                        {
                            state = CharaState.WALK;
                            break;
                        }
                        else if ( IsPushButton( A ) )
                        {
                            state = CharaState.ATTACK;
                            break;
                        }
                    }
                    break;
                }
                case CharaState.WALK:
                {
                    ChangeAnimation_Walk();

                    while ( true )
                    {
                        yield return null;

                        if ( !IsStickInput() )
                        {
                            yield return WaitForSeconds( 0.1f );
                            state = CharaState.IDLE;
                            break;
                        }
                    }
                    break;
                }
                case CharaState.ATTACK:
                {
                    ChangeAnimation_Attack();

                    while ( true )
                    {
                        yield return null;

                        yield return WaitForAnimationEnd();
                        yield return WaitForSeconds( 0.5f );
                        state = CharaState.IDLE;
                        break;
                    }
                    break;
                }
            }
        }
    }
}

まとめ

  • コルーチンとgoto文を使うことで状態遷移を実現することができます
  • コードがシンプルになります
  • 一連の流れを簡単に記述できるようになります
  • 柔軟性が増します
  • メンバ変数を増やさずに簡単に状態間で値のやり取りができるようになります
  • ただし、外部からの状態の変更が難しい、処理の共通化が難しいというデメリットもあります
  • なので、この手法が向かない場合もあります
  • goto文を使わなくても実装できますが、goto文を使った方が圧倒的にシンプルになります

*1:コルーチンによる流れの制御については、特別なメリットがあるのでまた別の記事で紹介したいところです