ブラウザ版クロスライトの実装を解説
盤面データの管理から、上下左右の反転、必ず解ける盤面の生成、Undo、ヒント、キーボード操作、音声まで、ゲームページの実装を順番に紹介します。
クロスライトは、選んだマスと上下左右のマスを同時に反転させ、すべてのライトを消すパズルゲームです。盤面は5×5、6×6、7×7から選べ、手数、経過時間、最少手数、Undo、ヒントにも対応しています。
外部ライブラリは使わず、PHPのページにHTML、CSS、JavaScriptを記述しています。PHPは共通ヘッダーなどを読み込むために使い、盤面の生成、入力、描画、タイマーはブラウザ上のJavaScriptで完結させています。
盤面を一次元配列で管理する
盤面は、点灯を1、消灯を0で表す一次元配列として管理しています。5×5なら25個、6×6なら36個の値を持ち、インデックスから行と列を計算できます。
let size = 5;
let board = Array(size * size).fill(0);
let moves = 0;
let seconds = 0;
let finished = false;
const index = (row, col) => row * size + col;
function formatTime(value) {
return `${Math.floor(value / 60)}:${String(value % 60).padStart(2, '0')}`;
}盤面の大きさをsizeとして持つと、難易度が変わっても同じ処理を使えます。indexは行・列を配列の位置へ変換し、DOMのボタンとゲーム内部の状態を対応づけます。
選んだマスと上下左右を求める
クロスライトの中心となる処理は、選ばれたマスに影響する位置を求める関数です。中心、上、下、左、右の5方向を候補にし、盤面の外に出た位置を除外します。
function affected(cell) {
const row = Math.floor(cell / size);
const col = cell % size;
const items = [
[row, col],
[row - 1, col],
[row + 1, col],
[row, col - 1],
[row, col + 1]
];
return items
.filter(([r, c]) => r >= 0 && r < size && c >= 0 && c < size)
.map(([r, c]) => index(r, c));
}
function toggle(cell, target = board) {
affected(cell).forEach(i => {
target[i] = target[i] ? 0 : 1;
});
}左上のマスでは、上と左が盤面の外になるため、中心・下・右の3マスだけが反転します。反転処理をtoggleへまとめ、通常の盤面と解答用の配列の両方へ適用できるようにしています。
操作から逆算して必ず解ける盤面を作る
ランダムな盤面をそのまま作ると、解けない盤面が生まれる可能性があります。そこで、最初はすべて消灯した盤面を用意し、ランダムに選んだマスを実際の操作と同じように反転させます。選んだマスを同じ順番で押せば消灯状態へ戻せるため、必ず解答を持つ盤面になります。
const settings = {
easy: { size: 5, scramble: 7 },
normal: { size: 6, scramble: 12 },
hard: { size: 7, scramble: 18 }
};
function buildPuzzle() {
const config = settings[levelEl.value];
size = config.size;
board = Array(size * size).fill(0);
solution = Array(size * size).fill(0);
const choices = [];
while (choices.length < config.scramble) {
const cell = Math.floor(Math.random() * board.length);
if (!solution[cell]) {
solution[cell] = 1;
choices.push(cell);
toggle(cell);
}
}
initial = board.slice();
history = [];
moves = 0;
finished = false;
render();
}solutionは、初期盤面を作るために選んだ操作を記録する配列です。盤面を表示するboardとは分けて保存することで、ヒント表示や操作後の解答管理に使えます。
盤面の状態からボタンを描画する
盤面配列を変更したら、DOMのボタンを作り直します。点灯中のマスにはonクラス、現在のカーソル位置にはcursorクラス、ヒントの対象にはhintクラスを付けます。
function render() {
boardEl.style.gridTemplateColumns = `repeat(${size}, 1fr)`;
boardEl.replaceChildren();
board.forEach((on, i) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'cl-cell'
+ (on ? ' on' : '')
+ (i === cursor ? ' cursor' : '')
+ (i === hintIndex ? ' hint' : '');
button.setAttribute(
'aria-label',
`${Math.floor(i / size) + 1}行${i % size + 1}列、${on ? '点灯' : '消灯'}`
);
button.setAttribute('role', 'gridcell');
button.setAttribute('aria-pressed', String(Boolean(on)));
button.addEventListener('click', () => play(i));
boardEl.appendChild(button);
});
}ゲーム内部の正しい状態は配列にあり、DOMはその状態を表示する役割に限定しています。aria-labelとaria-pressedも更新することで、点灯状態を読み上げでも確認できます。
操作履歴を保存してUndoする
Undoを実装するには、操作前の盤面と解答配列を履歴へ保存します。操作を取り消すときは、最後に保存した状態へ戻し、手数を1減らして再描画します。
function play(cell) {
if (finished) return;
history.push({
board: board.slice(),
solution: solution.slice(),
cell
});
toggle(cell);
solution[cell] = solution[cell] ? 0 : 1;
moves++;
cursor = cell;
hintIndex = -1;
render();
updateStatus();
if (board.every(value => value === 0)) {
clearGame();
}
}
function undo() {
if (!history.length || finished) return;
const previous = history.pop();
board = previous.board;
solution = previous.solution;
moves--;
hintIndex = -1;
render();
updateStatus();
}配列をそのまま履歴へ入れると、後の反転操作で履歴の中身まで変わってしまいます。そのためslice()でコピーを作っています。解答配列も同時に戻すことで、Undo後のヒントが現在の盤面とずれないようにしています。
現在の解答からヒントを表示する
盤面は「消灯状態から何を反転したか」を記録して生成しているため、その操作記録をヒントに利用できます。まだ値が1の位置から最初の一つを選び、アニメーションで知らせます。
function showHint() {
if (finished) return;
const available = solution
.map((value, i) => value ? i : -1)
.filter(i => i >= 0);
if (!available.length) {
messageEl.textContent = '現在の盤面は解けています';
return;
}
hintIndex = available[0];
cursor = hintIndex;
render();
messageEl.textContent =
`${Math.floor(hintIndex / size) + 1}行${hintIndex % size + 1}列を押してみましょう`;
}解答配列の値は、同じマスをもう一度押すと0へ戻ります。そのため、現在も1の位置だけを候補にすれば、盤面の状態に合わせた次の操作を示せます。
クリア判定と手数・時間を管理する
すべての値が0になったらクリアです。開始時にはタイマーを起動し、クリア時には停止します。難易度ごとの最少手数はlocalStorageへ保存しています。
function clearGame() {
finished = true;
clearInterval(timer);
timer = null;
const key = `crossLightBest-${levelEl.value}`;
const old = Number(localStorage.getItem(key) || 0);
if (!old || moves < old) {
localStorage.setItem(key, String(moves));
}
updateStatus();
messageEl.textContent = 'すべてのライトが消えました';
resultEl.textContent =
`${moves}手、${formatTime(seconds)}でクリアしました。`;
dialog.showModal();
}保存キーへ難易度名を含めることで、初級と上級の記録が混ざりません。localStorageはブラウザ単位の保存なので、サーバー側のログイン機能がなくても自己ベストを実現できます。
キーボード操作を追加する
マウスとタップだけでなく、矢印キーでカーソルを動かし、Enterまたはスペースで反転できるようにしています。カーソル位置は配列のインデックスで管理します。
document.addEventListener('keydown', event => {
let row = Math.floor(cursor / size);
let col = cursor % size;
if (event.key === 'ArrowUp') row = (row - 1 + size) % size;
else if (event.key === 'ArrowDown') row = (row + 1) % size;
else if (event.key === 'ArrowLeft') col = (col - 1 + size) % size;
else if (event.key === 'ArrowRight') col = (col + 1) % size;
else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
play(cursor);
return;
} else {
return;
}
event.preventDefault();
cursor = index(row, col);
render();
});行と列を端で反対側へ回り込ませることで、カーソルが盤面外へ出ないようにしています。決定キーではページのスクロールを止めてから、マウス操作と同じplayへ処理を集約しています。
音声とレスポンシブ対応
BGMと効果音はWeb Audio APIで生成しています。ブラウザの自動再生制限を避けるため、最初の操作後にAudioContextを初期化し、BGMと効果音を個別にON・OFFできるようにしています。
function tone(frequency, duration = .09, type = 'sine', volume = .18, channel = 'sfx') {
if (!audioContext || audioContext.state !== 'running') return;
if (channel === 'sfx' && sfxMuted) return;
if (channel === 'bgm' && bgmMuted) return;
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
const now = audioContext.currentTime;
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, now);
gain.gain.setValueAtTime(.0001, now);
gain.gain.exponentialRampToValueAtTime(volume, now + .015);
gain.gain.exponentialRampToValueAtTime(.0001, now + duration);
oscillator.connect(gain).connect(masterGain);
oscillator.start(now);
oscillator.stop(now + duration + .03);
}上級の盤面でも操作パネルが重ならないよう、ゲーム本体とサイドパネルをグリッドで分けています。画面幅が狭いときはサイドパネルを下へ移動し、盤面の操作領域を確保しています。
実装して分かったこと
クロスライトでは、盤面をランダムに作るよりも「解答になる操作から盤面を作る」方法が扱いやすいポイントでした。盤面と解答を別配列で持つことで、ヒントやUndoも同じ状態管理の上に実装できます。
実際に遊ぶときはクロスライトのページで、初級の5×5盤面から試せます。コードを読むときは、まずaffected、toggle、buildPuzzle、playの順に追うと、ゲームの中心的な流れをつかみやすくなります。