#はじめに
私が初めてコンピュータに触れたのは中学の頃でした。
そして、ゲーム業界に足を踏み入れてから早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;
}
最後に
一読すればお分かりかと思いますが、とても単純な方法です。
しかしながら、単純ゆえに必要に応じた変更が容易であり、色々なケースで使う事ができます。
単純だから移植も簡単です。
これによって私は『ステート処理をナァラティブ』に書けるようになりましたし、そして多くのバリエーションを生み出しました。
この手法で得られた『読み易さ』、『メンテナンス性』と『汎用性』の恩恵は計り知れません。
よろしければ、どうぞご自由に活用ください。
以上