ゲームプログラムとタスクシステム


現代においてのタスクシステム

初学者がゲームプログラム設計を調べ始めるとタスクシステムという言葉を目にすることがあります。忘れてよいです。まだ知らなかったなら幸いです。この記事を読まずにこの言葉を見たことも忘れてまっとうにゲーム作りに励んでください。

……タスクシステムのことを知りたい?

いやいや、書籍しか情報源の無いようなインターネット黎明期ならいざ知らず、今時こんなツッコミどころ満載のものを盲信するなんて基礎力皆無なことを自らふれまわるのと同じですよ。考えを改めるべきです。

とはいえ私も一時期はまってしまっていたのは事実。外部からの手助けがこの糞宗教からの脱出のきっかけになることもあるでしょう。私が脱出するのに使った抜け穴をお教えしようではないですか。使ったのは10年前ですがなーに今でも使えると思います、たぶん。

オリジナルのタスクシステム

オリジナルのタスクシステムが生まれた経緯は私が調べた限りではまっとうなものです。ただそれはその時代、その環境においてのみ有効に機能するものでした。魚が水から陸に上がったら死ぬように、タスクシステムがアセンブラから高級言語に上がったら死ぬのです。

情報源が主に掲示板(10年以上前の2ch)で見聞きした程度なのでかなり妄想入っていますがまずはオリジナルの説明から。

オリジナルの誕生経緯(ほぼ妄想)

オリジナルのタスクシステムが生まれた環境は貧弱で、使えるプログラミング言語がアセンブラのみ、CPUは8bit、OS無し、メインメモリは数十KBだったようです。malloc/freeやnew/deleteに相当する関数は存在せず、仮にあったまたは自作したとしてもフラグメンテーションによりメモリ不足を起こすだろうことが容易に推察されます。

この環境下で安全にメモリ動的確保の仕組みを実装するとしたら、固定長のメモリ領域を持つ構造体を要素数固定の配列として確保して、要素ごとに使用/未使用フラグを判定することで領域確保・解放を実現するのが現実的な方法でしょう。ちょっと無駄は出ますがメモリ不足を起こして止まるよりはましです。これでメモリ領域を動的確保して画像データなんかを保持可能になりました。

次はゲームオブジェクト保持についても検討します。2Dシューティングゲームでは敵機や弾の数がステージ毎に変化します。敵機が多い面、弾が多い面など特色も出したいので可変長なリストが使いたい。ここでもメモリ動的確保と同様、要素数固定の配列を用意して要素ごとにフラグで使用/未使用、それからオブジェクト種別を判断して使い分けるという実装するのもまたありでしょう。アセンブラで複雑な制御やりたくないですからね。

さて配列が2つ出てきました。なんだか似ている気がします。そしてアセンブラで似たようなコードを書くのは疲れます。メモリも不足気味です。……配列、一緒にしちゃっていいんじゃね?

実際そんな場面があったかは定かではありませんが、多かれ少なかれこのような経緯があってゲームオブジェクトリストと動的メモリ確保ライブラリが結び付いたと考えます。

疑似コード

1つの構造体でメモリ確保と複数種ゲームオブジェクトの役割を切り替えて、またそれらを1つの固定長配列で持つことで異なる種別間での要素数配分調整を可能にする。それをアセンブラで書く、のはきついので疑似コードで書くとこんな感じでしょうか。

// ゲーム内で唯一のリスト。
MemoryObject[32] MemoryObjectList;

struct MemoryObject
{
	public ObjectMode Mode; // このインスタンスの現在モード。
	public byte[2048] MemoryBlock; //これをModeに応じた構造体でキャストして使う。
}

public enum ObjectMode
{
	None, // 未使用領域
	LogoDataTask, // ロゴ表示タスク&ログ画像
	TitleDataTask, // タイトル表示タスク&タイトル画像・BGM
	Stage1RuleDataTask, // 1面表示や進行タスク
	PlayerCharacterDataTask, // 自機
	Enemy1DataTask, // 敵1移動タスク&現在地や方向データ
	Enemy2DataTask, // 敵2(〃)
	Enemy3DataTask, // 敵3(〃)
	Stage1BossDataTask, // 1面ボス(〃)
	PlayerBullet1DataTask, // 自機弾1(〃)
	PlayerBullet2DataTask, // 自機弾2(〃)
	Enemy1BulletDataTask, // 敵機弾1(〃)
	Enemy2BulletDataTask, // 敵機弾2(〃)
	Stage1BackgroundPicturesData, // 1面の背景画像置き場
	BulletPicturesData, // 弾丸画像置き場
	CharacterPicturesData, //キャラクタ画像置き場
	...
}

// タイマー割り込みハンドラ
void TimerInterruptHandler()
{
	// リスト要素全部舐める
	foreach (var obj in MemoryObjectList)
	{
		switch (obj.ObjectMode)
		{
		case ObjectMode.None:
			// なにもしない。
			break;
		case ObjectMode.LogoDataTask:
			(略)
		}
	}
}

現代環境でのコードレビュー

上記の疑似コードは本来はフルアセンブラで書かれるのでコーディングそのものが大変だとか、メモリ容量が今より5桁少ないだとか、そういう時代だという前提があって初めて良しと思えるものでした。

ここでちょっと一息いれて、現代的な環境で先の疑似コードを後輩や新人が書いてきたとしてコードレビューしてみましょう。C++/C#などの言語が利用可能、OS有り、malloc/new有り、潤沢なメモリ容量有り、そういった前提です。……ちょっと考えただけでツッコミどころ満載です。コードレビュー通すわけにはいきません。

  1. ゲームオブジェクトと画像データ(BulletPicturesData等)は別個のリストで管理すべきでは?
  2. MemoryObject.MemoryBlockがキャスト前提で型安全を利用しないのはなぜ? 危険なのでは?
  3. enum ObjectModeの要素の数だけ別々のクラスを作って配列も型ごとに別個に用意すべきでは? ObjectModeとMemoryObjectの存在意義ってなに?
  4. MemoryObjectListは可変長配列かリンクリストの方がよいのでは?
  5. 小さなオブジェクトと大きなオブジェクトで同じだけメモリ容量食うのは無駄。素直にnewしたらよいのでは?
  6. リスト1つだと1フレームの中で書き換え前のオブジェクトと書き換え済みのオブジェクトを見分けるの大変なのでは?
  7. 処理順番の保証をするには配列番号直接指定でオブジェクトを生成しなければならないのだと管理大変では? 処理順番に従って複数のループブロックを用意した方が良いのでは?

時代背景を無視してタスクシステムを現代に持ち込むと袋叩きになる。はっきりわかりました。めでたしめでたし。

改変版タスクシステム(改悪版タスクシステム)

まだ何か? ……現代的に抽象インタフェースを導入したから見てくれ? また馬鹿なことを。

疑似コード

class Task {
public:
	virtual void update()=0;
};

class TaskManager {
	Task *add(Task *);
	void update_alltask();
	void del(Task *);
};

糞だね!

疑似コード改

class Task {
public:
	virtual void update()=0;
	virtual void display()=0;
    virtual void recieve_event(const Event &e)
};

class TaskManager {
public:
	Task *add(Task *);
	void update_alltask();
	void display_alltask();
    void recieve_event(const Event &e)
	void del(Task *);
};

糞だね!(再掲)

一番ダメなところ

抽象クラスを良く見てみよう。3つの関数呼び出し以外のすべてを絶縁している。それだけだ。このインタフェースを通して自キャラの残機読み出したい場合どうすればいいの? 無理? じゃあ自機と敵機の衝突判定は? これも無理? 弾を撃った時の弾丸タスク生成やスコア表示の変更は? 全部無理?

つまりこれは、グローバル変数使用矯正ギプスでしかないわけですね? 糞だね!(再掲)

まとめ

オリジナルのタスクシステムは手書きフルアセンブラという貧弱な環境で真価を発揮するフレームワーク。現代の高級言語においては標準ライブラリとして整備済みの機能ばかり。採用しなければならない理由はない。

初学者向けの手助けになる部分を無理に探すとしたら、定期的なタイマー更新処理の実装方法が思いつかない人に「定期的にfor文でメソッド呼び出せばいいんだな」と学習させるサンプルになること。また「こんな無駄な苦労するくらいなら普通にクラス分けして普通にリスト個別に定義して普通に全部のリストについて関数呼び出し書く方がまだ楽だな」という実感と規模感を身に着けさせること。

そもそも触れないのが一番です。うっかり触ってしまった方も上記2点が身に付いたらさっさと見切りをつけて普通の設計を始めましょう。

どうしてもあきらめられない方へ

性格上どうしてもタスクシステムと呼ばれる何かを使って一区切りつくところまで成果を出さないと気が済まない難儀な方は、改変版(=改悪版)ではなくオリジナル風疑似コード版の方を使う方がまだマシかもしれません。MemoryObject.MemoryBlockをbyte[]型からobject型に変える感じで。ハンガリアン記法じゃありませんが、改変版の方はひどすぎです。

あとがき

PCから昔ブログに書いた記事を発掘してしまって、手直しとかしてたら興が乗ってついこんな文章&時間に・・・もう深夜、眠い。