ブラウザ版ナンプレの実装を解説
完成した盤面の生成、解答が一つになる問題作成、数字入力、メモ、ヒント、ステージ解放まで、ゲームページの実装を順番に紹介します。
ナンプレは、9×9の盤面を使い、縦列・横行・3×3のブロックに1から9を一度ずつ入れるパズルです。見た目は数字を入力するだけですが、ゲームとして成立させるには、問題を作る処理、解答を検証する処理、入力ミスやメモを管理する処理が必要になります。
今回のゲームは、PHPで共通ヘッダーと広告を読み込み、盤面と操作パネルをHTMLで作り、JavaScriptで問題生成からタイマー、入力判定、Canvasを使わないDOM描画までを担当しています。全100ステージを同じ仕組みで遊べるように、ステージ番号から問題を再現できる構成です。
盤面を配列で管理する
盤面は81個のセルを一つの配列に並べて管理しています。solutionは完成した正解、puzzleは問題として最初に表示する盤面、valuesはプレイヤーが現在入力している状態です。最初から表示されている数字はgivenで区別します。
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を分けることで、入力された数字をその場で正解と比較できます。
乱数の種からステージを再現する
ステージごとに毎回まったく違う問題を作るのではなく、ステージ番号から乱数を作る関数へ種を渡しています。乱数の種が同じなら同じシャッフル結果になるため、同じステージを開始したときに同じ問題を再現できます。
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ブロックのルールを満たす完成盤面を作ります。行の帯、列の束、帯の中の行、束の中の列、数字の並びをシャッフルしても、同じ数字が縦・横・ブロック内で重複しない性質は保たれます。
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は、最初からすべての数字が入った正解盤面です。一般的なナンプレ生成では、空欄を作ったあとにもルールを満たしているか確認しますが、この方法では元の完成盤面が正しいため、空欄にした位置だけを管理すれば問題の土台になります。
解答が一つになるように空欄を作る
完成盤面から数字を一つずつ消していき、消したあとも解答が一つだけ残るかを調べます。解答が複数になる場合は、その数字を戻します。前半のステージは手がかりを多く、後半は少なくすることで難易度を調整しています。
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は、空欄へ入れられる数字を試し、矛盾したら戻り、別の候補を試すバックトラッキングです。空欄の中から候補数が最も少ないセルを先に選ぶため、可能性の多いセルから順番に調べるより効率よく検証できます。
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クラスで表現し、キーボードやスクリーンリーダーでも操作しやすい構成にしています。
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回でゲームオーバーです。
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へ保存し、ページを再読み込みしても解放状況を維持します。ステージごとのベストタイムも同じ仕組みで保存します。
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つを追うと、ゲーム全体の流れをつかみやすくなります。