ブラウザ版フラッピーバードの実装を解説
Canvasに描いた鳥を重力で落下させ、羽ばたき操作、土管の生成、衝突判定、スコア、BGMまで実装する流れを紹介します。
フラッピーバードは、鳥を羽ばたかせて土管のすき間を通り抜けるアクションゲームです。操作はクリック、タップ、スペースキー、上矢印キーに対応しています。ルールはシンプルですが、Canvasの描画、物理演算、スクロールする障害物、衝突判定、スコア保存、音声再生など、ブラウザゲームの基本要素がまとまっています。
ゲームページはPHPで共通のヘッダーとフッターを読み込み、画面のHTMLとスタイルを用意しています。ゲームの進行はgame.jsが担当し、Canvasの480×640の座標系で鳥、土管、背景を毎フレーム描画します。
Canvasとゲームの状態を用意する
Canvasの描画コンテキストを取得し、画面の幅と高さを定数として保存します。ゲームの状態はready、playing、gameoverの3種類で管理しています。
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
let bird;
let pipes = [];
let score = 0;
let state = 'ready';
let lastTime = 0;
let animationId = 0;birdには位置、半径、速度、回転角度を持たせ、pipesには画面上に存在する土管を配列で保存します。DOMのスコア表示やオーバーレイ表示は、ゲーム状態が変化したときにJavaScriptから更新します。
鳥を重力で落下させる
鳥は何もしないと落下し、羽ばたく操作を受けたときだけ上向きの速度を設定します。毎フレーム、速度へ重力を加えてから位置を更新することで、自然に加速する落下を表現できます。
function resetBird() {
bird = {
x: 128, y: 290, radius: 17,
velocity: 0, rotation: 0, wing: 0
};
}
function flap() {
if (state !== 'playing') return;
bird.velocity = -370;
bird.wing = 1;
}
function updateBird(dt) {
bird.velocity += 1080 * dt;
bird.y += bird.velocity * dt;
bird.rotation = Math.max(-0.45,
Math.min(1.25, bird.velocity / 470));
bird.wing = Math.max(0, bird.wing - dt * 5);
}上方向を負の値としているため、羽ばたき直後は-370を設定します。落下速度に応じて回転角度を変えると、上昇時は上向き、落下時は下向きになるため、画像を描画するときにも飛行感が出ます。羽ばたきの強さをwingへ保存して、代替描画の翼の位置にも利用しています。
フレーム間隔を使ったゲームループ
アニメーションにはrequestAnimationFrameを使います。前回のフレームから経過した秒数をdtとして、鳥や土管の移動量を時間に合わせて計算します。タブの切り替えなどで大きな値になった場合は、1フレームの進みを最大0.032秒に制限しています。
function loop(now) {
if (state !== 'playing') {
draw();
return;
}
const dt = Math.min((now - lastTime) / 1000, 0.032);
lastTime = now;
update(dt);
draw();
animationId = requestAnimationFrame(loop);
}
animationId = requestAnimationFrame(loop);updateではゲームの状態だけを変更し、drawでは現在の状態をCanvasへ描画します。この役割を分けると、ゲームオーバー後に最終画面を描画したり、画像の読み込み完了後に再描画したりしやすくなります。
土管をランダムに生成する
土管は一定距離を進むたびに追加します。すき間の上端をランダムに選び、画面上端からすき間までと、すき間の下端から地面までの2つの長方形として描画します。すき間の大きさは固定し、位置だけを変えることで、ゲームとして通過可能な障害物にしています。
const groundHeight = 72;
const pipeWidth = 66;
const pipeGap = 158;
const pipeDistance = 205;
function addPipe() {
const minimum = 130;
const maximum = H - groundHeight - pipeGap - 75;
const gapTop = minimum + Math.random() *
(maximum - minimum);
pipes.push({
x: W + 22,
gapTop,
counted: false,
entered: false
});
}土管は画面の右端より外側から出現させます。spawnDistanceへ鳥の進行距離を蓄積し、pipeDistanceを超えたら次の土管を生成します。土管が左端から十分に離れたら配列から削除し、長時間プレイしても不要なオブジェクトが残り続けないようにしています。
土管と地面を移動させる
プレイヤーの鳥はほぼ固定位置に置き、背景や土管を左へ動かします。スコアが増えるほど速度を少しずつ上げることで、プレイを続けるほど難しくなります。背景の雲や地面の模様にも同じ移動量を使い、画面全体が流れているように見せています。
function update(dt) {
const speed = 172 + Math.min(score, 20) * 2.2;
worldOffset = (worldOffset + speed * dt) % 48;
spawnDistance += speed * dt;
if (spawnDistance >= pipeDistance) {
spawnDistance -= pipeDistance;
addPipe();
}
pipes.forEach(pipe => {
pipe.x -= speed * dt;
});
pipes = pipes.filter(pipe => pipe.x + pipeWidth > -20);
}worldOffsetは地面の模様を繰り返し表示するための値です。% 48で一定範囲に戻すため、値が無限に増え続けません。土管の配列は画面外へ出たものをfilterで取り除いています。
円と長方形の重なりで衝突判定する
鳥は円として扱い、土管との衝突は鳥の外周を含む範囲が土管の長方形と重なるかで判定します。まず鳥と土管の横方向が重なっているかを確認し、そのうえで鳥がすき間の上端より上、または下端より下に出ているかを調べます。
function hitsPipe() {
return pipes.some(pipe => {
const horizontal =
bird.x + bird.radius > pipe.x &&
bird.x - bird.radius < pipe.x + pipeWidth;
const outsideGap =
bird.y - bird.radius < pipe.gapTop ||
bird.y + bird.radius > pipe.gapTop + pipeGap;
return horizontal && outsideGap;
});
}すき間の中にいるときはoutsideGapが偽になるため、横方向が土管と重なっていても通過できます。画面上端や地面との衝突は、鳥の中心座標と半径から別に判定します。
通過した土管でスコアを増やす
土管が鳥の左側へ完全に抜けたタイミングでスコアを1増やします。同じ土管で何度も加点しないように、土管ごとにcountedフラグを持たせています。
const BEST_KEY = 'flappy-bird-best';
let best = Number(localStorage.getItem(BEST_KEY) || 0);
if (!pipe.counted && pipe.x + pipeWidth < bird.x) {
pipe.counted = true;
score += 1;
updateHud();
}
if (score > best) {
best = score;
localStorage.setItem(BEST_KEY, String(best));
}ハイスコアはlocalStorageへ保存するため、サーバー側のユーザー登録なしで同じブラウザから記録を引き継げます。保存する値は数値を文字列へ変換し、読み出すときにNumberへ戻しています。
Canvasをレイヤー順に描画する
1フレームの描画は、空、雲、遠景、土管、地面、鳥の順に行います。後から描いたものが手前に表示されるため、鳥を最後に描くと土管や背景に隠れません。鳥の画像が読み込めない場合は、JavaScriptの図形描画へ切り替えるフォールバックも用意しています。
function draw() {
drawBackground();
pipes.forEach(drawPipe);
drawGround();
drawBird();
}
function drawBird() {
ctx.save();
ctx.translate(bird.x, bird.y);
ctx.rotate(bird.rotation);
ctx.drawImage(birdImage, -24, -24, 48, 48);
ctx.restore();
}saveとrestoreでCanvasの変換状態を囲むことで、鳥の回転が次の描画へ影響しません。画像が未読み込みのときは楕円や円、三角形を組み合わせて鳥を描くため、ゲーム画面が空白にならないようにしています。
クリック、タップ、キーボードに対応する
マウスとタッチはCanvasと画面下部の羽ばたきボタンにpointerdownを登録し、キーボードはkeydownでスペースキーと上矢印キーを受け取ります。タッチ操作はブラウザのスクロールと競合しないよう、CanvasのCSSにtouch-action: noneを指定しています。
canvas.addEventListener('pointerdown', event => {
event.preventDefault();
flap();
});
flapButton.addEventListener('pointerdown', event => {
event.preventDefault();
flap();
});
document.addEventListener('keydown', event => {
if (event.code === 'Space' || event.code === 'ArrowUp') {
event.preventDefault();
flap();
}
});入力元ごとに物理演算を書くのではなく、すべてをflap関数へ集約しています。ゲーム開始前に羽ばたいた場合はstartGameへ進み、プレイ中だけ鳥の速度を変更します。ゲームオーバー後の入力は無視するため、表示中の結果画面が勝手に消えません。
Web Audio APIでBGMと効果音を作る
BGMと効果音は音声ファイルを読み込まず、Web Audio APIの発振器とゲインを使って生成しています。BGM用と効果音用にゲインを分け、ボタンからそれぞれを個別にオン・オフできます。
function tone(frequency, duration = 0.07,
type = 'sine', volume = 0.045) {
if (!soundOn || !audioContext ||
audioContext.state !== 'running') return;
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
const now = audioContext.currentTime;
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, now);
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.0001,
now + duration);
oscillator.connect(gain).connect(effectGain);
oscillator.start(now);
oscillator.stop(now + duration);
}ブラウザの自動再生制限があるため、AudioContextはゲーム開始や最初の操作の中で初期化しています。タブが非表示になったときはvisibilitychangeでBGMを停止し、再び表示されたときにプレイ中なら再開します。
レスポンシブ表示と広告領域
Canvas自体の座標系は480×640のままにし、CSSで画面幅に合わせて縮小します。大画面ではゲームとサイドパネルを横並びにし、スマートフォンでは縦並びに変更します。スマートフォンだけに表示する羽ばたきボタンを用意しているため、Canvasを直接タップしにくい場合でも操作できます。
.fb-layout {
display: grid;
grid-template-columns: minmax(300px, 480px) 190px;
gap: 16px;
}
@media (max-width: 680px) {
.fb-layout { display: block; }
.fb-touch-controls { display: block; }
.fb-side {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
}ゲームページでは右側固定広告を使うため、ゲーム本体を広告と重ならない幅に収めています。ゲーム内の各sectionは共通スタイルの大きな余白を継承せず、10pxの間隔でまとまるようにしています。
実装して分かったこと
フラッピーバードでは、難しいアルゴリズムよりも、毎フレームの更新順序をそろえることが重要です。重力と位置の更新、土管の移動、スコア判定、衝突判定、ゲームオーバー処理を同じループで扱い、描画は最後にまとめることで、表示と内部状態のずれを防げます。
ゲームを遊ぶときは、実際のフラッピーバードのページで、クリック・タップ・キーボードそれぞれの操作を試せます。コードを読むときは、まずstartGame、update、hitsPipe、drawの4つを追うと、ゲーム全体の流れをつかみやすくなります。