擬似マルチタスク

ゲーム画面で多数飛び交う敵や味方のキャラクター。コンピュータ上では、実際にはどうやってこれらの物体を管理し、そして動かしているのでしょうか。コンピュータゲームにおいては、例えば画面中に物体が10個あって個別に動いている場合は、実際には10個の物体それぞれが意思を持って動いているわけではありません。CPU という1本の手が、その物体の個性と特徴を元にしながら、一個一個順番に少しずつ動かしています。つまりはCPU が、存在する全ての物体に「ジョブ」を分け与えていることになります。最初に自機を動かし、次に敵1、敵2を動かす・・・といった感じです。これをかなり速いスピードで行っています。一連のジョブが終了したあと、画面の表示同期に合わせて物体の表示を更新しているので、それぞれが全く同じタイミングで動いているように見えるというわけです。

 例)
  メインループ:
   1.各物体を処理
    1-1.物体1番を処理する
    1-2.物体2番を処理する
    1-3.物体3番を処理する
         :
   2.画面表示同期を取る
   3.再びメインループに戻って繰り返し

GBA の場合、画面表示同期(VBLANK)に合わせるので、1秒間に60回のループ処理を行うことになります。また、そうすることで画面表示も滑らかに見えます。 擬似マルチタスクシステムは、こういった原理で、物体の個性と特徴を元にして、複数の物体を擬似的に並列処理させるためのシステムです。

擬似マルチタスクのメリットについて

個々の物体の独立性が高くなります。

一旦オブジェクトを発射してしまえば、あとは勝手に動いてくれるようなイメージです。

物体は、表示するキャラクタだけにとどまりません。

タイトル画面やメニュー、ランキング画面、フェードイン/アウトなどといった、「始め」があって「終わり」がある処理ならば、その全てを「物体」として定義できます。なお、シューティングゲームでは、1オブジェクト=1タスクとして扱うのが一般的のようです。

同じ物体を簡単にいくつも複製することができます。

例えば、画面に何10個も吐き出される敵弾は、いわば複製物です。ただし、実際にコードが物理的に複製されるわけではなく、複数個のタスクが同じ処理関数を通ることによって結果的に複製したように見えるということです。また、1つのマップスクロール関数を作成して、それを実行するタスクを2つ生成し、それぞれの処理スクリーン面、マップデータのポインタ、スクロールスピードを変えるだけで、2重スクロールが出来てしまうなどといった応用も可能です。

物体の処理手順を明確に出来ます。

例えば下図のような流れで、物体の処理ルーチンが書けます。

    Phase1:
     物体の初期化   (Phase2へ移行)
    
    Phase2:
     物体の移動その1 (一定の条件でPhase3へ移行)
    
    Phase3:
     物体の移動その2 (一定の条件でEndPhaseへ移行)
    
          :
          :
    
    EndPhase:
     物体の消滅処理  (タスク解放)

メインループを簡潔にすることができます

普通に組んでゆくと、キー操作などが必要となる箇所に来るたびにループをかけてキー入力待ちを行ってしまい、以下のような流れになると思います。または、メインループ内で、状況に応じてswitch~caseで分けるような構造になるかもしれません。

     ・初期処理
     ・タイトル画面
     ・ゲームループ
     ・終了処理
     ・ランキングループ
        :

しかし、「メニュー選択中でも背景スクロールを止めたくない」、「プレイ時間のカウントアップなど、常にバックグラウンドで処理したいものがある」などといったケースが発生した場合に、面倒なことになります。これらに素直に対応してゆくと、メインループもそれに応じて巨大化してゆくことになります。それを解決させるのが、この擬似並列処理システムになります。

※「擬似」と呼んでいるのは、あくまで完全な並列処理ではないためです。タイマ割り込みとスタックを使用して処理を細切れにするなどといった複雑な処理も一切行いません。ここで説明するタスクシステムはコンピュータ用語で言うところの「ノンプリエンプティブマルチタスク」に相当します。

タスクの構造

まず、全ての物体に共通となる構造体を定義します。全ての物体に共通するデータとしては、以下のようなものです。今回は仕組みの説明だけに留めているため、最低限の構成にしてあります。将来的にはもっと多くのメンバをこの構造体に追加することになるでしょう。

typedef struct {
	bool isUse;			// 使用フラグ
	u16  no;			// 自分自身のタスク番号
	void (*pAct)();			// 実行関数
	u16  phase;			// 動作フェーズ
	u8   work[48];			// 汎用ワークエリア
} ST_TASK;

使用フラグ

そのタスクが使用中か否かを示すフラグです。

自分のタスクの番号

自分自身の番号を知るために使用します。

実行関数

そのタスクが毎フレームごとにコールする処理関数を示す関数ポインタです。関数ポインタを使用することによって、case文無しで、コールする処理関数をオブジェクトごとに変更することが可能となります。各タスクごとに全く違った処理を行わせることが出来るようになります。

動作フェーズ

オブジェクト関数の処理段階を、番号で記憶します。物体には、必ず始めと終わりがあります。動作フェーズは主に、初期化(始まり)、メイン処理(移動などの全処理)、開放(終わり)に大別されます。

汎用ワークエリア

各タスク内において自由に使うことの出来るワークエリアです。タイトル画面ならばキー入力待ちのカウンタが入るでしょうし、敵の弾ならその座標や速度などがここに入ることになります。多めに確保できれば、それに越したことはありません。

タスクの確保

タスク構造体を定義したら、必要な分だけ、この構造体の実体を確保します。とりあえずは100個ほどにしておきます。この数の分だけオブジェクトを同時に出すことが出来るわけです。タスク登録時に満杯になった時のことも考慮して、ダミー用のワークも1つ用意しています。

#define MAX_TASK_CNT		100		// 最大タスク数

ST_TASK  Task[TASK_MAX_CNT];
ST_TASK  Dummy;

初期化関数

ここで必要な初期化を行います。サンプルでは、タスクの内容を0にしています。今回はこれだけでOKですが、将来的に拡張してゆく段階で何らかの初期化処理を追加する必要も出てくると思います。

//---------------------------------------------------------------------------
IWRAM_CODE void TaskInit()
{
	_Memset((u8*)&Task,  0x00, sizeof(ST_TASK) * TASK_MAX_CNT);
	_Memset((u8*)&Dummy, 0x00, sizeof(ST_TASK) * 1);
}

登録と開放

新しいタスクを登録しなければ、当然ですが何の処理も実行されることはありません。つぎにタスクの登録と解放関数を用意します。

登録関数

最初にタスクを100個設けましたが、その中で空いているもの、すなわち使用フラグがfalse のものを探します。これから動かす物体は、このワークを使って管理することになります。とりあえず、手っ取り早くシンプルにサーチを使って空きを探してゆくことにします。使用フラグがfalse のものを、先頭から順番に探してゆきます。見つかったら、取得したタスクワークのポインタを戻り値に返して、関数を抜けます。タスク100個全てが満杯だった場合も考慮して、その時はダミー領域を戻り値に返すようにします。

//---------------------------------------------------------------------------
IWRAM_CODE ST_TASK* TaskSet(void* pFunc)
{
	ST_TASK* p;
	u16 i;

	for(i=0; i<TASK_MAX_CNT; i++)
	{
		if(Task[i].isUse == FALSE)
		{
			p = &Task[i];

			p->isUse = TRUE;
			p->no    = i;
			p->phase = TASK_INIT;
			p->pAct  = pFunc;
			_Memset(p->work, 0, sizeof(p->work));

			//INITを実行します
			p->pAct(p);
			return p;
		}
	}

	return &Dummy;
}

解放関数

タスクは使い終わったら開放する必要があります。そうしないと、いずれタスクワークが満杯になり、新しいタスクが登録できなくなるためです。ここでは、自分自身のタスクワークの使用フラグをfalse にすればOKです。

//---------------------------------------------------------------------------
IWRAM_CODE void TaskDel(ST_TASK* p)
{
	p->isUse = FALSE;
}

実行関数

現在生きている全てのタスクの常時実行を行う部分です。ここでは、タスク先頭からサーチをかけ、動作中のものが存在すれば、そのタスクの関数ポインタの指し示す関数をコールします。関数ポインタを利用することで、各タスクごとに違った処理動作を可能とさせているのが、このシステムのカギです。

//---------------------------------------------------------------------------
IWRAM_CODE void TaskExec()
{
	ST_TASK* p;
	u16 i;

	for(i=0; i<TASK_MAX_CNT; i++)
	{
		if(Task[i].isUse == TRUE)
		{
			p = &Task[i];
			p->pAct(p);
		}
	}
}

これで、動作中の全てのタスク・・・すなわち、動作中の全てのオブジェクトに処理が行き渡ることになります。ここまでの関数群は、独立した1つのモジュールとして保存することをお奨めします。このモジュールは、他の殆どのゲームを作る際でも、そのまま使いまわすことが出来ます。

記述例

いよいよ物体を動かす部分となります。実際には自機、自機弾、敵弾、敵その1、敵その2・・・といった単位で設けてゆきます。このように、処理内容が大きく異なるものをそれぞれ1タスク関数で設けていくと管理が楽になります。この内部は、switch~case文を用いて動作フェーズ単位で物体の動作を記述していくのが定石です。動作フェーズは

に分けて書きます(フェーズタイプのdefine定義はヘッダファイルに記述)。メインフェーズは、新たなフェーズを増やして複数個に分けて記述することも可能です(例: case TASK_MAIN+1:)。

ST_TASK* pTaskMyShip;

(中略...)

//---------------------------------------------------------------------------
IWRAM_CODE void TaskSetMyShip()
{
	pTaskMyShip = TaskSet(TaskActMyShip);
}
//---------------------------------------------------------------------------
IWRAM_CODE void TaskActMyShip(ST_TASK* p)
{
	ST_TASK_WORK_MYSHIP* w = (ST_TASK_WORK_MYSHIP*)&p->work[0];
	u16 key;

	switch(p->phase)
	{
	// 初期化
	case TASK_INIT:
		w->XH    = SCR_XH / 2;
		w->YH    = 160 - 32;
		w->speed = 256;

		p->phase = TASK_MAIN;
		break;

	// 移動+自弾の発射
	case TASK_MAIN:
		key   = KeyGet2();
		w->vx = 0;
		w->vy = 0;

		if(key & KEY_RIGHT) w->vx =  w->speed;
		if(key & KEY_LEFT ) w->vx = -w->speed;
		if(key & KEY_UP   ) w->vy = -w->speed;
		if(key & KEY_DOWN ) w->vy =  w->speed;
		w->X += w->vx;
		w->Y += w->vy;

		if(w->XH <        12) w->XH = 12;
		if(w->XH > SCR_XH-12) w->XH = SCR_XH-12;
		if(w->YH <        12) w->YH = 12;
		if(w->YH > SCR_YH-12) w->YH = SCR_YH-12;

		if(w->rapidCnt == 0)
		{
			if(key & KEY_A)
			{
				TaskSetMyShot(w->X, w->Y);
				w->rapidCnt = 7;
			}
		}
		else
		{
			w->rapidCnt--;
		}

		SprSet(w->XH-8, w->YH-8, SPR_TILENO_MYSHIP);
		break;

	// 消滅
	case TASK_END:
		TaskDel(p);
		break;
	}
}

TaskActMyShip関数の先頭に、やや複雑なポインタ代入がありますが、やっていることは「ST_TASKのwrokメンバ以降のワークを、ST_TASK_WORK_MYSHIP構造体に見立てて、w というポインタで使用できるようにする」という意味になります。これによって、汎用ワークエリアに一時的にST_TASK_WORK_MYSHIP構造体メンバを割り当てたことになります。

物体の発射関数の記述

TaskSet関数は、取得できたタスクのポインタを返します。このポインタを利用して、初期値などの設定を呼び出し元が行うようにすることも可能です。たとえば自機が弾を発射したものの、発射起点位置は、自機の座標に従う場合です。なお、すでに登録数が満杯の場合でも、その場合はDummyのポインタが返ってくるので、最悪の場合でもメモリの不正アクセスは免れることができます。

//---------------------------------------------------------------------------
IWRAM_CODE void TaskSetMyShot(u16 x, u16 y)
{
	static const u8 shot_dir[] = {192, 192+6, 192-6, 192+12, 192-12};
	ST_TASK* p;
	ST_TASK_WORK_MYSHOT* w;
	u16 i;

	//扇状に射出します
	for(i=0; i<5; i++)
	{
		p = TaskSet(TaskActMyShot);
		w = (ST_TASK_WORK_MYSHOT*)&p->work[0];

		w->X     = x;
		w->Y     = y;
		w->speed = 1024;
		w->angle = shot_dir[i];
		w->vx    = SQR_VX(w->angle, w->speed);
		w->vy    = SQR_VY(w->angle, w->speed);
	}
}

サンプル

以上の方法を用いて作成したサンプルプログラムを掲載します。NO.89 task_testを参照してください。

おわりに

擬似マルチタスクを用いたプログラミングは、多少コード量が多くなって面倒かもしれません。実際に、関数コールによるオーバーヘッドも多くなります。また、処理手順で気を抜くと、いとも簡単に不正ポインタアクセスが発生してしまいます。特にやってしまう事としては、既に解放したタスクを、まだ生存しているものと見なして参照してしまう事などで、これを行うと一気に暴走にいたるケースもあるので、細心の注意を払わなければなりません。しかし、より柔軟に多数の物体の処理を行うには欠かせない機構です。

M-KAIさんがWWGP2001向けに提出されたジャッジメントシルバーソードにおいても、多少の機能追加はしていますが、ほぼ同じ仕組みのものを使用しているとのことです。実際にこれを使えるレベルに持ってゆくには、機能拡張が必要になってくると思います。機能拡張の例としては以下が挙げられます。

全タスクのフェーズを一括変更する関数の追加

GAMEOVERからタイトルに戻る際の一括タスククリアや、ポーズ処理などに便利です。

タスクに処理優先度を設ける

処理の実行順を管理したくなった場合や、キャラクタの表示優先度と絡めている場合には必須です。

タスク登録時の空きワーク探しに、自前のスタックを用いる

サーチを使って空きを探すよりも高速です。

タスクの連結リストを設ける

プログラムが少し複雑になりますが、動作タスクのサーチ処理を無くせる上、同じ優先度内のタスク登録順を明確にできるメリットがあります。

サンプルから試行錯誤してゆくうちに、擬似マルチタスクの魅力を徐々に実感できると思います。特に、一旦発射すればあとは勝手に動いてくれるようなイメージで組める事や、簡単にオブジェクトが複製できる事、そしてプログラムが全体的にスッキリする事の3点は大きいです。

akkera102追記(2023/05/08)

シューティング以外の万能方法として使えるかというと、少し考える必要はあるかと思います。コードが複雑ならない限りは正義です。メモリも少なくCPUも速くなかった時代のテクニック、または小規模だからという点に着目してください。

出典元

履歴


トップ   一覧 検索 最終更新   ヘルプ   最終更新のRSS