ブラウザ版ネオンピンポンの実装を解説
Canvas上で動くボールとパドル、難易度別のCPU、角度を変える反射、7点先取の試合管理、キーボード・マウス・タッチ操作、BGM・効果音まで、ネオンピンポンの実装を順番に紹介します。
ネオンピンポンは、左側のパドルを操作してコンピューターと対戦するブラウザゲームです。ボールを上下の壁で反射させ、相手のパドルを抜くと得点になります。見た目はシンプルですが、ゲームループ、衝突判定、CPUの追従、試合の状態管理、複数の入力方法、音声再生など、Canvasゲームの基本要素がまとまっています。
ゲームページは、PHPで共通ヘッダーやフッターを読み込み、HTMLで操作パネルを用意しています。ゲームの進行と描画は、外部ライブラリを使わずJavaScriptとCanvas APIで実装しています。
ゲームの状態を変数とオブジェクトで管理する
プレイヤーとCPUのパドル、ボール、得点、ゲームの進行状態をJavaScriptの変数で管理します。パドルは位置と大きさ、ボールは位置・半径・速度を持つオブジェクトにしています。
const W = canvas.width;
const H = canvas.height;
const WIN = 7;
let player;
let cpu;
let ball;
let playerScore = 0;
let cpuScore = 0;
let running = false;
let paused = false;
let matchOver = false;
let particles = [];runningは試合中かどうか、pausedは一時停止中かどうか、matchOverは勝敗が決まったかどうかを表します。描画や入力処理の入口で状態を確認することで、試合開始前や終了後にボールが動き続けることを防げます。
パドルとボールを試合ごとに初期化する
試合開始時と得点後には、両方のパドルを中央付近へ戻し、ボールを中央から再発射します。サーブ方向は毎回ランダムに変え、同じ展開が続かないようにしています。
function resetObjects(direction = (Math.random() < .5 ? -1 : 1)) {
player = { x: 24, y: H / 2 - 48, w: 13, h: 96 };
cpu = { x: W - 37, y: H / 2 - 48, w: 13, h: 96 };
const speed = levels[difficultyEl.value].ball;
ball = {
x: W / 2,
y: H / 2,
r: 8,
vx: direction * speed,
vy: (Math.random() - .5) * speed * .75,
speed
};
serveTimer = .85;
}ボールのvxは横方向、vyは縦方向の速度です。serveTimerを使って、得点直後にすぐボールを動かさず「READY」を表示する時間を作っています。サーブ開始処理を一つの関数にまとめることで、試合開始と得点後で初期化方法がずれません。
難易度を設定値として切り替える
初級・中級・上級の違いは、CPUの移動速度、追従時の誤差、ボールの初速で表しています。難易度ごとに処理を分けず、選択された設定をゲームループから参照する設計にしました。
const levels = {
easy: { cpuSpeed: 190, error: 58, ball: 265 },
normal: { cpuSpeed: 260, error: 34, ball: 300 },
hard: { cpuSpeed: 335, error: 15, ball: 335 }
};
difficultyEl.addEventListener('change', () => {
if (!running) resetObjects();
});CPUの速度を上げるだけでは、単純に難しいというより反応が速すぎるゲームになります。そこで、追従位置にerrorを加えて、初級ほど狙いに揺らぎが出るようにしています。試合中の難易度変更はボールやCPUの状態を不自然に変えないため、次の試合から反映します。
requestAnimationFrameでゲームループを作る
ゲームループでは、前回のフレームから経過した時間を計算し、状態の更新と描画を繰り返します。フレームレートが変わっても動く速さが大きく変わらないよう、移動量にdtを掛けています。
function loop(now) {
if (!running) return;
const dt = Math.min((now - last) / 1000, .03);
last = now;
if (!paused) update(dt);
draw();
if (running) requestAnimationFrame(loop);
}requestAnimationFrameはブラウザの描画タイミングに合わせて次のフレームを呼び出します。dtの上限を設定しているのは、タブ切り替えなどで長い時間が空いたときに、ボールが一度に画面外へ飛ぶのを抑えるためです。一時停止中もdrawは実行しているため、オーバーレイを表示したまま最後の画面を維持できます。
プレイヤーとCPUのパドルを動かす
プレイヤーのパドルは、キーボードの上下キーまたはW・Sキーで移動します。パドルが画面外へ出ないよう、位置を0からH - player.hの範囲に収めます。CPUはボールが右方向へ進んでいるときだけボールの位置を追い、左方向へ戻っているときは中央へ戻ります。
const level = levels[difficultyEl.value];
const move = 320 * dt;
if (keys.up) player.y -= move;
if (keys.down) player.y += move;
player.y = Math.max(0, Math.min(H - player.h, player.y));
const target = ball.vx > 0
? ball.y - cpu.h / 2 + Math.sin(performance.now() / 420) * level.error
: H / 2 - cpu.h / 2;
const delta = target - cpu.y;
cpu.y += Math.sign(delta) * Math.min(Math.abs(delta), level.cpuSpeed * dt);
cpu.y = Math.max(0, Math.min(H - cpu.h, cpu.y));CPUはボールの現在位置へ瞬間移動させず、cpuSpeed * dtを上限に少しずつ近づけています。これにより、速度の差がそのまま難易度の差になります。Math.sinによる揺らぎは、初級・中級でCPUが完全には正確にならないための簡単なミス表現です。
ボールを動かして上下の壁で反射する
ボールの位置に速度を加え、上下の壁へ到達したら縦方向の速度の符号を反転します。壁の外側へ少し入り込んだ位置を、壁の内側へ戻してから反射させることが重要です。
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.y - ball.r < 0) {
ball.y = ball.r;
ball.vy = Math.abs(ball.vy);
} else if (ball.y + ball.r > H) {
ball.y = H - ball.r;
ball.vy = -Math.abs(ball.vy);
}速度を単純にball.vy *= -1とするだけでも反射はできますが、ボールが壁の外へ出たあとに反転すると、フレームによって壁に埋まったり震えたりすることがあります。先にball.yを境界上へ戻すことで、次のフレームの判定を安定させています。
パドルとの衝突で打ち返す角度を決める
パドルに当たった位置を、パドルの中心からどれだけ離れているかという相対値に変換します。中心に当たれば横方向へ近く、端に当たれば上下方向へ大きく進むように速度を分けます。
function paddleHit(p, right) {
if (ball.y + ball.r < p.y || ball.y - ball.r > p.y + p.h) {
return false;
}
if (right && ball.x + ball.r < p.x ||
!right && ball.x - ball.r > p.x + p.w) {
return false;
}
if (right && ball.vx < 0 || !right && ball.vx > 0) {
return false;
}
const relative = (ball.y - (p.y + p.h / 2)) / (p.h / 2);
const speed = Math.min(ball.speed * 1.055, 540);
ball.vx = (right ? -1 : 1) * speed * Math.max(.62, 1 - Math.abs(relative) * .18);
ball.vy = relative * speed * .92;
ball.speed = speed;
ball.x = right ? p.x - ball.r : p.x + p.w + ball.r;
return true;
}最初の条件で、ボールがパドルの縦方向の範囲に入っているかを確認します。次に左右の位置と進行方向を確認し、パドルの裏側から当たった判定を除外します。最後に衝突位置からrelativeを計算し、打ち返すたびに速度を少しだけ上げています。ただし上限を設けて、終盤に操作不能な速さにならないようにしています。
得点、サーブ、勝敗を一つの処理にまとめる
ボールが左右の画面外へ出たら、抜かれた側と得点した側を判定します。得点を更新し、7点に到達していなければ新しいサーブを開始し、到達していれば試合終了のオーバーレイを表示します。
function point(playerWon) {
if (playerWon) playerScore++;
else cpuScore++;
updateScore();
flash = 1;
if (playerScore >= WIN || cpuScore >= WIN) {
running = false;
matchOver = true;
stopBgm();
title.textContent = playerScore > cpuScore ? 'YOU WIN' : 'CPU WIN';
text.textContent = `最終スコア ${playerScore} - ${cpuScore}`;
startButton.textContent = 'もう一度遊ぶ';
overlay.classList.add('show');
return;
}
resetObjects(playerWon ? 1 : -1);
}得点処理をpointへ集約すると、左へ抜けた場合と右へ抜けた場合で、表示・効果音・勝敗判定の実装が分散しません。得点後にresetObjectsを呼ぶことで、ラリーの再開処理も同じ流れにできます。
Canvasにコートとネオンの見た目を描く
描画関数では、背景、中央線、横方向のガイド、パドル、ボール、パーティクルを毎フレーム描き直します。Canvasは前のフレームを自動では消さないため、最初にグラデーション背景で全体を塗りつぶします。
function draw() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, '#0c2633');
g.addColorStop(1, '#07111d');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = '#8ceee055';
ctx.lineWidth = 2;
ctx.setLineDash([9, 11]);
ctx.beginPath();
ctx.moveTo(W / 2, 0);
ctx.lineTo(W / 2, H);
ctx.stroke();
ctx.setLineDash([]);
}パドルを描くときはshadowBlurとshadowColorを設定してから角丸長方形を描きます。プレイヤー側をシアン、CPU側をピンク、ボールを黄色に分けることで、暗い背景でも操作対象と相手を見分けられます。
パーティクルで衝突を分かりやすくする
パドルに当たったときや得点したときは、衝突位置から小さな粒を放ちます。粒ごとに位置、速度、寿命、色を持たせ、寿命が0になった粒を配列から削除します。
function burst(x, y, color, count) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 40 + Math.random() * 150;
particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: .4 + Math.random() * .45,
color
});
}
}
particles.forEach(p => {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
});
particles = particles.filter(p => p.life > 0);パーティクルはゲームの判定には使わず、視覚的なフィードバックだけを担当させています。ゲーム状態と演出を分けておくと、粒の数や寿命を調整してもボールの衝突判定へ影響しません。
キーボード、マウス、タッチを同じ状態へつなぐ
キーボード操作では上下キーまたはW・Sキーを押している間だけkeys.upやkeys.downをtrueにします。ゲームループ側は入力方法を意識せず、キー状態だけを見てパドルを動かします。
document.addEventListener('keydown', event => {
if (['ArrowUp', 'ArrowDown'].includes(event.code)) {
event.preventDefault();
}
if (event.code === 'ArrowUp' || event.code === 'KeyW') {
keys.up = true;
}
if (event.code === 'ArrowDown' || event.code === 'KeyS') {
keys.down = true;
}
});
document.addEventListener('keyup', event => {
if (event.code === 'ArrowUp' || event.code === 'KeyW') keys.up = false;
if (event.code === 'ArrowDown' || event.code === 'KeyS') keys.down = false;
});マウスとタッチでは、Canvas上のポインター位置をゲーム内の座標へ変換し、パドルの中心がその位置に合うようにします。CanvasがCSSで縮小表示されても、実際の描画サイズW・Hへ変換することがポイントです。
function setPlayer(clientY) {
if (!player) return;
const rect = canvas.getBoundingClientRect();
const y = (clientY - rect.top) * H / rect.height;
player.y = Math.max(0, Math.min(H - player.h, y - player.h / 2));
}
canvas.addEventListener('pointermove', event => setPlayer(event.clientY));
canvas.addEventListener('pointerdown', event => setPlayer(event.clientY));pointerイベントを使うことで、マウスとタッチを別々のイベント処理に分けずに済みます。スマートフォンでは画面下部の上・下ボタンも用意し、ボタンを押している間だけ同じkeysの値を変更しています。
Web Audio APIでBGMと効果音を作る
音声は音声ファイルを読み込まず、Web Audio APIの発振器とゲインで生成しています。ブラウザの自動再生制限があるため、AudioContextの初期化は「試合開始」やサウンドボタンのクリックから呼び出します。
function tone(freq, duration = .08, type = 'square', volume = .16) {
if (!audio || audio.state !== 'running' || sfxMuted) return;
const now = audio.currentTime;
const oscillator = audio.createOscillator();
const gain = audio.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(freq, now);
gain.gain.setValueAtTime(.0001, now);
gain.gain.exponentialRampToValueAtTime(volume, now + .01);
gain.gain.exponentialRampToValueAtTime(.0001, now + duration);
oscillator.connect(gain).connect(master);
oscillator.start(now);
oscillator.stop(now + duration + .03);
}パドルへの衝突、壁への接触、得点、勝利で周波数や波形を変えると、画面を見なくてもゲームの状況が分かります。BGMと効果音は別のミュート状態を持たせ、どちらかだけを消せるようにしています。
一時停止とページ離脱時の扱い
一時停止ではpausedを切り替え、オーバーレイの文言とボタンを変更します。また、ブラウザの別タブへ移動したときは、visibilitychangeイベントから一時停止を呼び出します。プレイヤーが戻ってきたときに、見ていない間だけボールが進んでいる状態を防ぐためです。
document.addEventListener('visibilitychange', () => {
if (document.hidden && running && !paused) {
pause();
}
});ゲームの状態変更をpause関数へ集約しているため、Pキー、オーバーレイのボタン、ページ離脱のどこからでも同じ一時停止処理を利用できます。
実装して分かったこと
ピンポンゲームでは、ボールを動かす処理よりも、衝突した瞬間に正しい方向へ反射させることが重要です。衝突判定では、ボールがパドルの範囲にあることだけでなく、パドルへ近づいている方向かどうかも確認しないと、同じパドルに何度も当たったような不自然な動きになります。
また、CPUを完全にボールへ追従させると、プレイヤーが勝てないゲームになりやすくなります。速度、狙いの誤差、ボールの速さを設定値として分けることで、難易度を調整しやすくなりました。
実際の動きを確認したい場合は、ネオンピンポンを遊んでみてください。まずは中級で、パドルの中央と端に当てたときのボールの角度の違いを試すと、衝突処理の効果を確認できます。