Goのコルーチンを活用して弾幕を記述してみた【ゲーム開発】
TL;DR
- 弾幕をシュッと書けるGoのコルーチン楽しい最高~~~!!
- makiuchi-d/arelo などのライブリロードツールと組み合わせて使えば開発体験でもLuaに見劣りしないため、実用性も高そう!
【CM】~ゆるい勉強会「Ebitengine ぷちConf」#3 8/30に開催します~
イベント詳細
「Go初心者もゲーム開発初心者でも誰でも参加できる、Ebitengine のゆるいLT/交流会」である Ebitengine ぷちConf の第三回が、8/30に開催されます。参加をご希望の方は、下記ページより登録してください!
今回もオフライン(渋谷)とオンライン(YouTube Live)のハイブリッド開催です。
また今回は、Ebitengine 開発者星さんへのお便り・質問を大募集中!頂いた質問の中からいくつかを現地でお答えいたします。こちらの Google Form から応募できますので、どしどし送ってください!
さらにさらに、今回は新たな試みとして、LT発表後の自由時間に試遊台も設ける予定です!自作ゲームを宣伝したい方、フィードバックが欲しい方はぜひこちらから!(必要な機材を把握したいので、何卒ご協力ください。)
もちろん、メインとなるLT発表も前回同様6枠ご用意しておりますので、どしどしご応募ください!詳しくはイベントページをチェック!
まえがき(Go目線)
Go1.23より、iter パッケージが追加予定です。これはざっくり言えば slice や map のようなデータ列を統一的に扱うための便利機能を提供するパッケージです。iter パッケージ自体の詳しい説明はこちらの記事にお譲りします。
この記事で注目するのは中でもちょっと特殊な iter.Pull 関数です。ざっくり使い方を示すとこんな感じ。(Playground)
package main
import (
"fmt"
"iter"
)
// iter.Seq を定義する
func seq(yield func(s string) bool) {
list := []string{"foo", "bar", "baz"}
fmt.Println("first call")
yield(list[0]) // 呼び出し元に値を返して中断
fmt.Println("second call")
yield(list[1]) // 呼び出し元に値を返して中断
fmt.Println("third call")
yield(list[2]) // 呼び出し元に値を返して中断
}
func main() {
// seq を iter.Pull に渡すことで、
// next() を呼び出すたびに seq の処理を yield() まで進めては中断するコルーチンを作る
next, stop := iter.Pull(seq)
defer stop()
fmt.Println(next())
fmt.Println(next())
fmt.Println(next())
// Output:
// first call
// foo true
// second call
// bar true
// third call
// baz true
}
関数が入れ子になっているのが難解ですが、サビは next() を呼び出すたびに seq の処理を yield() まで進めては中断するコルーチンを作る
のとこです。そうですGoでコルーチンが作れるのです!
コルーチンといえばみなさんも思い浮かべた通りゲーム、中でも弾幕シューティングゲームですね。Goにもついにゲーム開発のための機能が入って感慨深いです。そんなコルーチンで実際に弾幕を記述してみたのがこの記事となります。
まえがき(ゲーム開発目線)
弾幕記述言語、BulletMLというものがあります。XML形式で弾幕の動きを簡単に記述できるJava appletで、開発したのはABA Gamesさんです。
BulletML サンプルコード
<action label="top">
<repeat>
<times>100</times>
<action>
<fire>
<direction type="sequence">23</direction>
<bulletRef label="straight"/>
</fire>
<wait>1</wait>
</action>
</repeat>
</action>
<bullet label="straight">
<action>
<wait>20+$rand*50</wait>
<changeDirection>
<direction type="absolute">180</direction>
<term>10</term>
</changeDirection>
</action>
</bullet>
それぞれの弾が位置と速度・角度を持ち、<action>
タグ内でその変化を自由に記述するというシンプルなモデルですが、その組み合わせで無数の弾幕を記述することができます。今回はそんな可能性を秘めた弾幕記述言語 BulletML と同じくらいの能力を持ったシステムをGoのコルーチンで記述することを目指してみました。
Goはプログラミング言語である分BulletMLより自由度はもちろん高く、しかもLua等と比較したときには型があるおかげでエディタと組み合わせてぬるぬる候補が補完される型安心のメリットがあります。そして当然パフォーマンスも高いです。もしGoで弾幕を記述できるなら、そういったメリットを享受できるわけです。
コンパイル言語はそれらスクリプト言語と比べてコンパイルの時間を要するデメリットがありますが、Goは1, 2秒くらいでコンパイルが終わるため、うまくやればスクリプト言語に負けないくらい素早く動作確認することも可能なはずです。おまけとして最後にライブリロードツールとの組み合わせで開発体験を向上する方法も試してみました。
作ってみた
というわけで出来上がったのがこちらとなります!
キャプチャと対応するコードがこちら。
func top(yield next) {
// 渦弾幕
dir := 0.0
for range 180 {
// 画面中央から、7方向に渦を巻くように弾幕を発射
for _, t := range seq(7) {
fireSimpleBullet(vw/2, vh/2, dir+tau*t, sx/2)
}
// 発射方向をずらして次のフレームへ
dir -= tau / 110
yield()
}
// 矢印弾幕
for range 120 {
fireArrowBullet(vw/2, vh/13, tau/4+tau/30) // 画面中央上辺りから、左下に向けて矢印弾幕を発射
yield.skip(30) // 30フレーム待つ
fireArrowBullet(vw/2, vh/13, tau/4-tau/30) // 画面中央上辺りから、右下に向けて矢印弾幕を発射
yield.skip(30) // 30フレーム待つ
}
}
このコードだけだと説明不足ですが、少なくとも for range N
でシュッとループを回せたり、思い描いた順序通りに処理を記述できるクールさは伝わっていれば幸いです......!
工夫したこと
BulletMLと同じくらい便利にするためにいくつか便利関数を足しました。
seq(N)
「指定回数ループを回し、なおかつループの進んだ『割合』を使用する」処理は頻出です。seq(N)
関数は引数にループ数をとり、回数iと割合t (0以上1未満) を返すような iter.Seq2
型の関数です。汎用性は高く、例えばイージング関数に t
を渡して ease.OutQuad(t)
のようにすれば、線形以外の変化も簡単に実現できます。
例えば以下は duration
フレームだけかけて少しずつスピードを変える処理です。これはBulletMLで <term>
タグとして提供されている機能です。
for _, t := range seq(duration) {
b.speed = mix(from, to, t)
yield()
}
b.speed = to
<term>
タグのように短く書くことも可能です。
term(yield, duration, &b.speed, from, to)
yield.skip(N)
何もせず待機する処理も頻出です。for range N { yield() }
と書けば済む話ですが敢えてより短くするためのメソッドも用意してみました。
yield 関数に渡す引数
yield 関数の引数の個数は1個 (iter.Pull) または2個 (iter.Pull2) です。ですがゲームの場合 yield 関数に値を渡したいことは稀です。0引数バージョンを実現するためにちょっと面倒なことをしました。
func pull0(seq0 func(yield0)) (yield1 func() (empty, bool), stop func()) {
seq1 := func(yield1 func(empty) bool) {
yield0 := func() bool {
return yield1(empty{})
}
seq0(yield0)
}
return iter.Pull(seq1)
}
spawn 関数
弾の動きの中でもさらに非同期な処理を行いたい場合があります。例えば30フレームかけてスピードを加速しつつ、60フレームかけて角度も変えたいような場合です。spawn
関数を使って別のコルーチンをいつでも増やせる仕組みを作りました。
// spawn では止まらずに次に進む
spawn(&b.deleted, func(yield yield0) {
for range 360 {
b.dir += tau / 360
yield()
}
})
// spawn した処理と以下の処理は並行で進む
for _, t := range seq(b.duration) {
b.speed = mix(b.fromSpeed, b.toSpeed, t)
yield()
}
b.speed = b.toSpeed
コルーチンを作るタイミング
本題からは逸れるのですが、runtime.LockOSThread
関数との兼ね合いで、コルーチンを作るタイミングに注意が必要な場合があります。具体的には Ebitengine の場合 main
関数や init
関数で作ったコルーチンはゲーム中に呼び出せません。Update
関数の中などで作る必要があります。
func (a *app) Update() error {
// コルーチン a.top が作成済みでなければ
if a.top == nil {
a.top, a.stop = newCoro(top)
}
// ...後略...
理由はかなり難しくて、筆者はうまく説明できる自信がないので割愛します。議論は https://github.com/golang/go/issues/64777 あたりで継続しています。
弾オブジェクトをコルーチンのスタック上に載せない
ちゃんと計測していないのですが、弾を撃つ際に作ったオブジェクトをコルーチンのローカル引数にしてしまうと、それが一生残り続けてメモリの無駄になりそうな予感がしています。対策として、オブジェクトを作る処理は fire*
関数の中に閉じ込めるように書きました。
おまけ: ライブリロード
弾幕を記述したらその動きをすぐ確かめたいですよね。というわけで、ファイルの変更を検知してすぐに再ビルドし、プログラミングを再起動してくれるライブリロードツールを導入しましょう。今回は makiuchi-d/arelo を使ってみました。他にもたくさん類似のツールはありますが、筆者がWSLで作業するにあたって正しく再起動できたのがこの arelo でした。
インストール:
go install github.com/makiuchi-d/arelo@latest
ライブリロード開始:
arelo -p '**/*.go' -i '**/.*' -i '**/*_test.go' -- ./run-wsl.sh
あとはソースコードを変更するたびに自動でビルドされ、再起動してくれます。Goのビルドは二回目以降であれば1, 2秒で完了するため、ビルドと再起動を毎回行っているとは思えないほど動作確認が捗りました。もちろん、最適なワークフローは(特にゲームが大規模になるにつれ)適宜考え直す必要があるでしょうが、基礎的な部分でまったく苦にならないのできっとなんとかなるはずです。
一応、マルチモニターの場合 Ebitengine はカーソルのあるモニターにウィンドウの初期位置を設定するため、カーソルの位置には気を配っておくといいかもしれません😏
おわりに
Goについに弾幕を記述するための機能が入るということで今からテンション高まりまくっています!!!!!!!!!!!ぜひみなさんもGoで弾幕を書きましょう!!!!!!!!!!!!!!!!!!!!!!
ソースコードはこちらです、他にもコルーチンと関係あったりなかったりする様々な工夫があるのでぜひ参考にしてください!
Discussion