GAME

アステロイドランの実装を解説

Canvasに描く宇宙船と隕石、ゲームループ、画面端のワープ、隕石の分裂、入力処理、BGM・効果音まで、ブラウザ版アステロイドランの仕組みを紹介します。

アステロイドランは、宇宙船を操作して画面内の隕石をすべて破壊するシューティングゲームです。隕石は撃つと小さく分裂し、宇宙船は画面の端から反対側へ回り込みます。シンプルなルールですが、Canvasへの描画、毎フレームの更新、衝突判定、ゲーム状態の管理など、ブラウザゲームの基本をまとめて学べる題材です。

ゲームのページはPHPで共通ヘッダーやフッターを読み込み、ゲーム本体はJavaScriptで動かしています。画像素材やゲームエンジンには頼らず、Canvas 2D APIで宇宙船、隕石、レーザー、星、爆発エフェクトを描画しています。

ゲームオブジェクトを配列で管理する

ゲーム中に複数存在する隕石、レーザー、爆発の粒子は、それぞれ配列で管理しています。宇宙船は1機だけなのでオブジェクトとして持ち、毎フレーム位置や速度を更新します。

ゲームの状態を用意するJAVASCRIPT
let ship;
let rocks = [];
let shots = [];
let sparks = [];
let score = 0;
let wave = 1;
let ships = 3;
let running = false;
let paused = false;

隕石には座標、半径、大きさ、速度、回転角度、形状を保存します。レーザーには寿命を持たせ、爆発の粒子には速度と残り時間を持たせることで、同じ配列を使いながらそれぞれ異なる動きを表現できます。

隕石を生成するJAVASCRIPT
function makeRock(x, y, size = 3) {
    const radius = [0, 14, 25, 39][size];
    const angle = rnd(0, Math.PI * 2);
    const speed = rnd(18, 50) + wave * 3;

    return {
        x, y, r: radius, size,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed,
        angle: rnd(0, Math.PI * 2),
        spin: rnd(-1, 1),
        shape: Array.from({length: 9}, () => rnd(.72, 1.2))
    };
}

大きさごとに半径を変え、九つの頂点を少しずつ変形させています。円をそのまま描くのではなく、頂点ごとの倍率を使うことで、毎回少し違う岩石らしい輪郭になります。

requestAnimationFrameでゲームループを作る

ゲームは、入力を受け取り、オブジェクトを動かし、衝突を調べ、最後に画面を描く処理を繰り返します。ブラウザの描画タイミングに合わせるため、ループの呼び出しにはrequestAnimationFrameを使います。

ゲームループJAVASCRIPT
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);
}

nowと前回の時刻の差から、前のフレームから経過した秒数dtを求めています。速度にdtを掛けると、画面の更新回数が変わっても移動量が時間ベースになります。タブの切り替えなどで大きな値になったときにゲームが飛びすぎないよう、最大値も制限しています。

宇宙船を旋回と推進で動かす

宇宙船は、向いている角度、速度、位置を持ちます。左右キーでは角度を変え、推進キーでは向いている方向へ加速度を加えます。速度には少しずつ減衰をかけ、キーを離すとゆっくり減速する操作感にしています。

宇宙船の移動JAVASCRIPT
ship.angle += ((keys.right ? 1 : 0) -
    (keys.left ? 1 : 0)) * 3.7 * dt;

if (keys.thrust) {
    ship.vx += Math.cos(ship.angle) * 165 * dt;
    ship.vy += Math.sin(ship.angle) * 165 * dt;
}

ship.vx *= .992;
ship.vy *= .992;
ship.x += ship.vx * dt;
ship.y += ship.vy * dt;

宇宙船の前方は、角度の余弦をX方向、正弦をY方向として求めます。これを使えば、画面上のどの方向を向いていても同じ処理で推進できます。推進中は船尾に粒子を追加し、加速していることが見た目でも分かるようにしています。

画面端を通り抜けるワープ処理

アステロイドランでは、宇宙船や隕石が画面の端に到達すると、反対側から現れます。Canvasの外へ完全に消えてから瞬間移動すると見えなくなる時間ができるため、半径分の余白を含めて判定します。

画面端を回り込むJAVASCRIPT
function wrap(object) {
    if (object.x < -object.r) object.x = W + object.r;
    if (object.x > W + object.r) object.x = -object.r;
    if (object.y < -object.r) object.y = H + object.r;
    if (object.y > H + object.r) object.y = -object.r;
}

この処理を宇宙船、隕石、レーザーに共通して使っています。オブジェクトごとの移動処理と画面端の処理を分けることで、更新処理の見通しがよくなります。

レーザーと隕石の衝突を調べる

レーザーと隕石の衝突は、二つの中心点の距離が、それぞれの半径の合計より小さいかどうかで判定しています。レーザーは細長く見えますが、判定上は小さな円として扱うことで計算を簡単にしています。

円形オブジェクトの衝突判定JAVASCRIPT
function hit(a, b) {
    return Math.hypot(a.x - b.x, a.y - b.y) < a.r + b.r;
}

for (const shot of shots) {
    for (const rock of rocks) {
        if (!shot.dead && !rock.dead && hit(shot, rock)) {
            shot.dead = true;
            rock.dead = true;
            score += [0, 80, 40, 20][rock.size];
        }
    }
}

衝突したオブジェクトにはすぐ配列から削除せず、deadを付けます。ループ中に配列の要素を削除すると、次の要素を飛ばす可能性があるためです。衝突の確認が終わったあと、filterで寿命切れや破壊済みの要素をまとめて取り除きます。

大きな隕石を二つに分裂させる

大きさが中以上の隕石にレーザーが当たると、元の隕石を消して、サイズを一段階下げた隕石を二つ追加します。子ども側に少しランダムな速度を加えることで、同じ場所から別々の方向へ飛び出します。

隕石を分裂させるJAVASCRIPT
if (rock.size > 1) {
    for (let i = 0; i < 2; i++) {
        const child = makeRock(rock.x, rock.y, rock.size - 1);
        child.vx += rnd(-70, 70);
        child.vy += rnd(-70, 70);
        rocks.push(child);
    }
}

大きな隕石ほど得点を高くし、小さな隕石を残しておくことで、ウェーブの最後まで気を抜けないルールになります。すべての隕石にdeadが付いたあとで次のウェーブへ進めば、分裂した子どもを数え落とすこともありません。

ウェーブ、残機、ゲームオーバーを管理する

ウェーブが進むたびに隕石の数と速度が増えます。最初のウェーブでは少なめに生成し、上限を設けて画面が極端に埋まらないようにしています。全12ウェーブを突破するとクリア、残機が0になるとゲームオーバーです。

ウェーブを生成するJAVASCRIPT
function newWave() {
    rocks = [];
    const count = Math.min(5 + wave * 2, 19);

    for (let i = 0; i < count; i++) {
        let position;
        do {
            position = {x: rnd(25, W - 25),
                y: rnd(25, H - 25)};
        } while (Math.hypot(position.x - W / 2, position.y - H / 2) < 140);

        rocks.push(makeRock(position.x, position.y, 3));
    }
    newShip();
}

開始直後に宇宙船と隕石が重ならないよう、画面中央から一定距離以内の位置を候補から除外しています。宇宙船を失ったときは短い復活待ち時間を設け、無敵時間付きで新しい宇宙船を出します。

被弾時の処理JAVASCRIPT
function damage() {
    if (ship.inv > 0 || respawn > 0) return;

    ships--;
    burst(ship.x, ship.y, '#ffad66', 24);
    if (ships <= 0) {
        finish(false);
        return;
    }

    respawn = 1.8;
    newShip();
}

invは無敵時間、respawnは復活待ちを表します。被弾直後に同じ隕石との接触を何度も数えないための安全装置です。宇宙船を点滅させる表示もこの無敵時間を利用しています。

Canvasに星、隕石、宇宙船を描く

描画処理は、背景、星、隕石、レーザー、爆発、宇宙船の順に行います。先に描いたものが後ろに見えるため、背景から前景へ順番に描くと自然な重なりになります。

隕石の輪郭を描くJAVASCRIPT
function drawRock(rock) {
    x.save();
    x.translate(rock.x, rock.y);
    x.rotate(rock.angle);
    x.beginPath();

    for (let i = 0; i < rock.shape.length; i++) {
        const angle = i * Math.PI * 2 / rock.shape.length;
        const radius = rock.r * rock.shape[i];
        const px = Math.cos(angle) * radius;
        const py = Math.sin(angle) * radius;
        i ? x.lineTo(px, py) : x.moveTo(px, py);
    }

    x.closePath();
    x.fill();
    x.stroke();
    x.restore();
}

saveとrestoreで描画状態を囲むと、位置移動や回転がほかのオブジェクトに影響しません。隕石の形状は生成時に保存しているため、フレームごとに形を作り直さず、回転だけを更新できます。

キーボード、タッチ、マウスに対応する

キーボードでは左右キーまたはA・Dで旋回、上下キーまたはWで推進、スペースキーで発射、Pで一時停止を行います。キーを押している間だけフラグをtrueにし、ゲームループ側でその状態を読み取ります。

キーボード入力JAVASCRIPT
document.addEventListener('keydown', event => {
    if (event.code === 'ArrowLeft' || event.code === 'KeyA') {
        keys.left = true;
    }
    if (event.code === 'ArrowRight' || event.code === 'KeyD') {
        keys.right = true;
    }
    if (event.code === 'ArrowUp' || event.code === 'KeyW') {
        keys.thrust = true;
    }
    if (event.code === 'Space') fire();
});

スマートフォンでは画面下に旋回と推進のボタンを表示します。さらにCanvasをタップした位置へ宇宙船を向けて発射できるようにし、キーボードがない環境でも主な操作ができるようにしています。ボタンにはtouch-action: noneを指定し、連続入力中のスクロールを防いでいます。

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

音声ファイルは読み込まず、Web Audio APIの発振器とゲインを使って音を生成しています。レーザー、分裂、被弾、ウェーブ切り替えなどで周波数や波形を変えると、短い処理でもゲームらしい反応を作れます。

短い音を生成するJAVASCRIPT
function tone(frequency, duration = .08, type = 'square', volume = .12) {
    if (!audio || sfxMuted) return;

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

    oscillator.type = type;
    oscillator.frequency.setValueAtTime(frequency, time);
    gain.gain.setValueAtTime(volume, time);
    gain.gain.exponentialRampToValueAtTime(.0001, time + duration);
    oscillator.connect(gain).connect(master);
    oscillator.start(time);
    oscillator.stop(time + duration + .02);
}

ブラウザの自動再生制限に対応するため、AudioContextは「ミッション開始」などのユーザー操作の中で初期化します。BGMと効果音に別々のミュート状態を持たせ、音を消してもゲームの操作や進行は止まらないようにしています。

ハイスコアをlocalStorageに保存する

ハイスコアはサーバーへ送信せず、ブラウザのlocalStorageへ保存しています。ゲーム終了時に現在のスコアと記録を比較し、上回ったときだけ更新します。

ハイスコアを更新するJAVASCRIPT
let high = Number(localStorage.getItem('asteroid-run-high') || 0);

if (score > high) {
    high = score;
    localStorage.setItem('asteroid-run-high', String(high));
}

同じブラウザで遊ぶ限り、ページを再読み込みしても記録が残ります。ユーザー登録やデータベースを使わないゲームでは、少ないコードで自己ベストを実現できる方法です。

実装して分かったこと

アステロイドランでは、派手なグラフィックよりも「更新」と「描画」を分けることが重要です。updateでは位置、速度、衝突、スコアを変更し、drawではその時点の状態をCanvasへ表示します。この役割分担ができていると、一時停止やウェーブ切り替え、タッチ操作を追加しやすくなります。

また、隕石の分裂ではループ中に配列を変更するため、破壊フラグを付けてから最後に削除する設計が役立ちます。オブジェクトの生成、更新、衝突、削除の順序を意識すると、ゲームが複雑になっても不具合の原因を追いやすくなります。

実際に遊ぶときはアステロイドランのゲームページをご覧ください。コードを読むときは、まずloop、update、drawの3つを追い、そのあとにmakeRock、fire、damageを読むと、全体の流れをつかみやすくなります。