SHOEISHA iD

※旧SEメンバーシップ会員の方は、同じ登録情報(メールアドレス&パスワード)でログインいただけます

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

ゲームプログラミング入門

本格的なシューティングゲームを実現するタスクシステム

複数の処理を管理・実行する仕組み


  • X ポスト
  • このエントリーをはてなブックマークに追加

タスクシステムの定義

 タスクシステムは、作るゲームに依存しないTaskクラスと、依存するTaskExクラスに分けて定義します。TaskExクラスはTaskクラスを継承し、全てのタスクはTaskExクラスを継承することにします。タスクシステムは実体化する意味がないので、TaskクラスとTaskExクラスは抽象クラスとします。

 まずはTaskクラスについて解説するので、クラスの宣言にざっと目を通してください。使用状態を表すm_useと、自分のバイト数を表すm_sizeは、デフラグの他に、メモリ領域の内容をテキストファイルに書き出してデバッグする時にも必要です。優先度については後述します。

#pragma once

#pragma warning(disable:4291)  // new 演算子の警告を無効にする

#include<stdio.h>
#include<malloc.h>        // for malloc, free
#include<string.h>        // for memset, memmove

typedef unsigned char BYTE;
typedef unsigned long DWORD;

#define TRUE  1
#define FALSE 0

class Task
{
    BYTE m_use;           // TRUE 使用中 / FALSE 非使用
    DWORD m_size;         // バイト数
    float m_priority;     // 優先度
    static void Defrag(void);
protected:
    static BYTE *m_active,*m_free;
    Task *m_pre,*m_next;  // 前と次のタスクを指すポインタ
public:
    virtual ~Task(void){} // 仮想デストラクタ

    static void InitTaskList(void);
    static void ReleaseTaskList(void);

    void Delete(void);
    static void RunTask(void);

    void* operator new(size_t size,float priority=0.5f);
    void operator delete(void *pTask);

    void SetPriority(float priority);

    static DWORD GetSize(void);
    static DWORD GetCount(void);

    static void Dump(const char *filename);

    virtual void Main(void)=0;  // 処理関数
};
new演算子の例外処理
 new演算子は例外を投げる可能性があります。そのため、例外処理を実装していない本稿のプログラムをそのままコンパイルした場合、次の警告が出ます。
warning C4291: 'void *Task::operator new(size_t,float)'
 : 初期化コードが例外をスローすると、'new' 演算子を使用していると
メモリを解放しません。
 意味は「メモリの確保に失敗した場合の例外を受け取りなさい」ですが、オーバーロードしたnew演算子は、メモリの確保を行わないため、この誤った警告を無効としました。

タスクリストの初期化

 タスクシステムを使うためには、その核であるタスクリストを初期化しなければなりません。初期化といっても、初めはタスクが存在しないので、メモリ領域を確保し、タスクリストを構成するポインタを設定するだけです。タスクが存在しない時はm_activeNULLを代入します。

#include"TaskSystem.h"

#define MEM_SIZE  10240    // メモリ領域のバイト数
#define DFRG_SIZE   256    // 未使用領域がこのバイト数未満になったら
                           // デフラグ

static BYTE *g_buf=NULL;   // メモリ領域の先頭を指すポインタ

BYTE* Task::m_active=NULL; // 最初に実行するタスクを指すポインタ
BYTE* Task::m_free=NULL;   // 新しいタスクを追加する未使用領域の
                           // 先頭を指すポインタ

static DWORD g_size=0;     // 使用中のタスクに割り当てているバイト数
static DWORD g_count=0;    // 使用中のタスク数

// タスクリストの初期化
void Task::InitTaskList(void)
{
    ReleaseTaskList();

    g_buf=(BYTE*)malloc(MEM_SIZE); // メモリ領域の確保
    memset(g_buf,0,MEM_SIZE);

    m_active=NULL;
    m_free=g_buf;

    g_size=0;
    g_count=0;
}

タスクの生成・追加

 タスクの生成・追加はオーバーロードしたnew演算子を用いて行います。全てのタスクはTaskクラスを継承するため、例えば、Playerというタスクがあった場合new Player();と書けばPlayerタスクが生成・追加されます。ただし、オーバーロードしたnew演算子は優先度をデフォルト引数に持つため、省略した場合はデフォルト値0.5fが代入されます。省略せず、優先度を0.25fに設定したい場合はnew(0.25f) Player();と書きます。優先度とは、主に描画する順番を制御するために用いる0~1のfloat値です(値が小さい方を先に処理する)。

 下記のプログラムを見てください。はじめに、空き容量が不足していないかを調べます。不足している場合はデブラグ……をここで行えれば最良なのですが、次に述べる理由により行えないので、タスクの生成・追加を中止します。デフラグを行えない理由とは、例えば「自機タスク」が「弾タスク」を生成するように、タスクの生成・追加が他のタスクの実行中に発生するためです。デフラグによりメモリ領域の移動が起こっても、thisポインタは実行中の関数を抜けない限り更新されないので、実行中のタスクは自分を見失うことになります。デフラグを行うタイミングについては後述します。

// タスクの生成・追加
// タスクは優先度の値が小さい順につなげる
void* Task::operator new(size_t size,float priority)
{
    if(g_buf==NULL) return NULL;  // タスクリストが初期化されていない
    if(m_free+size >= g_buf+MEM_SIZE) return NULL;  // 空き容量不足

    g_size+=(DWORD)size;
    g_count++;

    Task *new_task=(Task*)m_free;
    m_free+=size;

    if(m_active==NULL)    // 現在タスクリストは空
    {
        m_active=(BYTE*)new_task;

        new_task->m_use      = TRUE;
        new_task->m_size     = (DWORD)size;
        new_task->m_pre      = new_task;
        new_task->m_next     = new_task;

        new_task->m_priority = priority;

        return new_task;
    }

    // タスクリストに挿入する

    Task *task,*next;
    for(task=(Task*)m_active;;task=next)
    {
        next=task->m_next;

        if(priority < task->m_priority)   // 同じ優先度なら末尾に挿入
        {
            if(task==(Task*)m_active)       // 先頭に挿入
            {
                m_active=(BYTE*)new_task;
            }
            new_task->m_pre  = task->m_pre;
            new_task->m_next = task;
            break;
        }
    }

    new_task->m_use      = TRUE;
    new_task->m_size     = (DWORD)size;
    new_task->m_priority = priority;

    new_task->m_pre->m_next = new_task;
    new_task->m_next->m_pre = new_task;

    return new_task;
}

 タスクリストにタスクを挿入する場合、既存のタスクが一つもない場合と、幾つかある場合とに、分けて考える必要があります。

 既存のタスクが一つもない場合は簡単です。新しく追加するタスクの、前のタスクも、次のタスクも自分だからです。

 既存のタスクが幾つかある場合は、優先度を考慮しなければなりません。タスクは、優先度の値が小さいものから順番につなげます。したがって、タスクリストを先頭から走査し、自分より優先度の値が大きい既存タスクが見つかった場合、その一つ前が新しいタスクを挿入する位置ということになります。一つ前が先頭ならm_activeも更新します。双方向リストをつなぐポインタは、自分だけではなく、挿入した前後のそれも書き換えなければならないことに注意してください。

 このように分けて考える必要がある処理は他にもあります。これを面倒に感じるなら、タスクリストの初期化から解放まで必ず存在し、実行の対象としない、ダミータスクを用いる方法があります。ただし、メモリ領域を無駄に消費してしまうという欠点があります。

 新しいタスクのためのメモリ領域は、未使用領域の先頭から割り当てます。現在タスクリストが空であっても、使わなくなったタスクが残っているかもしれないので、メモリ領域の先頭から割り当ててはいけません。

タスクの削除

 タスクの削除はオーバーロードしたdelete演算子を用いて行います。この処理も場合分けが必要で、その条件は、削除するタスクが最後のタスクか否か、です。

 最後のタスクである場合はm_activeNULLを代入するだけです。

 最後のタスクではない場合、削除する前後のタスクをつなぎ合わせなければなりません。前のタスクの次のタスクは、削除するタスクの次のタスクとなり、次のタスクの前のタスクは、削除するタスクの前のタスクとなります。

 削除とは言っても、タスクリストから除外するだけで、メモリ領域の解放はもちろん、メンバ変数の消去も行いません。ただし、削除したということを示すためm_useFALSEを代入します。他のメンバ変数は消す必要が無いばかりか、デフラグに必要であったり、メモリ領域の内容をテキストファイルに書き出してデバッグする時に役立ったりするため消しません。

 タスクの削除は他のタスクが行う場合もありますが、多くは自分で行います。自分で自分を削除する、という処理は普通では考えられませんが、メモリ領域の解放を伴わないタスクの削除の場合は問題なくできてしまいます。プログラムの流れとしても分かりやすく、タスクシステムの利点と言えます。自分を削除するためのコードはdelete this;です。

// タスクの削除
void Task::operator delete(void *pTask)
{
    if(pTask==NULL) return;
    Task *task=(Task*)pTask;

    if(task->m_use==FALSE) return; // 重複削除防止
    task->m_use=FALSE;  // 他のメンバ変数は後で使うため消さない

    g_size -= task->m_size;
    g_count--;

    if(task==(Task*)m_active)      // 最初に実行するタスクか?
    {
        if(task->m_next==(Task*)m_active)  // 最後のタスクか?
        {
            m_active=NULL;
            return;
        }
        else m_active=(BYTE*)task->m_next;
    }

    task->m_pre->m_next = task->m_next;
    task->m_next->m_pre = task->m_pre;
}

タスクリストのデフラグ

 タスクリストのデフラグとは、使わなくなったタスクの領域を詰め、未使用領域を増やすことです(図4)。タスクの優先度を考慮して並べ替える必要はないため、本当に詰めるだけです。したがって、走査の始点は、最初に実行するタスクではなく、メモリ領域の先頭です。メモリ領域の先頭には、それが使用中であるか否かにかかわらず、必ずタスクが存在します。さらに、全てのタスクは、使用状態を表すm_use、自分のバイト数を表すm_sizeを持つため、これらを頼りに、メモリ領域の先頭から後ろへとタスクをたどることができます。走査の終点は、未使用領域の先頭です。

 タスクリストに使用中のタスクが一つしかない場合は、そのタスクを移動させれば完了ですが、幾つかある場合は、メモリ領域の先頭から未使用領域の先頭まで、使用中のタスクを探さなければなりません。使用中のタスク全てを移動させたら、新たに未使用になった領域に0を書き込みます。わざわざ情報を消さなくてもプログラム的には問題ありませんが、デバッグの邪魔になるためです。

 デフラグは、使用中のタスクがない場合にも必要となることがあります。ちょっとおかしい感じがするかもしれませんが、使わなくなったタスクがメモリ領域に残っているだけであり、特殊な状況ではありません。

// タスクリストのデフラグ
void Task::Defrag(void)
{
    if(m_active==NULL)        // 使用中のタスクが無い
    {
        m_free=g_buf;
        memset(g_buf,0,MEM_SIZE);
        return;
    }

    BYTE *dest=g_buf;

    Task *task;
    DWORD size;

    task=(Task*)m_active;
    if(task->m_next == task)  // 唯一のタスクか?
    {
        size=task->m_size;

        memmove(g_buf,m_active,size);

        task=(Task*)g_buf;
        task->m_pre  = task;
        task->m_next = task;

        m_active=g_buf;
        dest+=size;
    }
    else
    {
        for(BYTE *source=g_buf; source<m_free; source+=size)
        {
            task=(Task*)source;
            size=task->m_size;
            if(task->m_use == FALSE) continue;

            memmove(dest,source,size);

            task=(Task*)dest;
            task->m_pre->m_next = task;
            task->m_next->m_pre = task;

            // 最初に実行するタスクか?
            if(source==m_active) m_active=dest;
            dest+=size;
        }
    }

    memset(dest,0,m_free-dest);  // 新しく未使用になった領域に
                                 // 0 を書き込む

    m_free=dest;
}

全てのタスクを実行

 タスクは、処理関数であるMainを呼び出すことによって実行します。実行する順番は優先度によりますが、優先度の順にタスクはつながっているため、タスクリストの先頭から実行すればよいことになります。注意しなければならないのは、処理関数の内部でタスクの追加や削除が行われた場合、実行する前後で次のタスクが異なる、ということです。もちろん正しいのは実行後の情報なので、実行後に次のタスクを調べます。また、自滅したとしても、そのタスクが持つ情報は失われないため、次のタスクを知ることができます。

 ループの終了条件は、次のタスクが最初のタスクである、つまり、自分が最後のタスクである、ことです。ただし、この終了条件は意図した通りに働かないこともあります。それは、最初のタスクが自滅した場合であり、次のタスクが新たに最初のタスクとなるためです。ループの終了条件はもう一つあり、m_activeNULLである、つまり、一つもタスクが存在しない、ことです。また、新しく追加したタスクは、実行中のタスクより優先度が高い場合、現在のループでは実行しません。

 このように全てのタスクを順番に実行することで、あたかも並列に処理しているかのように動作します。

 全てのタスクを実行したら、未使用領域の残量を調べ、規定の残量より少なくなっていた場合は、デフラグを行います。タスクを実行する前でも同じです。

// 全てのタスクを実行
void Task::RunTask(void)
{
    if(g_buf==NULL) return;  // タスクリストが初期化されていない

    Task *task,*next;
    for(task=(Task*)m_active; m_active; task=next)
    {
        task->Main();
        next=task->m_next;
        if(next==(Task*)m_active) break;
    }

    if(g_buf+MEM_SIZE-m_free < DFRG_SIZE) Defrag(); // デフラグ
}

自分以外のタスクを削除

 ゲームの場面転換などで多くのタスクを一気に削除したい場合に使います。自分以外としたのは、全てのタスクを削除し、新しいタスクを追加したい場合、自分以外を削除してから新しいタスクを追加し、最後に自分を削除する必要があるためです。また、自滅によって起こりうる前述の誤動作によって、一部のタスクが削除されないという事態を防ぐためでもあります。

 タスクを外部から削除する場合は、delete演算子に続けて削除したいタスクを指定します。Taskクラスでデストラクタを仮想関数としているので、これを継承したクラスのデストラクタも適切に呼び出されます。

 ちなみに、自滅したタスクはその後も処理を続けることが可能ですが、明らかにマナーに反するため自主的に禁止しましょう。

// 自分以外のタスクを削除
void Task::Delete(void)
{
    for(Task *task=this->m_next; task!=this; task=task->m_next)
    {
        delete task;
    }
}

タスクリストの解放

 全てのタスクを削除した後に、メモリ領域を解放します。

// タスクリストの解放
void Task::ReleaseTaskList(void)
{
    if(g_buf==NULL) return;  // タスクリストが初期化されていない

    for(Task *task=(Task*)m_active; m_active; task=(Task*)m_active)
    {
        delete task;
    }

    m_free=NULL;

    free(g_buf);  // メモリ領域の解放
    g_buf=NULL;
}

優先度の変更

 優先度を変更すること自体は簡単ですが、それに伴いタスクリストの順番が不正になったかもしれないため、つなぎ直す必要があります。ただし、タスクリストに唯一のタスクであった場合は、つなぎ直しません。

 つなぎ直しは、タスクリストからいったん除外し、適切な位置に挿入する、という手順で行います。優先度の等しいタスクがある場合は、その末尾に挿入します。

// 優先度の変更 と タスクリストのつなぎ直し
// タスクは優先度の値が小さい順につなげる
void Task::SetPriority(float priority)
{
    m_priority=priority;

    // タスクリストから除外する

    if(this==(Task*)m_active)  // 最初に実行するタスクか?
    {
        if(m_next==(Task*)m_active) return;  // 唯一のタスクか?
        m_active=(BYTE*)m_next;
    }

    m_pre->m_next = m_next;
    m_next->m_pre = m_pre;

    // タスクリストに挿入する

    Task *active=(Task*)m_active;

    Task *task,*next;
    for(task=active;;task=next)
    {
        next=task->m_next;

        if(priority < task->m_priority)  // 同じ優先度なら末尾に挿入
        {
            if(task==(Task*)m_active)       // 先頭に挿入
            {
                m_active=(BYTE*)this;
            }
            m_pre  = task->m_pre;
            m_next = task;
            break;
        }
    }

    m_pre->m_next = this;
    m_next->m_pre = this;

    // 最初に実行するタスクを指すポインタを再設定
    if(priority < active->m_priority) m_active=(BYTE*)this;
}

メモリ領域の内容をテキストファイルに書き出す

 困難なバグに直面した場合、メモリ領域を詳細に調べるために使います。このタスクシステムを作る過程でもお世話になりました。

// メモリ領域の内容をテキストファイルに書き出す
void Task::Dump(const char *filename)
{
    if(g_buf==NULL) return;  // タスクリストが初期化されていない

    FILE *fp=fopen(filename,"w");

    int i;

    fprintf(fp,"      ");
    for(i=0;i<16;i++)
    {
        if(i%16 == 8) fprintf(fp," ");
        fprintf(fp," %02x",i);
    }
    fprintf(fp,"\n");

    fprintf(fp,"      ");
    for(i=0;i<16;i++)
    {
        if(i%16 == 8) fprintf(fp,"-");
        fprintf(fp,"---");
    }
    fprintf(fp,"\n");

    for(i=0;i<MEM_SIZE;i++)
    {
        if(i%16 == 0) fprintf(fp,"%p |",g_buf+i);
        else if(i%16 == 8) fprintf(fp," ");
        fprintf(fp," %02x",*(g_buf+i));
        if(i%16 == 15) fprintf(fp,"\n");
    }

    fclose(fp);
}

使用中のタスクに割り当てているバイト数を返す

 タスクリストの初期化時に確保したメモリ領域のうち、使用中のタスクに割り当てているバイト数がわかれば最適化に役立ちます。

// 使用中のタスクに割り当てているバイト数を返す
DWORD Task::GetSize(void)
{
    return g_size;
}

使用中のタスク数を返す

 使用中のタスク数がわかれば最適化に役立ちます。

// 使用中のタスク数を返す
DWORD Task::GetCount(void)
{
    return g_count;
}

次のページ
拡張タスクシステムの定義

修正履歴

この記事は参考になりましたか?

  • X ポスト
  • このエントリーをはてなブックマークに追加
ゲームプログラミング入門連載記事一覧

もっと読む

この記事の著者

ひよこ(ヒヨコ)

職業ゲームプログラマーを志す学生です。

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

この記事は参考になりましたか?

この記事をシェア

  • X ポスト
  • このエントリーをはてなブックマークに追加
CodeZine(コードジン)
https://codezine.jp/article/detail/297 2006/07/24 19:47

おすすめ

アクセスランキング

アクセスランキング

イベント

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

新規会員登録無料のご案内

  • ・全ての過去記事が閲覧できます
  • ・会員限定メルマガを受信できます

メールバックナンバー

アクセスランキング

アクセスランキング