GAME

ブラウザ版ネオンビリヤードの実装を解説

Canvasにテーブルを描き、ドラッグ操作で狙いとパワーを決め、ボールの衝突とポケットインまでをブラウザー上で処理する方法を紹介します。

ネオンビリヤードは、白い手球をドラッグしてショットし、9個のボールをポケットへ落とすブラウザゲームです。ビリヤードらしい見た目を作るだけでなく、狙いの入力、パワーの調整、ボールの移動、壁での反射、ボール同士の衝突、ポケットの判定、ゲーム終了までを組み合わせています。

今回のゲームは、PHPで共通ヘッダーや広告を読み込み、ゲーム本体はHTMLのCanvasとJavaScriptで動かしています。外部のゲームエンジンを使わず、ブラウザー標準の機能だけで、操作してからボールが止まるまでの流れを実装しました。

ゲーム画面をCanvasにまとめる

ゲーム画面には、760×430ピクセルのCanvasを使っています。CSSでは画面幅に合わせて縮小しますが、JavaScriptの内部座標は固定しておくことで、マウスやタッチの座標をゲーム内の位置へ変換しやすくなります。

Canvasとテーブルの設定JAVASCRIPT
const canvas = document.getElementById('billiards');
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
const table = { x: 38, y: 36, w: 684, h: 358 };
const radius = 11;

テーブルの左上をtable.xとtable.y、幅と高さをwとhで持っています。壁やポケットの位置を同じ設定から計算すれば、描画だけ別の場所を参照してしまう不具合を防げます。

Canvasでは、毎フレーム背景、テーブル、ポケット、ガイド線、ボール、パーティクルの順に描画します。前のフレームを残さないよう、最初にclearRectまたは背景の塗りつぶしを行います。

ボールをオブジェクトで管理する

手球と的球は、位置、速度、半径、色、番号、活動中かどうかを持つオブジェクトとして管理します。ポケットに入ったボールはactive: falseにして、更新と描画の対象から外します。

ボールのデータJAVASCRIPT
const ball = {
    x: 520,
    y: 215,
    vx: 0,
    vy: 0,
    r: 11,
    color: '#ff5d73',
    number: 1,
    active: true,
    pocketed: 0
};

const cue = {
    x: 220,
    y: 215,
    vx: 0,
    vy: 0,
    r: 11,
    color: '#fff',
    active: true,
    cue: true
};

すべてのボールを同じ形で扱い、手球だけcue: trueを持たせています。描画では番号を表示するか、手球として白く描くかをこのフラグで分けます。ゲームの状態と見た目を一つのデータから作るため、ポケットイン後に表示だけ残る問題も起きにくくなります。

三角形に並べてゲームを開始する

9個の的球は、テーブルの右側へ3行の三角形に近い形で配置しています。行と列から位置を計算することで、球の座標を一つずつ手書きせずに済みます。

的球を初期配置するJAVASCRIPT
balls = [];
for (let i = 0; i < 9; i += 1) {
    const row = Math.floor(i / 3);
    const col = i % 3;
    balls.push({
        x: table.x + table.w * 0.68 + row * 23,
        y: table.y + table.h / 2 + (col - 1) * 24 + row * 12,
        vx: 0,
        vy: 0,
        r: 11,
        number: i + 1,
        active: true
    });
}

ゲームをやり直すときは、ボール配列、スコア、パーティクル、ショット中の状態をまとめて初期化します。初期化処理をresetへ集約すると、「新しいゲーム」ボタンとゲーム開始画面で同じ処理を再利用できます。

ドラッグ距離から狙いとパワーを作る

操作は、手球の近くからドラッグして、引き戻して離す方式です。手球からポインターまでの逆向きベクトルをショット方向にし、距離をパワーとして使います。引きすぎて速度が大きくなりすぎないよう、最大距離を150に制限しています。

ショットの速度を決めるJAVASCRIPT
const dx = cue.x - pointer.x;
const dy = cue.y - pointer.y;
const power = Math.min(Math.hypot(dx, dy), 150);
const magnitude = Math.hypot(dx, dy) || 1;

cue.vx = dx / magnitude * (power * 4.5);
cue.vy = dy / magnitude * (power * 4.5);
shotActive = true;

Math.hypotでベクトルの長さを求め、長さが0のときは1として割り算を避けています。dx / magnitudeとdy / magnitudeが方向だけを表す単位ベクトルになり、そこへパワーを掛けることで、どの方向でも同じ基準で速度を設定できます。

ドラッグ中は、白い点線で狙い、黄色い線で引き戻しの強さを表示します。入力中の情報をそのまま画面に描くことで、離す前にショットの結果を想像しやすくしています。

毎フレームで位置と速度を更新する

ショット後は、アニメーションフレームごとに速度を位置へ加えます。フレームの間隔が変わっても動きが極端に変わらないよう、前回からの経過時間を秒に変換して掛けています。

ゲームループの更新JAVASCRIPT
function update(delta) {
    for (const ball of [cue, ...balls]) {
        if (!ball.active) continue;

        ball.x += ball.vx * delta;
        ball.y += ball.vy * delta;
        ball.vx *= Math.pow(0.985, delta * 60);
        ball.vy *= Math.pow(0.985, delta * 60);

        if (Math.hypot(ball.vx, ball.vy) < 4) {
            ball.vx = 0;
            ball.vy = 0;
        }
    }
}

速度に一定の倍率を掛け続けることで摩擦を表現しています。速度が十分小さくなったら0へ丸め、いつまでも小数の速度で動き続ける状態を防ぎます。deltaには上限を設け、タブへ戻った直後などに大きく飛びすぎないようにしています。

テーブルの壁で反射させる

ボールの中心だけをテーブル内に収めると、半径の分だけボールが壁へめり込みます。そのため、左端ではtable.x + ball.r、右端ではtable.x + table.w - ball.rへ位置を戻してから、速度の符号を反転させます。

壁との反射JAVASCRIPT
if (ball.x - ball.r < table.x) {
    ball.x = table.x + ball.r;
    ball.vx = Math.abs(ball.vx);
}
if (ball.x + ball.r > table.x + table.w) {
    ball.x = table.x + table.w - ball.r;
    ball.vx = -Math.abs(ball.vx);
}

if (ball.y - ball.r < table.y) {
    ball.y = table.y + ball.r;
    ball.vy = Math.abs(ball.vy);
}

速度の大きさを変えず、該当する方向だけを反転するのがポイントです。壁の内側へ位置を補正してから反射させるため、同じフレームで何度も壁に当たるような振動も抑えられます。

ボール同士の衝突を計算する

2つのボールの中心間距離が、2つの半径の合計より短くなったら衝突しています。中心を結ぶ単位ベクトルを求め、めり込みを半分ずつ押し戻します。

衝突の基本判定JAVASCRIPT
const dx = second.x - first.x;
const dy = second.y - first.y;
const distance = Math.hypot(dx, dy);
const minimum = first.r + second.r;

if (distance < minimum && distance > 0) {
    const nx = dx / distance;
    const ny = dy / distance;
    const overlap = minimum - distance;

    first.x -= nx * overlap / 2;
    first.y -= ny * overlap / 2;
    second.x += nx * overlap / 2;
    second.y += ny * overlap / 2;
}

位置の重なりを解消しただけでは、ボールは跳ね返りません。接線方向ではなく、中心を結ぶ法線方向の相対速度を取り出し、正面から近づいている場合だけ速度を交換します。ビリヤードの見た目を自然にするには、位置補正と速度計算の両方が必要です。

ポケットインと手球を分けて処理する

ポケットは6か所の座標として持ち、ボールの中心がポケットへ近づいたらポケットインと判定します。的球ならスコアを加算して残り個数を更新し、手球なら画面から消したあと、少し待ってテーブルへ戻します。

ポケットを判定するJAVASCRIPT
for (const pocket of pockets) {
    if (distance(ball, pocket) >= 27) continue;

    ball.active = false;
    createPocketParticles(ball.x, ball.y, ball.color);

    if (ball.cue) {
        setTimeout(() => respotCueBall(), 350);
    } else {
        score += 10;
        updateHud();
    }
    return true;
}

手球と的球を同じポケット処理に入れつつ、cueフラグで結果を分けています。手球まで残り個数から減らすとクリア判定が壊れるため、スコア計算と残り球の管理も的球の場合だけ行います。すべての的球が非アクティブになったら、ゲームを停止してクリア画面を表示します。

ポケットインをパーティクルで伝える

ボールが消えるだけでは、何が起きたのか分かりにくくなります。そこで、ポケットへ入った位置から小さな粒を発生させ、速度、色、残り時間を持つパーティクルとして更新します。

パーティクルの更新JAVASCRIPT
particles.forEach(particle => {
    particle.x += particle.vx * delta;
    particle.y += particle.vy * delta;
    particle.vx *= 0.96;
    particle.vy *= 0.96;
    particle.life -= delta;
});

particles = particles.filter(particle => particle.life > 0);

寿命が0以下になった粒を配列から除外します。描画時は寿命の割合を透明度へ使うため、ポケットインの瞬間から自然に消えていきます。ゲームの正解状態には不要な情報ですが、操作結果を伝えるフィードバックとして重要な要素です。

Pointer Eventsでマウスとタッチに対応する

マウス専用のmousedownではなく、pointerdown、pointermove、pointerupを使っています。これにより、PCのマウスとスマートフォンのタッチを同じ処理で扱えます。Canvasにはtouch-action: noneを設定し、ドラッグ中にページがスクロールしないようにしています。

ポインター操作JAVASCRIPT
canvas.addEventListener('pointerdown', event => {
    if (shotActive || !cue.active) return;
    const point = toCanvasPosition(event);
    if (distance(point, cue) < 44) {
        dragging = true;
        canvas.setPointerCapture(event.pointerId);
        aim = point;
    }
});

canvas.addEventListener('pointermove', event => {
    if (dragging) aim = toCanvasPosition(event);
});

canvas.addEventListener('pointerup', releaseShot);

表示サイズと内部座標が異なるため、イベントの座標はCanvasの表示矩形からの割合で変換します。setPointerCaptureを使うと、ドラッグ中にポインターがCanvasの外へ出ても、同じCanvasでpointerupを受け取りやすくなります。

ゲームループとショット終了を管理する

ゲームループでは、前回の時刻との差を求めて更新し、最後に描画します。ショット中はボールが止まるまで新しい入力を受け付けず、全ボールの速度が十分小さくなったら次のショットへ戻します。

更新と描画の順番JAVASCRIPT
function loop(now) {
    if (!running) return;

    const delta = Math.min((now - last) / 1000, 0.032);
    last = now;

    if (!dragging) update(delta);
    draw();
    requestAnimationFrame(loop);
}

入力中に物理演算まで進めると、狙いを定めている間にボールが動いてしまうため、ドラッグ中はupdateを止めています。描画は続けるので、ガイド線だけはリアルタイムに変化します。

また、画面を離れたときはゲームを停止し、BGMも止めます。ゲームの状態、音声、アニメーションフレームを同じ終了処理で管理すると、ページへ戻ったあとに裏でゲームが進み続ける問題を防げます。

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

BGMと効果音は音声ファイルを読み込まず、Web Audio APIの発振器とゲインを使って生成しています。ボールを打ったとき、壁に当たったとき、ボールが衝突したとき、ポケットに入ったときで周波数や音色を変えると、画面の変化を耳でも確認できます。

短い効果音を鳴らすJAVASCRIPT
function tone(frequency, duration = 0.08, type = 'sine') {
    if (!audio || sfxMuted) return;

    const oscillator = audio.createOscillator();
    const gain = audio.createGain();
    const now = audio.currentTime;

    oscillator.type = type;
    oscillator.frequency.setValueAtTime(frequency, now);
    gain.gain.setValueAtTime(0.0001, now);
    gain.gain.exponentialRampToValueAtTime(0.12, now + 0.01);
    gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
    oscillator.connect(gain).connect(master);
    oscillator.start(now);
    oscillator.stop(now + duration + 0.02);
}

ブラウザーの自動再生制限があるため、AudioContextは「ゲーム開始」や「BGMボタン」など、ユーザー操作の中で初期化します。BGMと効果音のミュート状態を分けることで、音楽だけ止めて衝突音を残すといった設定にも対応できます。

レスポンシブ表示と広告領域

PCではゲーム画面と設定パネルを横並びにし、画面幅が狭い場合は設定を下へ移動します。Canvas自体はCSSで縮小しますが、内部の座標系は変えないため、同じ物理計算をPCとスマートフォンで共有できます。

ゲームページでは右側固定広告を使うため、大画面ではページ本体に広告分の右余白を確保しています。ゲーム画面や操作パネルの上に広告が重なると操作できなくなるので、ゲーム専用CSSのメディアクエリーで余白を管理しています。

まとめ

ネオンビリヤードでは、Canvas描画だけでなく、ベクトルによるショット入力、摩擦を含む速度更新、壁とボールの衝突、ポケット処理、パーティクル、Pointer Events、Web Audio APIを組み合わせています。

まずは手球を動かすだけの小さな実装から始め、壁の反射、的球との衝突、ポケット、スコア、音声の順に追加すると、どの処理がゲームの感触を作っているのか確認しやすくなります。実際にネオンビリヤードを遊びながら、速度倍率や摩擦係数を変更して挙動を比べてみてください。