はじめに
自作のゲームライブラリ(C++)で使っているタスクシステムについての解説となります。
Webで見かける情報ではタスクシステムはもう旧式の仕組みで使われることが無いといったやややネガティブな感想のものが多いです。それはおそらくゲームロジックやオブジェクトの挙動を実装するエンドプログラマーの視点によるもので、ゲームプログラムのフレームワークの中核部分は呼称や実装が違うだけで、いわゆるタスクシステム(相当の何か)になっています。UnityもMonoBehavourというタスクシステムで動作しています。エンドユーザーがタスクシステムの存在を知らなくてもコードが組めるのはUnityの持つ強力なツール群によるものです。しかし、タスクシステムの動作を知っていると、Updateメソッドのコーディングの際により深い理解が得られると思います。
概要
ゲームのプログラムはざっくり言ってしまうと、ゲームのオブジェクトをいかに制御するか?ということに帰結します。ごく簡単に書くと以下のような構造です。
while ( 1 ) {
for ( auto& task : tasklist ) {
task.Update();
}
}
while( 1 )はいわゆるゲームループと呼ばれるもので、ゲームが終わるまで処理を繰り返します。タスクシステムはタスクのリストからタスクを順番に取り出して、状態を更新してあげるのが役割です。タスクシステムに必須なのは更新の呼び出しを保証することだけで、これ以外の機能はそれぞれのタスクシステムの特色だったり個性だったりになります。
誰がタスクシステムを回すのか?
タスクシステムを回すのはゲームループを記述するメインプログラマーです。エンドプログラマーは基本的にタスクシステムを意識する必要はありません。エンドプログラマーは何をするのかというと、それは一定期間で繰り返し呼び出される前提でオブジェクトのロジックを実装することです。ここで2つ問題があります。1つ目は一定期間というのはあまり正確ではないということです。2つ目はエンドプログラマーが処理するオブジェクトをどうやって生成するかということです。この2つの問題はおよそ下記のようにして対応されることが多いです。
// 最初のオブジェクトを生成する
while ( 1 ) {
for ( auto& task : tasklist ) {
task.Update( deltatime ); // 経過時間を通知する
}
}
Unityでは最初のオブジェクトはおそらくシーンという形で自動的に用意されます。また経過時間はプログラマーが自分自身で計算するものとして扱われているようです(個人的には経過時間はシステムが自動で計測してグローバル関数でいつでも取得できるようにしておけばよいんではないかと思っています)。
タスクとリスト
タスクシステムの最低限必要な構成要素は処理を行うタスクとタスクを管理するリストです。それらの実装や要素の追加等は設計思想によるもので正解はありません。以降はあくまで私個人の自作のタスクシステムの仕様となります。
- タスクは自身の前後のタスクへのポインタを保持している
- タスクのリストは終端が無く環形になっている
- リストは開始位置のタスクのポインタを保持している
- リストを走査して最初のタスクに戻ったら終了
- タスクはリストのポインタを保持できる
- タスクにリストのポインタがある場合はその分岐したリストの走査を優先する
タスクは仕事
UnityのタスクのMonoBehavourはtransformを持っています。transformは位置(と回転と拡縮)情報を表します。これらがあるということはゲーム世界にオブジェクトが存在するということになります。しかしタスクを仕事という観点で見た場合、例えば得点計算をする仕事にはこれらの情報は不要です。物(オブジェクト)の振る舞いでMonoBehaviourというのはダジャレですが、Unityのタスクは空間に存在する物が基本になってしまっています。これはUnityがGUIでオブジェクトを実際に空間に配置していく事でゲームを作りやすくした事に起因しているのではないかと思っています。しかしタスクは物ではなく仕事であり、一定期間で状態を更新するような仕事は、ファイルの入出力、メモリの監視、ネットワーク処理などいろいろあります。ゲームオブジェクトの管理はタスクの多種多様な仕事の一つであると考えています。
リストの仕事
タスク自身の仕事はタスクが行うとして、タスクを取り扱うためにはタスクを管理する上位の存在が必要です。リストはタスクを取り扱いやすいようにする機能を提供するのが仕事になります。タスクそのものはあまり特別な機能は持っていませんが、リストの方は環形+分岐の構造になっていることが特色です。環形にしているのはある種のリスト走査のコードを簡潔にかけるようにするためで、例えば最後からN番目のタスクを取得するなどです。分岐はタスクのグループ化を助けるためで、例えば敵やアイテムなどを分岐リストでまとめることで、グループに対して同じ処理をかけることがやりやすいようにしています。
サンプル
基底のタスクから派生させて表示プリミティブのクラスを作成しますが使用方法は同じです。オブジェクトを生成して初期化する、必要が無くなったら後始末する、です。自作のライブラリの紹介も兼ねて使用方法のサンプルとなります。
#include <Opal.hpp> // ライブラリヘッダ
#include "Draw/prim/rect2.hpp" // 四角形オブジェクトクラス
static RECT2 rect2; // オブジェクトの生成
static void user_init( void ){ rect2.Open(); } // 初期化
static void user_free( void ){ rect2.Close();} // 後始末
static void user_main( void ){ rect2.AddTrans( +1, +1 ); } // 四角形が右下に移動
void main( void ){
return Opal( user_init, user_main, user_free ); // ゲームループにユーザー関数をセット
}