GAME

ブラウザ版ネオンピンボールの実装を解説

Canvas上で動くボールの物理演算、フリッパーとバンパーの衝突判定、スコア計算、BGM・効果音、キーボードとタッチ操作への対応を順番に紹介します。

ネオンピンボールは、左右のフリッパーでボールを打ち返し、バンパーやターゲットに当ててスコアを伸ばすブラウザゲームです。ピンボールは、画面を更新するだけのゲームとは異なり、重力で落ちるボール、壁との反射、動くフリッパー、連続する得点イベントを同時に扱います。

今回のゲームは、ページの共通部分をPHPで読み込み、ゲーム本体をJavaScriptとCanvasで実装しています。外部のゲームエンジンは使わず、状態管理、描画、衝突判定、入力、音声をひとつずつ組み立てました。この記事では、実際のゲームページで使っている考え方を、読みやすい小さなコードに分けて説明します。

ゲーム全体を状態と描画に分ける

ゲーム中に変化する値は、ボール、スコア、残りボール数、倍率、フリッパーの状態などです。これらを変数としてまとめ、毎フレーム状態を更新したあとにCanvasへ描画します。表示用のHTMLを毎回作り直すのではなく、ゲーム画面はCanvas、スコアやボタンはHTMLという役割分担にしています。

ゲーム状態を用意するJAVASCRIPT
const ball = {
    x: 475,
    y: 610,
    vx: 0,
    vy: 0,
    radius: 9,
    ready: true
};

let score = 0;
let balls = 3;
let multiplier = 1;
let running = false;
let paused = false;
let gameOver = false;

ボールの位置をxとy、速度をvxとvyで持ちます。readyは発射前かどうかを表し、発射前はボールをスタート位置に置いたままにします。runningやpausedを分けておくと、開始前・プレイ中・一時停止中・ゲームオーバーの表示を整理できます。

Canvasの座標とレスポンシブ表示

Canvas内部の座標は520×700に固定しています。CSSで画面幅に合わせて見た目の大きさを変えても、ゲーム内の座標や物理計算は同じです。描画の基準を固定すると、バンパーやフリッパーの位置を画面幅ごとに計算し直す必要がありません。

Canvasのサイズを固定するHTML / CSS
<canvas id="pinballCanvas" class="np-canvas"
    width="520" height="700" aria-label="ネオンピンボールのゲーム画面"></canvas>

.np-canvas {
    display: block;
    width: min(520px, 100%);
    height: auto;
    aspect-ratio: 520 / 700;
}

width属性とheight属性は描画バッファの大きさ、CSSのwidthは表示サイズです。この二つを混同すると、Canvasがぼやけたり、マウス座標とゲーム座標がずれたりします。入力座標を変換するときは、表示サイズに対する内部サイズの比率を掛けます。

ポインター座標をCanvas座標へ変換するJAVASCRIPT
function canvasPoint(event) {
    const rect = canvas.getBoundingClientRect();
    return {
        x: (event.clientX - rect.left) * canvas.width / rect.width,
        y: (event.clientY - rect.top) * canvas.height / rect.height
    };
}

このゲームのフリッパーはボタン操作が中心ですが、Canvasをクリックして発射する処理や、タッチ端末での座標入力を追加するときにも同じ変換が使えます。内部座標と表示座標を分けることが、PCとスマートフォンの両対応の基礎になります。

requestAnimationFrameでゲームループを作る

ゲームループは、ブラウザの描画タイミングに合わせて呼ばれるrequestAnimationFrameで作ります。1回のループで、経過時間を求め、物理状態を更新し、その結果を描画します。処理の順序を固定することで、描画だけが先に進んだり、表示だけが古い状態になったりすることを防げます。

更新と描画を繰り返すJAVASCRIPT
let lastTime = 0;

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

    const elapsed = Math.min((now - lastTime) / 1000, 0.033);
    lastTime = now;

    if (!paused && !gameOver) {
        update(elapsed);
    }
    draw();
    requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

経過時間を秒に直し、極端に大きくならないよう上限を設けています。タブをバックグラウンドにしてから戻ると、次のフレームまでに長い時間が空くことがあります。その時間をそのまま物理計算へ渡すと、ボールが壁を通り抜けることがあるためです。

実際のゲームでは、1フレームの移動距離が大きくなりすぎないよう、更新を複数回に分けるサブステップも使えます。

物理更新を小さく分けるJAVASCRIPT
function updateFrame(dt) {
    const step = 1 / 120;
    let remaining = dt;

    while (remaining > 0) {
        const slice = Math.min(step, remaining);
        update(slice);
        remaining -= slice;
    }
}

更新間隔を小さくすると、ボールが薄い壁を飛び越えるトンネリングを減らせます。毎回の計算量は増えますが、ピンボールのように速いボールと狭い部品があるゲームでは、安定した衝突判定につながります。

重力と発射でボールを動かす

ボールは、毎回の更新で重力を速度へ加え、その速度を位置へ加えます。発射時だけ上向きの速度を与え、通常時は重力によって少しずつ落下させます。実装では、発射ボタンやスペースキーが押されたときにreadyを解除します。

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

    ball.ready = false;
    ball.vx = -95;
    ball.vy = -560;
    tone(440, 0.08, 'triangle');
}

function updateBall(dt) {
    const gravity = 760;
    ball.vy += gravity * dt;
    ball.x += ball.vx * dt;
    ball.y += ball.vy * dt;
}

速度の単位を「1秒あたりのCanvas座標」にそろえると、フレームレートが変わっても動きが大きく変わりません。dtを掛けずに毎フレーム一定量を足すと、60fpsと120fpsで速度が変わってしまうため注意が必要です。

速度が増えすぎないようにするJAVASCRIPT
const speed = Math.hypot(ball.vx, ball.vy);
const maxSpeed = 920;

if (speed > maxSpeed) {
    const ratio = maxSpeed / speed;
    ball.vx *= ratio;
    ball.vy *= ratio;
}

バンパーに連続して当たると、反射のたびに速度が増える可能性があります。一定以上の速度を制限することで、ボールが一瞬で盤面の外へ飛び出すことを防ぎ、遊びやすい範囲に収めています。

円と円の衝突を反射へ変換する

バンパーやポストは円として扱えます。ボールの中心と相手の中心の距離が、二つの半径の合計以下なら衝突です。衝突したときは、二つの中心を結ぶ方向を法線として、ボールの速度を反射させます。

円形オブジェクトとの衝突JAVASCRIPT
function circleHit(object) {
    const dx = ball.x - object.x;
    const dy = ball.y - object.y;
    const distance = Math.hypot(dx, dy);
    const minDistance = ball.radius + object.radius;

    if (distance >= minDistance || distance === 0) return false;

    const nx = dx / distance;
    const ny = dy / distance;
    const dot = ball.vx * nx + ball.vy * ny;

    if (dot < 0) {
        ball.vx -= 2 * dot * nx;
        ball.vy -= 2 * dot * ny;
    }

    const push = minDistance - distance;
    ball.x += nx * push;
    ball.y += ny * push;
    return true;
}

nxとnyは衝突面の法線です。速度と法線の内積が負のときは相手へ近づいているため、法線方向の速度成分を反転します。位置も少し押し戻しておくことで、次のフレームでも同じ場所に重なり続けるのを避けています。

バンパーとスコアを結びつける

盤面には、異なる色と得点を持つ三つのバンパーを配置しています。衝突したバンパーから得点を受け取り、ヒット回数が一定数に達したら倍率を上げます。描画用のデータと得点用のデータを同じオブジェクトに持たせると、追加や調整がしやすくなります。

バンパーを定義するJAVASCRIPT
const bumpers = [
    { x: 155, y: 190, radius: 37, color: '#ff6fae', points: 100 },
    { x: 325, y: 180, radius: 34, color: '#ffd35e', points: 150 },
    { x: 238, y: 300, radius: 40, color: '#7b7cff', points: 200 }
];

function hitBumper(bumper) {
    if (!circleHit({ x: bumper.x, y: bumper.y, radius: bumper.radius })) {
        return;
    }

    score += bumper.points * multiplier;
    hits++;
    if (hits % 6 === 0) multiplier = Math.min(5, multiplier + 1);
    burst(bumper.x, bumper.y, bumper.color);
    tone(220 + bumper.points, 0.1, 'square');
    updateHud();
}

バンパーへの衝突を検出したら、得点、ヒット数、倍率、パーティクル、効果音を同じ場所で更新します。得点だけを増やして音や演出を別の場所で行うより、ひとつのイベントとしてまとめたほうが、あとから「特定のバンパーだけボーナスを付ける」といった変更をしやすくなります。

線分としてフリッパーに当てる

フリッパーは動く棒なので、円形のバンパーとは異なる判定が必要です。フリッパーの両端を線分と考え、ボールの中心から線分上の最も近い点を求めます。その距離がボールの半径とフリッパーの厚みの合計以下なら、フリッパーに当たったと判断します。

線分上の最近点を求めるJAVASCRIPT
function closestPointOnSegment(px, py, ax, ay, bx, by) {
    const dx = bx - ax;
    const dy = by - ay;
    const lengthSquared = dx * dx + dy * dy;
    const t = lengthSquared === 0
        ? 0
        : Math.max(0, Math.min(1,
            ((px - ax) * dx + (py - ay) * dy) / lengthSquared));

    return { x: ax + dx * t, y: ay + dy * t };
}

function flipperHit(flipper) {
    const nearest = closestPointOnSegment(
        ball.x, ball.y, flipper.ax, flipper.ay, flipper.bx, flipper.by
    );
    const dx = ball.x - nearest.x;
    const dy = ball.y - nearest.y;
    const distance = Math.hypot(dx, dy);
    const limit = ball.radius + flipper.thickness;

    if (distance >= limit || distance === 0) return false;
    ball.x += dx / distance * (limit - distance);
    ball.y += dy / distance * (limit - distance);
    ball.vy = -Math.abs(ball.vy) - 230;
    return true;
}

線分上の位置をtで表し、0未満なら始点、1より大きければ終点に丸めます。これで、フリッパーの中央だけでなく先端にも正しく当たります。実際のゲームでは、キーを押しているか、またはタッチボタンを押しているかでフリッパーの角度を変え、持ち上がったときに上向きの速度を加えています。

フリッパーの判定は、ボールを移動させた後、盤面の外へ落とす判定より前に行います。順番を逆にすると、フリッパーに当たる直前のボールが先に「落下」と判定される場合があるためです。

壁・レール・落下を処理する

左右のレールや上部の壁に当たった場合は、該当する速度成分の符号を反転します。単純なCanvasの端ではなく、斜めのレールや排出口をゲーム内の境界として扱うことで、ピンボールらしい盤面になります。

左右の壁で反射するJAVASCRIPT
function railHit() {
    const left = 35 + ball.radius;
    const right = 485 - ball.radius;

    if (ball.x < left) {
        ball.x = left;
        ball.vx = Math.abs(ball.vx) * 0.96;
    }
    if (ball.x > right) {
        ball.x = right;
        ball.vx = -Math.abs(ball.vx) * 0.96;
    }
    if (ball.y < 28 + ball.radius) {
        ball.y = 28 + ball.radius;
        ball.vy = Math.abs(ball.vy) * 0.96;
    }
}

function checkOut() {
    if (ball.y > 735) loseBall();
}

反射後の速度に0.96を掛けて、壁に当たるたびにわずかにエネルギーを失わせています。完全反射にすると、ボールが同じ軌道を長く繰り返しやすくなります。画面下部の排出口まで出たら、残りボール数を減らして次のボールをスタート位置へ戻します。

パーティクルと残像で衝突を見せる

物理計算だけでは、バンパーに当たった瞬間が分かりにくくなります。衝突位置に小さなパーティクルを作り、毎フレーム速度と透明度を更新して描画します。ボールの直前の位置をいくつか保存しておけば、ネオンの残像も表現できます。

衝突エフェクトを作るJAVASCRIPT
const particles = [];

function burst(x, y, color) {
    for (let i = 0; i < 14; i++) {
        const angle = Math.random() * Math.PI * 2;
        const speed = 50 + Math.random() * 150;
        particles.push({
            x, y,
            vx: Math.cos(angle) * speed,
            vy: Math.sin(angle) * speed,
            life: 0.45,
            color
        });
    }
}

function updateParticles(dt) {
    for (const particle of particles) {
        particle.x += particle.vx * dt;
        particle.y += particle.vy * dt;
        particle.vy += 180 * dt;
        particle.life -= dt;
    }
    for (let i = particles.length - 1; i >= 0; i--) {
        if (particles[i].life <= 0) particles.splice(i, 1);
    }
}

パーティクルは、発生、更新、寿命切れの削除という小さなオブジェクトの集合です。配列から要素を削除するときに前からspliceするとインデックスがずれるため、後ろから走査しています。これはタイマーやエフェクトを管理するときにも使える基本的なパターンです。

Canvasにネオンの見た目を描く

ゲームの背景やバンパーは、Canvasのパス、グラデーション、shadowBlurを組み合わせて描いています。先にぼかした大きな光を描き、その上に輪郭を描くと、少ないコードでもネオンらしい発光を作れます。

発光する円を描くJAVASCRIPT
function drawBumper(ctx, bumper) {
    ctx.save();
    ctx.beginPath();
    ctx.arc(bumper.x, bumper.y, bumper.radius, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(10, 16, 42, 0.95)';
    ctx.fill();
    ctx.shadowColor = bumper.color;
    ctx.shadowBlur = 24;
    ctx.strokeStyle = bumper.color;
    ctx.lineWidth = 4;
    ctx.stroke();
    ctx.restore();
}

function drawBall(ctx) {
    ctx.save();
    ctx.shadowColor = '#ffffff';
    ctx.shadowBlur = 18;
    ctx.fillStyle = '#ffffff';
    ctx.beginPath();
    ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
}

saveとrestoreで描画状態を囲むと、ある部品の影や線幅が次の部品へ漏れません。ボールやバンパーのような小さな部品に発光を付け、盤面の背景は暗いグラデーションにすることで、発光部分とのコントラストを確保しています。

キーボードとタッチボタンを同じ処理へつなぐ

PCでは左右の矢印キーまたはA・Dキー、スマートフォンでは画面下の左右ボタンを使います。入力方法ごとにフリッパーを直接動かすのではなく、最終的にkeys.leftとkeys.rightへ集約します。これにより、キーボードとタッチでゲームの挙動がずれません。

キー入力を状態へ反映するJAVASCRIPT
const keys = { left: false, right: false };

window.addEventListener('keydown', event => {
    if (event.key === 'ArrowLeft' || event.key.toLowerCase() === 'a') {
        keys.left = true;
        event.preventDefault();
    }
    if (event.key === 'ArrowRight' || event.key.toLowerCase() === 'd') {
        keys.right = true;
        event.preventDefault();
    }
    if (event.code === 'Space' || event.key === 'ArrowDown') launch();
});

window.addEventListener('keyup', event => {
    if (event.key === 'ArrowLeft' || event.key.toLowerCase() === 'a') keys.left = false;
    if (event.key === 'ArrowRight' || event.key.toLowerCase() === 'd') keys.right = false;
});
タッチボタンを長押しに対応させるJAVASCRIPT
function bindFlipperButton(button, side) {
    const press = event => {
        event.preventDefault();
        keys[side] = true;
    };
    const release = () => { keys[side] = false; };

    button.addEventListener('pointerdown', press);
    button.addEventListener('pointerup', release);
    button.addEventListener('pointercancel', release);
    button.addEventListener('pointerleave', release);
}

bindFlipperButton(leftButton, 'left');
bindFlipperButton(rightButton, 'right');

pointerイベントを使うと、マウス、タッチ、ペンを同じコードで扱えます。離したときだけでなく、キャンセルやボタンの外へ出たときもキー状態を戻すことが大切です。戻し忘れると、指を離したあともフリッパーが上がり続けます。

Web Audio APIでBGMと効果音を作る

効果音は、AudioContext、発振器、ゲインノードを接続して作っています。バンパー、フリッパー、発射などに短い音を割り当て、BGMは一定間隔で音階を切り替えます。音声ファイルを複数読み込まなくても、ゲームに反応する音を追加できます。

短い効果音を鳴らすJAVASCRIPT
let audioContext;
let masterGain;

function initAudio() {
    audioContext = new AudioContext();
    masterGain = audioContext.createGain();
    masterGain.gain.value = 0.12;
    masterGain.connect(audioContext.destination);
}

function tone(frequency, duration, type = 'sine') {
    if (sfxMuted || !audioContext) return;
    const now = audioContext.currentTime;
    const oscillator = audioContext.createOscillator();
    const gain = audioContext.createGain();

    oscillator.type = type;
    oscillator.frequency.value = frequency;
    gain.gain.setValueAtTime(0.08, now);
    gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
    oscillator.connect(gain).connect(masterGain);
    oscillator.start(now);
    oscillator.stop(now + duration);
}

ブラウザは、ユーザー操作なしに音声を再生できないことがあります。そのため、ゲーム開始ボタンや発射操作の中でAudioContextを初期化し、必要ならresume()を呼びます。BGMと効果音には別のミュート状態を持たせ、片方だけ止められるようにしています。

タブを離れたときはvisibilitychangeを監視してゲームを一時停止します。音だけが鳴り続けたり、戻った瞬間にボールが大きく移動したりする問題を避けるためです。

残りボール数とゲームオーバー

ボールが排出口から出ると、残りボール数を1減らします。残りがあればボールをスタート位置へ戻し、0になったらゲームオーバー画面を表示します。新しいボールを作るのではなく、同じボールオブジェクトを初期化して再利用しています。

ボールを失ったときの処理JAVASCRIPT
function resetBall() {
    ball.x = 475;
    ball.y = 610;
    ball.vx = 0;
    ball.vy = 0;
    ball.ready = true;
}

function loseBall() {
    balls--;
    updateHud();

    if (balls <= 0) {
        gameOver = true;
        running = false;
        stopMusic();
        showOverlay('GAME OVER');
        return;
    }
    resetBall();
}

ゲームオーバー時にアニメーションとBGMを止め、オーバーレイを表示する処理までを一つの関数にまとめています。得点や倍率はゲーム開始時に初期化し、HUDの更新も同じ関数から行うことで、内部状態と画面表示を一致させています。

実装後に確認するポイント

ピンボールはランダムに見えて、衝突の順番や入力状態によって不具合が見つかりやすいゲームです。実装後は、次のようなケースを順に確認します。

  • 発射前、プレイ中、一時停止中に、フリッパーや発射操作が意図せず動かないか
  • 左右の壁、上部の壁、バンパー、ポスト、フリッパーでボールが盤面外へ抜けないか
  • バンパーに当たったとき、得点・倍率・パーティクル・効果音が同時に更新されるか
  • キーを押したままタブを切り替えても、戻ったあとにフリッパーが張り付かないか
  • 残りボールが0になったときに、BGM、入力、アニメーションが停止するか
  • スマートフォンで左右ボタンを押し続けても、指を離した時点で確実に戻るか
  • BGMだけ、または効果音だけをミュートしたとき、もう一方の音が残るか

とくに衝突判定は、ボールの速度を上げた状態、フリッパーの先端、壁とバンパーの間などで試すと問題を見つけやすくなります。Canvasのゲームでは、状態更新と描画を分離しているか、画面を離れたときにループと音声を止められるかも重要な確認項目です。

まとめ

ネオンピンボールの実装では、次の要素を組み合わせました。

  • Canvasの固定座標を使った盤面描画
  • 重力、速度、反射によるボールの物理演算
  • 円と円、ボールと線分を使い分けた衝突判定
  • バンパーの得点、倍率、パーティクルによるフィードバック
  • キーボード、ポインター、タッチボタンの入力統合
  • Web Audio APIによるBGMと効果音
  • 一時停止、残りボール、ゲームオーバーの状態管理

実際の動きを確認したい場合は、ネオンピンボールを遊んでみてください。まずは発射して、左右のフリッパーでボールを中央のバンパーへ返すところから試せます。