MagicOnionで弾幕エンジンFsBulletML2を使おう — UnityとGodotは受けた座標を描画するだけ(おまけJev)
2013年に、BulletMLのF#実装である FsBulletMLをつくって公開していました。
今回作ったものは、そのリブートでFsBulletML2です(名前にひねりがない)。
BulletMLを、.NET(C#/F#) / Unity / MonoGame / Godot / ブラウザ(WASM・Fable)、あるいはサーバー上で動かすことができます。
実際に、このエンジンを有効活用したサイトも作りました(Cloudflareの勉強も兼ねて)。

右側の Monaco(VS Code ライクエディタ)で、弾幕DSL(xml,sxml,fsb,F# コンピュテーション式)を書いて、その場で動かすことができます。FsBulletML2の言語サービスを自前で実装しており、補完・診断・コード整形・共有などがサポートされています。
左側でFsBulletML2.Core(WASM・Blazor利用)を使用して弾幕を実際に走らせています。弾幕を動かす盤面とエディタが、それぞれが相互に連動する仕掛けが入っています(目玉機能)。この部分を繋いでいるのは Fable(F# を JavaScript または TypeScriptにトランスパイルするオープンソースツール) で F# を焼いた JS となっていて。F#で書いたものが、そのままブラウザで動く構成です。
と、MagicOnion と直接関係のない話がつづきましたが、この記事では Danmaku Gallery に置いてある読みもののうち、MagicOnion で弾幕エンジン(FsBulletML2)をサーバーに置いて動かすところを中心に、つまみ食いをしたダイジェストものになります。
新エンジンの設計変更で、一番おいしいと思うところは、
Unity と Godot でMagicOnion経由でFsBulletML2を利用するサンプル。
samples/FsBulletML2.Sample.Unity2D.MagicOnion Unity。F# の dll を 1 本 も持たない
samples/FsBulletML2.Sample.Godot.MagicOnion Godot。同じ口。F# は 1 本 も持たない
弾幕エンジンは F# で書いてあるのに、それを使う Unity と Godot の client にはそのエンジンへの参照が全くありません。なんなら BulletML のことすら知りません。ですが、弾幕を動かしてシューティングゲームを作ることができます。ちょっと極端な言い方をすると、client がやるのはサーバーから降ってきた弾の座標を画面に反映するだけです。
なぜそれができるのか、というのがこの記事の主な話となります。2013年に作った旧エンジン(FsBulletML)では、エンジンとフロントとのインターフェイスの設計に欠陥があったためこれができませんでした。設計って大事ですね。
2013 年のエンジンのリブート
FsBulletML2 は、2013 年に作ったFsBulletML の作り直しです。当時、実力不足だったり、モチベーションが持続しなかったり、時間を捻出できなかったりで、構想だけあって実現できなかった「理想の設計」を目指して再構築しました。
このリブート開発を進めるにあたっては、生成AIを使用しています。私自身、仕事でもプライベートでも、生成AIを活用した開発に前向きに取り組んでいます。今回のリブートでは専用のAIハーネスを構築したことで、非常に高速に開発を進めることができました。当たり前のことではありますが、開発が順調だと〝楽しい〟です。いわゆる「コードを書くこと自体が楽しい」という価値観は、ここ数年で急速に失われてしまった印象があります(悲しい)。そのぶん、設計を深く突き詰められたり、より多くの成果物を生み出せたりする喜びがあるのも事実です。まぁ、このAI時代のエンジニアリングの流れは不可逆で、もう戻れません。がっつり乗っかっていく所存です。「F# 大好き!F#は素晴らしい…あなた自身が決して、決して、絶対に見る必要がないならね(時事ネタ)」(話がそれました)。
主となる、弾幕定義を読み込んで動かすというエンジンそのものの役割は変わっていなくて、内部的な最適化や動かし方の設計が置き換わって、できることが増えています。
エンジンが新しくなって置き換わったところを、いくつか紹介します。
| 2013 年(FsBulletML) | 2026 年(FsBulletML2) | |
|---|---|---|
| フロントエンドとの繋ぎこみ | エンジンが弾を呼び返して、その場で書き込む(bullet.Dir <- …) |
値を渡して値を受け取る |
| Core の軸 | 弾のオブジェクトを書き換えながら構文木を辿る | Sim モナド(RWS)で、環境・状態・効果を型で分ける |
| パッケージの割り方 | Core が読む段(XML と XPath)まで抱える | 読む段は Parser へ。Core は何も参照しない |
| F# の中で弾幕を書く | 判別共用体を直に書くしかない | コンピュテーション式(および、判別共用体) |
| コンパイル済み弾幕を型として引く(TypeProviders) | F# 3.0 Developer Preview の見本を貼っていた。入口 4 本 | SDK 8.11.0 を貼る。入口 6 本 |
| 参照が循環したとき | 打ち止めが無く、読む段が止まらない | 自己参照と bulletRef どうしの相互参照は走る。解けない形は読む時点で落とす |
| ブラウザ | 走らせる場所が無かった | WASM と Fable |
| エディタ | 無い | Playground(補完・診断・コード整形・共有)。撃った弾の数を行ごとに出し、盤面で選んだ弾がいま居る行をハイライトする |
| AI による弾幕生成 | 無い | Jev に弾幕生成プロンプトを投げて、弾幕の特徴を判断させ、その特徴からエンジンが BulletML をコンピュテーション式で組んで生成する |
| パフォーマンス(速さ) | 毎フレーム 構文木を探索する。mutable で値を書き換えるが遅い |
関数型スタイルのまま、アルゴリズムを最適化。先が読めるフレームは探索しない |
| 検証 | Core と Parser の単体テスト | 同梱弾幕の全数 走行、旧エンジンが保存した軌跡との突き合わせ、フロント 2 種 の等価、公開 API の面 |
| エンジンを置く場所 | ゲームと同じプロセスでしか動かせない | 別プロセス(MagicOnion でサーバー上)で動かせる |
すべてが新しいというわけではありません。
DSL のパーサーと、弾幕を型として引く設計は、旧エンジンから踏襲しているところが多いです。DSL記述形式のうち xml / sxml(S式) / fsb(インデント形式) は 2013 年に既に作ってあって、それを読むインターフェイスもほぼ当時のまま持ってきています。
弾幕の挙動(振る舞い)も 2013 年の実装を正本にしていて、旧エンジンで動作させて保存した弾幕の軌跡と、新エンジンの弾幕の軌跡を突き合わせて、弾幕の動き(挙動)が変わっていないことを保証しています。作り直しでいちばん静かに壊れやすいのがここなので、先に検証基盤を作ってから大規模な設計変更に取り掛かりました。
表のいちばん上と、いちばん下。この 2 つは同じことを言っていることになります。フロントとのお約束事を変更したので、新エンジンではサーバー上で動作させることができるようになりました。
MagicOnionを使用した例を含め、
Unity / MonoGame / Godot、で新エンジンを利用するサンプルプログラムが以下に置いてあります(UnityはECS対応版もあり)。
なぜサーバーに置けるのか
旧エンジンの API は、IBulletmlObject のメンバを一通りフロント側が実装してエンジンへ渡す形でした。エンジンがそれを呼び返す仕組みだったので、実装する側が「いつ呼ばれるか」を知らないと書けませんでした。
新エンジンは 1 フレーム 進める関数が 1 本あるだけで、真ん中の 3 本がありません。
減ったのは矢印の本数ではなく、エンジンの途中でフロントのコードが走る場面のほうです。
let f = Runner.stepWith env run { run.Motion with Pos = myPos }
myPos <- myPos + f.Delta
run <- f.Run
for child in f.Spawned do ...
フロントから値を渡して、エンジンから値を受け取る往復が 1 回で、途中に呼び出し元のコードが挟まらないかたちです。
ここまではただ「読みやすい」の話に見えますが、この形はネットワーク境界にそのまま置けることを意味します。エンジンの途中でフロントを呼び返す形だと、その呼び返しが 1 往復ぶんの遅延になってしまいます。呼び返しがない設計にエンジンの実装を対応させたので、サーバーでのエンジン実行が成り立つようになりました。
インターフェイスを決める
MagicOnion はいわゆる.proto を書く必要がないので非常に便利でノーストレスです。C# の interface がそのままサービスの定義となります。共有プロジェクトに置いて、サーバーが実装し、client は同じ interface 越しに呼ぶだけでシームレスに繋がる感覚。C#大統一理論ここにありですね。
public interface IDanmakuHub : IStreamingHub<IDanmakuHub, IDanmakuHubReceiver>
{
ValueTask<RoomInfo> JoinAsync(JoinRequest request); // 部屋に入る
ValueTask LeaveAsync(); // 出る
ValueTask SetPlayerAsync(float x, float y); // 自機の位置。aim がこれを読む
ValueTask ShootAsync(float x, float y); // 自機の弾。撃つのもサーバー
}
// サーバーから降ってくるもの
public interface IDanmakuHubReceiver
{
void OnFrame(FrameDto frame);
}
インターフェイスは上記のようにシンプルなものです。
MagicOnionでのやり取りにはいくつか決まり事があって。Hub の口は ValueTask, ValueTask<T> 等を返す。Unary の口は UnaryResult<T> を返す。受け口(IDanmakuHubReceiver)は void。サーバーは投げっぱなしで、返事を待たない感じ。
エンジンが配るのは「弾がどこにあり、どっちを向いているか」まで
client へ送るのは、弾 1 発につき以下のようになります。
[MessagePackObject]
public struct BulletDto
{
[Key(0)] public int Id; // Roomの中で一意。コマをまたいで同じ弾は同じ番号
[Key(1)] public float X;
[Key(2)] public float Y;
[Key(3)] public float Dir; // 度。真上が 0 で時計回り
[Key(4)] public byte Kind; // 0 = 敵の弾 / 1 = 自機の弾。絵を選ぶためだけ
}
弾幕挙動を表す changeDirection も accel も <term> もインターフェイスに登場しません。これが登場してしまった瞬間に client が運動則を持ち直すことになって、サーバーと 2 か所に同じものが在る形になってしまいます。
同じ理由で、ShootAsync が渡すのは弾の位置だけです。何発 出るか・どう飛ぶかはサーバー側のエンジンが決めます。client 側で撃つ形にすると「敵の弾はサーバー、自機の弾はローカル」の 2 系統になってしまってclient が運動則を持ってしまうことになるので。
空間の決めごと(Y の向き、盤面の広さ、弾を撃ち始める場所)は、Roomに入ったときに RoomInfo で 1 度だけ配ります。Space を client に決めさせない。aim は座標系の上で計算されるので、サーバーと client が別の空間を持つと弾の軌跡が壊れてしまうのでそれは避けます。
進めるゲームループは、Hub の外に置く
Hub は接続 1 本につき 1 個作られて、切れたら捨てられます。状態を置くと再接続で消えてしまうので、置いていいのは「この接続がどのRoomに居るか」だけです。
フレームを進めるのはRoomの仕事です。
Room 1 つが PeriodicTimer を回して、1 フレーム進めて、配るかたち。
async Task RunAsync()
{
// PeriodicTimer は遅れを繰り越さない。1 コマ 遅れても、取り返そうとして詰めて撃たない
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / 60));
while (await timer.WaitForNextTickAsync(stopping.Token))
{
if (Volatile.Read(ref hasPlayer) != 0)
{
Unpack(Interlocked.Read(ref pendingPlayer), out var px, out var py);
source.SetPlayer(px, py);
}
// 撃つのは進める前。後にすると、撃った弾がこのコマで 1 度 も進まないまま並びに出る
while (pendingShots.TryDequeue(out var packed))
{
Unpack(packed, out var sx, out var sy);
source.Shoot(sx, sy);
}
var snapshot = source.Step();
group.All.OnFrame(new FrameDto
{
Frame = Interlocked.Increment(ref frame),
Bullets = snapshot.Bullets,
PlayerHits = (ushort)snapshot.PlayerHits,
EnemyHits = (ushort)snapshot.EnemyHits,
});
}
}
自機の位置と、撃った合図は、どちらも別のスレッドから届きます。扱いが逆になるかたちです。
自機の位置は「いまどこか」さえわかればよいので、最も新しい 1 組だけ持って古いものは捨てちゃいます。撃った合図は「出来事」なので、捨てると弾が 1 発も出ません。なので並べて持つようにします。
位置を long 1 本に詰めているのは、書き込みを 1 回で終わらせるためです。
x と y を別々に書くと、x だけ新しい組をループが読むことがある。
同じ分かれ目が、client 側にもう一度出てくる
受け口は Unity の主スレッドでは呼ばれません。ここで transform を触っちゃいけません。いちばん新しいフレームを置いておいて、Update が取りに来る形にします。
sealed class Receiver : IDanmakuHubReceiver
{
FrameDto latest;
public void OnFrame(FrameDto frame)
=> Interlocked.Exchange(ref latest, frame); // ここは主スレッドではない
public FrameDto Take() => Interlocked.Exchange(ref latest, null);
}
置き換えでいいのは、1 フレームが丸ごと「いま在る弾 全部」なので。処理が遅れて 2 フレーム溜まったら、古いほうは捨ててしまってよいです。弾が生まれた合図だけを送る形にしていたら、捨てた合図の弾が以後ずっと出て来ないことになってしまいます。
ただし、当たり判定の数だけは捨てられません。
public void OnFrame(FrameDto frame)
{
var previous = Interlocked.Exchange(ref latest, frame);
if (previous == null) { return; }
// 位置は「いまどこか」なので古いコマを落としてよいが、
// 当たりは出来事で、落とすとその 1 発 が無かったことになる
Interlocked.Add(ref pendingPlayerHits, previous.PlayerHits);
Interlocked.Add(ref pendingEnemyHits, previous.EnemyHits);
}
サーバー側で「自機の位置」と「撃った合図」を別に扱ったのと、同じです。
1 フレームの中に 2 種類入っているので、捨て方も 2 通りになります。
当たり判定は、どちらが持つと安いか
弾の寿命をサーバーへ渡すと、当たり判定の置き場が自動では決まりません。
もちろんclient でやる形でも書くことはできるので、コストを 4 つに割って比べてみました。
| サーバーで判定 | client で判定 | |
|---|---|---|
| 下り | 増えない。当たった弾が消えるぶん減る | 増えない。当たった弾は消えない |
| 上り | 増えない(自機の位置は aim のため既に送っている) |
当たった弾の番号を毎回 送る |
| 走査 | 弾を落とす走査に相乗り。増えるループは 0 本 | client に 1 本 増える |
| client が 2 人 | 割れない | どちらの言い分を採るか決まらない |
| ずれ | 自機の位置が届くまでの遅れ | 弾が消えるまで 1 往復 |
結論。4 つとも同じ側に倒れました。サーバーがコスト安いです。
ふつう「遅れ」だけは client 側に有利になるのですが、ここでは当たった弾を消すのに 1 往復要るので、1 つも逆を向かないほうがよさそうです。
決め手はもう 1 つあります。サーバー側で弾を管理する以上、client では実際の弾を本当に消すことはできないということ。消しても次のフレームでサーバーが同じ番号を client に送ってきてしまいます。消せない判定は「数えるだけ」となって、重なっているあいだ同じ弾を何度も数えないための表(もう数えた弾を覚えておく)が必要になってしまいます。素直にサーバー側で弾を消すと、その表が丸ごと要らなくなります。
弾が消える理由は大きく 3 つあって。寿命を使い切った・盤面の外へ出た(描画対象外)・衝突による消滅。サーバー側でやると、これらが 1 本の走査で決まります。
for (int i = 0; i < live.Count; i++)
{
var b = live[i];
bool dead = !b.Used || (!b.IsRoot && Field.IsOutside(b.X, b.Y));
// 撃つ側 は当たらない。根 は弾ではなく、弾を出す口
if (!dead && !b.IsRoot) { /* … 当たりを見る … */ }
if (dead) { continue; }
live[w++] = b;
}
判定のためにループを足さないのが大事ですね。
同じMagicOnionのサーバーを Godot から叩く
サーバーは同じ MagicOnion で立てた 1 本のままで、
client側だけ差し替えられます。
Unityとの違いを簡単に。
| Unity | Godot | |
|---|---|---|
| 待つ | UniTask (Forget/destroyCancellationToken) |
素の ValueTask
|
| 知らせる | R3 の ReactiveProperty
|
自前の箱 1 個 |
| HTTP/2 |
YetAnotherHttpHandler を使う |
.NET の SocketsHttpHandler で足りる |
依存を引く段も 1 つ減って、
Godot は素の .NET なので PackageReference が 2 行そのまま通るかたちです。
サーバーとのつなぎは Unity とまったく同じです。
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
channel = GrpcChannel.ForAddress(Host);
var service = MagicOnionClient.Create<IDanmakuService>(channel);
Names = await service.ListAsync();
hub = await StreamingHubClient.ConnectAsync<IDanmakuHub, IDanmakuHubReceiver>(
channel, receiver);
Danmaku Gallery と FsBulletML2 について(つまみ食い)
MagicOnion の話とは別に、サイトの読みものからいくつか。
Core は関数型スタイルで駆動、RWS を 1 本に畳んだ Sim モナド
弾幕を動かすエンジンでは、1 フレーム 進める間に、3 種類の副作用を扱います。
旧エンジン(FsBulletML)では、弾を動かす操作はオブジェクトの値を随時書き換える副作用バリバリのかたちで実装していました。FsBulletML2では、関数型スタイルに刷新しています。通常、関数型で書くとパフォーマンスを犠牲にするかたちになりますが、適切な実装と工夫によって、カバーできることも多いです。実際、FsBulletML2はベンチマークで旧エンジンより5倍のパフォーマンスを出せています(当社比)。
| 計算 | やること | Core では |
|---|---|---|
| Reader | 環境(自機の位置・乱数・難度)を読む | Env |
| State | 弾の状態を持ち回る | BulletState |
| Writer | 効果(撃った・消えた)を書く | Effect list |
FsBulletML2では、副作用の扱いについてモナドで対処しています。いくつか方法が考えられますが、今回採用したのはモナド変換子で 3 本 積み重ねるのではなく、1 本 のコンピュテーション式に畳む方法です。Env は配られるだけで戻ってこない。BulletState は 1 本 の線として段を通る。Effect は横へ積み上がる。3 つ の向きが互いに干渉しないので 1 つに畳むことができます。
Sim<'a> の正体は Env -> BulletState -> SimResult<'a> という関数で、値・状態・効果の 3 つ を 1 つ の record で返します。1 段 進めるたびに出る包みは 1 枚 で済みます。
sim { ... } と書けるようにしているBuilderの定義は以下のとおり。
type internal SimBuilder() =
member inline _.Return x = Sim.ret x
member inline _.ReturnFrom (m: Sim<'a>) = m
member inline _.Bind (m: Sim<'a>, [<InlineIfLambda>] f: 'a -> Sim<'b>) = Sim.bind f m
member inline _.BindReturn (m: Sim<'a>, [<InlineIfLambda>] f: 'a -> 'b) : Sim<'b> =
fun env st ->
let r = m env st
{ Value = f r.Value; State = r.State; Emit = r.Emit }
member inline _.Zero () = Sim.ret ()
member _.Delay (f: unit -> Sim<'a>) = f
member _.Run (f: unit -> Sim<'a>) = f ()
member inline _.Combine (a: Sim<unit>, [<InlineIfLambda>] b: unit -> Sim<'a>) =
Sim.bind (fun () -> b ()) a
member inline _.For (xs: seq<'x>, [<InlineIfLambda>] f: 'x -> Sim<unit>) =
Seq.fold (fun acc x -> Sim.bind (fun () -> f x) acc) (Sim.ret ()) xs
member this.While (guard, body) =
if guard () then Sim.bind (fun () -> this.While (guard, body)) (body ()) else Sim.ret ()
Combine も For も While も、中で Sim.bind を呼んでいるだけ。効果を積む・状態を持ち回るという仕事は全部 bind 1 本 が持っていて、Builderはそこへ構文を流し込む口でしかありません。使う側は、弾を消す命令ならこう書けます。
let vanish (p: Progress) : Sim<RunState * Progress> =
sim {
do! Sim.emit Vanished
return Ended, PVanish true
}
Sim.bind は同梱弾幕の突き合わせのテストで 300 万回以上 通るので、ここのコストがそのまま弾幕走行のコストになります。なので速くする手もこの 1 本 の最適化に集中させました。Emit を voption に、包みを struct に、bind とBuilderを inline に、判別共用体の包みを関数型の別名に。この 4 手 で、例えば homing laser の弾幕を 60 フレーム 走らせる間のヒープ確保(GC が片付けるメモリ)は 47% 減らせました。時間もほぼ同じだけ縮みました。
最後にもう 1 つ、忘れられがちな BindReturn を定義して、let! の直後の return で組んでいた無駄な SimResult を消せました。
and!(MergeSources / BindNReturn)は、入れてみたところ逆にヒープ確保が 1〜4% 増えてしまいました。Sim は BulletState を 1 本 の線で持ち回るので、並べたものが本当に並んで走る形(applicative)にはならないためです。and! が悪いのではなく、今回のケースの最適化には当てはまらなかっただけです。脳死で追加すると痛い目に遭います。
先が読めるフレームは、構文木を探索しない
画面に 2,000 発 弾が出ているケースで、素直にBulletMLのエンジンを組むと、毎フレーム 2,000 回構文木を探索する実装になってしまいます。ところが、実際は、その多くが探索しなくても答えが分かる弾です。探索不要のケースで省略するように実装すると、弾幕の構成に次第では、非常に効率的に処理することができます。
| インターフェイス | 何が言えるか |
|---|---|
HasNoScript |
このフレームは aim を読まない |
ConstantDelta |
1 フレームの差分がもう変わらない |
QuietFrames |
あと何フレーム 何も起こさないか |
LinearPlan |
あと何フレーム 一定の割合で変わるか |
ConstantDelta が言えるのは、差分が Accel + (速さ × 向き) の 1 本だから。
Speed / Dir / Accel を変えるのは命令だけなので、
弾幕の台本が 1 本も無ければ、命令は 1 つも走らないし、その弾が消えるまで同じ値になります。
不要な計算を省略することで、パフォーマンスにかなり効いてきます。
言葉から弾幕を組む(Jev)
最近、流行っていたJevを使ってみる。
Playground には、「世代を重ねて増える弾」のように文章(プロンプト)でAIに頼むと、弾幕が生成される AI メニューがあります。裏で使っているAIは Typesafe AI が作っている Jev というモデルで Cloudflare の Workers AI から typesafe/jev で簡単に呼べます(非常に低コストだがお金がかかる)。
Jev は、よくある LLM と違って文章を返しません。問いの形(選択肢・目盛り・真偽)をこちらで決めて投げると、型の付いた確率だけが返ってくる非常にシンプルなもので。自前のプログラムに組み込むことが容易です。
とくに工夫なしに LLM に BulletML を直に書かせると、閉じていない xml や構文として成立しないもの、終わらない repeat で画面を埋め尽くす弾幕にしてしまうなど問題が起こりやすい。LLMを使用する場合は、そのような意図しない出力がなされないよう制御する工夫が必要になります。ところが、Jev はそもそも BulletML を書けない(決まった型しか返せない)ので、それを前提に役割を設計しました(これが面白いところですね)。どのような弾幕を生成するかの軸を束にしておいて、Jev にはどのような弾幕デザインなのかの特徴だけを答えてもらう。その特徴をもとに BulletML を構築するのはエンジン側の生成器という役割分担です。
たとえば「世代を重ねて増える弾」なら、まず「どの組み方の弾幕か」を選ばせます。
"family"
==> createObj [ "type" ==> "choice"
"instructions" ==> "Is the request a figure drawn by bullets, ... or an ordinary danmaku pattern?"
"criteria"
==> createObj [ "classic" ==> "an ordinary pattern: a spiral, a ring, a fan, aimed shots or a curtain"
"harmonic" ==> "a figure drawn by bullets: a flower with petals, a star, a heart, ..."
"lineage" ==> "bullets that give birth to new bullets over time: ..." ] ]
"generations"
==> createObj [ "type" ==> "score"
"instructions" ==> "How many generations of bullets are born one after another?"
"criteria" ==> [| "one generation"; "two generations"; "three generations" |] ]
family が lineage(系譜)に落ちたら、何世代 続くか・飛びながら軌跡を撒くか・止まってから弾けるか、といった系譜の軸の目盛りを使います。問いは 1 回の呼び出しに全部 載せられるので、往復は 1 回で済みます。
どの仕様も、BulletML 定義の組み立ては手で弾幕を書くときと同じ。F#からは扱いやすい内部DSLのコンピュテーション式があるのでそれを使えます(ここで、FsBulletML2 の DSLの設計が活きてきました!)。生成器は Jev のことを知らないので、仕様さえ渡せば人が書いたものでも同じ弾幕を出すことができます。
弾が弾を産む系譜は、放っておくと弾数が指数関数的に膨らんじゃうので。AI が異常値を叩きだしても大丈夫なように、同時に画面に居る弾の数には上限を設けて、Unit テストでそれを超えてしまわないように制限しています。越えそうなら、まず波の間隔を伸ばして(形はそのまま、ゆっくりになるだけ)、それでも足りなければ弾の出し方を少しずつ間引くようにしています。
動いている弾幕と連動する、弾幕DSLエディタ
冒頭の Playground(画像あるやつ)について少し。
エディタは補完・診断・コード整形つきで、xml / sxml(S 式) / fsb(インデント記法) / F#のコンピュテーション式の、どれで書いても動作するようになっています。左側の盤面で弾幕を走らせながら、盤面をクリックして選んだ弾がいま居る行をエディタ上でハイライトします。
冒頭のスクショで行の右に出ている数も、走らせた結果から出しているもので、2 種類あります。
| どこに出るか | 何の数か | |
|---|---|---|
×4,230 (723) |
<fire> の行の右 |
その行が何発 撃って、いま何発 生きているか |
10% |
行の右 | その行に弾が何 弾フレーム 居たか |
弾幕の重さが、どの行から出ているかが字で出るわけです。
もう 1 つが Gallery。

作例を、見た目の形で探すことができ、ユーザーが投稿したものを見ることもできます。
弾幕は名前だけ見ても、どのような動きをするのかが分からないので。一覧では弾幕の軌跡の絵で並べるようにして、ホバーで実際の動きをすぐに確認できるようにしました。気になったものをクリックすれば、そのまま Playground で中身が読めて編集もできます。
ここは絵の出し方を少し工夫していて、一覧にはエンジンを積んでいません。一覧では画像だけ(WebP 240x320)を並べるようにしていて。マウスを載せた対象にだけエンジンで描画して、弾が動き出すような作りになっています。
- Danmaku Gallery: https://danmaku.gallery/
- 旧エンジン(2013 年): https://zecl.github.io/FsBulletML/
- 新エンジン(2026 年): https://github.com/zecl/FsBulletML-2
- NuGet: https://www.nuget.org/packages/FsBulletML2.Core
サイトのほうにも、いろいろと記事があります。
この記事はそこからのつまみ食いなので、気になったところがあればぜひ覗いてみてください。
Discussion