4・状態遷移プログラミング



ゲームに出てくるキャラクターには、それぞれの「状態」というべき物を持っています。例えば、シューティングゲームの敵で考えてみましょう。

このように、ゲーム中のキャラの動きは、単純な動作の組み合わせで成り立っています。
まあ、このように単純な動きだけならばいいのですが、もっと複雑な動きをする敵もいます。中には、現状の判断をして次の行動を変えてくるような敵もいます。

そのような、単純な動作を複雑に組み合わせて行動するようなキャラを作る場合、その「状態」の切り替えを行う際の条件判断・初期化などの部分がどうしても複雑になるため、非常にバグが出やすくなっています。

そこで、それを回避する方法として、ここで説明する「状態遷移プログラミング」という方法を挙げてみました。

<注>
もちろん、このようなやり方を実際には「状態遷移プログラミング」と呼ぶのかどうかはわかりません。
ここでは、その言い方で統一しておきます。

さて、自分が言う「状態遷移プログラミング」というのは、簡単に言うと、

「キャラクターなどのそれぞれの状態に対し、その状態になるための初期設定の部分と、その状態の時に行う処理を、それぞれ別々に定義してやる」

というやり方です。

先ほど挙げた敵キャラで具体的に考えてみましょう。「状態1・突進してくる」の場合、まず、

・画面上のどこに出現するか?
・どの方向に、何フレーム飛んでいくのか?
と言うのをきちっと決めてやる必要があります。その後、

・設定された速度で移動する
・フレーム数をカウントし、時間が来れば次の状態に移行する
といった処理を毎回(アクションゲームの場合、毎フレーム)行います(もちろん、他にやるべき事はたくさんありますが、説明を簡単にするため省略します)。
この、「状態1になったときに最初に行う処理」「状態1の間、ずっと行う処理」をそれぞれ定義しておきます。もちろん、他の状態に関しても同じように「初期設定」と「実際の処理」をきちんと定義しておきます。

で、状態の遷移(状態が切り替わること)があった場合に「それぞれの状態の初期設定処理」を行い、そして現在の状態に応じて「その状態の時の実際の処理」を毎回行っていいくわけです。はい、理屈自体は実に簡単ですね(笑)。

つまり、「単純な処理の組み合わせで動いているなら、その単純な動きをそれぞれ別々に処理させてやれば、プログラム組むのん簡単やん!」ということです。

・・・何か、プログラム組む際のヒジョーに基本的なことを述べているようですが、意外なことに、これがきちっと出来ていないプログラムというのは少なくないようです。


さて、具体的にどうやってプログラムするのか、見ていきましょう。サンプルは、先ほどのザコ敵です。


1・状態の遷移を考える

まず、このザコ敵の状態遷移を考えてみましょう。まあ、単純に考えて、下図1のようになると考えられます。

状態遷移図
図1

※なお、図の四角で囲まれた文字が「状態」で、矢印が「状態遷移(状態が変更されること)」で、その近くにある文字が、その状態遷移を引き起こす原因です。

この敵が生成されると、まず状態1になります。その後、図のようにして色々と状態を変化させていくのです。
見ての通り、一つ一つの状態の処理とそれの初期設定は非常に簡単なものばかりです。


2・状態毎の処理、状態遷移処理(初期化)を用意してやる

次に、状態毎の処理、状態遷移処理(初期化)を用意します。図1を見ればわかるとおり、

★状態毎の処理(図で言うところの、四角の部分に当たる)

状態1・突進
状態2・停止
状態3・弾をばらまく
状態4・逃げる
爆発(実際の処理)
★状態遷移処理(図で言うところの、矢印の部分に当たる)

状態1へ遷移
状態2へ遷移
状態3へ遷移
状態4へ遷移
爆発(初期設定)
消滅
・・・といったような処理が必要になってきます。で、これらを処理するための関数を用意してやりましょう。ソースは、とりあえずCの書き方で書いておきます。

//===========================================================
//	敵その1用処理
//
//	なお、下記のソースに出てくるENEMY_PARMというのは、
//  敵のパラメータを持っている構造体である。
//===========================================================

//-------------------------------------------------
//	状態遷移関数

//----[ 状態1へ遷移 ]----
void InitEnemy1State01(ENEMY_PARM *p)
{
・・・中身は省略(爆)
}

//----[ 状態2へ遷移 ]----
void InitEnemy1State02(ENEMY_PARM *p)
{
・・・中身は省略(爆)
}

//----[ 状態3へ遷移 ]----
・・・以下、同様にして「状態遷移関数」を全部用意

//-------------------------------------------------
//	状態毎の処理関数

//----[ 状態1の処理 ]----
void ProcEnemy1State01(ENEMY_PARM *p)
{
・・・中身は省略(爆)
}

//----[ 状態2の処理 ]----
void ProcEnemy1State02(ENEMY_PARM *p)
{
・・・中身は省略(爆)
}
・・・以下、同様にして「状態毎の処理関数」を全部用意
こういう感じで、「状態遷移関数」「状態毎の処理関数」を全部用意してやります。


3・毎回行う処理用関数を用意

早い話、上記の状態遷移や状態毎の処理を呼び出すための関数ですね。簡単に書くと、

//<<<構造体の中身>>>
//	StateCanger	状態遷移が合ったら、その状態遷移番号。
//			無ければ−1
//	State		現在の状態番号

	if(p->StateChange!=-1)
	{
	//状態遷移があったぞ!
		switch(p->StateChang)
		{
			case	CHANGE_ENEMY1_STATE_01:	//状態1へ遷移
			{
				InitEnemy1State01(p);
				break;
			}
			・・・略・・・
		}
		p->StateChange=-1;
	}

	switch(p->State)
	{
		case	ENEMY_STATE_01:	//状態1
		{
			ProcEnemy1State01(p);
			break;
		}
		・・・略・・・
	}
・・・と、こんな感じです。ここで気が付いた方もおられると思いますが、第3回のジャンプテーブルが使えます。入力・返り値が全部同じですので。
ジャンプテーブルを使った場合、

	//状態遷移関数へのポインタ
	typedef void ENEMY01_CHG_STATE_FUNC(ENEMY_PARM *);
	//状態毎の処理関数へのポインタ
	typedef void ENEMY01_PROC_STATE_FUNC(ENEMY_PARM *);

	//状態遷移テーブル
	ENEMY01_CHG_STATE_FUNC	Enemy01ChgState[]={
		InitEnemy1State01,
		・・・略・・・
	};

	//状態毎の処理テーブル
	ENEMY01_CHG_STATE_FUNC	Enemy01StateProc[]={
		ProcEnemy1State01,
		・・・略・・・
	};

//実際の処理。ここから先を呼び出す。

	if(p->StateChange!=-1)
	{
	//状態遷移があったぞ!
		Enemy01ChgState[p->StateChange](p);
		p->StateChange=-1;
	}
	Enemy01StateProc[p->State](p);


はい、凄くシンプルになりました。あとは、この処理を毎フレーム(リアルタイムゲームの場合)呼び出してやればOKです。

なお、構造体のStateChangeというメンバは、それぞれの状態毎の処理の中で、状態遷移を起こす必要があるかどうかチェックし、遷移する場合にその状態遷移番号を入れるようにしてください。そうすると、次のフレームの最初に状態遷移関数を呼び出すようになっています。
また、次の状態番号は、基本的に状態遷移関数の中でのみいじるようにしてください。その方が、「どういう時に状態が遷移するのか」といった事が用意に把握できるからです。

このようにしておくと、外部から状態を遷移させるような必要が出た場合(例・プレイヤーの弾を食らう、ボスが登場したので、ザコは全部自爆させたい)でも、この構造体のStateChangeに値を入れるようにするだけで済むので、かなり処理が楽になります。


4・その他細かい点

★状態遷移関数についてのネタ・その1

状態遷移関数の中で、必ずしも状態を切り替える必要はありません。
例えば、敵が攻撃を食らった場合、その敵の耐久力が残っていれば、状態を「爆発」に遷移させる必要はありません。この場合、その敵の体力(ダメージ)を計算し、0以下になれば爆発に遷移するようにすればOKです。

ですので、こういう場合は、状態遷移関数の中でどの状態に遷移するか(またはそのままでいるか)と言うことを判断させるようにすればOKです。
また、中には一部のパラメータだけを変更(例・キャラの色など)したい場合などもあります。この場合も、状態遷移関数の中でそのパラメータをいじり、状態は変更しないようにしておくとよいです。


★状態遷移関数についてのネタ・その2

異なる状態遷移関数から同じ状態へと移るように処理を書いても問題ありません。

例えば、色が違うだけで後は全部同じ動作の場合なんてのもあります。この場合、「色を赤に設定し、状態nに遷移する」「色を青に設定し、状態nに遷移する」と言ったように、別々の状態遷移関数を用意してやった方が便利です。

また、第1回の「仮想コード処理」を用いている場合には、同じような処理で、仮想コードの先頭アドレス(ポインタ)が違うだけ、というような状態もあり得ます。


★外部とのやりとりについて

上記の説明中にも出てきましたが、この敵自体を処理する部分(「状態遷移関数」「状態毎の処理関数」、または敵クラスのメソッド)以外から、この敵の状態を変えたい場合もあります。「プレイヤーの弾を食らう」「ボスが登場したので、ザコは全部自爆させたい」といったやつです。

この場合、外部から状態遷移を引き起こす要因にそれぞれ番号付けをしておき、それに応じて次の状態へ遷移させるようにしておくと楽です。
例えば、要因として
・自爆命令
・ダメージ食らった
・壁にぶつかった
・上から降ってきた物につぶされた
・地雷を踏んだ
と言うようなものがあるとします。これらにそれぞれ番号をつけておきます。

enum CHANGE_STATE_OUTSIDE {
	CHG_OUTSIDE_KILL_MYSELF,	//自爆命令
	CHG_OUTSIDE_DAMAGE,		//ダメージ食らった
	CHG_OUTSIDE_WALL,		//壁にぶつかった
	CHG_OUTSIDE_DROP_OBJ,		//上から降ってきた物につぶされた
	CHG_OUTSIDE_BOMB,		//地雷を踏んだ
	MAX_OUTSIDE_CHG,	//外部からの状態遷移要因の最大数
};
で、これらの外部からの命令を受けた場合に、次のどの状態に遷移するかと言うことを、2次元配列で定義しておきます。「状態遷移テーブル」と言ったところでしょうか。

	int	TableChgStateOutside[MAX_STATE][MAX_OUTSIDE_CHG]={
		{・・・略・・・・},	//状態0の時の状態遷移
		{・・・略・・・・},	//状態1の時の状態遷移
		{・・・略・・・・},	//状態2の時の状態遷移
		・・・略・・・・
	};
さらに、外部からの要因によって状態遷移を行うための関数を用意してやります。

void ChgStateOutside(ENEMY_PARM *p,int mes)
{
	p->StateChange=TableChgStateOutside[p->State][mes];
}
で、外部から状態遷移を行いたい場合はこの関数を呼び出せばいいわけです。

このように、「ある状態の時に、ある外部からのメッセージ(命令)が来たら、どの状態に遷移(パラメータをいじるだけの場合もあるが・・・)するのか?」ということを、テーブルを用意して一括で管理するようにしておくとかなり便利です。


★構造体か、クラスか

上記の例では、Cの書き方なので、構造体を用いました。では、C++を使う場合には、やはりクラスを使った方がいいのでしょうか?
自分としては、クラスを使った方がいいと思います。むしろ使うべき、いや、使わねばならない!(笑)

まあ、冗談はともかく、「使ってもOKな場合であれば、できるだけ使った方が楽なんじゃないかな〜?」といった感じです。 理由は、
・基本的にメンバ変数をいじっていればいいので、「p->」と言った部分は書く必要がないし、状態遷移関数・状態毎の処理関数に引数を渡す必要がない
・継承を利用すればかなり便利
・自分以外のキャラのパラメータをいじる可能性が0である
・別のクラスなら、同じ名前でもいっこうに問題ないため、関数名を別々につけてやる必要がない
といった点です。特に、3番目のヤツが重要です。
Cの場合、可能性は低いですが「自分のパラメータをいじっているつもりだが、実はポインタは隣のヤツを指していた!衝撃の事実なんて事が起こり得ます。しかし、C++の場合、基本的に自分以外のヤツのパラメータ(メンバ変数)はいじれないようになっています。

しかし、Cの達人ともなると、

「そんなの、気をつけて書けばいいだけじゃん」

と思われる方もおられると思います。まあ、実際そうなんですが・・・。
しかし、可能性は低いですが、「絶対あり得ない」とも言い切れないのです。もし、この手のバグが発生した場合(ポインタがずれる、など)、原因の特定は非常に困難です。
しかし、C++の場合、言語の仕様として基本的にそういうことが出来ない(※1)ようになっています。つまり、可能性がCよりも遙かに少ないということです。
ですので、開発時・デバッグ時の労力を減らすためには、是非ともC++のクラスを使うことをオススメします。

また、クラスを使った場合、状態遷移関数が「オブジェクト(インスタンス。早い話がキャラクターそのもの)に対しての命令」という感覚で扱えるため、感覚的にわかりやすくなるという利点もあります。
つまり、「おら、状態2へと遷移せんかい!」「状態はそのままで色だけ赤くならんかい!こんボケがぁ!」という感じでプログラムできるのです。「大したこと無いな」と思う方もおられると思いますが、意外と重要です。
だって、状態が遷移しない場合もあるのに、「状態遷移関数」ではへんでしょ?




・・・このようなやり方を行う「状態遷移プログラミング」ですが、キャラの動作の切り替えを追いかけたり、新しい動作を追加したりするような作業が非常に楽なので、かなり使えるはずです。
また、複雑な動きの場合でもif文などをあまり使わずにプログラムできるので、かなりすっきりとした形でソースがかけるようになります。

もし、「if文が多くて中身が訳わかんないよ〜!」とか「ここをいじったら動かなくなりそうなので、怖くていじれない(汗)といった事でお悩みの方がおられましたら、一度この考え方でプログラムを組んでみることをオススメします。




<おまけ>


なお、先にも書きましたが、意外とこの「状態遷移」という考え方を用いていない、またはきちっと利用できていないプログラムは多いようです。
例えば、ゲーム名やメーカー名は挙げませんが、

・CPUが、そのゲームでは絶対に出来ない「しゃがみ大>立ち小」というチェーンコンボをしてくる
・技の途中で変な動きをすることがある(別の技のモーションが混じる)
・超必投げの最中に相手がはずれ、反撃を食らってしまう(投げた側は一人で投げの動作を行っている)
・本来出来るはずのキャンセルが、時々出来なくなったりする
・ある技の出始めに特殊行動を行うことが出来るため、ほとんど隙のない連係が可能(※2)

といったようなバグの出る市販の格ゲーが存在します。これらのバグは全て、「状態遷移」がちゃんと行われていない、または変なところに遷移しているために起こっているモノであると考えられます。
キャラクターの状態とその遷移がきちんと定義されており、かつ、それらの遷移がきちんと行われるようなシステムであれば、まずこういうバグは発生しません(※3)

もちろん、プログラムにミスがあれば発生しますが(当たり前)、それでもチェックは簡単です。その、おかしな行動をとる状態に遷移するための処理や、そこに遷移させようとしている処理の部分を見つけ、それらを細かくチェックすればいいわけですから・・・。





(※1)言語の仕様として基本的にそういうことが出来ない

char型(8ビット整数型。ー128〜127)に5217という値が代入できないのと同じ事。
つまり、基本的に言語として出来ない事なので、それが起こることはありえない、というわけ。

ただし、C++の場合、配列をいじる際に上限の判定をしないので(要素数8の配列の9番目にアクセスできたりする)、それを利用すれば一応可能ではある。普通はメソッド(メンバ変数)に対してそんな事しないと思うが・・・。


(※2)連係が可能

一見バグっぽくないが、本来あり得ない動作なので、これはれっきとしたバグである。
なお、この「ある技」とほぼ同じ操作(立ちかしゃがみかの違い)で行う別の技では同じような事が出来ないので、明らかに開発側のミス(チェック不足?)である。


(※3)発生しません

なお、UeSyuの格ゲー「クレールVSサフィーユ」のプレイヤーなどは、厳しくこの考え方に基づいているため、この手のバグは発生しにくくなっています。






プログラムのネタ!の目次へ