ピンボールの物理シミュレーション — フリッパー・バンパー・重力をCanvas APIで実装する
このトピックを深掘りする
ブラウザゲーム開発
ゲームループ・衝突判定・経路探索・迷路生成など、ブラウザゲーム開発の実装パターン集
全7本中 7 本目。
次に読む記事
ブラウザゲーム開発者の作業環境 2026 — 203個のコンポーネントを支えるツールチェーン
203個のブラウザゲーム・インタラクションを開発する環境を紹介。Claude Code中心のワークフロー、bun + Biome + Vite、Git Worktreeによる並列開発。
ブラウザで作るBreakoutゲーム — Canvas APIで反射角とレベルデザインを攻略する
Canvas APIとrequestAnimationFrameでBreakoutゲーム(ブロック崩し)を実装する。パドルの反射角計算、ブロック配置のレベルデザイン、スコア管理の勘所をコンポーネント実装を通じて解説。
迷路生成アルゴリズム比較 — 再帰バックトラッキング・Kruskal・Primが作る迷路の違い
再帰バックトラッキング、Kruskal法、Prim法など複数の迷路生成アルゴリズムの特性と、生成される迷路の構造的な違いを比較する。
シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →
ピンボールを構成する物理要素
ピンボールは単純に見えて、複数の異なる物理モデルが共存するゲームだ。ボールには重力と減衰が働き、バンパーは定速反発、フリッパーは回転体としてボールを弾く。壁は境界制約、ドレインはゲームオーバー判定。これらを320x480の論理ピクセル空間にまとめた。
const W = 320
const H = 480
const BALL_R = 6
const GRAVITY = 0.15
const DAMPING = 0.98
const BUMPER_BOUNCE = 8
const FLIPPER_LEN = 50
const FLIPPER_W = 8定数を論理ピクセルで定義し、Canvas API描画時に ResizeObserver で取得したスケール値を掛ける。この設計にすると物理演算はデバイス解像度に依存しない。
ボールの運動: 重力・減衰・積分
ボールの更新は毎フレーム4行:
ball.vy += GRAVITY * dt
ball.vx *= DAMPING
ball.vy *= DAMPING
ball.x += ball.vx * dt
ball.y += ball.vy * dtphysics.ts のシンプレクティックオイラー法と同じ構造だが、Pinballでは速度に直接減衰 (DAMPING = 0.98) を掛けている。空気抵抗を明示的にモデル化するより、毎フレーム2%ずつ速度を落とすほうが計算が安く、結果も自然に見える。
重力定数が 0.15 と小さいのは、dtがミリ秒ベースではなくフレーム比率 (rawDt / 16.67) で正規化されているため。60fpsなら dt approx 1.0 になり、GRAVITY * dt がそのまま1フレームあたりの加速度になる。
const rawDt = time - lastTimeRef.current
const dt = Math.min(rawDt, 32) / 16.67Math.min(rawDt, 32) はタブが非アクティブだったときの巨大dtを32msにクランプする。これがないとタブ復帰時にボールが壁を突き抜ける。
バンパーの衝突判定と反発
バンパーはすべて円なので、円同士の衝突判定で処理する:
for (const bumper of bumpers) {
const dx = ball.x - bumper.x
const dy = ball.y - bumper.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minDist = BALL_R + bumper.r
if (dist < minDist && dist > 0) {
const nx = dx / dist
const ny = dy / dist
// 位置を分離
ball.x = bumper.x + nx * minDist
ball.y = bumper.y + ny * minDist
// 速度を法線方向に固定値で設定
ball.vx = nx * BUMPER_BOUNCE
ball.vy = ny * BUMPER_BOUNCE
}
}ここで physics.ts のインパルスベース応答を使わなかった理由がある。バンパーは「ぶつかると一定速度で弾き返す」装置であり、入射速度に関係なく BUMPER_BOUNCE = 8 の速度を与える。現実のピンボール台のバンパーにはソレノイドが入っていて、接触を検知すると電磁力で弾くので、反発係数モデルより定速反発モデルのほうが挙動が正確になる。
位置の分離は ball.x = bumper.x + nx * minDist で、ボールをバンパー表面にぴったり配置する。インパルスベースの分離(質量比で按分)ではなく、バンパーが静止物体なので100%ボール側を動かす。
フリッパーの回転とボールの打ち出し
フリッパーは回転する線分としてモデル化する。ピボット(支点)から FLIPPER_LEN の長さで伸びる太い線分だ。
フリッパーの角度制御
const FLIPPER_REST_ANGLE = 0.45 // 下がった状態(ラジアン)
const FLIPPER_UP_ANGLE = -0.55 // 上がった状態
const FLIPPER_SPEED = 0.25
// 毎フレームの角度更新
for (const f of [flipL, flipR]) {
const diff = f.targetAngle - f.angle
if (Math.abs(diff) > 0.01) {
f.angle += diff * FLIPPER_SPEED * dt * 3
} else {
f.angle = f.targetAngle
}
}targetAngle はキー入力で即座に切り替わるが、実際の angle は差分の一定割合ずつ追従する(指数的減衰)。これにより、キーを押した瞬間に「バシッ」と跳ね上がる動きが再現できる。FLIPPER_SPEED * dt * 3 の係数3は体感で調整した値で、速すぎると物理的な「溜め」がなくなり、遅すぎるとボールを弾けない。
フリッパーとボールの衝突
フリッパーは幅を持った線分なので、ボールとの衝突判定は「点と線分の最近点」を求める:
const cosA = Math.cos(f.angle)
const sinA = Math.sin(f.angle)
const dir = f.side === 'left' ? 1 : -1
const endX = f.pivotX + cosA * FLIPPER_LEN * dir
const endY = f.pivotY + sinA * FLIPPER_LEN * dir
// ボールを線分に射影
const segDx = endX - f.pivotX
const segDy = endY - f.pivotY
const segLen = Math.sqrt(segDx * segDx + segDy * segDy)
const t = Math.max(0, Math.min(1,
((ball.x - f.pivotX) * segDx + (ball.y - f.pivotY) * segDy)
/ (segLen * segLen)
))
const closestX = f.pivotX + t * segDx
const closestY = f.pivotY + t * segDyt は線分上の最近点のパラメータで、0がピボット、1が先端。Math.max(0, Math.min(1, ...)) でクランプすることで、線分の延長線上には衝突しない。
最近点からボール中心までの距離が BALL_R + FLIPPER_W / 2 未満なら衝突:
if (cDist < hitDist && cDist > 0) {
const nx = cdx / cDist
const ny = cdy / cDist
ball.x = closestX + nx * hitDist
ball.y = closestY + ny * hitDist
// フリッパーが回転中なら追加ブースト
const isFlipping = Math.abs(f.targetAngle - f.angle) > 0.1
const flipBoost = isFlipping ? 6 : 0
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy)
const bounceSpeed = Math.max(speed * 0.7, 3) + flipBoost
ball.vx = nx * bounceSpeed
ball.vy = ny * bounceSpeed
}ここが実装上の最も重要な判断だ。フリッパーの角速度からボールに与えるエネルギーを正確に計算するには、衝突点での接線速度を求めてインパルスを計算する必要がある。しかし、その正確さはゲーム体験に寄与しない。代わりに isFlipping(回転中かどうか)を判定して、回転中なら flipBoost = 6 を加算する離散的なモデルにした。プレイヤーが「タイミングよく打てば強く飛ぶ」と感じられれば十分だ。
ガイドウォールと反射
ピンボール台の下部には、ボールをフリッパーに誘導する斜めのガイドウォールがある。これも線分とボールの衝突判定:
const guideCollision = (
gx1: number, gy1: number,
gx2: number, gy2: number,
) => {
// 線分への射影(フリッパーと同じアルゴリズム)
// ...
if (gDist < BALL_R + 3 && gDist > 0) {
const gnx = gcDx / gDist
const gny = gcDy / gDist
ball.x = gcx + gnx * (BALL_R + 3)
ball.y = gcy + gny * (BALL_R + 3)
// 速度の反射
const dot = ball.vx * gnx + ball.vy * gny
ball.vx -= 2 * dot * gnx * 0.6
ball.vy -= 2 * dot * gny * 0.6
}
}フリッパーとの違いは速度の計算方法。ガイドウォールは静止した壁なので、法線方向の速度成分を反転する反射モデル(v' = v - 2(v cdot n)n)を使う。0.6 はエネルギー損失係数で、完全反射(1.0)だとボールが永遠に跳ね続けるのを防ぐ。
ランチャーの充填と発射
ボールの初期発射にはチャージ式のランチャーを実装した:
// 充填(スペースキー押下中)
if (chargingRef.current && currentPhase === 'launching') {
chargeRef.current = Math.min(
chargeRef.current + LAUNCHER_CHARGE_RATE * dt,
LAUNCHER_MAX,
)
}
// 発射(スペースキー解放時)
const power = Math.max(LAUNCHER_MIN, chargeRef.current)
ballRef.current.vy = -power
ballRef.current.vx = -0.5LAUNCHER_MIN = 6 は最低発射速度。一瞬だけキーを押した場合でもボールがバンパーに届くための保証。vx = -0.5 の微小な横速度は、ランチャーレーンからフィールドへ自然に出るための工夫だ。
壁の境界制約
壁の衝突は単純なAABB制約:
// 左壁
if (ball.x - BALL_R < WALL_THICK) {
ball.x = WALL_THICK + BALL_R
ball.vx = Math.abs(ball.vx) * 0.8
}
// 右壁
if (ball.x + BALL_R > cw - WALL_THICK) {
ball.x = cw - WALL_THICK - BALL_R
ball.vx = -Math.abs(ball.vx) * 0.8
}0.8 の減衰は壁材質の反発係数に相当する。physics.ts の constrainToBounds は body.restitution を使うが、ピンボールでは壁ごとに異なる値を使いたかったのでインライン実装にした。
底面(ドレイン)には壁がなく、ball.y > ch + BALL_R * 2 でボールが画面外に出たことを検出してボールを失う処理につなげる。
Canvas描画のスケーリング
物理演算は320x480の論理空間で行い、描画時にスケーリングする:
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width } = entry.contentRect
const scale = width / W
scaleRef.current = scale
const h = H * scale
resizeCanvas(canvas, width, h)
}
})
// 描画ループ
ctx.save()
ctx.scale(dpr * scale, dpr * scale)
// ... 320x480座標系で描画
ctx.restore()resizeCanvas は canvas.width = width * dpr を設定するユーティリティ。ctx.scale(dpr * scale, ...) でデバイスピクセル比とCSSスケールの両方を一度に適用する。物理座標をそのまま描画関数に渡せるので、座標変換のバグが入りにくい。
physics.tsを使わなかった理由
sakimytocomには汎用の physics.ts があるが、Pinballではあえて使わなかった。理由は3つ:
- バンパーの反発モデルが違う:
physics.tsはインパルスベース(入射速度に依存)だが、バンパーは定速反発が必要 - フリッパーは回転体:
Body型に回転角度や角速度がないため、フリッパーの線分衝突を表現できない - 衝突応答のカスタマイズ: ガイドウォール(反射 + 減衰)、フリッパー(回転ブースト)、バンパー(定速反発)でそれぞれ異なる応答が必要
汎用エンジンの制約を無理に拡張するより、ゲーム固有の物理をインラインで書いたほうが見通しがよい。The Nature of Codeでも強調されているが、物理シミュレーションは「正確さ」ではなく「説得力」を目指すべきだ。プレイヤーが気持ちよく感じる挙動を、最小限のコードで実現することが目標になる。
まとめ:ピンボール物理で使った技術と道具
ゲーム物理の設計判断を支えてくれた3冊。ゲームプログラミングC++は衝突応答の実装パターン、数学・物理学入門はベクトル演算の基礎固め、The Nature of Codeは「シミュレーションの説得力」という考え方を教えてくれた。