ブラウザ版ネオンビリヤードの実装を解説
Canvasにテーブルを描き、ドラッグ操作で狙いとパワーを決め、ボールの衝突とポケットインまでをブラウザー上で処理する方法を紹介します。
ネオンビリヤードは、白い手球をドラッグしてショットし、9個のボールをポケットへ落とすブラウザゲームです。ビリヤードらしい見た目を作るだけでなく、狙いの入力、パワーの調整、ボールの移動、壁での反射、ボール同士の衝突、ポケットの判定、ゲーム終了までを組み合わせています。
今回のゲームは、PHPで共通ヘッダーや広告を読み込み、ゲーム本体はHTMLのCanvasとJavaScriptで動かしています。外部のゲームエンジンを使わず、ブラウザー標準の機能だけで、操作してからボールが止まるまでの流れを実装しました。
ゲーム画面をCanvasにまとめる
ゲーム画面には、760×430ピクセルのCanvasを使っています。CSSでは画面幅に合わせて縮小しますが、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にして、更新と描画の対象から外します。
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行の三角形に近い形で配置しています。行と列から位置を計算することで、球の座標を一つずつ手書きせずに済みます。
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に制限しています。
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が方向だけを表す単位ベクトルになり、そこへパワーを掛けることで、どの方向でも同じ基準で速度を設定できます。
ドラッグ中は、白い点線で狙い、黄色い線で引き戻しの強さを表示します。入力中の情報をそのまま画面に描くことで、離す前にショットの結果を想像しやすくしています。
毎フレームで位置と速度を更新する
ショット後は、アニメーションフレームごとに速度を位置へ加えます。フレームの間隔が変わっても動きが極端に変わらないよう、前回からの経過時間を秒に変換して掛けています。
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へ位置を戻してから、速度の符号を反転させます。
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つの半径の合計より短くなったら衝突しています。中心を結ぶ単位ベクトルを求め、めり込みを半分ずつ押し戻します。
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か所の座標として持ち、ボールの中心がポケットへ近づいたらポケットインと判定します。的球ならスコアを加算して残り個数を更新し、手球なら画面から消したあと、少し待ってテーブルへ戻します。
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フラグで結果を分けています。手球まで残り個数から減らすとクリア判定が壊れるため、スコア計算と残り球の管理も的球の場合だけ行います。すべての的球が非アクティブになったら、ゲームを停止してクリア画面を表示します。
ポケットインをパーティクルで伝える
ボールが消えるだけでは、何が起きたのか分かりにくくなります。そこで、ポケットへ入った位置から小さな粒を発生させ、速度、色、残り時間を持つパーティクルとして更新します。
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を設定し、ドラッグ中にページがスクロールしないようにしています。
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を受け取りやすくなります。
ゲームループとショット終了を管理する
ゲームループでは、前回の時刻との差を求めて更新し、最後に描画します。ショット中はボールが止まるまで新しい入力を受け付けず、全ボールの速度が十分小さくなったら次のショットへ戻します。
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の発振器とゲインを使って生成しています。ボールを打ったとき、壁に当たったとき、ボールが衝突したとき、ポケットに入ったときで周波数や音色を変えると、画面の変化を耳でも確認できます。
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を組み合わせています。
まずは手球を動かすだけの小さな実装から始め、壁の反射、的球との衝突、ポケット、スコア、音声の順に追加すると、どの処理がゲームの感触を作っているのか確認しやすくなります。実際にネオンビリヤードを遊びながら、速度倍率や摩擦係数を変更して挙動を比べてみてください。