衝突判定の基礎 — 円と矩形、そしてインパルスベースの衝突応答
このトピックを深掘りする
ブラウザゲーム開発
ゲームループ・衝突判定・経路探索・迷路生成など、ブラウザゲーム開発の実装パターン集
全7本中 5 本目。
次に読む記事
TypeScriptで型安全なゲームループを設計する — Snake・Tetrisの状態管理
Snake、Tetris、Breakoutの実装を通じて、TypeScriptの型システムを活用したゲーム状態管理とフレームレート制御を解説する。
ピンボールの物理シミュレーション — フリッパー・バンパー・重力をCanvas APIで実装する
ピンボールの物理演算をCanvas APIで実装する。フリッパーの回転運動、バンパーの反発、重力とボールの挙動を解説。
ブラウザゲーム開発者の作業環境 2026 — 203個のコンポーネントを支えるツールチェーン
203個のブラウザゲーム・インタラクションを開発する環境を紹介。Claude Code中心のワークフロー、bun + Biome + Vite、Git Worktreeによる並列開発。
シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →
physics.tsの全体像
sakimytocomの物理エンジンは src/utils/physics.ts に約170行。TypeScriptで書かれたBody型、力の適用、積分、衝突判定、衝突応答、境界制約の6つの関数で構成される。
type Body = {
id: string
pos: Vec2
vel: Vec2
acc: Vec2
mass: number
radius: number
restitution: number
isStatic: boolean
}isStatic フラグがあるのは壁やパドルなど「動かない物体」を同じBody型で扱うため。
円同士の衝突判定
2つの円が重なっているかは、中心間の距離と半径の和を比較するだけ:
function detectCollision(a: Body, b: Body) {
const dx = b.pos.x - a.pos.x
const dy = b.pos.y - a.pos.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minDist = a.radius + b.radius
if (dist >= minDist || dist === 0) return null
return {
normal: { x: dx / dist, y: dy / dist },
depth: minDist - dist,
}
}dist === 0 のガードは、完全に重なった場合のゼロ除算を防ぐ。実際のBreakoutでボールがパドルに埋まるバグを経験して追加した。
インパルスベースの衝突応答
衝突が検出されたら2つの処理を行う: 位置の分離と速度の変更。
位置の分離
質量比に応じてめり込みを解消する:
const totalMass = a.mass + b.mass
if (!a.isStatic && !b.isStatic) {
const ratio = b.mass / totalMass
a.pos.x -= normal.x * depth * ratio
a.pos.y -= normal.y * depth * ratio
b.pos.x += normal.x * depth * (1 - ratio)
b.pos.y += normal.y * depth * (1 - ratio)
} else if (!a.isStatic) {
a.pos.x -= normal.x * depth
a.pos.y -= normal.y * depth
}質量が大きい物体ほど動きにくい。直感に合う。
速度の変更(インパルス)
運動量保存則と反発係数から、衝突後の速度を計算する:
const relVelX = b.vel.x - a.vel.x
const relVelY = b.vel.y - a.vel.y
const relVelDotNormal = relVelX * normal.x + relVelY * normal.y
// すでに離れていれば何もしない
if (relVelDotNormal > 0) return
const restitution = Math.min(a.restitution, b.restitution)
const inverseMassSum = (a.isStatic ? 0 : 1 / a.mass) + (b.isStatic ? 0 : 1 / b.mass)
if (inverseMassSum === 0) return
const impulse = (-(1 + restitution) * relVelDotNormal) / inverseMassSum
if (!a.isStatic) {
a.vel.x -= (impulse / a.mass) * normal.x
a.vel.y -= (impulse / a.mass) * normal.y
}
if (!b.isStatic) {
b.vel.x += (impulse / b.mass) * normal.x
b.vel.y += (impulse / b.mass) * normal.y
}restitution = 1 なら完全弾性衝突(ElasticCollision)、restitution = 0 なら完全非弾性衝突。Pongのボールは0.95くらいに設定している。
Breakoutでの矩形判定
ブロックとボールの衝突はAABB(軸平行バウンディングボックス)と円の判定になる:
function circleRectCollision(
circle: { x: number; y: number; radius: number },
rect: { x: number; y: number; width: number; height: number },
) {
const closestX = Math.max(rect.x, Math.min(circle.x, rect.x + rect.width))
const closestY = Math.max(rect.y, Math.min(circle.y, rect.y + rect.height))
const dx = circle.x - closestX
const dy = circle.y - closestY
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist >= circle.radius) return null
return {
normal: dist > 0
? { x: dx / dist, y: dy / dist }
: { x: 0, y: -1 },
depth: circle.radius - dist,
}
}矩形上の最近点を求めてから円との距離を計算する。この手法はどんなアスペクト比の矩形にも対応できる。
積分法の選択
physics.ts ではシンプルなシンプレクティックオイラー法を採用:
function integrate(body: Body, dt: number) {
body.vel.x += body.acc.x * dt
body.vel.y += body.acc.y * dt
body.pos.x += body.vel.x * dt
body.pos.y += body.vel.y * dt
body.acc.x = 0
body.acc.y = 0
}Velocity Verletのほうがエネルギー保存性は高いが、このサイトのユースケース(短時間のインタラクション)では差が視覚的に判別できない。コードの単純さを優先した。
usePhysics hookとの連携
Reactコンポーネント側では usePhysics hookを通じて物理エンジンを利用する:
const { bodiesRef, addBody, applyForce } = usePhysics([
{ id: 'ball', pos: { x: 200, y: 100 }, radius: 10, restitution: 0.9 },
])hookが requestAnimationFrame ループを管理し、コンポーネントは bodiesRef.current を読んで描画するだけ。物理と描画の関心を分離する設計。
まとめ:衝突判定の実装に使った道具たち
衝突判定と応答の数学的背景は、ゲーム物理学の教科書が最も体系的。道具棚に置いた書籍はどちらもこの分野の定番。