東方弾幕風のタスクシステムを Lua で再現する

そろそろ半年ほど前に提唱した BarrageLL の制作を再開していきたいと思います:
東方弾幕風の不満点、およびその解決策 - 野良C++erの雑記帳
弾幕記述フレームワーク BarrageLL 開発開始 - 野良C++erの雑記帳

実は最近、ちょこちょこ githubLua 自体のちょっとした拡張をやってたのですが、
そろそろ、実際に弾幕を作るにあたって必要なものを作っていかないとなー、と思ってたのです。


で、とりあえず今回は、東方弾幕風の最大の特徴である task を移植してみます。
別に移植しなくてもコルーチン使えばいいじゃん、と思うかもですが、やはり慣れたモノは使いやすい。
慣れとか関係なくても、毎回コルーチンのリストを作ったりするのは面倒ですし、ユーティリティはあっていいです。
とまぁ、そんな感じ。

導入

まず東方弾幕風の task って何よ、というところから。参考はこのへん:
http://www.geocities.co.jp/SiliconValley-Oakland/9951/products/thdnhlec/index.html



東方弾幕風スクリプトでは、基本的に毎フレームの処理を @MainLoop 内に書いていくのですが、
愚直に毎フレームの処理を記述していく場合、状態機械を作る必要があって面倒です。
例えば 100 フレームに一回、自機狙い弾を撃ちたい場合は

let framecount = 0
@MainLoop {
  framecount++;
  
  if( framecount % 100 == 0 ) {
    CreateShot01( GetX(), GetY(), 1.0, GetAngleToPlayer(), WHITE01, 10 );
  }
}

とか書かないといけません。
これは簡単な例なので分かりやすいですが、もっと複雑になってくると絶望です。
東方弾幕風は、この処理を、流れに沿った記述に書き換えることができます。

// 最初に呼び出される関数で、弾幕全体の流れを記述する task を起動
@Initialize {
  Main()
}
// MainLoop は yield する(実際には毎フレーム行うべき処理を記述するが)
@MainLoop {
  yield;
}
// メインタスク。弾幕の流れを記述する
task Main() {
  loop {
    loop(100){ yield; } // 100 フレーム待って
    CreateShot01( GetX(), GetY(), 1.0, GetAngleToPlayer(), WHITE01, 10 ); // 弾を撃つ
  }
}

前者では「フレームカウンタを回し、条件分岐して、…」といった非本質的な事柄が出てきますが、
後者は「100フレーム待って弾を撃つ、という無限ループ」という、直感的な記述を行えているのが分かります。
もちろん task や yield などの動作を知っていることが前提ですが、これらはぶっちゃけ定型文です。
loop(n){ yield; } といった暗号を wait(n); という関数にまとめるなどすれば、可読性は更に上がります。
詳しく説明するのは面倒なので このへんで切り上げますが、とにかく楽なので、是非ともこれは導入したい。

簡単に移植してみよう

で、その導入ですが、実は日記に書いてないだけで、少し前に書いたものが存在してたりしました。
使い方は task モジュールを require して、

  • task を起動したい場合には、起動したい関数を task.wrap でラップして、得られた関数を普通に呼び出す
  • yield を使いたい場合には task.yield() を呼び出す

これだけ。
実装はこちらです:
http://github.com/gintenlabo/BarrageLL/blob/14d8b2815ee2de2447339c32be36da9ab48430b4/lua/task.lua
で、これでも十分に良かったのですが、
せっかくオブジェクト指向的側面をもった Lua を使ってるんだから、もう少し便利に使えないか、と。
つまり、 task.yield() で処理を譲り渡すサイクルが、メインの他にあってもいいんじゃないか、と思い、
今日、本格的に BarrageLL の開発に乗り出す手始めに、拡張 task システムを作ってみたのです。

タスクシステム 仕様

で、作ってみた結果の仕様がこちら:

タスク

タスクシステムで扱う処理の単位。コルーチンに限らず、一般に「何度も反復して呼び出されるもの」を扱える。
ある値 t がタスクの要件を満たすとは、以下のいずれかの要件を満たす必要がある:

  • t は coroutine.create で制作された、まだ処理が終了していないコルーチンである。
  • t() という関数呼び出し(あるいは __call メタメソッド呼び出し)が可能である。

タスクはタスクリストによって管理され、終了しない限りタスクリストから繰り返し呼び出される。
タスクの実行をこれ以上行わせない場合は、タスクの側からその旨を通知する必要があり、

  • t がコルーチンである場合には、その実行が終了すれば自動的に終了される。
  • t がコルーチン以外の場合には、 t() が特殊値 task.stopIter を返したときに終了される。
タスクリスト task.TaskList

タスクを束ねる単位をタスクリストと呼び、 task モジュール上のクラス TaskList で表現される。
以降は TaskList の詳細な仕様である。

TaskList:new()
新しいタスクリストを制作する。単に task.newList() でもよい。
TaskList:add(t)
タスク t をタスクリストに追加する。
タスクリストの実行中に呼び出された場合には現在実行されているタスクの直前に追加される。
そうでない場合にはタスクリストの最後尾に追加される。
TaskList:start(f, ...)
関数 f(...) をコルーチンとして呼び出し、タスクに追加する。
この関数は東方弾幕風の task の動作を真似ており、まず f(...) が呼ばれ、最初の yield で呼び出し元に制御を戻し、以降は呼び出し元のタスクが実行される前に実行される。
細かい動作は後に記す。
TaskList:resumeAll()
今まで追加されたすべてのタスクを実行する。
この関数はタスクリスト実行中には呼び出すことはできない。
呼び出すべきタスクが存在しない場合、この関数は特殊値 task.stopIter を返す。
TaskList:running()
タスクリストの実行中は現在実行中のタスクを返す。そうでなければ false を返す。
関数 TaskList:start( f, ... ) の詳細

ある一点を除き、以下のコードと等価である:

local co = coroutine.wrap(f)
coroutine.resume( co, ... )
if coroutine.status(co) ~= "dead" then
  self:add(co)
end

唯一の差異は、 coroutine.resume( co, ... ) の呼び出しの時に、タスク呼び出しと同じ処理が行われる点。
つまり、resume 中において、 task.running() の値は co に、task.parent() の値は self になる。

特殊タスク task.mainThread

どこのタスクリストからも呼び出されていない部分に対応する、概念上のタスク。
thread 型ではないが、 task.yield() に対し実質的に thread であるかのように振舞う。

特殊タスクリスト task.root

task.mainThread を呼び出している、概念上のタスクリスト。
root が存在する状況において、 Lua のプログラムは、実質的に以下のような処理を行っているとみなせる:

repeat
  local ret = task.root.resumeAll()
until ret == task.stopIter

この概念上のタスクリストが存在するおかげで、メインスレッド上での task.yield や task.add が上手く機能する。

関数 task.yield(), task.wait(n)

task.yield() は、現在のタスクの実行を中断し、タスクリストの別の関数に処理を譲る。
現在のタスクがコルーチンでない場合には、現在のタスクを呼び出したタスクリストに対し、
そのタスクリストに対し resumeAll 呼び出しを行ったタスク上において task.yield() された場合と、実質的に同じ動作をする。
もし現在のタスクがコルーチンでなく、かつ呼び出し元のタスクリストが root の場合、エラーになる。
task.wait(n) は n 回 task.yield() を呼び出す。

関数 task.running()

現在実行中のタスクを得る。この関数は task.parent():running() と等価である。
現在実行中のタスクが存在しない場合には task.mainThread を返す。
この動作も task.parent():running() と全く同じである。

関数 task.parent()

現在実行中のタスクリストを得る。
タスク実行中に他のタスクをタスクリストから実行することは可能であるので、
その場合 task.parent() は、実行中のタスクリストのうち、最も最近に resumeAll されたものを返す。
実行中のタスクリストが存在しない場合(つまり実行中のタスクが mainThread の場合)には、 task.root を返す。

関数 task.add(t), task.start( f, ... )

task.parent():add(t), task.parent():start( f, ... ) と完全に等価である。

使ってみる

とりあえず root に関しては上手く動作することを確認。
http://github.com/gintenlabo/BarrageLL/blob/22289c901e8cbea334d27f358c2a8189f556bba2/lua/task_test.lua
複数のタスクリストを作った場合の詳しいテストは、後ほど。