ブラウザで作るBreakoutゲーム — Canvas APIで反射角とレベルデザインを攻略する
このトピックを深掘りする
ブラウザゲーム開発
ゲームループ・衝突判定・経路探索・迷路生成など、ブラウザゲーム開発の実装パターン集
全7本中 2 本目。
次に読む記事
迷路生成アルゴリズム比較 — 再帰バックトラッキング・Kruskal・Primが作る迷路の違い
再帰バックトラッキング、Kruskal法、Prim法など複数の迷路生成アルゴリズムの特性と、生成される迷路の構造的な違いを比較する。
探索アルゴリズムを可視化する — A*・BFS・DFSが迷路を解く様子
MazeRunnerコンポーネントで実装したA*、BFS、DFSの探索過程を可視化する技法と、各アルゴリズムの比較を解説する。
衝突判定の基礎 — 円と矩形、そしてインパルスベースの衝突応答
physics.tsの実装を解説しながら、円同士・円と矩形の衝突判定アルゴリズム、反発係数によるバウンド制御、インパルスベースの衝突応答の数学的背景を掘り下げる。
シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →
なぜブロック崩しなのか
ブロック崩し(Breakout)は、ゲームプログラミングの基礎要素を凝縮した教材だ。矩形と円の衝突判定、反射ベクトルの計算、ゲームステート管理、レベルデザイン——これらすべてが200行程度のコードに収まる。BreakoutMiniコンポーネントの実装を通じて、Canvas APIでゲームを作る勘所を整理する。
ゲームループの骨格
ブロック崩しのゲームループは「入力 → 更新 → 描画」の3フェーズで回る。requestAnimationFrameで毎フレーム呼ばれるコールバック内に、物理シミュレーションと描画を詰め込む:
const onFrame = (ctx: CanvasRenderingContext2D, w: number, h: number, time: number) => {
// 1. デルタタイム計算
const delta = time - lastTime
lastTime = time
const dt = Math.min(delta, 32) / 16.67
// 2. ボール位置の更新
ball.x += ball.vx * speedMult * dt
ball.y += ball.vy * speedMult * dt
// 3. 衝突判定(壁・パドル・ブリック)
// ...
// 4. 描画
renderBricks(ctx, bricks)
renderPaddle(ctx, paddleX, paddleY)
renderBall(ctx, ball)
}Math.min(delta, 32) のクランプが重要。タブがバックグラウンドから復帰すると delta が数百msに跳ね上がり、ボールが壁を貫通する。32msでキャップすれば約2フレーム分のスキップに留まる。
パドルの反射角設計
ブロック崩しの操作感を決定づけるのが、パドルに当たったときの反射角だ。単純に vy = -vy とするだけでは、ボールが同じ軌道を往復するだけで退屈になる。
BreakoutMiniでは、パドルのどこに当たったかで反射角を変える:
// パドル上のヒット位置を -1〜1 に正規化
const hitPos = (ball.x - paddleX) / (PADDLE_WIDTH / 2)
// ヒット位置に応じて反射角を決定(-90° ± 60°)
const angle = -Math.PI / 2 + hitPos * (Math.PI / 3)
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy)
ball.vx = Math.cos(angle) * speed
ball.vy = Math.sin(angle) * speedなぜこの方式が効くのか。hitPos が 0(パドル中央)なら真上に返る。±1(端)なら左右60°に飛ぶ。プレイヤーはパドルの当て方で狙いを定められるようになり、「コントロールしている感覚」が生まれる。
この角度範囲(±60°)は意図的な制限だ。±90°まで広げると水平に近い軌道が出現し、ボールが延々と左右を往復してゲームが停滞する。逆に±30°に狭めると操作の自由度が消える。
ブリックとの衝突判定
ブリック(矩形)とボール(円)の衝突判定は、最近接点(closest point)アルゴリズムで実装する:
for (const brick of bricks) {
if (!brick.alive) continue
// 矩形上のボール最近接点を求める
const closestX = Math.max(brick.x, Math.min(ball.x, brick.x + brick.w))
const closestY = Math.max(brick.y, Math.min(ball.y, brick.y + brick.h))
// 最近接点とボール中心の距離を判定
const dx = ball.x - closestX
const dy = ball.y - closestY
if (dx * dx + dy * dy <= BALL_RADIUS * BALL_RADIUS) {
brick.alive = false
// 反射処理...
}
}Math.max と Math.min の組み合わせで、ボール中心から矩形への最近接点を算出する。この距離がボール半径以下なら衝突。Math.sqrt を省略して二乗同士で比較するのは、平方根の計算コストを避ける定番テクニック。
反射方向の決定 — 最小オーバーラップ法
衝突を検知したあと、ボールをX方向に跳ね返すかY方向に跳ね返すかを決める必要がある。BreakoutMiniでは最小オーバーラップ法を採用している:
const overlapLeft = ball.x + BALL_RADIUS - brick.x
const overlapRight = brick.x + brick.w - (ball.x - BALL_RADIUS)
const overlapTop = ball.y + BALL_RADIUS - brick.y
const overlapBottom = brick.y + brick.h - (ball.y - BALL_RADIUS)
const minOverlapX = Math.min(overlapLeft, overlapRight)
const minOverlapY = Math.min(overlapTop, overlapBottom)
if (minOverlapX < minOverlapY) {
ball.vx = -ball.vx // 横から当たった
} else {
ball.vy = -ball.vy // 上下から当たった
}4辺それぞれのめり込み量を計算し、最もめり込みが浅い方向に跳ね返す。この方式は「ボールが最後に通過した面」を推定しているのと同義で、直感的に正しい反射が得られる。
分離軸定理(SAT)のような本格的な手法もあるが、軸揃え矩形と円の衝突なら最小オーバーラップで十分。計算量も分岐も少ない。
レベルデザインの勘所
BreakoutMiniのブリック配置は3行8列。シンプルだが、いくつかの設計判断が埋まっている:
const ROWS = 3
const COLS = 8
const BRICK_GAP = 2
const BRICK_TOP_OFFSET = 40
const ROW_COLORS = ['#ef4444', '#f59e0b', '#10b981']行数は3が最適解。理由は3つある。まず、コンポーネントの高さ400pxに対してブリック・パドル・ボールの移動空間を確保する必要がある。次に、行ごとの色分けで「上の赤は遠い=難しい」という視覚的ヒエラルキーが成立する。最後に、24個(3×8)はクリアまでの時間が1〜2分に収まり、ミニゲームとしてのテンポが良い。
ブリック幅を動的計算しているのも重要だ:
const brickW = (canvasW - BRICK_GAP * (COLS + 1)) / COLSCanvas幅からギャップを引いて列数で割る。これでどの画面幅でもブリックが端まで均等に並ぶ。レスポンシブ対応をCSS側ではなくJavaScript側で行う、Canvas特有のパターン。
速度の動的調整
ブロック崩しが単調にならないための仕掛けとして、残りブリック数に応じてボール速度を上げる:
const aliveCount = bricks.filter(b => b.alive).length
const speedMult = 1 + (1 - aliveCount / totalBricks) * 0.5全ブリック残存時は1.0倍、半分壊すと1.25倍、残り1個で1.5倍。プレイヤーが上達するにつれ難度が上がる自然なカーブになる。最大でも1.5倍なのは、これ以上速くするとボールが壁を貫通するリスクが高まるため。
壁衝突の書き方
壁との衝突判定で ball.vx = -ball.vx ではなく ball.vx = Math.abs(ball.vx) と書いているのには理由がある:
if (ball.x - BALL_RADIUS <= 0) {
ball.x = BALL_RADIUS
ball.vx = Math.abs(ball.vx)
}
if (ball.x + BALL_RADIUS >= w) {
ball.x = w - BALL_RADIUS
ball.vx = -Math.abs(ball.vx)
}-ball.vx だと、1フレーム内で複数回壁判定が走った場合に符号が二重反転して壁にめり込む。Math.abs で符号を強制すれば、何回判定が走っても正しい方向を向く。地味だが、この手のバグはデバッグが厄介。
ゲームステート管理
BreakoutMiniには5つのステートがある: idle、playing、active、win、gameOver。状態遷移を整理する:
idle → playing(タップでゲーム開始)
playing → active(タップでボール発射)
active → playing(ボール落下、残機あり)
active → gameOver(ボール落下、残機なし)
active → win(全ブリック破壊)
win / gameOver → playing(タップでリトライ)playing と active を分離しているのがポイント。playing はボールがパドルに張り付いた状態で、プレイヤーに狙いを定める猶予を与える。この「発射前の一呼吸」が操作感に大きく影響する。
パフォーマンス上の工夫
BreakoutMiniでは、ゲーム状態をすべて useRef で管理している。useState にするとボール位置の更新ごとに再レンダリングが走り、60fpsの維持が困難になる。ReactのレンダリングサイクルとCanvas の描画サイクルを分離するのが、Reactでゲームを作る際の基本設計。
useState はオーバーレイUI(「Game Over」「You Win!」の表示)にだけ使い、物理状態は useRef に閉じ込める。
まとめ:CanvasでBreakoutゲームを作る道具たち
ブロック崩しの実装は、ゲームプログラミングと物理シミュレーションの入門として最適。道具棚に置いた書籍では、反射ベクトル計算の数学的背景や、より複雑な衝突応答(回転・摩擦)まで体系的に学べる。