キャラクターが壁をすり抜ける:速さと判定の順番を疑う
遊びをつくる執筆にClaudeを使用しています。

横スクロールのゲームで、キャラクターが壁に向かって走る場面を考えてみます。ふだんの歩きでは壁にきちんと止まります。ところがダッシュ中や落下の終わり際だけ、壁の向こう側に抜けてしまうことがあります。壁の当たり判定を書き忘れたなら、歩いていても抜けるはずです。「速いときだけ」「たまに」抜けるなら、疑う場所はほぼ決まります。 この記事では、症状から原因までを確認する順番を、短いコードで示します。確認するのは次の4点です。 実行結果の数字は載せません。コードを動かして、自分の手元で答えを出す形にしています。
まず「速いときだけ」かを切り分ける
最初にやるのは、コードを読むことではなく、症状の出る条件の整理です。次の4つを一つずつ変えて、症状が増えるか減るかを見ます。
- キャラクターの速さを変える(遅くすれば消えるか、速くすれば増えるか)
- 壁の厚みを変える(厚くすれば消えるか)
- フレームレートを変える(重い画面で増えるか、ブラウザのタブを切り替えて戻した直後に出るか)
- 壁に当たる角度を変える(真正面は止まるのに、角や斜めで抜けるか)
ここで見分けたいのは、症状が「速さ・厚み・フレームレートに応じて増減するか」です。増減するなら、判定の書き忘れではなく、判定が当たりを見逃している可能性が高くなります。
もう一つの手がかりは、再現の不安定さです。同じ速さでも、開始位置が少し違うだけで止まったり抜けたりするなら、離散的な判定(1フレームごとに位置を確かめる方式)の隙間を疑います。その理由は次の節の算術で説明します。
一歩の移動量と壁の厚みを比べる
ゲームは連続的に動いているように見えて、実際は1フレームごとに位置を更新し、そのたびに重なりを確かめています。「更新前は壁の手前、更新後は壁の向こう」なら、どの時点でも重なりが検出されず、すり抜けます。この現象は「トンネリング」と呼ばれます。
横一次元で考えます。幅10のキャラクターが、厚み6の壁(位置200〜206)に向かって進むとします。キャラクターの左端をxとすると、重なる条件は x + 10 > 200 かつ x < 206 です。つまり重なるのは190より大きく206より小さい範囲で、その幅は「キャラクターの幅+壁の厚み」の16です。
一歩の移動量が16より小さければ、どこかの一歩は必ずこの範囲に入ります。16以上なら、範囲を飛び越える一歩が起こりえます。たとえば一歩が30なら、開始位置0から数えて180の次は210で、190〜206を丸ごと飛び越えます。
この算術を手元で確かめるためのコードです。HTMLファイル1つで動き、外部の部品も通信も使いません。
<!doctype html>
<meta charset="utf-8">
<p>速さ(px/秒) <input id="spd" type="number" value="1800">
fps <input id="fps" type="number" value="60">
分割数 <input id="sub" type="number" value="1">
<button id="go">発射</button></p>
<pre id="log"></pre>
<script>
const WALL_X = 200, WALL_W = 6, SIZE = 10;
const overlap = x => x + SIZE > WALL_X && x < WALL_X + WALL_W;
const val = id => +document.getElementById(id).value;
function run(speed, fps, steps) {
let x = 0;
for (let frame = 1; frame <= 600; frame++) {
for (let i = 0; i < steps; i++) {
x += speed / fps / steps;
if (overlap(x)) return "当たった(フレーム" + frame + ")";
}
if (x > 400) return "すり抜けた";
}
return "到達せず";
}
document.getElementById("go").onclick = () => {
const s = val("spd"), f = val("fps"), n = val("sub");
document.getElementById("log").textContent +=
"速さ" + s + " fps" + f + " 分割" + n +
" 一歩=" + (s / f / n).toFixed(1) + "px → " + run(s, f, n) + "\n";
};
</script>次の順で試すと、仕組みが見えてきます。
- 初期値(速さ1800、60fps、分割1)で発射します。一歩は30pxで、上の算術どおりなら範囲を飛び越える設定です。
- 速さを600に下げて発射します。一歩は10pxなので、16より小さい設定です。
- 速さを1800に戻し、分割数を2、3と増やします。一歩が16を下回る分割数がどこかを、先に計算で予想してから確かめます。
- 速さを1500や1200など、一歩が16以上になる別の値にして、発射を繰り返します。開始位置が0で固定なので、一歩が16以上でも止まる速さと抜ける速さが混ざることがわかります。
「速いのに止まる」ことがあるのは、飛び越えが確実ではなく、着地点が範囲に入るかどうかの運に左右されるからです。再現が不安定になる原因はここにあります。
時間の刻みと処理順を点検する
一歩の移動量は「速さ×時間の刻み(dt)」で決まります。速さを変えていないのに症状が出るなら、dtが想定より大きくなっていないかを疑います。
dtを前回フレームからの経過時間で計算している場合、重い処理で1フレームが長引いたときや、ブラウザのタブを切り替えて戻した直後に、dtが大きくなることがあります。そのまま 位置 += 速さ × dt とすると、一歩が突然大きくなります。ここでの確認は次の3点です。
- dtを画面に表示するか、一定値を超えたときにログへ出して、大きなdtが入っていないか見る
- dt = Math.min(dt, 1 / 30) のように上限を設けると症状が消えるか見る
- 画面のリフレッシュレートが違う環境でも、同じ速さで動いているか見る(フレームごとに固定量を足している実装は、環境によって速さが変わります)
次に、移動と判定の処理順を点検します。症状ごとの疑い方は次のとおりです。
- 移動の前に判定している:1フレーム遅れて壁にめり込み、その状態で次の判定が走ります。「移動→判定→補正」の順にそろっているかを確認します。
- X方向とY方向を同時に動かしている:角で引っかかったり、角を抜けたりします。X方向を動かして判定・補正し、そのあとY方向を動かして判定・補正する「軸ごとの処理」に分けると、原因を切り分けやすくなります。
- めり込みを「近い側」へ押し戻している:壁の中心を越えるほど深く入ったとき、押し戻しの向きが反対側になり、すり抜けたように見えます。押し戻しの向きを、現在の位置の近さではなく、前フレームの位置(どちら側から来たか)で決めているかを確認します。
最後の例は、トンネリングの隙間は通っていないのに、見た目が同じ症状になる別原因です。ログに前フレームの位置と補正後の位置を出し、「補正で反対側に出ていないか」を一度だけ見ておく価値があります。
直し方は症状に合わせて選ぶ
原因の見当がついたら、次の対処から選びます。
- 分割(サブステップ)、仕組み:1フレームを小さな刻みに分けて、そのたびに移動と判定を行う、向く場面:速さの上限が決まっている、注意点:速さが上がると分割数も要る。判定の回数が増える
- 移動量の上限、仕組み:一歩の最大値を、最も薄い壁と自分の幅の合計未満に抑える、向く場面:落下速度などに上限を付けてよい、注意点:上限が見た目の速さに響く
- 軌跡で判定(スイープ)、仕組み:前の位置から今の位置までの範囲で壁と重なるかを確かめる、向く場面:弾など速くて小さいもの、注意点:二次元では範囲の求め方が増える
- 壁を厚くする、仕組み:重なる範囲を広げる、向く場面:見た目を変えてよい、注意点:根本の解決ではなく、速さを上げると再発する
スイープ判定の考え方は、先のコードで overlap(x) の代わりに「前の位置から今の位置までの範囲」を使うだけです。
const sweptHit = (prev, next) =>
Math.max(prev, next) + SIZE > WALL_X && Math.min(prev, next) < WALL_X + WALL_W;run の中で const prev = x; を移動の前に置き、overlap(x) を sweptHit(prev, x) に差し替えると、分割数を1のままでも、一歩が大きい設定の挙動がどう変わるかを確かめられます。ただし、この判定が検出するのは「通り道のどこかで壁と重なった」ことだけです。止める位置は別に決める必要があり、壁の手前に戻すか、速さを消すかなど、ゲームの手触りに合わせて選びます。
選ぶ目安は、まず一歩の移動量の上限を見積もることです。上限が低く収まるなら分割か移動量の上限で足りることが多く、速い弾のように上限が高いものだけスイープ判定にする、と使い分けると無理がありません。
公式作品で「判定の見え方」を確かめる
アソバースの公式作品にも、速さと判定の関係を観察しやすいものがあります。いずれも内部の実装は確認済みの範囲にないため、ここでは「設計から読み取れること」と、作品を開いて確かめる観点だけを挙げます。
小さな軌道は、発射角度と初速を決め、引力で曲がる軌道でゲートを目指す作品です。初速を自分で選べる設計なので、一歩の移動量が大きくなる条件を自分で作れます。全軌道予測(点線)と前回の軌道(細い実線)が表示され、予測を消して挑むこともできます。確かめ方の一例は次のとおりです。
- 初速を大きくした発射で、点線と実際の軌道が重なるか見る
- 前回の軌道との差が、初速によって変わるか見る
- ゲートの付近で、通過したかどうかの見た目と結果が合うか見る
ずれが見えた場合は、予測と実際で計算の刻みが違う可能性を手がかりにできます。ただし、実際にずれるかどうか、その理由が何かは、作品を開いて確かめる部分です。
紙ひこうき郵便は、風船にぶつかると紙が傷み、3回で終了する作品です。操縦は左右キー・ドラッグ・矢印ボタンで行います。当たり判定がそのまま終了条件につながる設計なので、判定と見た目が合っているかが遊びの納得感に直結すると読み取れます。確かめ方は、風船の縁をかすめたときに「当たった」と感じる位置と、実際に紙が傷む位置が一致するかを見ることです。ドラッグで素早く動かした場合と、キー操作でゆっくり動かした場合を比べると、速さによる違いも観察できます。
自作のゲームでも、同じ観点を持ち込めます。アソバースでは、投稿画面の入力から表示の確認、公開までを3段階で進められ、公開後も内容の更新ができます。速さの設定を変えた版を表示の確認で試し、不具合が残っていれば更新で直す流れを取れます。フレームレートの違う端末があれば、そこでも確認する価値があります。
次の行動
自分のゲームで、いちばん速く動くものの「1フレームあたりの移動量」を計算し、最も薄い壁の厚みとキャラクターの幅の合計と比べてください。移動量のほうが大きければ、上のコードで分割数を増やす試験をして、症状が消える分割数を探すところから始められます。dtの上限の有無と、移動・判定・補正の順番も、ログを数行足して確かめてください。

