GAME

ブラウザ版マインスイーパーの実装を解説

盤面の作成から地雷の配置、空白マスの連鎖展開、マウスとタッチ操作への対応まで、ゲームページの実装を順番に紹介します。

マインスイーパーは、数字を手がかりに地雷の位置を推理し、安全なマスをすべて開くゲームです。見た目はシンプルですが、盤面の状態管理、周囲のマスの探索、複数の入力方法、ゲーム開始と終了の管理など、ブラウザゲームに必要な要素が一通り含まれています。

今回のゲームは、外部ライブラリを使わず、PHPのページにHTML、CSS、JavaScriptを記述しています。PHPはページの共通部分を読み込むために使い、ゲームの進行はブラウザ上のJavaScriptで完結させています。

ゲームの状態を配列で管理する

盤面は、マスごとの状態を持つオブジェクトの配列として管理しています。初級なら9×9、中級なら16×16、上級なら30×16なので、行と列を二次元配列で持つ方法も考えられます。今回は一つの配列に並べ、インデックスから座標を計算する形にしました。

盤面の初期状態JAVASCRIPT
cells = Array.from(
    { length: settings.cols * settings.rows },
    () => ({
        mine: false,
        flag: false,
        open: false,
        count: 0
    })
);

mineは地雷かどうか、flagは旗を立てたか、openは開いたかを表します。countには、そのマスの周囲8マスにある地雷の数を保存します。表示用のDOMとゲーム内部の状態を分けることで、再描画や難易度変更もしやすくなります。

最初のクリックを安全にする

ゲーム開始前には地雷を配置していません。最初にマスが開かれたとき、クリックされたマスとその周囲を地雷配置の候補から除外し、残った場所をシャッフルして地雷を置きます。これにより、開始直後に必ず地雷を踏むことを防いでいます。

地雷を配置するJAVASCRIPT
function placeMines(safeIndex) {
    const excluded = new Set([safeIndex, ...neighbors(safeIndex)]);
    const choices = [];

    for (let i = 0; i < cells.length; i++) {
        if (!excluded.has(i)) choices.push(i);
    }

    // Fisher-Yates法で候補をシャッフル
    for (let i = choices.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [choices[i], choices[j]] = [choices[j], choices[i]];
    }

    choices.slice(0, settings.mines)
        .forEach(index => cells[index].mine = true);
}

シャッフルにはFisher-Yates法を使っています。配列の後ろから、まだ確定していない位置とランダムに選んだ位置を交換する方法です。候補を順に選ぶよりも、各配置を均等に作りやすいという利点があります。

周囲8マスを座標から求める

配列のインデックスだけでは、上下左右や斜めの関係を扱いにくいため、まず列と行へ変換します。あとは、横方向と縦方向をそれぞれ-1から1まで動かし、自分自身を除いた最大8マスを調べます。盤面の端では、範囲外にならないかを確認します。

周囲のマスを取得するJAVASCRIPT
function neighbors(index) {
    const x = index % settings.cols;
    const y = Math.floor(index / settings.cols);
    const result = [];

    for (let dy = -1; dy <= 1; dy++) {
        for (let dx = -1; dx <= 1; dx++) {
            if (!dx && !dy) continue;
            const nx = x + dx;
            const ny = y + dy;
            if (nx >= 0 && nx < settings.cols &&
                ny >= 0 && ny < settings.rows) {
                result.push(ny * settings.cols + nx);
            }
        }
    }
    return result;
}

地雷を置いたあと、すべての安全なマスについてこの関数を使い、周囲にある地雷を数えます。開くときの判定と数字の計算で同じ関数を再利用できるため、周囲の定義が処理ごとにずれる心配もありません。

空白マスをまとめて開く

数字が0のマスを開くと、その周囲にある安全なマスも自動的に開かれます。この処理は再帰でも書けますが、今回はキューを使った幅優先探索にしています。開く候補をキューに追加し、取り出したマスが空白ならさらに近傍を追加します。

空白マスを連鎖して開くJAVASCRIPT
const queue = [index];
const seen = new Set();

while (queue.length) {
    const current = queue.shift();
    const cell = cells[current];

    if (seen.has(current) || cell.open || cell.flag || cell.mine) {
        continue;
    }
    seen.add(current);
    cell.open = true;

    if (cell.count === 0) {
        neighbors(current).forEach(next => queue.push(next));
    }
}

seenを使って、同じマスを何度も処理しないようにしています。旗を立てたマスや地雷はキューに入っても開かず、数字のマスではそこで展開が止まります。この分岐によって、空白領域だけが自然に広がります。

クリック、右クリック、長押しを使い分ける

操作方法は、マスを開くクリック、旗を立てる右クリック、スマートフォン向けの長押しに分けています。右クリックではブラウザ標準のコンテキストメニューをキャンセルし、対象マスの旗を切り替えます。タッチ操作では、pointerdownから480ミリ秒後に長押しと判定し、直後のクリックでマスが開かないようにフラグを管理しています。

通常クリックと右クリックJAVASCRIPT
boardEl.addEventListener('click', event => {
    const cellEl = event.target.closest('.cell');
    if (!cellEl || longPressed) {
        longPressed = false;
        return;
    }

    const index = Number(cellEl.dataset.index);
    if (cells[index].open) {
        chord(index);
    } else {
        openCell(index);
    }
});

boardEl.addEventListener('contextmenu', event => {
    const cellEl = event.target.closest('.cell');
    if (!cellEl) return;
    event.preventDefault();
    toggleFlag(Number(cellEl.dataset.index));
});

クリックされた要素から.cellを探し、data-indexを数値へ変換して盤面の状態を参照します。開いていないマスならopenCell、開いている数字なら周囲を調べるchordへ分岐します。右クリックはpreventDefaultでメニューを止めてから、同じマスの旗を切り替えます。

タッチ操作の長押しJAVASCRIPT
boardEl.addEventListener('pointerdown', event => {
    const cellEl = event.target.closest('.cell');
    if (!cellEl || event.pointerType === 'mouse') return;

    longPressed = false;
    longPressTimer = setTimeout(() => {
        longPressed = true;
        toggleFlag(Number(cellEl.dataset.index));
        if (navigator.vibrate) navigator.vibrate(35);
    }, 480);
});

['pointerup', 'pointercancel', 'pointerleave'].forEach(type =>
    boardEl.addEventListener(type, () => clearTimeout(longPressTimer))
);

マウスのポインターは右クリック処理に任せ、タッチやペンだけで長押し判定を行います。480ミリ秒以内に指を離した場合はタイマーを解除し、長押しが成立した場合はlongPressedtrueにします。続けて発生するクリックイベントを無視することで、旗を立てた直後に同じマスを開く事故を防いでいます。

開いた数字を両クリックすると、周囲にある旗の数が数字と一致した場合だけ、残りの周囲のマスを開きます。これは、旗の数を確認してから周囲を展開する「コード」と呼ばれる操作です。入力方法を増やす場合も、最終的には同じopenCelltoggleFlagへ処理を集約しています。

数字の周囲をまとめて開くJAVASCRIPT
function chord(index) {
    const cell = cells[index];
    if (ended || !cell.open || !cell.count) return;

    const around = neighbors(index);
    const flagCount = around.filter(next => cells[next].flag).length;
    if (flagCount === cell.count) {
        around.forEach(openCell);
    }
}

数字と周囲の旗の数が一致したときだけopenCellを実行します。旗の数が合わない段階では何も起こらないため、プレイヤーが確認せずに周囲を開いてしまうことを防げます。

ゲーム開始・終了と表示を分離する

ゲームには、開始前、プレイ中、終了後という状態があります。startedendedを使い、開始前は最初のクリックで地雷配置とタイマーを開始し、終了後はクリックや旗操作を受け付けないようにしています。

最初のクリックでゲームを開始するJAVASCRIPT
function openCell(index) {
    if (ended || cells[index].flag || cells[index].open) return;

    if (!started) {
        start(index);
    }

    const cell = cells[index];
    if (cell.mine) {
        cell.open = true;
        render(index);
        finish(false);
        return;
    }

    // 安全なマスを開く処理へ続く
}

openCellの入口で終了済み、旗付き、開示済みのマスを弾きます。まだ開始していなければ、クリックされたインデックスをstartへ渡します。startの中でそのマスと周囲を安全地帯にしてから地雷を配置するため、ゲーム開始の順番が保証されます。

画面を状態から描画するJAVASCRIPT
function render(index) {
    const cell = cells[index];
    const element = boardEl.children[index];

    element.className = 'cell';
    element.textContent = '';

    if (cell.open) {
        element.classList.add('revealed');
        if (cell.mine) {
            element.classList.add('mine');
            element.textContent = '✹';
        } else if (cell.count) {
            element.dataset.number = cell.count;
            element.textContent = cell.count;
        }
    } else if (cell.flag) {
        element.classList.add('flagged');
        element.textContent = '⚑';
    }
}

ゲームの正しい状態はcellsにあり、DOMはその状態を表示する役割に限定しています。renderの最初に表示をリセットしてから、開いたマス、地雷、数字、旗の順にクラスや文字を設定するため、前の表示が残りません。実際の実装では、視覚表示と同時にARIAラベルも更新しています。

クリアとゲームオーバーJAVASCRIPT
function finish(won) {
    ended = true;
    clearInterval(timerId);
    stopBgm();

    if (won) {
        cells.forEach((cell, index) => {
            if (cell.mine && !cell.flag) {
                cell.flag = true;
                flags++;
                render(index);
            }
        });
        messageEl.textContent = `CLEAR! ${seconds}秒`;
    } else {
        cells.forEach((cell, index) => {
            if (cell.mine) {
                cell.open = true;
                render(index);
            }
        });
        messageEl.textContent = 'GAME OVER — もう一度挑戦!';
    }
}

終了処理では、まずendedを設定して以後の入力を止め、タイマーとBGMも停止します。クリア時は残った地雷に旗を表示し、失敗時は地雷を開いて盤面の答えを示します。成功と失敗で表示は変わりますが、終了処理を一か所にまとめているため、停止漏れが起きにくい構成です。

難易度、タイマー、ベストタイム

難易度は、列数、行数、地雷数を設定オブジェクトにまとめています。ボタンを押すと選択中の難易度を変更してresetを実行するため、盤面の初期化処理を難易度ごとに複製せずに済みます。

難易度を設定するJAVASCRIPT
const LEVELS = {
    beginner: { label: '初級', cols: 9, rows: 9, mines: 10 },
    intermediate: { label: '中級', cols: 16, rows: 16, mines: 40 },
    expert: { label: '上級', cols: 30, rows: 16, mines: 99 }
};

document.getElementById('difficulty').addEventListener('click', event => {
    const button = event.target.closest('[data-level]');
    if (!button) return;
    levelKey = button.dataset.level;
    reset();
});

難易度の違いを設定値として切り出すと、盤面生成、地雷数表示、勝利判定が同じコードで動きます。ボタンにはdata-levelを持たせ、押された値をlevelKeyへ保存してから盤面を初期化します。

タイマーとベストタイムJAVASCRIPT
function start(index) {
    placeMines(index);
    started = true;
    timerId = setInterval(() => {
        seconds = Math.min(999, seconds + 1);
        updateHud();
    }, 1000);
}

function saveBest() {
    const key = `minesweeper-best-${levelKey}`;
    const old = localStorage.getItem(key);
    if (old === null || seconds < Number(old)) {
        localStorage.setItem(key, String(seconds));
    }
}

タイマーはsetIntervalで1秒ごとに更新し、終了時にclearIntervalで停止します。ベストタイムのキーへ難易度名を含めることで、初級と上級の記録が混ざりません。localStorageはブラウザ単位の保存なので、サーバー側のユーザー登録なしで自己ベストを実現できます。

音声とレスポンシブ対応

効果音とBGMはWeb Audio APIで生成しています。効果音は地雷を踏んだときや旗を切り替えたときに短い音を鳴らし、BGMは複数の音程を一定間隔で再生します。ブラウザの自動再生制限があるため、AudioContextの初期化はユーザー操作の中で行っています。BGMと効果音は個別にミュートできます。

効果音を生成するJAVASCRIPT
function tone(freq, duration = .06, type = 'square', volume = .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(.0001, time + duration);
    oscillator.connect(gain);
    gain.connect(audioCtx.destination);
    oscillator.start(time);
    oscillator.stop(time + duration);
}

音声ファイルを読み込まず、発振器と音量用のゲインを接続して短い音を作っています。音量を時間とともに小さくすることで、クリック音の末尾が不自然に切れません。ミュート中は早期リターンし、操作自体はそのまま続けられるようにしています。

画面幅に応じた盤面表示CSS
.board-scroll {
    max-width: 100%;
    overflow: auto;
    overscroll-behavior: contain;
}

@media (max-width: 700px) {
    .minesweeper-layout {
        flex-direction: column;
    }

    .game-side {
        width: 100%;
        display: grid;
        grid-template-columns: 1fr 1fr;
    }
}

上級の盤面はスマートフォンの画面幅を超えるため、盤面を囲む要素だけに横スクロールを設定しています。画面が狭くなったときはゲーム本体とサイドパネルを縦並びに変え、操作パネルが盤面に重ならないようにしています。

実装して分かったこと

マインスイーパーでは、地雷をランダムに置く処理よりも、盤面上の関係を正しく扱うことが重要です。特に、端のマスで範囲外へアクセスしないこと、旗を立てたマスを誤って開かないこと、連鎖展開で同じマスを繰り返し処理しないことがポイントになります。

ゲームを遊ぶときは、実際のマインスイーパーのページで初級から試せます。コードを読むときは、まずresetopenCellneighborsの3つを追うと、ゲーム全体の流れをつかみやすくなります。