ブラウザ版リバーシの実装を解説
盤面の持ち方、合法手の判定、相手の石を反転する処理、CPUの思考、パスと終了判定、石のアニメーション、BGM・効果音まで、リバーシの実装を順番に紹介します。
リバーシは、相手の石を自分の石ではさんで反転させ、最後に自分の石を多く残すボードゲームです。ルールは盤面上の8方向を調べるだけに見えますが、合法手の判定、複数方向の反転、CPUの手番、置ける場所がないときのパスなど、状態管理の練習に向いた題材です。
今回のゲームは、PHPで共通ヘッダーとフッターを読み込み、HTMLとCSSで盤面を用意し、ゲームの進行はJavaScriptで処理しています。外部ライブラリや画像素材に頼らず、DOM要素とWeb Audio APIで盤面・アニメーション・音声を組み立てています。
盤面を一次元配列で管理する
リバーシの盤面は8行8列、合計64マスです。二次元配列でも表現できますが、実装では一次元配列にして、行と列からインデックスを計算しています。空きマスを0、プレイヤーを1、CPUを2としておくと、比較や描画が単純になります。
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方向を調べます。方向を個別の処理として書くと、反転処理や合法手判定で同じコードが増えるため、行方向と列方向の差分を配列にまとめます。
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個以上連続し、その先に自分の石があれば、その空きマスは合法手です。反対に、相手の石の先が盤外や空きマスなら反転できません。
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個以上を返す場所だけを集めます。実際に石を置くときも同じ関数を使うことで、「置けると表示されたのに、置く処理では反転しない」という不一致を防げます。
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を別々に渡すことで、プレイヤーが何が起きたかを理解しやすくなります。
const result = place(board, cell, PLAYER);
board = result.state;
lastMove = cell;
render(
[cell, ...result.flips],
result.flips
);描画関数では、changedに含まれる石へnewクラスを付け、flipsに含まれる石へflippingクラスを付けます。CSSのtransformアニメーションによって、追加された石と反転した石を視覚的に区別できます。
置ける場所をヒントとして表示する
人間の手番では、合法手へ薄い光の点を表示しています。これは盤面配列の情報から毎回計算しているため、石を置いたあとに古いヒントが残りません。ボタンのARIAラベルにも「置ける場所」を反映し、見た目だけに依存しない操作にしています。
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を使うことが、ルールのずれを防ぐポイントです。
パスとゲーム終了を状態遷移で扱う
自分の番に合法手がない場合は、盤面を変えずに相手へ手番を渡します。両者に合法手がない、または盤面がすべて埋まったときにゲーム終了です。パスを「エラー」として扱わず、通常の手番遷移の一つにすると処理を整理できます。
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にとって有利な手を最大化し、次のプレイヤーの手では評価値を最小化するミニマックス法を使います。
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を初期化して再生を始めます。
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の順に追うと、ルールと画面のつながりを理解しやすくなります。