GAME

ブラウザ版スカイホッパーの実装を解説

足場を渡りながら自動で跳び続けるゲームを題材に、Canvas描画、物理演算、カメラのスクロール、入力、アイテム、音声の仕組みを紹介します。

スカイホッパーは、左右に移動して足場へ着地し、画面の上へ進み続けるアクションゲームです。プレイヤーは自動的に跳ねるため、主な操作は左右の移動だけです。短いコードの中に、ゲームループ、物理演算、衝突判定、スクロール、効果音、タッチ操作など、ブラウザゲームの基本が含まれています。

ゲームページはPHPで共通のヘッダーやフッターを読み込み、ゲーム本体はHTMLのCanvasとJavaScriptで動かしています。Canvasの大きさは420×640に固定し、CSSで小さな画面にも収まるようにしています。

ゲームの状態と定数をまとめる

ゲームの進行に必要な値は、プレイヤー、足場、カメラ、特殊効果、スコアなどに分けて管理しています。画面の大きさや重力を定数にしておくと、難易度や操作感を調整しやすくなります。

ゲームの基本設定JAVASCRIPT
const W = 420;
const H = 640;
const GRAVITY = 0.34;
const JUMP = -10.6;
const PLATFORM_H = 12;
const MAX_PLATFORMS = 18;

let player;
let platforms = [];
let camera = 0;
let effects = { jump: 0, narrow: 0, low: 0 };
let running = false;
let paused = false;

playerには位置と速度、platformsには足場の配列を保存します。cameraはワールド座標を画面へ表示するための移動量です。アイテムの効果時間はeffectsに秒数として保存し、毎フレーム少しずつ減らします。

次の足場を到達可能にして生成する

足場を完全にランダムに置くと、ジャンプでは届かない場所が生まれてしまいます。そこで、直前の足場を基準にして、次の足場の高さと横方向の距離を制限します。横位置は画面外へ出ないようにMath.maxとMath.minで挟みます。

次の足場を作る考え方JAVASCRIPT
function makeReachablePlatform(y, previous) {
    const width = 60 + Math.random() * 51;
    const direction = Math.random() < 0.5 ? -1 : 1;
    const offset = 70 + Math.random() * 65;
    const center = previous.x + previous.width / 2 + direction * offset;
    const x = Math.max(8, Math.min(W - width - 8, center - width / 2));

    return makePlatform(
        y,
        x,
        width,
        Math.random() < 0.15 ? 'breakable' : 'normal'
    );
}

通常の足場だけでなく、一定時間だけ存在する足場や左右に動く足場も混ぜています。足場を生成する関数を分けることで、「画面の初期配置」と「上へ進んだときの追加配置」で同じデータ形式を使えます。

重力と自動ジャンプをゲームループで更新する

毎フレーム、まず速度に重力を加え、その速度を位置へ反映します。プレイヤーが足場へ着地したときだけ上向きの速度を設定するため、ボタンを押さなくても連続して跳ねる仕組みになります。

プレイヤーの物理演算JAVASCRIPT
function update(dt) {
    const step = Math.min(2, dt / 16.67);
    const gravity = effects.low > 0 ? GRAVITY * 0.58 : GRAVITY;

    player.vy += gravity * step;
    player.y += player.vy * step;
    player.x += player.vx * step;

    if (player.x > W) player.x = -player.width;
    if (player.x + player.width < 0) player.x = W;
}

フレーム間隔が一時的に大きくなっても動きが極端に進まないよう、dtから作る倍率を2までに制限しています。左右端では反対側から現れるラップ処理を入れて、画面端で移動が止まらないようにしています。

落下中だけ足場との衝突を判定する

足場に乗る判定では、プレイヤーが上昇中に足場をすり抜けることが重要です。そこで、現在の足元だけでなく前フレームの足元も使い、「前は足場より上、今は足場の高さ以下」という条件を確認します。横方向も重なっている場合だけ着地にします。

足場への着地JAVASCRIPT
const previousBottom = player.y - player.vy + player.height;
const currentBottom = player.y + player.height;

if (player.vy > 0 &&
    previousBottom <= platform.y &&
    currentBottom >= platform.y &&
    player.x + player.width > platform.x &&
    player.x < platform.x + platform.width) {
    player.y = platform.y - player.height;
    player.vy = JUMP * (effects.jump > 0 ? 1.35 : 1);
}

速度が正のときだけ判定することで、足場の裏側から上へ抜ける動作を許可しています。着地位置を足場の上へ補正してから速度を上向きに戻すため、足場にめり込まず自然に跳ね返ります。

動く足場と壊れる足場

足場にはnormal、moving、breakableなどの種類があります。動く足場は、基準位置baseXと時間から正弦波を計算して左右へ動かします。壊れる足場は着地したフレームでbrokenにして、次の更新で描画と衝突の対象から外します。

動く足場の位置を更新するJAVASCRIPT
if (platform.type === 'moving') {
    platform.x = platform.baseX +
        Math.sin(gameFrame * 0.035 + platform.phase) * 42;
    platform.x = Math.max(8, Math.min(
        W - platform.width - 8,
        platform.x
    ));
}

ランダムに動かすのではなく、同じ式から位置を求めるため、動きが滑らかで再現しやすくなります。画面端で足場が見切れないように、ここでも位置を画面内へ制限しています。

カメラを追従させて高さをスコアにする

プレイヤーが画面上部から220ピクセル以内へ入ると、カメラを上へ動かします。プレイヤーや足場はワールド座標のまま保持し、描画時だけcameraを引くことで、足場の配置と画面表示を分離しています。

カメラとスコアJAVASCRIPT
if (player.y - camera < 220) {
    camera = player.y - 220;
}

const score = Math.max(0, Math.floor(-camera / 4));
scoreEl.textContent = String(score);

const best = Number(localStorage.getItem('doodle-jump-best') || 0);
if (score > best) {
    localStorage.setItem('doodle-jump-best', String(score));
}

カメラが上がるほどワールド座標は小さくなるため、カメラの移動量を4で割った値を高さとして表示しています。自己ベストはlocalStorageへ保存し、サーバー側のアカウントなしで同じブラウザから記録を続けられるようにしています。

アイテムでプレイ感を変える

足場には、ジャンプ力を上げるjump、足場の当たり判定を狭くするnarrow、重力を弱くするlowのアイテムが出現します。取得時に効果名と秒数を設定し、更新処理でカウントダウンします。

アイテム効果を時間制で管理するJAVASCRIPT
function collectItem(item) {
    if (item.type === 'jump') {
        effects.jump = 8;
    } else if (item.type === 'narrow') {
        effects.narrow = 7;
    } else if (item.type === 'low') {
        effects.low = 7;
    }
}

effects.jump = Math.max(0, effects.jump - dt / 1000);
effects.narrow = Math.max(0, effects.narrow - dt / 1000);
effects.low = Math.max(0, effects.low - dt / 1000);

効果そのものをプレイヤーへ直接書き換えるのではなく、残り時間を状態として持たせています。ジャンプ力、重力、足場の幅を計算する場所がそれぞれの効果を参照するため、時間切れになると自然に通常状態へ戻ります。

Canvasの描画とゲームループ

描画処理では、空のグラデーション、雲、足場、アイテム、プレイヤーを順番に描きます。足場やプレイヤーの座標からcameraを引いて、スクロールしたワールドを画面へ表示します。

更新と描画を繰り返すJAVASCRIPT
function loop(now) {
    const dt = lastTime ? now - lastTime : 16.67;
    lastTime = now;

    if (running && !paused) {
        update(dt);
    }
    draw();
    animationId = requestAnimationFrame(loop);
}

requestAnimationFrameを使うと、ブラウザの描画タイミングに合わせて処理できます。ゲームが一時停止中でもdrawは続けるため、再開ボタンや一時停止表示をすぐに反映できます。タブが非表示になったときは一時停止し、戻ってきた瞬間に大きく時間が進む問題も防いでいます。

キーボードとタッチ操作を同じ状態へ集約する

キーボードでは左右矢印キーとA・Dキー、スマートフォンでは画面下の左右ボタンを使います。入力方法ごとにプレイヤーの位置を直接変更せず、左右方向を表す値へ集約してから、更新処理で速度へ反映しています。

左右入力を切り替えるJAVASCRIPT
function setDirection(direction) {
    player.direction = direction;
    player.vx = direction * 4.2;
}

window.addEventListener('keydown', event => {
    if (event.key === 'ArrowLeft' || event.key.toLowerCase() === 'a') {
        setDirection(-1);
    }
    if (event.key === 'ArrowRight' || event.key.toLowerCase() === 'd') {
        setDirection(1);
    }
});

タッチボタンにはpointerdownとpointerupを登録し、指を押している間だけ方向を維持します。マウス、タッチ、ペンを同じPointer Eventsで扱えるため、操作処理の重複を抑えられます。スマートフォンではCSSのタッチ操作用パネルを表示し、Canvasの外側で操作できるようにしています。

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

音声ファイルを用意する代わりに、Web Audio APIの発振器で短い音を生成しています。ジャンプ、着地、アイテム取得、ゲームオーバーなどで周波数や長さを変え、BGMは一定間隔で音を鳴らします。AudioContextは、ブラウザの自動再生制限に合わせてユーザー操作後に初期化します。

短い音を生成するJAVASCRIPT
function tone(freq, duration = 0.06, type = 'square', volume = 0.045) {
    if (!audioCtx || sfxMuted) return;

    const oscillator = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    const time = audioCtx.currentTime;

    oscillator.type = type;
    oscillator.frequency.setValueAtTime(freq, time);
    gain.gain.setValueAtTime(volume, time);
    gain.gain.exponentialRampToValueAtTime(0.001, time + duration);
    oscillator.connect(gain).connect(audioCtx.destination);
    oscillator.start(time);
    oscillator.stop(time + duration);
}

BGMと効果音には別々のミュート状態を持たせ、設定ボタンから個別に切り替えられるようにしています。ゲームの状態管理と音声処理を分けることで、ゲームオーバー時の停止や再スタート時の再開も整理しやすくなります。

まとめ

スカイホッパーでは、足場を到達可能な範囲で生成し、重力と着地判定で自動ジャンプを作り、カメラの移動量を高さへ変換しています。さらに、動く足場や壊れる足場、時間制のアイテム効果を同じ状態管理へ組み込みました。

ゲームループと描画を分け、キーボードとタッチの入力を方向値へ集約すると、ブラウザゲームを拡張しやすくなります。実際の動きや効果音を確認したい場合は、スカイホッパーを遊んでみてください。