ブラウザ版ネオンピンボールの実装を解説
Canvas上で動くボールの物理演算、フリッパーとバンパーの衝突判定、スコア計算、BGM・効果音、キーボードとタッチ操作への対応を順番に紹介します。
ネオンピンボールは、左右のフリッパーでボールを打ち返し、バンパーやターゲットに当ててスコアを伸ばすブラウザゲームです。ピンボールは、画面を更新するだけのゲームとは異なり、重力で落ちるボール、壁との反射、動くフリッパー、連続する得点イベントを同時に扱います。
今回のゲームは、ページの共通部分をPHPで読み込み、ゲーム本体をJavaScriptとCanvasで実装しています。外部のゲームエンジンは使わず、状態管理、描画、衝突判定、入力、音声をひとつずつ組み立てました。この記事では、実際のゲームページで使っている考え方を、読みやすい小さなコードに分けて説明します。
ゲーム全体を状態と描画に分ける
ゲーム中に変化する値は、ボール、スコア、残りボール数、倍率、フリッパーの状態などです。これらを変数としてまとめ、毎フレーム状態を更新したあとにCanvasへ描画します。表示用のHTMLを毎回作り直すのではなく、ゲーム画面はCanvas、スコアやボタンはHTMLという役割分担にしています。
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 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がぼやけたり、マウス座標とゲーム座標がずれたりします。入力座標を変換するときは、表示サイズに対する内部サイズの比率を掛けます。
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回のループで、経過時間を求め、物理状態を更新し、その結果を描画します。処理の順序を固定することで、描画だけが先に進んだり、表示だけが古い状態になったりすることを防げます。
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フレームの移動距離が大きくなりすぎないよう、更新を複数回に分けるサブステップも使えます。
function updateFrame(dt) {
const step = 1 / 120;
let remaining = dt;
while (remaining > 0) {
const slice = Math.min(step, remaining);
update(slice);
remaining -= slice;
}
}更新間隔を小さくすると、ボールが薄い壁を飛び越えるトンネリングを減らせます。毎回の計算量は増えますが、ピンボールのように速いボールと狭い部品があるゲームでは、安定した衝突判定につながります。
重力と発射でボールを動かす
ボールは、毎回の更新で重力を速度へ加え、その速度を位置へ加えます。発射時だけ上向きの速度を与え、通常時は重力によって少しずつ落下させます。実装では、発射ボタンやスペースキーが押されたときにreadyを解除します。
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で速度が変わってしまうため注意が必要です。
const speed = Math.hypot(ball.vx, ball.vy);
const maxSpeed = 920;
if (speed > maxSpeed) {
const ratio = maxSpeed / speed;
ball.vx *= ratio;
ball.vy *= ratio;
}バンパーに連続して当たると、反射のたびに速度が増える可能性があります。一定以上の速度を制限することで、ボールが一瞬で盤面の外へ飛び出すことを防ぎ、遊びやすい範囲に収めています。
円と円の衝突を反射へ変換する
バンパーやポストは円として扱えます。ボールの中心と相手の中心の距離が、二つの半径の合計以下なら衝突です。衝突したときは、二つの中心を結ぶ方向を法線として、ボールの速度を反射させます。
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は衝突面の法線です。速度と法線の内積が負のときは相手へ近づいているため、法線方向の速度成分を反転します。位置も少し押し戻しておくことで、次のフレームでも同じ場所に重なり続けるのを避けています。
バンパーとスコアを結びつける
盤面には、異なる色と得点を持つ三つのバンパーを配置しています。衝突したバンパーから得点を受け取り、ヒット回数が一定数に達したら倍率を上げます。描画用のデータと得点用のデータを同じオブジェクトに持たせると、追加や調整がしやすくなります。
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();
}バンパーへの衝突を検出したら、得点、ヒット数、倍率、パーティクル、効果音を同じ場所で更新します。得点だけを増やして音や演出を別の場所で行うより、ひとつのイベントとしてまとめたほうが、あとから「特定のバンパーだけボーナスを付ける」といった変更をしやすくなります。
線分としてフリッパーに当てる
フリッパーは動く棒なので、円形のバンパーとは異なる判定が必要です。フリッパーの両端を線分と考え、ボールの中心から線分上の最も近い点を求めます。その距離がボールの半径とフリッパーの厚みの合計以下なら、フリッパーに当たったと判断します。
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の端ではなく、斜めのレールや排出口をゲーム内の境界として扱うことで、ピンボールらしい盤面になります。
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を掛けて、壁に当たるたびにわずかにエネルギーを失わせています。完全反射にすると、ボールが同じ軌道を長く繰り返しやすくなります。画面下部の排出口まで出たら、残りボール数を減らして次のボールをスタート位置へ戻します。
パーティクルと残像で衝突を見せる
物理計算だけでは、バンパーに当たった瞬間が分かりにくくなります。衝突位置に小さなパーティクルを作り、毎フレーム速度と透明度を更新して描画します。ボールの直前の位置をいくつか保存しておけば、ネオンの残像も表現できます。
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を組み合わせて描いています。先にぼかした大きな光を描き、その上に輪郭を描くと、少ないコードでもネオンらしい発光を作れます。
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へ集約します。これにより、キーボードとタッチでゲームの挙動がずれません。
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;
});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は一定間隔で音階を切り替えます。音声ファイルを複数読み込まなくても、ゲームに反応する音を追加できます。
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になったらゲームオーバー画面を表示します。新しいボールを作るのではなく、同じボールオブジェクトを初期化して再利用しています。
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と効果音
- 一時停止、残りボール、ゲームオーバーの状態管理
実際の動きを確認したい場合は、ネオンピンボールを遊んでみてください。まずは発射して、左右のフリッパーでボールを中央のバンパーへ返すところから試せます。