GAME

ブラウザ版ブロック崩しの実装を解説

Canvasに描画したパドルとボールを動かし、ブロックとの衝突、ステージ進行、キーボード・タッチ操作、BGMまで実装する流れを紹介します。

ブロック崩しは、左右に動かすパドルでボールを跳ね返し、画面上のブロックをすべて壊すゲームです。ルールはシンプルですが、リアルタイムに動く物体、衝突判定、入力、ゲームオーバーなど、ブラウザゲームの基本が詰まっています。

今回のゲームは、ページの共通部分をPHPで読み込み、ゲーム本体をHTML、CSS、JavaScriptで実装しています。画面はHTMLのCanvasに描画し、ゲームの状態はJavaScriptのオブジェクトと変数で管理します。外部ライブラリを使わないため、ゲームの動きとコードの対応を確認しやすい構成です。

Canvasをゲーム画面にする

CanvasはJavaScriptから図形や画像を描画できる領域です。今回のCanvasは幅420、高さ560で、ボールやパドルはこの座標系の中を動きます。

Canvasと描画コンテキストHTML / JAVASCRIPT
<canvas id="gameCanvas" width="420" height="560"></canvas>

const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;

getContext('2d')で2D描画用のコンテキストを取得します。以降はctx.fillRectで長方形、ctx.arcで円を描きます。画面を毎フレーム描き直すため、描画前にclearRectで前のフレームを消します。

ゲームの状態をオブジェクトで管理する

パドルやボールの位置を別々の変数に分けることもできますが、関連する値をオブジェクトにまとめると扱いやすくなります。ボールは位置のほかに半径、速度、発射前かどうかを持っています。

パドルとボールの初期値JAVASCRIPT
const PADDLE_Y = 520;

let paddle = {
    x: 158, y: PADDLE_Y, w: 104, h: 12, speed: 360
};
let ball = {
    x: 210, y: PADDLE_Y - 9, r: 7,
    vx: 0, vy: 0, stuck: true
};

stuck: trueは、ゲーム開始直後のボールをパドルに載せておくための状態です。発射前はボールの位置をパドルの中央に合わせ、SpaceキーやCanvasのタップでstuckfalseに変えます。

ボールを発射するJAVASCRIPT
function launch() {
    if (!running || paused || !ball.stuck) return;

    ball.stuck = false;
    const speed = 235;
    ball.vx = (Math.random() > 0.5 ? 1 : -1) * speed * 0.55;
    ball.vy = -speed;
}

Canvasの上方向を負の値としているため、vy = -speedでボールは上へ進みます。左右の速度にはランダムな符号を付け、毎回同じ方向に出ないようにしています。

ブロックを配列から作る

ブロックを一つずつHTMLに書くのではなく、行と列のループで作成します。各ブロックは位置、大きさ、色、耐久値を持つオブジェクトです。耐久値が2以上のブロックは、一度当たっても消えず、表示の濃さと数字で残りの耐久を示します。

ブロックを生成するJAVASCRIPT
function makeBricks() {
    bricks = [];
    const rows = Math.min(4 + stage, 8);
    const cols = 8, gap = 5, bw = 46, bh = 18;
    const startX = (W - (cols * bw + (cols - 1) * gap)) / 2;

    for (let row = 0; row < rows; row++) {
        for (let col = 0; col < cols; col++) {
            bricks.push({
                x: startX + col * (bw + gap),
                y: 58 + row * (bh + gap),
                w: bw, h: bh, alive: true, hp: 1, maxHp: 1
            });
        }
    }
}

startXを計算しているのは、ブロック全体を画面中央に揃えるためです。実際のゲームではbrickExistsで行と列ごとの配置を変え、ステージごとに異なる模様を作っています。ステージが進むほど行数と耐久値も増えます。

毎フレームのゲームループ

リアルタイムゲームでは、入力を受け取り、状態を更新し、画面を描画する処理を繰り返します。ブラウザではrequestAnimationFrameを使うと、画面の更新に合わせて処理を呼び出せます。

ゲームループJAVASCRIPT
let last = 0;

function loop(now) {
    if (!running) return;

    // ミリ秒を秒へ変換し、極端に大きな移動を防ぐ
    const dt = Math.min((now - last) / 1000, 0.025);
    last = now;

    if (!paused) update(dt);
    draw();
    requestAnimationFrame(loop);
}

dtは前回のフレームから経過した秒数です。ボールの移動量を速度 × dtで計算するため、更新速度が少し変わっても動きが極端に速くなりません。タブ切り替えなどで大きな値になった場合は、0.025秒を上限にしています。

位置を速度から更新するJAVASCRIPT
function update(dt) {
    if (keys.left) paddle.x -= paddle.speed * dt;
    if (keys.right) paddle.x += paddle.speed * dt;
    paddle.x = Math.max(0, Math.min(W - paddle.w, paddle.x));

    if (ball.stuck) {
        ball.x = paddle.x + paddle.w / 2;
        ball.y = paddle.y - ball.r - 2;
        return;
    }
    ball.x += ball.vx * dt;
    ball.y += ball.vy * dt;
}

パドルの座標は、画面の左端より小さくならず、右端からはみ出さないように制限します。この処理がないと、パドルの一部が画面外へ消えてしまいます。

壁とパドルでボールを跳ね返す

左右の壁に当たったら横方向の速度vx、上の壁では縦方向の速度vyの符号を反転します。位置も壁の内側へ戻しておくのがポイントです。速度だけを反転すると、ボールが壁の中に入り込んだフレームで何度も衝突することがあります。

画面端の衝突JAVASCRIPT
if (ball.x - ball.r < 0) {
    ball.x = ball.r;
    ball.vx = Math.abs(ball.vx);
} else if (ball.x + ball.r > W) {
    ball.x = W - ball.r;
    ball.vx = -Math.abs(ball.vx);
}
if (ball.y - ball.r < 0) {
    ball.y = ball.r;
    ball.vy = Math.abs(ball.vy);
}

パドルとの衝突は、ボールが下向きに進んでいるときだけ調べます。パドルの中央からどれだけ離れた位置に当たったかをhitとして、端に当たるほど横方向へ強く跳ね返します。

当たった位置で反射角を変えるJAVASCRIPT
if (ball.vy > 0 && circleRect(ball, paddle)) {
    ball.y = paddle.y - ball.r;
    const hit = (ball.x - (paddle.x + paddle.w / 2))
        / (paddle.w / 2);
    const speed = Math.min(480,
        Math.hypot(ball.vx, ball.vy) * 1.01);

    ball.vx = speed * hit * 0.9;
    ball.vy = -Math.sqrt(
        Math.max(speed * speed - ball.vx * ball.vx, 10000)
    );
}

Math.hypotで衝突前の速さを求め、横方向の速度を変えたあとも速さが大きく変わらないように縦方向を計算しています。横方向へ寄りすぎないように、速さにも上限を設定しています。

円と長方形の衝突判定

ボールは円、パドルとブロックは長方形として扱います。円と長方形の衝突は、長方形の中でボールの中心に最も近い点を求め、その点と中心の距離が半径より小さいかで判定できます。

円と長方形を判定するJAVASCRIPT
function circleRect(circle, rect) {
    const nearestX = Math.max(
        rect.x, Math.min(circle.x, rect.x + rect.w)
    );
    const nearestY = Math.max(
        rect.y, Math.min(circle.y, rect.y + rect.h)
    );
    const dx = circle.x - nearestX;
    const dy = circle.y - nearestY;

    return dx * dx + dy * dy < circle.r * circle.r;
}

平方根を使わず、距離の二乗同士を比較しています。ブロックに当たったら耐久値を1減らし、0になったときだけalivefalseにします。

ブロックを壊して反射するJAVASCRIPT
for (const brick of bricks) {
    if (!brick.alive || !circleRect(ball, brick)) continue;

    brick.hp--;
    brick.alive = brick.hp > 0;
    score += brick.alive ? 5 : 10;

    const cameFromSide = oldX + ball.r <= brick.x
        || oldX - ball.r >= brick.x + brick.w;
    if (cameFromSide) ball.vx *= -1;
    else ball.vy *= -1;
    break;
}

現在のボール位置だけを見ると、ブロックの上下左右どこから来たか分かりません。そこで移動前のoldXと比較し、横から入ったと判断できる場合だけvxを反転し、それ以外はvyを反転しています。処理後のbreakは、1フレームに複数回同じボールを処理しないためのものです。

描画処理で状態を画面へ反映する

更新処理は数値を変更するだけにし、見た目はdrawへ集めています。毎回背景、ブロック、パドル、ボールの順に描くと、後から描いたものが手前に表示されます。

ボールとパドルを描画するJAVASCRIPT
function draw() {
    ctx.clearRect(0, 0, W, H);
    ctx.fillStyle = '#1e2330';
    ctx.fillRect(0, 0, W, H);

    ctx.fillStyle = '#5ee7c9';
    ctx.beginPath();
    ctx.roundRect(paddle.x, paddle.y, paddle.w, paddle.h, 6);
    ctx.fill();

    ctx.fillStyle = '#ffffff';
    ctx.beginPath();
    ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
    ctx.fill();
}

実際のページではブロックに色、影、耐久値に応じた透明度を設定しています。ボールがパドルに載っている間はSPACE / TAP TO LAUNCHを表示し、初めて遊ぶ人にも次の操作が分かるようにしています。

ステージ、残機、ゲームオーバー

すべてのブロックが消えたかは、someで生きているブロックが残っているかを調べます。残っていなければ次のステージを作り、ステージ10を終えるとクリアです。ボールが画面下へ落ちた場合は残機を1減らし、残機が残っていればパドルとボールを初期化します。

ステージクリアと残機JAVASCRIPT
if (!bricks.some(brick => brick.alive)) {
    score += 500 * stage;
    stage++;
    makeBricks();
    resetPaddleBall();
}

if (ball.y - ball.r > H) {
    lives--;
    if (lives <= 0) finish(false);
    else resetPaddleBall();
}

ステージごとにブロックの行数、パドルの幅、ボールの速度を変えています。ステージが進むとパドルを少し狭くし、ボールを速くすることで、ブロックを増やすだけでなく操作の難しさも上げています。

キーボード、マウス、タッチに対応する

入力方法が異なっても、最終的にはkeys.leftkeys.rightを変更する形に統一しています。キーボードのキーを押している間だけフラグをtrueにし、離したらfalseに戻します。

キー入力を状態へ変換するJAVASCRIPT
document.addEventListener('keydown', event => {
    if (event.code === 'ArrowLeft' || event.code === 'KeyA') {
        keys.left = true;
    }
    if (event.code === 'ArrowRight' || event.code === 'KeyD') {
        keys.right = true;
    }
});

document.addEventListener('keyup', event => {
    if (event.code === 'ArrowLeft' || event.code === 'KeyA') {
        keys.left = false;
    }
    if (event.code === 'ArrowRight' || event.code === 'KeyD') {
        keys.right = false;
    }
});

マウスやタッチでは、Canvasの表示サイズが内部サイズと異なる場合があります。そのため、画面上の座標をCanvasの座標へ変換してからパドルの中心を合わせます。

ポインター位置をCanvas座標へ変換するJAVASCRIPT
function pointerMove(event) {
    const rect = canvas.getBoundingClientRect();
    const clientX = event.touches
        ? event.touches[0].clientX
        : event.clientX;
    const x = (clientX - rect.left) * W / rect.width;

    paddle.x = Math.max(
        0, Math.min(W - paddle.w, x - paddle.w / 2)
    );
}

Canvasがスマートフォンの画面幅に合わせて縮小されても、rect.widthとの比率で計算するため、指の位置とパドルの位置がずれません。Canvasのタップはボールの発射にも使い、操作を説明文だけに頼らないようにしています。

一時停止とオーバーレイ

一時停止中にゲームループ自体を止める方法もありますが、今回はループを続けて描画し、updateだけを止めています。これにより、画面のオーバーレイを表示したまま状態を保てます。

一時停止を切り替えるJAVASCRIPT
function pause() {
    if (!running) return;
    paused = !paused;

    if (paused) {
        overlayMsg.textContent = 'PAUSED';
        overlayBtn.textContent = 'つづける';
        overlay.classList.add('show');
    } else {
        overlay.classList.remove('show');
        last = performance.now();
    }
}

再開時にlastを現在時刻へ戻すのが重要です。戻さないと、一時停止していた時間が次のdtに含まれ、再開直後にボールが大きく移動してしまいます。

Web Audio APIでBGMと効果音を鳴らす

効果音は、ブロックや壁に当たったときに短い音を作るtone関数へまとめています。オシレーターで波形と周波数を指定し、ゲインを短時間で下げることで、外部の音声ファイルがなくても音を鳴らせます。

短い効果音を作るJAVASCRIPT
function tone(freq, duration, type = 'square', volume = 0.06) {
    if (!audioCtx || sfxMuted) return;

    const oscillator = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    const now = audioCtx.currentTime;
    oscillator.type = type;
    oscillator.frequency.value = freq;
    gain.gain.setValueAtTime(volume, now);
    gain.gain.exponentialRampToValueAtTime(.0001, now + duration);
    oscillator.connect(gain);
    gain.connect(audioCtx.destination);
    oscillator.start();
    oscillator.stop(now + duration);
}

ブラウザでは、利用者の操作なしに音声を再生できないことがあります。そこでスタートボタンのクリック時にAudioContextを作成し、停止状態ならresumeします。BGMは一定間隔で音階を切り替えるタイマー、効果音は衝突イベントから呼び出す仕組みです。

BGMと効果音にはそれぞれミュートボタンを用意しています。音を追加するだけでなく、利用者が個別に止められるようにすることも大切です。

実装時に確認したいこと

Canvasゲームは、動いているように見えても境界条件で不具合が起きやすい分野です。次のようなケースを実際に操作して確認します。

確認項目CHECKLIST
・パドルが左右の端からはみ出さない
・ボールが壁の中に入り込まずに反射する
・ブロックを壊した直後に同じブロックを二重処理しない
・ボールを落とすと残機が減り、3回でゲームオーバーになる
・全ブロックを壊すと次のステージへ進む
・Space、マウス、タッチでボールを発射できる
・Pキーで停止し、再開直後にボールが飛びすぎない
・音声の開始、BGMミュート、効果音ミュートが動作する

特に衝突判定は、ボールの速度が速くなると1フレームでブロックを通り抜ける問題も起こります。今回の実装では、まず移動量を抑え、衝突したブロックを1回ずつ処理するシンプルな方法を採用しています。より高速なゲームにするなら、移動前後の線分とブロックの交差を調べる方法も候補になります。

まとめ

ブロック崩しの基本は、次のゲームサイクルに分けると整理しやすくなります。

ゲームの基本サイクルJAVASCRIPT
入力を受け取る
    ↓
dtを使ってパドルとボールを更新する
    ↓
壁・パドル・ブロックとの衝突を判定する
    ↓
スコアや残機、ステージを更新する
    ↓
Canvasへ背景、ブロック、パドル、ボールを描画する
    ↓
requestAnimationFrameで次のフレームへ

この構造を理解すると、ブロック崩しだけでなく、ピンボールやシューティング、アクションゲームにも応用できます。まずはブロックの数を変える、アイテムを追加する、ボールを複数にするなど、小さな改造から試すとゲームループと状態管理への理解が深まります。

実際に遊びながらコードを確認したい場合は、ブロック崩しのゲームページをご覧ください。