Help us understand the problem. What is going on with this article?

ステート処理はナァラティブに

More than 3 years have passed since last update.

はじめに

私が初めてコンピュータに触れたのは中学の頃でした。
そして、ゲーム業界に足を踏み入れてから早2X年。

未だ、現役のゲームプログラマです。

長く活躍するために、私はプログラムを行う上で一つの流儀を持っています。

『プログラムは物語を語るように記述する』

連続した処理は、連続して記述する。

そうすることで理解しやすく、メンテナンスが簡単なプログラムを書くことができます。

ゲームの最たるは、『如何にステートを記述するか』です。

様々な都合ではなく、『人間の読み物としてプログラムする』

その手法の紹介をします。

ゲームプログラムはステートのカタマリ

『何フレームあとには何して』

『あれが完了したら何して』

…等というのが延々と続くのがゲームプログラム。

ゲームをプログラムするってことはステート処理を書き続けること。

そう、プログラマは書いて、読み直して、修正して…と

完成までずっとステート処理と付き合い続けます。

一般的なステート処理

自分の書き方を伝える前に、一般的なステート処理の紹介をします。


//ステートに名前を付け、enumで列挙する。

enum STATE
{
    S_NONE,
    S_START,
    S_STEP1,
    S_STEP2,
    S_STEP3,
    S_END
};

//現在のステートと次のステートを保持する2つの変数を用意する。

STATE m_curState = S_NONE;
STATE m_nextState = S_NONE;

//描画と同期したUpdate関数内にステート単位の処理を書く。

void Update()
{
    if (m_curState!=m_nextState)
    {
     m_curState = m_nextState;
        switch(m_curState)
        {
        case S_START:   (S_STARTの初期処理)    break;
        case S_STEP1:   (S_STTEP1の初期処理)   break;
        case S_STEP2:   (S_STTEP2の初期処理)   break;
        case S_STEP3:   (S_STTEP3の初期処理)   break;
        case S_END:     (S_ENDの初期処理)      break; 
        }
    }
    else 
    {
        switch(m_curState)
        {
        case S_START:   (S_STARTの更新処理)    break;
        case S_STEP1:   (S_STTEP1の更新処理)   break;
        case S_STEP2:   (S_STTEP2の更新処理)   break;
        case S_STEP3:   (S_STTEP3の更新処理)   break;
        case S_END:     (S_ENDの更新処理)      break; 
        }
    }
};

//スタート用の関数 Start()を書く
void Start()
{
    m_nextState = S_START;
}

以上がよく事例でも登場する、『switch』を使ったステート処理の例です。

switchを使う場合、ステートの記述が処理によって分散し、解りにくいという欠点があります。

そのため、先述の『物語のように書く』ことはできません。

現場で好まれる理由

オブジェクト指向の教本には『switchを使うな』と書かれており、実際それ(switch)を嫌う方もいます(私もその一人です)。

しかし担当者が初級レベルである場合も多く、無理をした属人的手法がとられるより、本手法の方が現場において安心できる側面があります。

代替手法を提供するものは、このことを肝に銘じる必要があります(つまり初級者でも理解できるものが必要)。

ステート関数

私が使う手法の要は、ステートを1関数に見立てる方法です。

これを便宜上、『ステート関数』と勝手に呼んでいます。

以下のように記述します。

    void S_HOGE(bool bFirst) // ステートHOGEの関数
    {
        if (bFirst)
        {
            //初期処理
        }
     else 
        { 
            //更新処理
            if (終了?)
            {
                //終了処理
                Goto(次のステート);
            }
        }
    }

ステート関数の引数はbool1個のみです。

ステート関数は、引数がtrueの時のみ初期処理を行い、falseの時は更新処理を行います。更新処理内で終了検知し、終了処理をします。

このように関数単位でステートとなれば、大変見やすくなります。

ステートが連続して動作する場合は、その関数をステートの順に羅列して書くことで大変解り易く、プログラミング作業を捗らせることができます。

仕様書のステート図を、1クラス内にそのままの形で記述する事が可能となります。

私自身、これがメンテナンス上の大きなアドバンテージとなりました。

ステート管理クラス

ステート関数の呼出しをするのがステート管理クラスです。

ステート管理クラスはステートのリクエスト提供と呼び出しを行います。

クラスが持つメンバ変数は現在用と次のステート用の二つで、ステートを関数ポインタ(デリゲート)として扱います。

関数も、リクエスト用とフレームワーク接続用のUpdate関数の二つです。

リクエスト用は単純で、リクエストがあったステート関数のポインタを次のステートに格納するだけです。

Update関数では、次のステートがあった場合は現在のステートと入れ替え、引数にtrueを入れてステートを呼び出します。
そうでない場合は引数にfalseを入れて呼び出します。

管理クラスの記述例 (Unity)

例としてUnityで作成した管理クラスを以下に示します。

public class StateManager 
{
    Action<bool> m_curstate;
    Action<bool> m_nextstate;
    
    //リクエスト
    public void Goto(Action<bool> func) 
    { 
        m_nextstate = func;  
    }
    
    //更新
    public void Update()
    {
        if (m_nextstate!=null)
        {
            m_curstate = m_nextstate;
            m_nextstate = null;
            m_curstate(true);
        }
        else
        {
            m_curstate(false);
        }
    }
}

使用例 (Unity)

Unityでの使用例は以下のようになります。

ステート管理クラスをメンバとして、Start()にて初期化し、Update()にてステート管理クラスのUpdateを呼び出します。

ステートは最初に、Start()でのGoto()で、S_IDLEに設定されます。
S_IDLE内では、初期処理で現時刻+2秒をm_limitに設定し、本処理で現時刻がm_limitに達するのを待ち、S_WORKへ移動します。そして、最後にS_ENDへ移動します。

public class Hoge : MonoBehaviour {

    StateManager m_sm;

    void Start()
    {
        m_sm = new StateManager();
        m_sm.Goto(S_IDLE);
    }

    void Update()
    {
        m_sm.Update();
    }
    
    // ステート処理
    float m_limit;
    void S_IDLE(bool bFirst)
    {
        if (bFirst) 
        { 
            Debug.Log("S_IDLE Initialization");
            m_limit = Time.time + 2; 
        }
        else 
        {
            if (m_limit < Time.time)
            {
                m_sm.Goto(S_WORK);
            }
        }
    }

    void S_WORK(bool bFirst)
    {
        if (bFirst)
        {
            Debug.Log("S_WORK Initialization");
        }
        else 
        {
            Debug.Log("S_WORK Update");
            m_sm.Goto(S_END);
        }
    }
    
    void S_END(bool bFirst)
    {
    }
}

C++の場合

C++の例を示します。
言語の特性上、関数ポインタの扱いは厳密にしなくてはなりません。
そのため、ステート管理クラスはテンプレートとして提供されます。

ステート管理クラス(C++)

template<typename  T> class StateManager
{
    typedef void (T::*STATE_T)(bool isFirst);

    T *m_base;

    STATE_T m_curState;
    STATE_T m_nextState;

public:
    StateManager(T *base) 
        : 
        m_base(base), 
        m_curState(NULL), 
        m_nextState(NULL) {}

    void Goto(STATE_T state) 
    { 
        m_nextState = state;
    }

    void Update()
    {
        if (m_nextState != NULL )
        {
            m_curState = m_nextState;
            m_nextState = NULL;
            (m_base->*m_curState)(true);
        }
        else
        {
            (m_base->*m_curState)(false);
        }
    }
};

使用例 (C++)

利用する側は、ステート管理クラスに利用側のクラスをテンプレートの引数に設定します。
そしてステートの取り扱いを簡単にするため、STATEマクロを定義しています。

class HOGE {

#define STATE(X) (&HOGE::X) 

    StateManager<HOGE> m_sm;

public:
    HOGE() : m_sm(this) {}

    void Start() // 上位関数が本クラス初期化後すぐに呼び出すことを想定
    {
        m_sm.Goto(STATE(S_IDLE));
    }

    void Update() // 上位関数が更新時に呼び出すことを想定
    {
        m_sm.Update();
    }

private:
    float m_limit;
    void S_IDLE(bool bFirst)
    {
        if (bFirst) {
            printf("Initialize IDLE\n");
            m_limit = time() + 2;
        }
        else
        {
            if (m_limit < time())
            {
                m_sm.Goto(STATE(S_WORK));
            }
        }
    }
    void S_WORK(bool bFirst)
    {
        if (bFirst) {
            printf("Initialize WORK\n");
        }
        else
        {
            m_sm.Goto(STATE(S_END));
        }
    }
    void S_END(bool bFirst)
    {
    }
};
float time()
{
    clock_t t = clock();
    long it = (long)t;
    return (float)it / 1000.0f;
}

最後に

一読すればお分かりかと思いますが、とても単純な方法です。

しかしながら、単純ゆえに必要に応じた変更が容易であり、色々なケースで使う事ができます。

単純だから移植も簡単です。

これによって私は『ステート処理をナァラティブ』に書けるようになりましたし、そして多くのバリエーションを生み出しました。

この手法で得られた『読み易さ』、『メンテナンス性』と『汎用性』の恩恵は計り知れません。

よろしければ、どうぞご自由に活用ください。

以上

tohmas
シニア価格がうれしいゲームプログラマ
https://statego.programanic.com/index-j.html
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした