見出し画像

タスクを利用して状態遷移を同時に沢山動かそう

状態遷移は今の状態を維持するか、次の状態へ遷移するか、毎フレーム二者択一の判断を迫られます。これを実現するには少なくとも状態を毎フレーム更新する必要があります:

void (*state)();    // 状態関数の登録先(関数ポインタ)
while( true ) {
    state();    // 毎フレーム更新
}

関数ポインタによる状態遷移については以下の記事を参照下さい:

世界に存在する状態遷移が一つなら、上のコードのように単独のstate関数を呼び続ければ良いのですが、ゲームの世界は物で溢れていて、それらがほぼすべて独自の状態遷移を持っています。つまり策無くベタに書くなら、

void (*stateHero)();
void (*stateEnemy1)();
void (*stateEnemy2)();
...

while( true ) {
    stateHero();
    stateEnemy1();
    stateEnemy2();
    ...
}

と、存在する物すべての状態を更新する羽目になります。もちろんこんな書き方は秒で破綻してしまいますから、うまい工夫が必要です。もし更新関数が同じ関数型なら関数ポインタの配列にまとめられるのでもっとスマートになります:

void (*states[1024])();   // 関数ポインタの配列

while( true ) {
    // 配列に並んだ状態関数をどどっと更新
    for ( int i = 0; i < num; ++i ) {
        states[i]();
    }
}

states配列に使用している状態関数を登録してその数を覚えておけば、無駄なく更新出来ます。つまりすべき事は「状態関数の型を同じにする」というルール敷きだけです。

この同じ型の状態関数ポインタ配列による更新の仕組みをフレームワークとして整えたのが「タスクシステム」です。世界に沢山ある毎フレーム更新が必要な個々の処理を「タスク(仕事)」と捉え、それを配列にかき集めて一斉に更新する事で同時に沢山の物を1フレーム分だけ動かします。世界に参加したい物は、システムから空のタスク(メモリの塊)をもらい受けます。それを自分仕様に初期化し、登録関数を通してタスクシステムに登録します。一度登録されたタスクは上のコードのように毎フレーム自動的に更新がかかり、世界に存在するようになります。

そんなタスクシステムの基本単位であるTask(タスク)からまずは見てみましょう。

タスクオブジェクトを作って登録

一つのタスクを表現するタスク構造体を次のように定義します:

struct Task {
    bool (*state)( Task *task );    // 状態関数を指す関数ポインタ
    char blob[64];      // メモリブロック
};

state関数ポインタにはこのタスクの処理で表現する状態関数を登録します。引数のTaskポインタには自分自身のポインタが渡ります。戻り値のboolはタスクの寿命で、trueならまだタスク継続中、falseならタスク終了(破棄)を表します。blobはこのタスクが自由に使って良いメモリバッファです。サイズは対象プラットフォームの搭載メモリとゲーム内で発生するタスク量から概算して決めます。

タスクシステムが用意しているpopTask関数を呼び出すと、このタスクオブジェクトを一つ貰えます:

Task* popTask() {
    Task *task = stackPos;
    stackPos--;    // スタック位置を下げる
    return task;
}

Taskオブジェクトはスタックに沢山積まれていて、popTask関数が呼ばれる度にそこから一つpopされます。この辺りの詳細な実装はコンテナ設計のお話で状態遷移とは別のカテゴリーなのでここでは割愛します。

得たタスクオブジェクトを目的に合わせて初期化します:

Task *task = popTask();
task->state = &createEnemy;         // 敵生成関数でスタート
Enemy *enemy = (Enemy*)task->blob;  // バッファを敵用にキャストし初期位置を登録
enemy->posX = 10.0f;
enemy->posY = 20.0f;

stateには一番最初に駆動させるエントリー状態関数を登録します。必要ならblobも適当な構造体(上の例ではEnemy構造体)にキャストして初期化します。

画像1

初期化したタスクはregisterTask関数を通してタスクシステムに登録します:

registerTask( task );

こうして登録したタスクはタスク配列に積まれ、毎フレームそのstate関数ポインタが呼び出され更新されるようになります。上の例だとstateポインタを通してcreateEnemy関数が呼ばれ、敵の属性などが初期化されます:

bool createEnemy( Task *task ) {
    Enemy *enemy = (Enemy*)task->blob;
    enemy->speed = 3.0f;
    enemy->hp = 300;
    task->state = &moveEnemy;    // 「動く」状態へ遷移
    return true;
}

初期値と共に大事なのが引数のstate関数ポインタに次の状態であるmoveEnemy関数を渡している所。引数はタスク自身であるため、これでそのタスクの状態が遷移する事になるんです。

これで状態遷移を繰り返し、末端まで到達してタスクがいらなくなったら、最後の状態遷移関数でfalseを返します。タスクシステムはタスク更新後の戻り値を監視していて、falseが返ってきたら更新処理をやめ、Taskオブジェクトをスタックに戻します。これでそのタスクの寿命は終了。次に必要になった人に渡され全く違うタスクとして再利用されます。

タスクシステムの更新処理はmain関数のようなトップレベルの関数内で行います:

int main() {
    while( true ) {
        // 登録タスクを更新
        int nextIdx = ( curIdx + 1 ) % 2;    // 次のタスク配列番号
        Task **curTasks = activeTasks[ curIdx ];   // 更新対象タスク配列
        Task **nextTasks = activeTasks[ nextIdx ]; // 次回のタスク配列
        int &num = activeTaskNum[ curIdx ];  // 有効タスク数を保持(参照で!)
        int c = 0;
        for ( int i = 0; i < num; ++i ) {
            Task *task = curTasks[ i ];
            bool res = task->state( task );
            if ( res == false ) {
                pushTask( task );   // 使用済みタスクをスタックへ返却
            } else {
                nextTasks[ c ] = task;    // 継続なので次のタスク配列へ積む
                c++;
            }
        }
        activeTaskNum[ nextIdx ] = c;
        curIdx = nextIdx;   // 配列を切り替え
    }
}

タスクが登録されている配列からTaskを取り出し、そのstate関数を呼んで現在の状態を更新しています。更新の結果破棄となった場合はスタックにTaskポインタを戻し、継続する場合は次のタスクリストへ再登録しています。全更新を終えたら配列を切り替えて次に備えます。これはいわゆる「ダブルバッファ」と呼ばれる手法です。これにより寿命を迎えたタスクが抜けたタスク配列が自動的に出来上がるため、タスクの登録順番が維持されます。

画像2

上のコードで更新をかける配列の数numが参照になっているのは、更新中に新しいタスクが登録された時にnumの値が変わるためです。この辺りの登録タイミングは時間ズレの原因にもなるため本当はもっと厳密に設計する必要があります。

ちなみに、タスクに別のタスクへのポインタ変数を持たせてリンクリストにする方法もあります。ちょっと面倒が増えますが、ダブルバッファにしなくて良いなど利点も色々あります。

キャストは腹をくくって割り切るのです

上記のタスクシステムはTask構造体が色々な物に化ける大変自由度が高い仕組みですが、その自由度はblobのキャストに支えられています。createEnemy関数の中で引数のTaskポインタが持つblobをEnemyポインタにキャストしました。これはそういうデータが入ってくる事を前提とした実装になっています。「いやそれはちょっと怖いな。createEnemy関数の引数をEnemy*にしたらいいやん」と思いたくなるのですが、それだとcreateEnemy関数の型が異なるためTaskのstate関数ポインタ変数に代入できなくなってしまいます。だからすべての遷移関数の型は固定。これはタスクシステムの宿命です。腹をくくって割り切りましょう。

このようにC言語ベースでタスクシステムを構築して沢山のタスクの状態を遷移させる事は可能です。実際上記のようなタスクシステムを搭載した商用ゲームは多分沢山あります。ただそうは言っても時代はオブジェクト指向。C++を知っている方は記事を読みながら終始悶々としていた事でしょうw。わかります、そのお気持ち^^。そろそろC++側に武器をシフトしましょうか。次はクラス内状態遷移のお話です。

この記事が気に入ったらサポートをしてみませんか?