GAME

ネオンランナーの実装を解説

Canvasのゲームループから、ジャンプとしゃがみ、障害物との衝突判定、スコア保存、BGM・効果音まで、横スクロールアクションの仕組みを順番に紹介します。

ネオンランナーは、自動で走り続けるプレイヤーを操作し、地面の障害物をジャンプで越え、低い飛行障害物をしゃがんで避けるゲームです。走行距離が伸びると速度が上がり、途中に現れるデータチップを取るとスコアが加算されます。

このゲームは、PHPで共通のページ部分を読み込み、ゲーム本体をHTML、CSS、JavaScriptで実装しています。ゲーム画面はCanvasに描画し、JavaScriptが毎フレーム状態を更新してから画面を書き換えます。マインスイーパーが盤面の状態を順番に更新するゲームだとすれば、ネオンランナーは時間の経過に合わせて世界を動かし続けるゲームです。

ゲームに必要な状態をまとめる

最初に、プレイヤー、障害物、チップ、走行距離、スコア、ゲームの状態などを変数として用意します。プレイヤーは位置と大きさに加えて、ジャンプ中の速度としゃがみ状態を持ちます。障害物とチップは、画面内に存在するものを配列で管理します。

ゲームの状態JAVASCRIPT
const player = {
    x: 100,
    y: groundY - 45,
    width: 34,
    height: 45,
    velocityY: 0,
    ducking: false
};

let obstacles = [];
let chips = [];
let distance = 0;
let score = 0;
let state = 'ready';
let lastTime = 0;

stateにはready、playing、paused、gameoverのいずれかを保存します。状態を一つの変数にまとめることで、開始前や一時停止中にゲームループが進まないようにしたり、終了後の入力を制限したりできます。

時間を基準にゲームを更新する

画面の更新にはrequestAnimationFrameを使います。ブラウザが次に画面を描画するタイミングでloopが呼ばれ、前回から経過した秒数をdtとしてupdateへ渡します。フレームレートが変わっても動きの速さが大きく変わらないように、移動量は必ずdtを掛けて計算します。

ゲームループJAVASCRIPT
function loop(now) {
    if (state !== 'playing') {
        draw();
        return;
    }

    const dt = Math.min((now - lastTime) / 1000, 0.035);
    lastTime = now;
    update(dt);
    draw();

    if (state === 'playing') {
        animationId = requestAnimationFrame(loop);
    }
}

dtには最大値を設定しています。タブを切り替えた直後などに長い時間が一度に渡されると、プレイヤーが障害物を飛び越したり、画面外へ飛んだりするためです。updateでゲームの状態を変え、drawでその結果をCanvasへ描画する役割分担にしています。

走行距離に合わせて速度を上げる

ネオンランナーでは、プレイヤー自身を画面の中で大きく移動させるのではなく、障害物や背景を左へ動かすことで走っているように見せています。速度は走行距離から計算し、一定の範囲で徐々に上昇させます。

速度を計算するJAVASCRIPT
function getSpeed() {
    return 260 + Math.min(260, distance * 0.018);
}

function update(dt) {
    const speed = getSpeed();
    distance += speed * dt;
    worldX = (worldX + speed * dt) % 48;

    obstacles.forEach(obstacle => {
        obstacle.x -= speed * dt;
    });
    chips.forEach(chip => {
        chip.x -= speed * dt;
    });
}

初期速度は260で、距離に応じて最大520まで上がります。背景の位置を表すworldXも同じ速度で進めるため、地面のラインが流れます。画面の外へ出たオブジェクトは配列から削除し、ゲームが長く続いても不要なデータが増え続けないようにしています。

重力とジャンプを実装する

ジャンプは、プレイヤーの縦方向の速度を一度だけ上向きに変え、その後は重力を毎フレーム加えます。速度が負の間は上昇し、0を超えると下降します。地面に着いたら位置と速度を初期化して、次のジャンプを受け付けられるようにします。

重力と着地JAVASCRIPT
player.velocityY += 1500 * dt;
player.y += player.velocityY * dt;

if (player.y >= groundY - player.height) {
    player.y = groundY - player.height;
    player.velocityY = 0;
}

function jump() {
    if (state === 'ready' || state === 'gameover') {
        startGame();
        return;
    }
    if (state !== 'playing' || player.y < groundY - player.height - 3) return;
    player.velocityY = -610;
    player.ducking = false;
}

ジャンプ中の再ジャンプを防ぐため、jumpではプレイヤーが地面付近にいるかを確認しています。開始前やゲームオーバー後にジャンプ操作をした場合は、そのまま新しいゲームを開始する設計です。

障害物とチップをランダムに出す

障害物は画面右端の少し外側に生成し、更新処理で左へ移動させます。地面の障害物と飛行障害物を同じ配列で扱い、flyingで種類を区別しています。飛行障害物は立った状態では当たり、しゃがむと下を通れる高さになるように配置します。

障害物を生成するJAVASCRIPT
function addObstacle() {
    const flying = distance > 520 && Math.random() < 0.27;
    const height = flying ? 25 : 26 + Math.random() * 26;
    const width = flying ? 42 : 22 + Math.random() * 18;
    const y = flying
        ? groundY - 52 - Math.random() * 4
        : groundY - height;

    obstacles.push({
        x: W + 25, y, width, height, flying, passed: false
    });
}

飛行障害物は走行距離が520を超えてから出現するため、ゲーム開始直後にジャンプとしゃがみを同時に覚えなくても済みます。生成間隔にも乱数と距離による補正を加え、進むほど次の障害物が少し早く現れるようにしています。チップも別のタイマーで生成し、取ったときに25点を加えます。

しゃがみを含めた衝突判定

見た目のプレイヤー全体をそのまま当たり判定にすると、少し離れているのに衝突したように感じることがあります。そこで、プレイヤーの左右と上下に余白を設けた小さな矩形を作り、実際の衝突判定に使います。しゃがみ中は矩形の高さを低くします。

プレイヤーの当たり判定JAVASCRIPT
function playerBox() {
    const height = player.ducking ? 27 : player.height;
    return {
        x: player.x + 5,
        y: player.y + player.height - height + 4,
        width: player.width - 10,
        height: height - 7
    };
}

function overlaps(a, b) {
    return a.x < b.x + b.width &&
        a.x + a.width > b.x &&
        a.y < b.y + b.height &&
        a.y + a.height > b.y;
}

二つの矩形が重なっているかを、上下左右の4条件で判定しています。updateでは、まず障害物との衝突を調べ、衝突したらfinishGameを呼びます。チップについては同じ判定関数を使い、重なったチップを回収済みにしてスコアを加算します。

通過スコアとゲームオーバー

障害物をプレイヤーの後ろまで通過させたときにも1点を加えています。障害物にpassedを持たせることで、同じ障害物が画面外へ進むまで何度も加点されるのを防ぎます。

障害物を通過したか調べるJAVASCRIPT
obstacles.forEach(obstacle => {
    if (!obstacle.passed &&
        obstacle.x + obstacle.width < player.x) {
        obstacle.passed = true;
        score += 1;
    }
});

if (obstacles.some(obstacle => overlaps(box, obstacle))) {
    finishGame();
    return;
}

finishGameでは状態をgameoverに変え、BGMを停止し、ベストスコアを更新して画面中央のオーバーレイを表示します。ゲームループは状態を確認して次のフレームを予約するため、終了後にオブジェクトだけが動き続けることもありません。

Canvasに背景とキャラクターを描く

描画は背景、チップ、障害物、地面、プレイヤーの順に行います。後から描いたものが前面に表示されるため、地面を障害物より後に描くことで、障害物の足元が地面に接して見えます。背景の空や山、地面のラインもCanvas APIの図形で作っています。

1フレームを描画するJAVASCRIPT
function draw() {
    drawBackground();
    chips.forEach(drawChip);
    obstacles.forEach(drawObstacle);
    drawGround();
    drawPlayer();
}

プレイヤーや障害物は、描画位置と当たり判定の位置を同じ状態から計算しています。障害物の種類によって色や形を変え、プレイヤーがしゃがんだときは描画する高さも縮めています。CanvasのshadowColorとshadowBlurを使うことで、画像素材を用意せずにネオン風の発光を表現しました。

キーボードとタッチ操作を共通化する

ジャンプ処理は、キーボード、Canvasのタップ、画面下のジャンプボタンから同じjump関数を呼び出します。しゃがみもキーボードの押下・解放とボタンのポインター操作から、同じsetDuckingへ集約しています。

入力をゲーム処理へ渡すJAVASCRIPT
document.addEventListener('keydown', event => {
    if (event.code === 'Space' ||
        event.code === 'ArrowUp' || event.code === 'KeyW') {
        event.preventDefault();
        jump();
    }
    if (event.code === 'ArrowDown' || event.code === 'KeyS') {
        event.preventDefault();
        setDucking(true);
    }
});

document.addEventListener('keyup', event => {
    if (event.code === 'ArrowDown' || event.code === 'KeyS') {
        setDucking(false);
    }
});

canvas.addEventListener('pointerdown', event => {
    event.preventDefault();
    jump();
});

ブラウザ標準のスクロールや選択がゲーム操作の邪魔をしないよう、必要なキーやポインターイベントではpreventDefaultを呼びます。しゃがみは押している間だけ有効にし、pointerup、pointercancel、pointerleaveで解除しています。

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

音声ファイルを読み込まず、Web Audio APIの発振器とゲインを使って短い音を生成しています。効果音はジャンプ、チップ取得、一定数の通過、ゲームオーバーなどで鳴らします。BGMは音程の配列を250ミリ秒ごとに読み出して、簡単なメロディーとして再生します。

短い効果音を鳴らすJAVASCRIPT
function tone(frequency, duration = 0.08,
    type = 'square', level = 0.08) {
    if (!sfxOn || !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(level, now);
    gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
    oscillator.connect(gain).connect(masterGain);
    oscillator.start(now);
    oscillator.stop(now + duration);
}

ブラウザの自動再生制限に対応するため、AudioContextの初期化はゲーム開始やボタン操作の中で行っています。BGMと効果音は別々のフラグで管理し、全体の音量は共通のゲインへ反映します。ページを離れたときは一時停止処理を呼び出し、裏で音やゲームが進み続けないようにしています。

ベストスコアをブラウザに保存する

ベストスコアはサーバーへ送信せず、ブラウザのlocalStorageへ保存しています。ページを開いたときに保存済みの値を読み込み、ゲームオーバー時に今回のスコアが上回っていれば更新します。

ベストスコアの保存JAVASCRIPT
const BEST_KEY = 'sky-runner-best';
let best = Number(localStorage.getItem(BEST_KEY) || 0);

function finishGame() {
    state = 'gameover';
    stopMusic();

    if (score > best) {
        best = score;
        localStorage.setItem(BEST_KEY, String(best));
    }
    updateHud();
}

保存キーにゲーム固有の名前を付けているため、他のゲームの記録と混ざりません。保存されるのは同じブラウザの同じサイトでの記録なので、端末やブラウザを変えると共有されない点には注意が必要です。

実装して分かったこと

ネオンランナーでは、プレイヤーを画面中央に置き、周囲のオブジェクトを時間に合わせて動かすことで、シンプルな構成でも横スクロール感を作れました。特に、移動量をフレーム数ではなく経過時間で計算すること、描画用の見た目と衝突判定用の矩形を分けることが、動作を安定させるポイントです。

また、障害物の生成、物理演算、衝突判定、スコア、音声をすべてupdateから呼び出せる形にすると、ゲームの進行を追いやすくなります。コードを読むときは、まずstartGame、loop、update、drawの順に追い、その後でjump、addObstacle、finishGameを確認すると全体像をつかみやすくなります。

実際に遊ぶときはネオンランナーのゲームページを開いて、ジャンプとしゃがみのタイミングを試してみてください。最初は地面の障害物だけを意識し、速度が上がってきたら飛行障害物とチップにも対応すると、少しずつ記録を伸ばせます。