GAME

ブラウザ版ナンプレの実装を解説

完成した盤面の生成、解答が一つになる問題作成、数字入力、メモ、ヒント、ステージ解放まで、ゲームページの実装を順番に紹介します。

ナンプレは、9×9の盤面を使い、縦列・横行・3×3のブロックに1から9を一度ずつ入れるパズルです。見た目は数字を入力するだけですが、ゲームとして成立させるには、問題を作る処理、解答を検証する処理、入力ミスやメモを管理する処理が必要になります。

今回のゲームは、PHPで共通ヘッダーと広告を読み込み、盤面と操作パネルをHTMLで作り、JavaScriptで問題生成からタイマー、入力判定、Canvasを使わないDOM描画までを担当しています。全100ステージを同じ仕組みで遊べるように、ステージ番号から問題を再現できる構成です。

盤面を配列で管理する

盤面は81個のセルを一つの配列に並べて管理しています。solutionは完成した正解、puzzleは問題として最初に表示する盤面、valuesはプレイヤーが現在入力している状態です。最初から表示されている数字はgivenで区別します。

ナンプレの状態JAVASCRIPT
let stage = 1;
let solution = [];
let puzzle = [];
let values = [];
let given = [];
let notes = [];
let selected = -1;
let mistakes = 0;
let hints = 3;
let seconds = 0;
let finished = false;

セル番号から行と列を求めるときは、Math.floor(index / 9)が行、index % 9が列になります。表示用のvaluesと正解のsolutionを分けることで、入力された数字をその場で正解と比較できます。

乱数の種からステージを再現する

ステージごとに毎回まったく違う問題を作るのではなく、ステージ番号から乱数を作る関数へ種を渡しています。乱数の種が同じなら同じシャッフル結果になるため、同じステージを開始したときに同じ問題を再現できます。

ステージ用の乱数を作るJAVASCRIPT
function rng(seed) {
    return function() {
        seed |= 0;
        seed = seed + 0x6D2B79F5 | 0;
        let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
        t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
        return ((t ^ t >>> 14) >>> 0) / 4294967296;
    };
}

function shuffle(array, random) {
    for (let i = array.length - 1; i > 0; i--) {
        const j = Math.floor(random() * (i + 1));
        [array[i], array[j]] = [array[j], array[i]];
    }
    return array;
}

shuffleにはFisher-Yates法を使っています。行、列、数字の並びをこの乱数でシャッフルすることで、正しいナンプレの構造を保ったまま、ステージごとに異なる盤面を作れます。

正しい完成盤面を組み立てる

最初に、行・列・3×3ブロックのルールを満たす完成盤面を作ります。行の帯、列の束、帯の中の行、束の中の列、数字の並びをシャッフルしても、同じ数字が縦・横・ブロック内で重複しない性質は保たれます。

完成盤面の基本パターンJAVASCRIPT
const bands = shuffle([0, 1, 2], random);
const stacks = shuffle([0, 1, 2], random);
const rows = bands.flatMap(band =>
    shuffle([0, 1, 2], random).map(row => band * 3 + row)
);
const cols = stacks.flatMap(stack =>
    shuffle([0, 1, 2], random).map(col => stack * 3 + col)
);
const numbers = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9], random);

solution = [];
rows.forEach(row => cols.forEach(col => {
    solution.push(numbers[(row * 3 + Math.floor(row / 3) + col) % 9]);
}));

ここで作ったsolutionは、最初からすべての数字が入った正解盤面です。一般的なナンプレ生成では、空欄を作ったあとにもルールを満たしているか確認しますが、この方法では元の完成盤面が正しいため、空欄にした位置だけを管理すれば問題の土台になります。

解答が一つになるように空欄を作る

完成盤面から数字を一つずつ消していき、消したあとも解答が一つだけ残るかを調べます。解答が複数になる場合は、その数字を戻します。前半のステージは手がかりを多く、後半は少なくすることで難易度を調整しています。

問題の手がかり数を決めるJAVASCRIPT
let clues = stage <= 25 ? 42
    : stage <= 50 ? 36
    : stage <= 75 ? 31
    : 27;

if (stage % 5 === 0) clues++;

puzzle = solution.slice();
for (const index of shuffle([...Array(81).keys()], random)) {
    if (puzzle.filter(Boolean).length <= clues) break;

    const old = puzzle[index];
    puzzle[index] = 0;
    if (solutionCount(puzzle.slice()) !== 1) puzzle[index] = old;
}

puzzle.slice()でコピーを渡しているのは、解答数を調べる再帰処理が盤面を書き換えるためです。元の問題配列を直接渡さず、検証が終われば元の盤面へ戻せるようにしています。

バックトラッキングで解答数を数える

solutionCountは、空欄へ入れられる数字を試し、矛盾したら戻り、別の候補を試すバックトラッキングです。空欄の中から候補数が最も少ないセルを先に選ぶため、可能性の多いセルから順番に調べるより効率よく検証できます。

候補を試して解答数を調べるJAVASCRIPT
function solutionCount(grid, limit = 2) {
    let best = -1;
    let candidates = null;

    for (let index = 0; index < 81; index++) {
        if (grid[index]) continue;
        const used = new Set();
        const row = Math.floor(index / 9);
        const col = index % 9;

        for (let i = 0; i < 9; i++) {
            used.add(grid[row * 9 + i]);
            used.add(grid[i * 9 + col]);
        }
        const top = Math.floor(row / 3) * 3;
        const left = Math.floor(col / 3) * 3;
        for (let r = top; r < top + 3; r++) {
            for (let c = left; c < left + 3; c++) {
                used.add(grid[r * 9 + c]);
            }
        }

        const choices = [1,2,3,4,5,6,7,8,9]
            .filter(number => !used.has(number));
        if (!choices.length) return 0;
        if (!candidates || choices.length < candidates.length) {
            best = index;
            candidates = choices;
        }
    }

    if (best < 0) return 1;
    let count = 0;
    for (const number of candidates) {
        grid[best] = number;
        count += solutionCount(grid, limit - count);
        grid[best] = 0;
        if (count >= limit) return count;
    }
    return count;
}

このゲームでは、解答が0個か1個かを確認できれば問題生成には十分なので、2個見つかった時点で探索を打ち切ります。limitを設けることで、解答が複数ある問題を最後まで数え続ける必要がありません。

盤面をDOMから描画する

Canvasではなく、81個のbutton要素を盤面として作っています。選択中のセル、同じ行・列・3×3ブロック、同じ数字、最初から表示された数字をCSSクラスで表現し、キーボードやスクリーンリーダーでも操作しやすい構成にしています。

セルを描画するJAVASCRIPT
function render() {
    boardEl.innerHTML = '';

    for (let index = 0; index < 81; index++) {
        const cell = document.createElement('button');
        cell.className = 'sdk-cell';
        cell.dataset.i = index;
        cell.setAttribute('role', 'gridcell');

        if (given[index]) cell.classList.add('given');
        if (values[index]) cell.textContent = values[index];
        cell.addEventListener('click', () => select(index));
        boardEl.appendChild(cell);
    }
    renderNumbers();
}

実際の実装では、選択セルと同じ行・列・ブロックへpeer、同じ数字へsame、選択セルへselectedを加えています。状態が変わるたびに盤面全体を描画し直すため、セルごとの表示を個別に戻す処理が増えません。

数字入力、ミス、メモを処理する

数字ボタンまたはキーボードから入力された数字は、選択セルの正解と比較します。正解なら値を保存し、同じ行・列・ブロックに残っているメモからその数字を削除します。不正解ならミスを1回増やし、3回でゲームオーバーです。

数字を入力するJAVASCRIPT
function input(number) {
    if (selected < 0 || given[selected] || paused || finished) return;

    if (noteMode && !values[selected]) {
        notes[selected].has(number)
            ? notes[selected].delete(number)
            : notes[selected].add(number);
        render();
        return;
    }

    if (number !== solution[selected]) {
        mistakes++;
        updateHud();
        if (mistakes >= 3) end(false);
        return;
    }

    values[selected] = number;
    notes[selected].clear();
    removePeerNotes(selected, number);
    render();
}

メモモードでは、セルの値を確定せず、Setへ候補数字を追加・削除します。正しい数字を確定したときは、同じグループのメモからその数字を消すため、候補が盤面の変化に合わせて整理されます。

ヒント、クリア、ステージ解放を管理する

ヒントは残り回数を確認してから、未完成のセルへ正解を入力します。クリア時には次のステージをlocalStorageへ保存し、ページを再読み込みしても解放状況を維持します。ステージごとのベストタイムも同じ仕組みで保存します。

クリア時に進行状況を保存するJAVASCRIPT
function end(win) {
    finished = true;
    clearInterval(timer);

    if (win) {
        const unlocked = Math.max(
            Number(localStorage.getItem('sudokuUnlocked') || 1),
            Math.min(TOTAL, stage + 1)
        );
        localStorage.setItem('sudokuUnlocked', unlocked);

        const key = 'sudokuBest' + stage;
        const old = Number(localStorage.getItem(key) || 999999);
        localStorage.setItem(key, Math.min(old, seconds));
    }
    overlay.classList.add('show');
}

ブラウザのlocalStorageは端末やブラウザごとの保存領域なので、サーバー側のアカウントなしで進行状況を記録できます。ステージ選択欄を作るときは、保存された解放番号より後ろの選択肢をdisabledにしています。

タイマー、一時停止、音声を実装する

タイマーはステージ開始時にsetIntervalで動かし、毎秒secondsを増やします。一時停止中、またはクリア・ゲームオーバー後は更新しないため、表示時間と実際のプレイ時間がずれません。

一時停止ではpausedを切り替えて盤面にオーバーレイを表示します。BGMと効果音はWeb Audio APIで生成し、ユーザー操作の中でAudioContextを初期化します。自動再生制限のあるブラウザでも、ステージ開始や音声ボタンの操作後に再生できるようにしています。

実装して分かったこと

ナンプレでは、数字を入力する画面よりも、正しい問題を生成する処理が重要です。完成盤面を先に作り、空欄を一つずつ消しながら解答数を確認することで、ステージ数が増えても同じアルゴリズムを使って問題を作れます。

ゲームを遊ぶときは、実際のナンプレのページでメモ、ヒント、ステージ選択を試せます。コードを読むときは、まずmakeStage、solutionCount、input、render、endの5つを追うと、ゲーム全体の流れをつかみやすくなります。