GAME

ブラウザ版リバーシの実装を解説

盤面の持ち方、合法手の判定、相手の石を反転する処理、CPUの思考、パスと終了判定、石のアニメーション、BGM・効果音まで、リバーシの実装を順番に紹介します。

リバーシは、相手の石を自分の石ではさんで反転させ、最後に自分の石を多く残すボードゲームです。ルールは盤面上の8方向を調べるだけに見えますが、合法手の判定、複数方向の反転、CPUの手番、置ける場所がないときのパスなど、状態管理の練習に向いた題材です。

今回のゲームは、PHPで共通ヘッダーとフッターを読み込み、HTMLとCSSで盤面を用意し、ゲームの進行はJavaScriptで処理しています。外部ライブラリや画像素材に頼らず、DOM要素とWeb Audio APIで盤面・アニメーション・音声を組み立てています。

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

リバーシの盤面は8行8列、合計64マスです。二次元配列でも表現できますが、実装では一次元配列にして、行と列からインデックスを計算しています。空きマスを0、プレイヤーを1、CPUを2としておくと、比較や描画が単純になります。

盤面の定数と初期配置JAVASCRIPT
const SIZE = 8;
const EMPTY = 0;
const PLAYER = 1;
const CPU = 2;

function at(row, col) {
    return row * SIZE + col;
}

function reset() {
    board = Array(SIZE * SIZE).fill(EMPTY);
    board[27] = CPU;
    board[28] = PLAYER;
    board[35] = PLAYER;
    board[36] = CPU;
}

at(行, 列)を通して配列へアクセスすると、盤面を一次元で持っていてもコードからは座標として読めます。resetでは中央の4マスへ初期石を置き、ゲーム開始時の盤面を作ります。

8方向を共通のデータにする

リバーシでは、上下左右と4つの斜め方向の合計8方向を調べます。方向を個別の処理として書くと、反転処理や合法手判定で同じコードが増えるため、行方向と列方向の差分を配列にまとめます。

調べる方向JAVASCRIPT
const DIRECTIONS = [
    [-1, -1], [-1, 0], [-1, 1],
    [ 0, -1],          [ 0, 1],
    [ 1, -1], [ 1, 0], [ 1, 1]
];

function inside(row, col) {
    return row >= 0 && row < SIZE &&
        col >= 0 && col < SIZE;
}

insideで盤面の外を参照しないことを保証します。方向配列と範囲確認を共通化すると、端や角のマスでも同じ探索処理を安全に使えます。

合法手は「相手の石の先に自分の石がある」

空きマスならどこへでも置けるわけではありません。ある方向に相手の石が1個以上連続し、その先に自分の石があれば、その空きマスは合法手です。反対に、相手の石の先が盤外や空きマスなら反転できません。

1方向で反転できる石を探すJAVASCRIPT
function flipsFor(state, cell, player) {
    if (state[cell] !== EMPTY) return [];

    const row = Math.floor(cell / SIZE);
    const col = cell % SIZE;
    const opponent = player === PLAYER ? CPU : PLAYER;
    const flips = [];

    DIRECTIONS.forEach(([dr, dc]) => {
        let r = row + dr;
        let c = col + dc;
        const line = [];

        while (inside(r, c) && state[at(r, c)] === opponent) {
            line.push(at(r, c));
            r += dr;
            c += dc;
        }

        if (line.length && inside(r, c) &&
            state[at(r, c)] === player) {
            flips.push(...line);
        }
    });
    return flips;
}

探索の途中で見つけた相手の石を、いったんlineへ保存します。最後に自分の石で閉じられた場合だけ、その行をflipsへ追加します。途中で空きマスになった場合は、その方向の候補を破棄します。

合法手の一覧と反転処理を分ける

合法手の一覧は、64マスを調べてflipsForが1個以上を返す場所だけを集めます。実際に石を置くときも同じ関数を使うことで、「置けると表示されたのに、置く処理では反転しない」という不一致を防げます。

合法手を集めて盤面を更新するJAVASCRIPT
function legalMoves(state, player) {
    const moves = [];
    for (let i = 0; i < state.length; i++) {
        if (flipsFor(state, i, player).length) {
            moves.push(i);
        }
    }
    return moves;
}

function place(state, cell, player) {
    const next = state.slice();
    const flips = flipsFor(state, cell, player);
    next[cell] = player;
    flips.forEach(index => next[index] = player);
    return { state: next, flips };
}

placeは元の配列を直接変更せず、slice()でコピーを作ってから新しい盤面を返します。CPUの先読みでも同じ関数を使えるため、実際の着手と仮想的な着手のルールが一致します。

表示では置いた石と反転した石を分けて動かす

ゲーム画面では、着手した場所に新しい石のアニメーションを付け、反転した石には裏返るアニメーションを付けています。ゲーム内部では同じ盤面更新でも、表示側へchangedとflipsを別々に渡すことで、プレイヤーが何が起きたかを理解しやすくなります。

着手と反転を描画へ渡すJAVASCRIPT
const result = place(board, cell, PLAYER);
board = result.state;
lastMove = cell;

render(
    [cell, ...result.flips],
    result.flips
);

描画関数では、changedに含まれる石へnewクラスを付け、flipsに含まれる石へflippingクラスを付けます。CSSのtransformアニメーションによって、追加された石と反転した石を視覚的に区別できます。

置ける場所をヒントとして表示する

人間の手番では、合法手へ薄い光の点を表示しています。これは盤面配列の情報から毎回計算しているため、石を置いたあとに古いヒントが残りません。ボタンのARIAラベルにも「置ける場所」を反映し、見た目だけに依存しない操作にしています。

合法手を盤面へ反映するJAVASCRIPT
const legal = current === PLAYER && !locked
    ? new Set(legalMoves(board, PLAYER))
    : new Set();

button.className = 'rv-cell' +
    (legal.has(index) ? ' legal' : '');
button.setAttribute(
    'aria-label',
    legal.has(index) ? '置ける場所' : '空きマス'
);

CPUの思考中や終了後はヒントを消し、クリックも受け付けないようにしています。表示と入力の両方で同じlegalMovesを使うことが、ルールのずれを防ぐポイントです。

パスとゲーム終了を状態遷移で扱う

自分の番に合法手がない場合は、盤面を変えずに相手へ手番を渡します。両者に合法手がない、または盤面がすべて埋まったときにゲーム終了です。パスを「エラー」として扱わず、通常の手番遷移の一つにすると処理を整理できます。

CPUがパスする場合JAVASCRIPT
function nextTurn() {
    if (gameOver()) {
        finish();
        return;
    }

    current = CPU;
    const moves = legalMoves(board, CPU);
    if (!moves.length) {
        current = PLAYER;
        locked = false;
        setStatus('CPUは置ける場所がないためパスします。');
        render();
        return;
    }
    setTimeout(cpuMove, 450);
}

lockedはCPUの思考中や終了後の入力を止めるためのフラグです。手番を切り替える前後でrenderを呼ぶことで、合法手のヒントと入力可能な状態を同時に更新します。

CPUは評価値とミニマックスで手を選ぶ

CPUの難易度は、合法手からランダムに選ぶ初級、数手先を読む中級・上級に分けています。先読みでは、CPUにとって有利な手を最大化し、次のプレイヤーの手では評価値を最小化するミニマックス法を使います。

難易度ごとに探索深さを変えるJAVASCRIPT
function chooseCpuMove() {
    const moves = legalMoves(board, CPU);
    const depth = difficulty.value === 'easy' ? 1
        : difficulty.value === 'normal' ? 2 : 3;

    if (difficulty.value === 'easy') {
        return moves[Math.floor(Math.random() * moves.length)];
    }

    let best = moves[0];
    let bestScore = -Infinity;
    moves.forEach(move => {
        const next = place(board, move, CPU).state;
        const score = minimax(next, PLAYER, depth - 1);
        if (score > bestScore) {
            bestScore = score;
            best = move;
        }
    });
    return best;
}

評価値には、四隅を高く評価し、角の隣をやや低く評価する重みを使っています。リバーシでは石の数だけでなく、次の手の多さや安定した角の確保も重要なので、盤面位置の重みを加えると単純なランダムCPUより対戦らしくなります。

音声はユーザー操作の中で開始する

BGMと効果音はWeb Audio APIの発振器で生成しています。ブラウザの自動再生制限があるため、最初の着手や「新しいゲーム」ボタンなど、ユーザー操作の中でAudioContextを初期化して再生を始めます。

短い効果音を生成するJAVASCRIPT
function tone(freq, duration = .08, type = 'sine') {
    if (!audio || audio.state !== 'running' || sfxMuted) return;

    const now = audio.currentTime;
    const oscillator = audio.createOscillator();
    const gain = audio.createGain();
    oscillator.type = type;
    oscillator.frequency.setValueAtTime(freq, now);
    gain.gain.setValueAtTime(.0001, now);
    gain.gain.exponentialRampToValueAtTime(.14, now + .012);
    gain.gain.exponentialRampToValueAtTime(.0001, now + duration);
    oscillator.connect(gain).connect(master);
    oscillator.start(now);
    oscillator.stop(now + duration + .03);
}

石を置いたときは短い音を鳴らし、反転する石が複数ある場合は少しずつ周波数を変えています。BGMと効果音には別々のミュート状態を持たせ、音を消してもゲーム操作は継続できるようにしています。

レスポンシブ対応と実装のポイント

大きな画面では盤面と設定パネルを横に並べ、スマートフォンでは縦に並べています。盤面は正方形を保ち、セルの幅をグリッドで自動調整することで、画面幅に合わせて8×8の比率を維持します。ゲーム本体には右側固定広告と重ならない余白を確保しています。

リバーシの実装で特に重要なのは、合法手の判定と実際の反転処理を同じ関数から作ることです。さらに、盤面状態、手番、入力ロック、終了状態を分けて管理すると、パスやCPUの思考中でも表示が不安定になりません。

実際のリバーシのページでは、まず合法手の光を確認し、角を取る手や相手の選択肢を減らす手を試してみてください。コードを読むときは、flipsFor、place、nextTurnの順に追うと、ルールと画面のつながりを理解しやすくなります。