GAME

ブラウザ版ミニゴルフの実装を解説

Canvasにコースを描き、ドラッグの距離をショットの強さへ変換し、壁の反射とカップインを判定するまで、9ホールのミニゴルフゲームの仕組みを順番に紹介します。

ミニゴルフは、ボールを後ろへ引く方向と距離を決め、離した瞬間にショットするゲームです。9つのコースにはそれぞれ壁があり、壁に当てた反射も利用しながら、少ない打数でカップを目指します。

このゲームは、PHPで共通ヘッダーを読み込むページと、HTMLのcanvasへ描画するJavaScriptで構成しています。ゲームの状態と物理計算はgame.jsに集め、見た目とレスポンシブ対応はgame.cssで管理しています。

ゲーム全体の構成

ゲームページには、スコア表示、Canvas、操作説明、サウンド設定、結果モーダルを配置しています。ゲーム開始前はオーバーレイを表示し、開始ボタンを押した後だけCanvasの操作を受け付けます。

ゲーム画面の中心部分HTML
<canvas id="course" class="mg-canvas"
    width="760" height="460"
    aria-label="ミニゴルフのコース"></canvas>
<div id="gameOverlay" class="mg-overlay show">
    <h2>9ホールに挑戦</h2>
    <button id="startButton" type="button">ゲーム開始</button>
</div>

Canvasの論理サイズは760×460に固定し、CSSで表示幅だけを変えます。JavaScriptは常に論理サイズを基準に計算するため、スマートフォンでCanvasが縮小表示されても、ボールの位置や壁の座標がずれません。

コースをデータとして定義する

9ホールの違いは、スタート位置、カップ位置、パー、壁の一覧です。これらをholes配列へまとめると、同じゲームロジックを使いながらコースだけを切り替えられます。

1ホール分のデータJAVASCRIPT
const holes = [
    {
        par: 3,
        start: [100, 350],
        cup: [660, 110],
        walls: []
    },
    {
        par: 3,
        start: [110, 105],
        cup: [650, 355],
        walls: [{ x: 280, y: 0, w: 26, h: 285 }]
    }
];

壁は左上の座標x、yと幅w、高さhを持つ矩形です。横長の壁と縦長の壁を組み合わせるだけで、直線的なコースから迷路のようなコースまで作れます。コースを増やすときも、配列へデータを追加すれば済みます。

ホールを切り替えて初期化する

現在のホール番号はholeIndexで管理しています。setHoleでは、ボールをスタート位置へ戻し、そのホールの打数、移動状態、狙い線、軌跡を初期化します。

ホールを開始するJAVASCRIPT
function setHole(index) {
    holeIndex = index;
    const current = currentHole();
    ball.x = current.start[0];
    ball.y = current.start[1];
    ball.vx = 0;
    ball.vy = 0;
    strokes = 0;
    moving = false;
    aiming = false;
    trail = [];
    updateHud();
    draw();
}

ボールの速度を0に戻すことが重要です。速度を初期化し忘れると、前のホールで残っていた動きが次のホールへ持ち越され、スタート直後にボールが動いてしまいます。

Canvasへコースを描画する

描画処理は毎フレームdrawを呼び、背景、壁、カップ、狙い線、軌跡、ボールの順に描きます。後から描いたものが手前に表示されるため、ボールを最後に描くと、壁や背景に隠れません。

背景と壁を描くJAVASCRIPT
function draw() {
    const current = currentHole();
    ctx.clearRect(0, 0, W, H);

    const gradient = ctx.createLinearGradient(0, 0, W, H);
    gradient.addColorStop(0, '#318d61');
    gradient.addColorStop(1, '#1c674d');
    ctx.fillStyle = gradient;
    ctx.fillRect(0, 0, W, H);

    ctx.fillStyle = '#0d4b39';
    current.walls.forEach(wall => {
        roundRect(wall.x, wall.y, wall.w, wall.h, 7);
    });
}

芝生のグラデーションと斜めの線を背景へ重ね、壁を濃い色で描いています。壁の形状は当たり判定でも同じ矩形データを使うため、見た目の壁と衝突判定の壁が別々になりません。

カップとボールを描くJAVASCRIPT
const [cx, cy] = current.cup;
ctx.fillStyle = '#092d25';
ctx.beginPath();
ctx.ellipse(cx, cy, 17, 9, 0, 0, Math.PI * 2);
ctx.fill();

ctx.fillStyle = '#f8fff8';
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
ctx.fill();

カップは楕円、旗は線と三角形、ボールは円として描いています。ゲーム上の位置はすべてCanvasの座標で管理し、画面上のピクセル数とは分けています。

ドラッグを狙いへ変換する

プレイヤーはボールからドラッグを開始し、ボールと反対方向へ引いてから指やマウスを離します。Canvasが縮小表示される可能性があるため、イベント座標をそのまま使わず、表示領域からの相対位置を論理座標へ変換します。

画面座標をCanvas座標へ変換するJAVASCRIPT
function resizePoint(event) {
    const rect = canvas.getBoundingClientRect();
    return {
        x: (event.clientX - rect.left) * W / rect.width,
        y: (event.clientY - rect.top) * H / rect.height
    };
}

clientXはブラウザー上の座標です。Canvasの表示幅が論理幅Wと異なる場合でも、rect.widthとの比率を掛けることで、ゲーム内の正しい位置へ変換できます。

狙いを表示するJAVASCRIPT
function drawAim() {
    const dx = ball.x - aimPoint.x;
    const dy = ball.y - aimPoint.y;
    const length = Math.hypot(dx, dy) || 1;
    const scale = Math.min(length, 155);
    const endX = ball.x + dx / length * scale;
    const endY = ball.y + dy / length * scale;

    ctx.setLineDash([7, 7]);
    ctx.beginPath();
    ctx.moveTo(ball.x, ball.y);
    ctx.lineTo(endX, endY);
    ctx.stroke();
    ctx.setLineDash([]);
}

ボールからドラッグ先へのベクトルを反転すると、ボールを引いた方向と逆向きのショット方向になります。線の長さは155までに制限し、引きすぎても強さが上限を超えないようにしています。

ショットの強さを速度へ変換する

ショット時は、ドラッグの距離を0から1の強さへ正規化し、その値へ最大速度を掛けます。打数はショットを実際に開始したときだけ増やし、短すぎるドラッグは無効にしています。

ボールを打つJAVASCRIPT
function shot() {
    const dx = ball.x - aimPoint.x;
    const dy = ball.y - aimPoint.y;
    const length = Math.hypot(dx, dy);
    if (length < 12) return;

    const strength = Math.min(length, 155) / 155;
    ball.vx = dx / length * (strength * 8.5);
    ball.vy = dy / length * (strength * 8.5);
    strokes += 1;
    totalStrokes += 1;
    moving = true;
    aiming = false;
}

dx / lengthとdy / lengthは方向だけを取り出す正規化ベクトルです。方向と強さを分けて考えることで、同じ方向へ弱く打つ場合と強く打つ場合を簡単に表現できます。

ゲームループでボールを動かす

ボールが動いている間は、毎フレーム位置へ速度を加え、速度へ摩擦を掛けます。速度が十分に小さくなったら停止させ、次のショットを受け付けます。

移動と摩擦JAVASCRIPT
function animate() {
    if (moving) {
        const oldX = ball.x;
        const oldY = ball.y;
        ball.x += ball.vx;
        ball.y += ball.vy;
        ball.vx *= .982;
        ball.vy *= .982;

        if (Math.hypot(ball.vx, ball.vy) < .08) {
            ball.vx = 0;
            ball.vy = 0;
            moving = false;
            trail = [];
        }
    }
    draw();
    requestAnimationFrame(animate);
}

摩擦係数.982を毎フレーム掛けると、ショット直後は速く、時間が経つとゆっくりになります。停止判定を設けないと、非常に小さな速度でいつまでもアニメーションが続くため、.08未満を停止としています。

画面の端と壁で反射させる

画面の端に出ないようにボールを範囲内へ戻し、速度の該当成分を反転させます。壁に衝突した場合は、衝突前の座標へ戻してから速度を反転します。反射時に.78を掛けているので、壁へ当たるたびに少しずつ勢いが落ちます。

矩形の壁との衝突JAVASCRIPT
function intersects(wall) {
    return ball.x + ball.r > wall.x &&
        ball.x - ball.r < wall.x + wall.w &&
        ball.y + ball.r > wall.y &&
        ball.y - ball.r < wall.y + wall.h;
}

function bounce(wall, oldX, oldY) {
    if (oldX + ball.r <= wall.x ||
        oldX - ball.r >= wall.x + wall.w) {
        ball.vx *= -.78;
    }
    if (oldY + ball.r <= wall.y ||
        oldY - ball.r >= wall.y + wall.h) {
        ball.vy *= -.78;
    }
    ball.x = oldX;
    ball.y = oldY;
}

ボールの中心だけで判定すると、半径分だけ壁へめり込むため、ball.rを加減して円と矩形の重なりを調べています。どの方向から壁へ入ったかは、移動前のoldXとoldYを使って判断します。

カップインを判定する

カップに近づいただけでは入ったことにせず、カップ中心からの距離とボールの速度を同時に確認します。これにより、速いボールがカップの上を通過しただけなのに入った扱いになることを防ぎます。

カップインの判定JAVASCRIPT
function checkCup() {
    const current = currentHole();
    const distance = Math.hypot(
        ball.x - current.cup[0],
        ball.y - current.cup[1]
    );

    if (distance < 16 && Math.hypot(ball.vx, ball.vy) < 2.2) {
        ball.x = current.cup[0];
        ball.y = current.cup[1];
        ball.vx = 0;
        ball.vy = 0;
        moving = false;
        return true;
    }
    return false;
}

判定が成功したらボールをカップ中心へ置き、速度を0にしてから次のホールへ進みます。最後のホールならfinishを呼び、合計打数とパーを表示します。

ポインターイベントでマウスとタッチに対応する

マウス専用のmousedownとタッチ専用のtouchstartを別々に使う代わりに、ポインターイベントを使っています。マウス、タッチ、ペンの入力を同じ処理で扱えるため、入力処理の重複を減らせます。

ドラッグからショットまでJAVASCRIPT
canvas.addEventListener('pointerdown', event => {
    if (!running || moving) return;
    const point = resizePoint(event);
    if (Math.hypot(point.x - ball.x, point.y - ball.y) > 32) return;

    aiming = true;
    aimPoint = point;
    canvas.setPointerCapture(event.pointerId);
});

canvas.addEventListener('pointermove', event => {
    if (!aiming) return;
    aimPoint = resizePoint(event);
    draw();
});

canvas.addEventListener('pointerup', event => {
    if (!aiming) return;
    aimPoint = resizePoint(event);
    aiming = false;
    shot();
});

ボールから32ピクセル以内で始まったドラッグだけを狙い操作として受け付けます。setPointerCaptureを呼ぶと、ドラッグ中にポインターがCanvasの外へ出ても、そのCanvasがpointermoveとpointerupを受け取れます。

ホール進行と合計打数を管理する

ホールごとの打数はstrokes、ラウンド全体の打数はtotalStrokesで分けています。カップイン後に少し待ってから次のホールへ移ることで、結果メッセージを読む時間を作っています。

次のホールへ進むJAVASCRIPT
window.setTimeout(() => {
    if (holeIndex === holes.length - 1) {
        finish();
    } else {
        setHole(holeIndex + 1);
    }
}, 950);

最後のホールかどうかをholeIndexと配列の長さで判定します。ホール数を9から変更しても、固定の数字ではなくholes.lengthを参照するため、進行処理を変更せずに済みます。

ベストスコアを保存する

ラウンド終了時の合計打数が過去の記録より少なければ、localStorageへ保存します。保存先はこのブラウザだけなので、ユーザー登録やサーバー通信なしで自己ベストを表示できます。

ベストスコアを更新するJAVASCRIPT
let best = Number(localStorage.getItem('mini-golf-best') || 0);

const isBest = !best || totalStrokes < best;
if (isBest) {
    best = totalStrokes;
    localStorage.setItem('mini-golf-best', String(best));
}

初回は保存値がないので0になります。0は実際のスコアとして成立しないため、!bestを条件に含めて初回スコアを保存します。ベスト表示はリセットしても残り、ブラウザの保存データを消すと初期化されます。

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

音声ファイルを読み込むのではなく、Web Audio APIの発振器とゲインを使って短い音を生成しています。ゲーム開始ボタンなど、ユーザー操作の中でAudioContextを初期化することで、自動再生制限にも対応しています。

短い音を鳴らすJAVASCRIPT
function tone(frequency, duration = .08, type = 'sine', gain = .08) {
    if (!audio || audio.state !== 'running' || !sfxOn) return;

    const now = audio.currentTime;
    const oscillator = audio.createOscillator();
    const envelope = audio.createGain();
    oscillator.type = type;
    oscillator.frequency.setValueAtTime(frequency, now);
    envelope.gain.setValueAtTime(.0001, now);
    envelope.gain.exponentialRampToValueAtTime(gain, now + .01);
    envelope.gain.exponentialRampToValueAtTime(.0001, now + duration);
    oscillator.connect(envelope).connect(master);
    oscillator.start(now);
    oscillator.stop(now + duration + .02);
}

ショット、壁への衝突、カップインで周波数や長さを変えています。BGMは一定間隔のタイマーから複数の音程を順番に鳴らし、BGMと効果音の設定を別々に切り替えられるようにしています。

レスポンシブ表示と操作性

大きな画面ではゲーム本体とサイドパネルを2列にし、760ピクセル以下では縦並びにします。Canvasにはtouch-action: noneを指定し、タッチドラッグ中にページ全体がスクロールしないようにしています。

狭い画面で縦並びにするCSS
.mg-layout {
    display: grid;
    grid-template-columns: minmax(300px, 760px) 220px;
    gap: 18px;
}

@media (max-width: 760px) {
    .mg-layout {
        display: block;
    }

    .mg-side {
        display: grid;
        grid-template-columns: 1fr 1fr;
    }
}

ゲーム本体の表示幅は画面に合わせて縮みますが、Canvasの論理座標は変えません。ボタンにはフォーカス表示を用意し、Canvasには代替テキストとなるaria-labelも設定しています。

実装して分かったこと

ミニゴルフで重要なのは、ボールの位置、速度、狙い中、移動中、ゲーム終了後という状態を混ぜないことです。描画は現在の状態を表示し、入力は状態を変更し、アニメーションは速度を位置へ反映する役割に分けると、処理の流れを追いやすくなります。

特に、画面座標からCanvas座標への変換、壁へのめり込みを防ぐための移動前座標、カップとの距離だけでなく速度も見る判定がポイントです。遊ぶときはミニゴルフのゲームページで、まず直線の1ホール目を試し、次に壁の反射が必要なコースへ進んでみてください。

コードを読む順番は、holesのコース定義、setHoleの初期化、shotの速度設定、animateの移動、checkCupの判定がおすすめです。この5つを追うと、1回のドラッグがカップインまでどのように処理されるかを理解できます。