GAME

ブラウザ版クロスライトの実装を解説

盤面データの管理から、上下左右の反転、必ず解ける盤面の生成、Undo、ヒント、キーボード操作、音声まで、ゲームページの実装を順番に紹介します。

クロスライトは、選んだマスと上下左右のマスを同時に反転させ、すべてのライトを消すパズルゲームです。盤面は5×5、6×6、7×7から選べ、手数、経過時間、最少手数、Undo、ヒントにも対応しています。

外部ライブラリは使わず、PHPのページにHTML、CSS、JavaScriptを記述しています。PHPは共通ヘッダーなどを読み込むために使い、盤面の生成、入力、描画、タイマーはブラウザ上のJavaScriptで完結させています。

盤面を一次元配列で管理する

盤面は、点灯を1、消灯を0で表す一次元配列として管理しています。5×5なら25個、6×6なら36個の値を持ち、インデックスから行と列を計算できます。

盤面を初期化するJAVASCRIPT
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方向を候補にし、盤面の外に出た位置を除外します。

反転する位置を取得するJAVASCRIPT
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へまとめ、通常の盤面と解答用の配列の両方へ適用できるようにしています。

操作から逆算して必ず解ける盤面を作る

ランダムな盤面をそのまま作ると、解けない盤面が生まれる可能性があります。そこで、最初はすべて消灯した盤面を用意し、ランダムに選んだマスを実際の操作と同じように反転させます。選んだマスを同じ順番で押せば消灯状態へ戻せるため、必ず解答を持つ盤面になります。

解ける盤面を生成するJAVASCRIPT
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クラスを付けます。

盤面を描画するJAVASCRIPT
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減らして再描画します。

操作とUndoJAVASCRIPT
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の位置から最初の一つを選び、アニメーションで知らせます。

ヒントを表示するJAVASCRIPT
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へ保存しています。

クリア処理JAVASCRIPT
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またはスペースで反転できるようにしています。カーソル位置は配列のインデックスで管理します。

矢印キーと決定キーJAVASCRIPT
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できるようにしています。

音を鳴らすJAVASCRIPT
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の順に追うと、ゲームの中心的な流れをつかみやすくなります。