状態遷移の「時間」を使って初期化してはいけない
皆さん「時間」と「時刻」の区別、出来ていますでしょうか?時間はある2つの点の経過を計ったものです。一方で時刻は何らかの絶対的なゼロ基点を基準とした時の時間を言います。マラソンで2時間6分というのはスタートからゴールまでの経過の測定値なので時間、2020年3月28日18:00は西暦1年1月1日0:00をゼロ基点とした時の時刻(経過時間)です。日常感覚的には時間は「間」があるもの、時刻は瞬間、ですよね。
話は変わって、状態遷移というのは「状態」と「遷移」に分かれます。状態は今の状態をキープするフェーズなのでこれは「時間」の概念です。一方遷移は一瞬で切り替わる物なので「時刻」での処理です。状態は時間処理、遷移は時刻処理。これを守っていれば状態遷移は平穏。しかし、これを破ると目に見えないバグが埋め込まれる可能性があります。
初期化用状態を作ったら良い気がする~の罠
すごーくありがちな例を一つ。関数ポインタとかc#のSysem.Actionを使った状態遷移で、キャラクタを「上に60フレーム歩かせて、次に右に120フレーム歩かせる」としましょう。これを実装する時、時間リセットが大概問題になります。疑似コードをご覧下さい:
std::function< void() > state_;
int t = 0;
void walk_up_60() {
t++;
walk( UP, 1 ); // 上へ1歩移動
if ( t == 60 )
state_ = walk_right_120;
}
void walk_right_120() {
t++;
walk( RIGHT, 1 ); // 右へ1歩移動
if ( t == 120 ) {
state_ = stop;
}
}
int main() {
state_ = walk_up_60;
while( true ) {
state_(); // state_をグルグル
}
}
このコード、処理のバグがあるの分かりますでしょうか?
最初60フレーム上に歩く所は大丈夫。でも、60フレーム目にwalk_right_120関数にスイッチしてるのに、時刻tをリセットし忘れています。その為walk_right_120関数は時刻t=61からスタート。結果右へ120フレーム動かすつもりが60フレームしか動かないバグになります。ちゃんとtを再初期化しないと (>_<)/めっ
状態を遷移する時にはこのように何かの値をリセットしたり初期化し直す事がしょっちゅうあります。そして良く忘れると(^-^;。上の例なら遷移前にt=0をちゃちゃっと入れれば直るのですが、「初期化処理を明確にすれば…」という意識と状態遷移に固執してしまうがあまり、次のような過剰実装をしてしまう事があります:
void walk_up_60() {
t++;
walk( UP, 1 ); // 上へ1歩移動
if ( t == 60 )
state_ = walk_right_120_init; // 右移動の初期化へ
}
// 右移動の初期化
void walk_right_120_init() {
// 時刻をリセット
t = 0;
state_ = walk_right_120; // 右へ
}
// 右移動
void walk_right_120() { ... }
walk_up_60() → walk_right_120_init() → walk_right_120()。walk_up_60関数はあくまでも歩く状態なんだから、初期化は別の状態に任せよう。うん、仕事の切り分けが明確になった(^-^)。…という発想。
これ、ヤバイです。だって、walk_right_120_init関数を実行するのに1フレーム使っちゃうんですから。歩き始めてから右向いて止まるまで、本来はきっかり120フレームのはずが、上の初期化状態がある事で121フレームの一連動作になってしまっています。これは仕様を満たしていないバグです。
初期化は時間じゃなくて時刻
「いやいや、ふつーこんな事しないでしょww」って思うでしょ?これがねぇ、やりたくなるのですよ、状態遷移に視野狭窄になってしまうと。特にC++の関数ポインタとかstd::function、C#のSystem.Actionとかコルーチンなどのささっと書ける利便性の高い状態遷移を使うと、「あ~、まぁ、1フレーム使っても別にいいか」みたいに闇堕ちしやすいんですよ、マジで(特に疲れてる時とか締め間際とか)。
はっきりさせなければならない事ですが、状態遷移を設計する時に、その状態が「時間」を消費する物なのか、瞬間的な「時刻」で終わる物なのかを区別する事はひじょーーーーに大切です!もう一度言います、ひじょーーーーに大切です!!
初期化というのは本動作する前に終わってなければならない事で、これはフレーム時間を消費して行うものではありません。ですから初期化は「時間処理」ではなくて「時刻処理」です。だから、上のような初期化は状態にしては決していけない物なんです。
時刻処理で終わる物を時間処理(状態)として状態遷移の中に組み込んでしまうと、特にゲームのような離散時間できっちり動作するようなものは、目に見えにくいランタイムバグとして残ってしまいます。なまじ動いてしまうので分かりにくいだけに、この手のバグは厄介です。
初期化はチマチマ書く。それが嫌なら遷移処理関数を挟むのも手
「それがまずいのは分かったけど、めんどくせーんすよ、初期化毎回書くの」という気持ちが芽生えた方。喝ーっ!これは状態遷移を仕様通りに正しく動かすための決まりです。メンドクサイでバグを埋め込むのは本末転倒。正しい箇所で時刻処理で終わる初期化を書いてください。
とは言え、わかります。確かに面倒臭いんですよ、初期化処理を書くのは。であれば、遷移する時に関数を一つ挟むという手もあります。例として挙げたキャラクタの歩く状態遷移も、次のような遷移処理関数を挟むとまだマシな書き方にできます:
// 遷移処理関数
void nextState( std::function &next ) {
t = 0;
state_ = next;
}
// 上へ移動
void walk_up_60() {
t++;
walk( UP, 1 ); // 上へ1歩移動
if ( t == 60 )
nextState( walk_right_120 );
}
// 右移動
void walk_right_120() { ... }
nextState関数は状態遷移時に呼び出す関数で、引数の状態をセットしてくれます。この時必要な前初期化(t=0)も一緒にやってくれます。
こういう関数を挟めば初期化忘れを防ぐ事が出来ますし、初期化も時刻処理になっているので動作がずれる事もありません。ちょっとした工夫ですが、これをしないと「初期化部分をコピペ 」という最悪な悪堕ちまでしてしまうため、実は効果的です。
という事で、今回は状態遷移でプログラマが闇堕ちしがちな「時間」を使う初期化の罠のお話でした。ではまた(^-^)
※ 最後までお読み頂きありがとうございます。本コーナーで「こんな事について書いて欲しい」「こういう所がうまくいかないのだけどアイデア下さい」などリクエストやお困りな事がありましたらコメント、Twitter(@Marupeke_IKD)等でお気軽に教えて下さい(^-^)。出来る範囲で恐縮ですが検討させて頂き、記事としてアップ致します!