blog
コンテナを足したら敵が置物になった
倉庫を漁るシューティングを作っていて、部屋の中に物入れ(コンテナ)を置くようにした。プレイヤーはその陰に隠れて撃ち合える。せっかく遮蔽物を足すなら、当たり判定もちゃんと持たせようと思った。
移動はコンテナをすり抜けられない。射線もコンテナに遮られる。弾もコンテナに当たって消える。この3系統は自分でも念入りに確認した。どれも同じ物入れの一覧を見ていて、当時のJSDocにも「3系統は全部containsPoint1箇所を通る」と書いてあった。1箇所直せば3つとも直る設計のつもりだった。
(このJSDoc自体、すり抜け対策にcrossesContainerを足した時点で不正確になっていた。実際は壁だけがcontainsPoint1箇所で、コンテナはcontainsPointとcrossesContainerの組み合わせ、射線はそのどちらとも違う掃引を使う。同じ回のレビューで事実どおりに書き直している。)
レビューで実機のログを見ていたら、動かない敵がいた。近づいても撃ってこない。近づきすぎても動かない。どの方向にも歩けていない。
洗ったつもりの3系統は、たしかに正しかった
最初はコンテナの当たり判定そのものを疑った。containsPoint を見直しても、移動・射線・弾のどの系統も部屋の実装どおりに動いている。バグはそこには無かった。
疑うところを間違えていた。動けない敵は、そもそも歩けない場所に置かれていた。
spawnFoes は部屋の中心からジッタを振って敵の初期位置を決める。このジッタは、コンテナを導入する前の「部屋の矩形の中ならどこでもいい」という前提のまま書かれていた。コンテナが部屋の中に浮かぶようになった後も、そこは変わっていなかった。200シードで実測すると、湧いた敵2200体のうち350体、15.9%がコンテナの円の中に重なって湧いていた。重なった敵は8方向のどこにも動けない置物になる。
読む側は直したのに、置く側を忘れていた
移動・射線・弾は、どれもコンテナの位置を読んで判定する側だ。ここは全部直した。
スポーンは逆で、コンテナの位置を無視して新しい座標を書き込む側だった。読む側の当たり判定をいくら正確にしても、書く側が古い前提のまま座標を生成していれば、その座標は端から歩けない場所になる。書く側と読む側は別の関数で、別のタイミングで動く。片方を直しても、もう片方には何も伝わらない。
直し方は、スポーン時にも歩けるかどうかを確認して、歩けなければ引き直すことにした。
let x = geom.center.x;
let z = geom.center.z;
for (let attempt = 0; attempt < BALANCE.foeSpawnJitterAttempts; attempt += 1) {
const cx = geom.center.x + (rng.next() * 2 - 1) * halfX;
const cz = geom.center.z + (rng.next() * 2 - 1) * halfZ;
if (containsPoint(world, cx, cz, BALANCE.playerRadius)) {
x = cx;
z = cz;
break;
}
}
20回引き直しても歩ける場所が見つからなければ、部屋の中心へ逃がす。部屋の中心が歩けることは別の試験で保証済みなので、最後の逃げ場として使える。黙ってコンテナの中に置いたままにはしない。
テストは全部緑のままだった
厄介なのは、この不具合がある間もテストは落ちていなかったことだ。移動・射線・弾の判定を単体で試験すれば、どれも正しく動く。「敵がコンテナの中に湧かない」ことを確かめる試験そのものが存在しなかったので、緑は保たれていた。実機を触って、動かない敵を目で見て、初めて気づいた種類のバグだった。
脱出率にも表れていた。コンテナ導入直後の測定では脱出率が91.7%まで上がっていて、想定していた30〜90%の帯を超えていた。原因の一部はこの不具合で、動けない敵は攻めてこない分プレイヤーに有利に働く。スポーンを直したら85.0%に戻り、帯の中に収まった。この85.0%はこの時点の値で、その後に保管庫へ護衛を置いて測り直しているので、いまは65.0%に落ち着いている。
三重に見ていたつもりの当たり判定が、実は「見る側」しか洗っていなかった。値の意味を変えたら、読む側だけでなく書く側、置く側も同じだけ洗う必要がある。今回は自分のプロジェクトのCLAUDE.mdにすでに書いてあった教訓のはずだったが、コンテナという新しい概念を足すときに、それが「置く側」の変更でもあることに気づけなかった。
このコンテナと当たり判定はProd Runで実際に触れる。