【ゲーム】JavaScript:04 オセロ(リバーシ)

 8×8 の盤上で黒(プレイヤー)と白(コンピュータ)が交互に石を置き、相手の石を直線上で挟み返すことで自分の色にひっくり返し、最終的に盤面上に多くの石を持つほうが勝利となる、Webブラウザ上で動作する定番ボードゲームです。コンピュータレベル(簡単・普通・難しい)を選んで戦えます。

ゲームの遊び方

 画面上部のプルダウンで「簡単」「普通」「難しい」のいずれかを選び、🔄リセットボタンを押すとゲーム開始。黒石がプレイヤーの手番です。置ける場所をクリックすると、その手で裏返せる相手石が自動でひっくり返ります。続いてコンピュータ(白)が同様に石を置き……を繰り返します。

ルール

  • 交互に空いているマスに石を1つずつ置く。
  • 石を置いた位置から見て、隣接する直線上に必ず相手の石が1枚以上あり、その先に自分の石がある場合のみ置ける。
  • 置ける場所がない場合はパスし、両者ともに置ける場所がなくなるか、盤面が埋まるとゲーム終了。
  • 終了時に盤面上の黒/白を数え、多いほうの勝利。

コンピュータのレベル

コンピュータのレベル設定は、次のロジックで動作します。

  • easy: 完全ランダム
  • medium: ひっくり返す石の数で上位半分からランダム
  • hard: 最大数をひっくり返す手をランダムに

🎮ゲームプレイ

以下のリンク先から実際にプレイできます。

04 オセロ(リバーシ)

素材のダウンロード

以下のリンクから使用する素材をダウンロードできます。

othello_bg.png

ゲーム画面イメージ

プログラム全文(othello.html)

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>⚫️⚪️ オセロゲーム 🏆</title>
    <style>
        /* 全体リセットと背景 */
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: Arial, sans-serif;
            background: url('othello_bg.png') no-repeat center center fixed, linear-gradient(135deg, #445, #112);
            background-size: cover;
            color: #fff;
            text-align: center;
            padding: 20px;
        }
        /* タイトル */
        h1 {
            font-size: 2.5rem;
            background-color: rgba(0,0,0,0.7);
            display: inline-block;
            padding: 10px 20px;
            border-radius: 8px;
            text-shadow: 2px 2px 4px #000;
            margin-bottom: 20px;
        }
        /* コントロール */
        #controls {
            width: 460px;
            margin: 10px auto 20px;
            background: rgba(0,0,0,0.6);
            padding: 10px;
            border-radius: 6px;
        }
        #controls label { color: #fff; font-weight: bold; margin-right: 8px; }
        #controls select { padding: 8px 12px; font-size: 1rem; border: none; border-radius: 4px; background: #fff; color: #000; margin-right: 10px; }
        #controls button { padding: 8px 12px; font-size: 1rem; border: none; border-radius: 4px; background: #28a745; color: #fff; cursor: pointer; }
        #controls button:hover { background: #218838; }
        /* ゲーム盤 */
        #game-board {
            display: grid;
            grid-template-columns: repeat(8, 50px);
            grid-auto-rows: 50px;
            gap: 2px;
            justify-content: center;
            margin: 0 auto 20px;
            background-color: rgba(0,0,0,0.6);
            padding: 4px;
            border-radius: 8px;
            /* 幅を盤サイズに合わせて自動計算 */
            width: calc(8 * 50px + 7 * 2px + 2 * 4px);
        }
        .cell {
            background: #228B22;
            border: 1px solid #006400;
            border-radius: 4px;
            position: relative;
            cursor: pointer;
        }
        .cell.disabled { cursor: not-allowed; opacity: 0.6; }
        .disk {
            width: 80%; height: 80%;
            border-radius: 50%;
            position: absolute; top: 10%; left: 10%;
        }
        .disk.black { background: black; }
        .disk.white { background: white; }
        .disk.flip { animation: flip 0.5s forwards; }
        @keyframes flip {
            0% { transform: rotateY(0deg); }
            50% { transform: rotateY(90deg); background: transparent; }
            100% { transform: rotateY(180deg); }
        }
        /* ステータス */
        #status {
            width: 460px;
            margin: 0 auto 20px;
            background: rgba(0,0,0,0.6);
            padding: 10px 20px;
            border-radius: 4px;
            font-size: 1.2rem;
            text-shadow: 1px 1px 2px #000;
        }
        /* ルール */
        #rules {
            width: 690px;
            margin: 0 auto;
            background: rgba(0,0,0,0.6);
            padding: 12px;
            border-radius: 6px;
            text-align: left;
            font-size: 0.95rem;
        }
        #rules strong { display: block; margin-bottom: 8px; }
        #rules ul { list-style: disc inside; }
    </style>
</head>
<body>
    <h1>⚫️⚪️ オセロゲーム 🏆</h1>
    <div id="controls">
        <label for="difficulty">コンピュータレベル:</label>
        <select id="difficulty">
            <option value="easy">簡単</option>
            <option value="medium">普通</option>
            <option value="hard">難しい</option>
        </select>
        <button onclick="resetGame()">🔄 リセット</button>
    </div>
    <div id="game-board"></div>
    <div id="status">👤 プレイヤーのターン</div>
    <div id="rules">
        <strong>ゲームのルール</strong>
        <ul>
            <li>プレイヤーは黒、コンピュータは白を交互に置きます。</li>
            <li>相手の石を直線で挟むと、その間の石が自分の色にひっくり返ります。</li>
            <li>置ける場所がなくなるか盤が埋まるとゲーム終了です。</li>
        </ul>
    </div>
    <script>
        const SIZE = 8;
        const board = document.getElementById('game-board');
        const status = document.getElementById('status');
        const difficulty = document.getElementById('difficulty');
        let gameState = Array.from({length:SIZE}, () => Array(SIZE).fill(null));
        let currentPlayer = 'black';
        let gameActive = true;
        // 初期化
        function initializeBoard() {
            board.innerHTML = '';
            for (let r = 0; r < SIZE; r++) {
                for (let c = 0; c < SIZE; c++) {
                    const cell = document.createElement('div');
                    cell.className = 'cell';
                    cell.dataset.row = r;
                    cell.dataset.col = c;
                    cell.addEventListener('click', handleCellClick);
                    board.appendChild(cell);
                }
            }
            setInitialDisks();
        }
        function setInitialDisks() {
            placeDisk(3,3,'white'); placeDisk(4,4,'white');
            placeDisk(3,4,'black'); placeDisk(4,3,'black');
        }
        function placeDisk(r,c,color) {
            const cell = document.querySelector(`.cell[data-row='${r}'][data-col='${c}']`);
            const disk = document.createElement('div'); disk.className = 'disk ' + color;
            cell.appendChild(disk); gameState[r][c] = color;
        }
        function handleCellClick(evt) {
            if (!gameActive || currentPlayer !== 'black') return;
            const r = +evt.currentTarget.dataset.row;
            const c = +evt.currentTarget.dataset.col;
            if (gameState[r][c] || !isValidMove(r,c,'black')) return;
            makeMove(r,c,'black');
            if (isGameOver()) { declareWinner(); return; }
            if (!hasValidMoves('white')) {
                status.textContent = '🤖 コンピュータは置ける場所がありません。プレイヤー続行。';
                return;
            }
            switchTurn();
            setTimeout(computerTurn, 500);
        }
        function isValidMove(r,c,color) {
            return getFlippableDisks(r,c,color).length > 0;
        }
        function getFlippableDisks(r,c,color) {
            const opp = color==='black'?'white':'black';
            const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
            let flips = [];
            dirs.forEach(([dx,dy]) => {
                let path = [];
                let x=r+dx, y=c+dy;
                while (x>=0 && x<SIZE && y>=0 && y<SIZE && gameState[x][y]===opp) {
                    path.push([x,y]); x+=dx; y+=dy;
                }
                if (x>=0 && x<SIZE && y>=0 && y<SIZE && gameState[x][y]===color) {
                    flips = flips.concat(path);
                }
            });
            return flips;
        }
        function makeMove(r,c,color) {
            const flips = getFlippableDisks(r,c,color);
            flips.forEach(([x,y]) => flipDisk(x,y,color));
            placeDisk(r,c,color);
        }
        function flipDisk(r,c,color) {
            gameState[r][c]=color;
            const cell = document.querySelector(`.cell[data-row='${r}'][data-col='${c}']`);
            cell.innerHTML = '';
            const disk = document.createElement('div'); disk.className = 'disk ' + color + ' flip';
            cell.appendChild(disk);
        }
        function computerTurn() {
            if (!gameActive) return;
            const candidates = [];
            for (let r=0; r<SIZE; r++) for (let c=0; c<SIZE; c++) {
                if (!gameState[r][c]) {
                    const flips = getFlippableDisks(r,c,'white').length;
                    if (flips>0) candidates.push({r,c,flips});
                }
            }
            if (candidates.length===0) {
                if (!hasValidMoves('black')) { declareWinner(); return; }
                status.textContent = '🤖 コンピュータは置ける場所がありません。プレイヤー続行。';
                return;
            }
            const level = difficulty.value;
            let choice;
            if (level==='easy') {
                choice = candidates[Math.floor(Math.random()*candidates.length)];
            } else if (level==='medium') {
                const sorted = [...candidates].sort((a,b)=>b.flips-a.flips);
                const half = Math.ceil(sorted.length/2);
                choice = sorted[Math.floor(Math.random()*half)];
            } else {
                const max = Math.max(...candidates.map(c=>c.flips));
                const best = candidates.filter(c=>c.flips===max);
                choice = best[Math.floor(Math.random()*best.length)];
            }
            makeMove(choice.r,choice.c,'white');
            if (isGameOver()) { declareWinner(); return; }
            if (!hasValidMoves('black')) {
                status.textContent = '👤 プレイヤーは置ける場所がありません。コンピュータ続行。';
                setTimeout(computerTurn,500);
                return;
            }
            switchTurn();
        }
        function switchTurn() {
            currentPlayer = currentPlayer==='black'?'white':'black';
            status.textContent = currentPlayer==='black'?'👤 プレイヤーのターン':'🤖 コンピュータのターン';
        }
        function hasValidMoves(color) {
            return gameState.some((row,r)=>row.some((cell,c)=>!cell && isValidMove(r,c,color)));
        }
        function isGameOver() {
            return !hasValidMoves('black') && !hasValidMoves('white');
        }
        function declareWinner() {
            gameActive=false;
            const flat = gameState.flat();
            const b = flat.filter(x=>x==='black').length;
            const w = flat.filter(x=>x==='white').length;
            if (b>w) status.textContent = `🏅 プレイヤー勝利! 黒:${b} 白:${w}`;
            else if (w>b) status.textContent = `🏅 コンピュータ勝利! 黒:${b} 白:${w}`;
            else status.textContent = `🤝 引き分け! 黒:${b} 白:${w}`;
        }
        function resetGame() {
            gameState = Array.from({length:SIZE}, ()=>Array(SIZE).fill(null));
            currentPlayer='black'; gameActive=true;
            status.textContent='👤 プレイヤーのターン';
            initializeBoard();
        }
        // 実行
        initializeBoard();
    </script>
</body>
</html>

アルゴリズムの流れ

ステップ処理内容
盤面初期化 (initializeBoard)8×8 のセルを生成し、setInitialDisks で中央4マスに黒白を配置
合法手判定 (isValidMove)getFlippableDisks で裏返せる石があれば合法手と判定
裏返し対象取得 (getFlippableDisks)8方向に向かって相手色が連続し、その先に自色がある経路を探索し、ひっくり返す座標リストを返却
石配置・反転 (makeMove/flipDisk)指定マスに石を placeDisk、返却リストを flipDisk で CSS アニメ付きに置き換え
プレイヤー操作 (handleCellClick)クリックで合法手判断→makeMove→終了判定→パス判定→コンピュータ手番へ
コンピュータ操作 (computerTurn)合法手リストを収集し、レベル別にランダム/上位半分/最大取得数から最適手を選択→makeMove→終了判定→次のターン
終了判定 (isGameOver/declareWinner)両者とも合法手なしでゲーム終了→黒/白をカウントし勝敗/引き分けを表示

組み込みメソッド・命令

メソッド用途
document.createElementセルや石の DOM 要素を動的に生成
element.addEventListenerクリック/プルダウン変更イベントの監視
querySelector特定セルの取得
Array.from / Array.fill盤面用 2D 配列の初期化
Array.prototype.some配列内に合法手があるかどうかチェック
Array.prototype.flat2D 配列を平坦化し石数カウントに利用
setTimeoutコンピュータ番の待ち時間演出

関数の詳細

関数名機能
initializeBoard()盤面セルの生成・イベント登録 → setInitialDisks() で初期石配置
getFlippableDisks(r,c,color)指定マスから 8 方向の相手色連続経路を調べ、最終的に自色で挟む位置までの座標リストを返す
makeMove(r,c,color)石を配置 → getFlippableDisks の結果を flipDisk でアニメ付き反転
flipDisk(r,c,color)gameState の更新とともに、該当セルの石を CSS アニメ .flip でひっくり返す
computerTurn()全合法手を収集 → 選択アルゴリズム(easy/medium/hard)で一手選択 → makeMove → ターン交代またはパス判定
hasValidMoves(color)盤面全セルを走査し、少なくとも1つ合法手があるかを判定
isGameOver()hasValidMoves('black')hasValidMoves('white') 両方が false の場合、ゲーム終了とする
declareWinner()両色の石数をカウントし、勝敗または引き分けメッセージを #status に表示
resetGame()gameState とターン・状態を初期化し、initializeBoard() を再実行

改造のポイント

  • AI をミニマックスや重み付き評価関数に置き換えて高度化。
  • 合法手ハイライト機能を追加してユーザーに分かりやすくガイド。
  • 現在の石数スコアをリアルタイムで表示。
  • タッチ/モバイル操作に対応するため touchstart イベントを追加。
  • ダーク/ライトテーマ切替や盤サイズのレスポンシブ化。

これらを組み合わせて、自分だけのオリジナルオセロを作ってみてください!