【JavaScript/HTML】4x4ミニオセロゲームを作ってみた


こんにちは！今回は4x4のミニオセロゲームをHTML、CSS、JavaScriptのみで実装する方法をご紹介します。盤面が小さいミニオセロは通常の8x8オセロよりも戦略が単純でわかりやすく、JavaScriptの学習にもぴったりです。

この記事では以下の内容について説明します：
- ゲームの要件と仕様
- 設計方針（オブジェクト指向アプローチ）
- 実装の詳細
- ユーザーインターフェースの構築
- AI(コンピュータ) の実装方法

## 完成イメージ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/9b6f199e-3a92-4559-a75e-63118dccfc2a.png)

<iframe width="560" height="315" src="https://www.youtube.com/embed/Xy3GphC6iFs?si=1htmEvAx8MErbhAw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

完成したゲームでは以下の機能が実現できます：

- 4x4のオセロ盤面
- 黒と白の石を交互に置ける
- 有効な手の表示
- 石を裏返すアニメーション
- シンプルなコンピュータ対戦
- ゲームログの表示
- デバッグモード

## ゲームの要件と仕様

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/69308d17-fe2c-44d7-a3c2-5835a740e297.png)

### 基本要件
1. 4x4のゲーム盤を作成
2. プレイヤーが交互に石を置ける
3. オセロのルールに従って石を裏返す
4. 有効な手がない場合はパス
5. ゲーム終了時に勝者を表示

### 追加機能
1. 有効な手を視覚的に表示
2. コンピュータプレイヤーの実装
3. ゲームログの表示
4. デバッグモード
5. ゲーム状態の表示（現在のプレイヤー、石の数など）

### 技術仕様
- フレームワークなしの純粋なHTML/CSS/JavaScript
- オブジェクト指向設計（クラスベース）
- レスポンシブデザイン
- モダンなCSSアニメーション

## 設計方針

![mini-othello-design.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/f3727762-b3fc-45a3-bd70-4af2a01370ae.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/2fc686ac-f76e-49b7-8ad6-e677140c2c6b.png)

ゲームは主に2つのクラスで構成されています：

1. **GameState** - ゲームのロジックと状態を管理
2. **GameUI** - ユーザーインターフェースと表示を管理

この分離によって、ゲームのロジックとUIを独立して扱うことができ、コードの保守性が向上します。

### GameStateクラス

GameStateクラスはゲームの状態を管理し、以下の責任を持ちます：

- ゲーム盤の状態管理
- 有効な手の計算
- 石を置いた時の処理
- プレイヤーの切り替え
- ゲーム終了条件のチェック
- コンピュータプレイヤーの戦略実装

### GameUIクラス

GameUIクラスはユーザーインターフェースを管理し、以下の責任を持ちます：

- DOM要素の取得と操作
- ユーザー入力のハンドリング
- 盤面の視覚的な更新
- ゲーム情報の表示更新
- ゲームログの管理

## 実装の詳細

### 1. ゲーム盤の表現

ゲーム盤は4x4の二次元配列で表現しています：

```javascript
this.board = [
    [".", ".", ".", "."],
    [".", "W", "B", "."],
    [".", "B", "W", "."],
    [".", ".", ".", "."]
];
```

- `"."` - 空のセル
- `"B"` - 黒石
- `"W"` - 白石

初期状態では、中央の4マスに黒と白の石が交互に配置されています。

### 2. 有効な手の計算

有効な手を計算するアルゴリズムは以下の通りです：

1. 空のセルごとに8方向（上、下、左、右、斜め）をチェック
2. 各方向で、相手の石が連続しているかを確認
3. 相手の石の先に自分の石があるかを確認
4. 条件を満たす場合、そのセルは有効な手となる

```javascript
getValidMoves() {
    const validMoves = [];
    const opponent = this.currentPlayer === "B" ? "W" : "B";

    // 各方向のオフセット（左上、上、右上、左、右、左下、下、右下）
    const directions = [
        [-1, -1], [-1, 0], [-1, 1],
        [0, -1],           [0, 1],
        [1, -1],  [1, 0],  [1, 1]
    ];

    for (let row = 0; row < 4; row++) {
        for (let col = 0; col < 4; col++) {
            // 空のセルのみチェック
            if (this.board[row][col] !== ".") continue;

            let isValidMove = false;

            // 各方向をチェック
            for (const [dr, dc] of directions) {
                let r = row + dr;
                let c = col + dc;
                let hasOpponentDisk = false;

                // 相手のディスクが連続しているか確認
                while (this.isValidPosition(r, c) && this.board[r][c] === opponent) {
                    hasOpponentDisk = true;
                    r += dr;
                    c += dc;
                }

                // 自分のディスクで挟んでいるか確認
                if (hasOpponentDisk && this.isValidPosition(r, c) && this.board[r][c] === this.currentPlayer) {
                    isValidMove = true;
                    break;
                }
            }

            if (isValidMove) {
                validMoves.push([row, col]);
            }
        }
    }

    return validMoves;
}
```

### 3. 石を置いた時の処理

石を置いた時の処理は以下の手順で行います：

1. 指定された位置に石を置く
2. 8方向それぞれで、挟まれる相手の石を特定
3. 挟まれた石を裏返す
4. 裏返された石の位置を記録（アニメーション用）

```javascript
applyMove(row, col) {
    if (this.board[row][col] !== ".") {
        return { valid: false, flipped: [] };
    }

    const opponent = this.currentPlayer === "B" ? "W" : "B";
    const directions = [
        [-1, -1], [-1, 0], [-1, 1],
        [0, -1],           [0, 1],
        [1, -1],  [1, 0],  [1, 1]
    ];

    let isValidMove = false;
    const flipped = [];

    // 各方向をチェック
    for (const [dr, dc] of directions) {
        const toFlip = [];
        let r = row + dr;
        let c = col + dc;

        // 相手のディスクが連続しているか確認
        while (this.isValidPosition(r, c) && this.board[r][c] === opponent) {
            toFlip.push([r, c]);
            r += dr;
            c += dc;
        }

        // 自分のディスクで挟んでいるか確認
        if (toFlip.length > 0 && this.isValidPosition(r, c) && this.board[r][c] === this.currentPlayer) {
            isValidMove = true;
            flipped.push(...toFlip);
        }
    }

    // 有効な手の場合、盤面を更新
    if (isValidMove) {
        this.board[row][col] = this.currentPlayer;

        for (const [r, c] of flipped) {
            this.board[r][c] = this.currentPlayer;
        }

        this.lastMove = [row, col];
        this.flippedDisks = flipped;
        return { valid: true, flipped };
    }

    return { valid: false, flipped: [] };
}
```

### 4. コンピュータプレイヤーの実装

![mini-othello-ai-strategy-svg.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/9b667826-baf3-406c-a8b2-28dc0d7d6c4a.png)

コンピュータプレイヤーは簡単な戦略を持っています：

1. 角の位置があれば最優先で選択（角は戦略的に有利な位置）
2. それ以外は、最も多くの石を裏返せる手を選択

```javascript
getAIMove() {
    const validMoves = this.getValidMoves();
    if (validMoves.length === 0) return null;

    // 簡易的な戦略：角を優先、次に多くの石を裏返せる手を選択
    const corners = [[0, 0], [0, 3], [3, 0], [3, 3]];

    // コーナーがあれば最優先
    for (const corner of corners) {
        for (const move of validMoves) {
            if (move[0] === corner[0] && move[1] === corner[1]) {
                return move;
            }
        }
    }

    // 最も多くの石を裏返せる手を選択
    let bestMove = validMoves[0];
    let maxFlips = 0;

    for (const [row, col] of validMoves) {
        // 一時的に手を適用して裏返る石の数を確認
        const boardCopy = this.board.map(row => [...row]);
        const { flipped } = this.applyMove(row, col);

        // 盤面を元に戻す
        this.board = boardCopy;

        if (flipped.length > maxFlips) {
            maxFlips = flipped.length;
            bestMove = [row, col];
        }
    }

    return bestMove;
}
```

### 5. UI実装

UIの実装では、以下の主要な機能を提供しています：

1. 盤面の表示と更新
2. 有効な手の視覚的表示
3. ゲーム情報の更新（現在のプレイヤー、石の数など）
4. ユーザー入力の処理
5. AIの手の実行
6. ゲームログの表示

例えば、盤面の更新処理は次のように実装されています：

```javascript
updateBoard() {
    const cells = document.querySelectorAll('.cell');
    cells.forEach(cell => {
        // 既存のディスクをクリア
        cell.innerHTML = '';

        const row = parseInt(cell.dataset.row);
        const col = parseInt(cell.dataset.col);

        if (this.gameState.board[row][col] === "B") {
            const disk = document.createElement('div');
            disk.className = 'disk black';

            // 最後に裏返されたディスクにはハイライトを適用
            if (this.gameState.flippedDisks.some(([r, c]) => r === row && c === col)) {
                disk.classList.add('highlight');
            }

            cell.appendChild(disk);
        } else if (this.gameState.board[row][col] === "W") {
            const disk = document.createElement('div');
            disk.className = 'disk white';

            // 最後に裏返されたディスクにはハイライトを適用
            if (this.gameState.flippedDisks.some(([r, c]) => r === row && c === col)) {
                disk.classList.add('highlight');
            }

            cell.appendChild(disk);
        }
    });
}
```

## CSSスタイリング

ゲームのスタイリングにはモダンなCSSテクニックを使用しています：

1. Flexboxとグリッドレイアウト
2. CSSアニメーション（石の裏返し効果）
3. ボックスシャドウと境界線効果
4. レスポンシブデザイン

特に石の裏返しアニメーションは、以下のCSSで実装されています：

```css
.highlight {
    animation: highlight 1s ease-in-out;
}

@keyframes highlight {
    0% { transform: scale(1); }
    50% { transform: scale(1.2); box-shadow: 0 0 10px #ffcc00; }
    100% { transform: scale(1); }
}
```

## 実装の工夫点

パフォーマンスの最適化
処理の無駄を減らすため、不要なDOM操作を抑え、必要な箇所のみを効率的に更新しています。また、アルゴリズムも高速なものを採用し、快適な動作を実現しています。

ユーザー体験の向上
視覚的に有効な手を示したり、石が裏返るアニメーションを取り入れたりすることで、操作しやすく分かりやすいインターフェースを心がけています。ゲームの進行状況もログで確認できるようにしています。

コードの可読性と保守性
ゲームロジックとUIの責務を分け、意味のある名前やコメントを使ってコードを整理しています。コーディングスタイルも統一し、読みやすく変更しやすい構造にしています。

今後の発展の可能性
将来的にはAIの難易度設定、盤面サイズの変更、ゲーム履歴の保存やオンライン対戦など、多様な機能追加が可能です。統計情報の表示など、分析機能も検討できます。

## まとめ

今回は4x4のミニオセロゲームをHTML、CSS、JavaScriptで実装する方法を紹介しました。オブジェクト指向設計を採用することで、コードの保守性と拡張性を高めることができました。

特にゲームロジックとUIを分離することで、ゲームルールの変更やUIの改善を独立して行うことができます。また、シンプルながらも戦略的なゲームプレイを実現するAIの実装方法も学びました。

ぜひこのコードを基にして、自分だけのオリジナル機能を追加してみてください。JavaScript学習の良い題材になると思います！


## 参考リンク

- [オセロのルール - Wikipedia](https://ja.wikipedia.org/wiki/%E3%82%AA%E3%82%BB%E3%83%AD)
- [JavaScript クラス - MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Classes)
- [CSS アニメーション - MDN](https://developer.mozilla.org/ja/docs/Web/CSS/CSS_Animations/Using_CSS_animations)

## 実装例

```html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ミニオセロ（4x4）</title>
    <style>
        body {
            font-family: 'Meiryo', 'Hiragino Kaku Gothic Pro', sans-serif;
            background-color: #f0f0f0;
            display: flex;
            flex-direction: column;
            align-items: center;
            padding: 20px;
        }

        .game-container {
            display: flex;
            flex-direction: column;
            align-items: center;
            background-color: white;
            padding: 20px;
            border-radius: 10px;
            box-shadow: 0 4px 8px rgba(0,0,0,0.1);
            margin-bottom: 20px;
        }

        h1 {
            color: #333;
            text-align: center;
            margin-bottom: 20px;
        }

        .game-info {
            margin-bottom: 15px;
            font-size: 18px;
            text-align: center;
        }

        .current-player {
            font-weight: bold;
            padding: 5px 10px;
            border-radius: 5px;
            display: inline-block;
            margin: 10px 0;
        }

        .black-player {
            background-color: #333;
            color: white;
        }

        .white-player {
            background-color: #eee;
            color: black;
            border: 1px solid #ccc;
        }

        .turn-info {
            font-size: 16px;
            margin-bottom: 15px;
        }

        .board {
            display: grid;
            grid-template-columns: 30px repeat(4, 60px);
            grid-template-rows: 30px repeat(4, 60px);
            gap: 2px;
            background-color: #888;
            padding: 2px;
            border-radius: 5px;
            margin-bottom: 20px;
        }

        .header-cell {
            display: flex;
            justify-content: center;
            align-items: center;
            background-color: #666;
            color: white;
            font-weight: bold;
        }

        .cell {
            display: flex;
            justify-content: center;
            align-items: center;
            background-color: #1e8449;
            position: relative;
        }

        .disk {
            width: 45px;
            height: 45px;
            border-radius: 50%;
            transition: transform 0.3s, background-color 0.3s;
        }

        .black {
            background-color: #000;
            border: 2px solid #000;
        }

        .white {
            background-color: #fff;
            border: 2px solid #ccc;
        }

        .valid-move {
            width: 20px;
            height: 20px;
            border-radius: 50%;
            background-color: rgba(255, 255, 255, 0.3);
            position: absolute;
            cursor: pointer;
        }

        .highlight {
            animation: highlight 1s ease-in-out;
        }

        @keyframes highlight {
            0% { transform: scale(1); }
            50% { transform: scale(1.2); box-shadow: 0 0 10px #ffcc00; }
            100% { transform: scale(1); }
        }

        .controls {
            display: flex;
            flex-direction: column;
            gap: 10px;
            margin-top: 20px;
            width: 100%;
            max-width: 400px;
        }

        button {
            padding: 10px 15px;
            background-color: #2c3e50;
            color: white;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            font-size: 16px;
            transition: background-color 0.3s;
        }

        button:hover {
            background-color: #34495e;
        }

        .game-log {
            width: 100%;
            max-width: 400px;
            height: 200px;
            overflow-y: auto;
            border: 1px solid #ccc;
            padding: 10px;
            margin-top: 15px;
            background-color: white;
            border-radius: 5px;
            font-family: monospace;
        }

        .score-display {
            display: flex;
            justify-content: space-around;
            width: 100%;
            max-width: 300px;
            margin: 15px 0;
        }

        .score-item {
            display: flex;
            align-items: center;
            gap: 10px;
        }

        .score-disk {
            width: 20px;
            height: 20px;
            border-radius: 50%;
        }

        .game-over-panel {
            margin-top: 20px;
            padding: 15px;
            background-color: rgba(255, 255, 255, 0.9);
            border-radius: 10px;
            text-align: center;
            box-shadow: 0 4px 8px rgba(0,0,0,0.2);
            display: none;
        }

        .winner-text {
            font-size: 24px;
            font-weight: bold;
            margin-bottom: 10px;
        }
    </style>
</head>
<body>
    <div class="game-container">
        <h1>ミニオセロ（4x4）</h1>

        <div class="game-info">
            <div class="turn-info">ターン: <span id="turn-count">1</span></div>
            <div>現在のプレイヤー: <span id="current-player" class="current-player black-player">黒</span></div>
        </div>

        <div class="score-display">
            <div class="score-item">
                <div class="score-disk black"></div>
                <span id="black-count">2</span>
            </div>
            <div class="score-item">
                <div class="score-disk white"></div>
                <span id="white-count">2</span>
            </div>
        </div>

        <div class="board" id="game-board">
            <!-- ヘッダー行 -->
            <div class="header-cell"></div>
            <div class="header-cell">0</div>
            <div class="header-cell">1</div>
            <div class="header-cell">2</div>
            <div class="header-cell">3</div>

            <!-- ボード行 -->
            <!-- 1行目 -->
            <div class="header-cell">0</div>
            <div class="cell" data-row="0" data-col="0"></div>
            <div class="cell" data-row="0" data-col="1"></div>
            <div class="cell" data-row="0" data-col="2"></div>
            <div class="cell" data-row="0" data-col="3"></div>

            <!-- 2行目 -->
            <div class="header-cell">1</div>
            <div class="cell" data-row="1" data-col="0"></div>
            <div class="cell" data-row="1" data-col="1"></div>
            <div class="cell" data-row="1" data-col="2"></div>
            <div class="cell" data-row="1" data-col="3"></div>

            <!-- 3行目 -->
            <div class="header-cell">2</div>
            <div class="cell" data-row="2" data-col="0"></div>
            <div class="cell" data-row="2" data-col="1"></div>
            <div class="cell" data-row="2" data-col="2"></div>
            <div class="cell" data-row="2" data-col="3"></div>

            <!-- 4行目 -->
            <div class="header-cell">3</div>
            <div class="cell" data-row="3" data-col="0"></div>
            <div class="cell" data-row="3" data-col="1"></div>
            <div class="cell" data-row="3" data-col="2"></div>
            <div class="cell" data-row="3" data-col="3"></div>
        </div>

        <div class="controls">
            <button id="btn-ai-move">AIの手を実行</button>
            <button id="btn-restart">ゲームをリスタート</button>
            <label>
                <input type="checkbox" id="debug-mode"> デバッグモードを有効にする
            </label>
        </div>

        <div class="game-log" id="game-log"></div>

        <div class="game-over-panel" id="game-over-panel">
            <div class="winner-text" id="winner-text"></div>
            <div>黒: <span id="final-black-count"></span> 石</div>
            <div>白: <span id="final-white-count"></span> 石</div>
            <button id="btn-new-game">新しいゲームを開始</button>
        </div>
    </div>

    <script>
        // ゲームの状態を管理するクラス
        class GameState {
            constructor() {
                this.board = [
                    [".", ".", ".", "."],
                    [".", "W", "B", "."],
                    [".", "B", "W", "."],
                    [".", ".", ".", "."]
                ];
                this.currentPlayer = "B"; // B: 黒, W: 白
                this.turn = 1;
                this.gameOver = false;
                this.passCount = 0;
                this.lastMove = null;
                this.flippedDisks = [];
                this.debugMode = false;
            }

            // 座標が盤面内にあるか確認
            isValidPosition(row, col) {
                return row >= 0 && row < 4 && col >= 0 && col < 4;
            }

            // ディスクの数を数える
            countDisks() {
                let blackCount = 0;
                let whiteCount = 0;

                for (let row = 0; row < 4; row++) {
                    for (let col = 0; col < 4; col++) {
                        if (this.board[row][col] === "B") {
                            blackCount++;
                        } else if (this.board[row][col] === "W") {
                            whiteCount++;
                        }
                    }
                }

                return { blackCount, whiteCount };
            }

            // 有効な手を取得
            getValidMoves() {
                const validMoves = [];
                const opponent = this.currentPlayer === "B" ? "W" : "B";

                // 各方向のオフセット（左上、上、右上、左、右、左下、下、右下）
                const directions = [
                    [-1, -1], [-1, 0], [-1, 1],
                    [0, -1],           [0, 1],
                    [1, -1],  [1, 0],  [1, 1]
                ];

                for (let row = 0; row < 4; row++) {
                    for (let col = 0; col < 4; col++) {
                        // 空のセルのみチェック
                        if (this.board[row][col] !== ".") continue;

                        let isValidMove = false;

                        // 各方向をチェック
                        for (const [dr, dc] of directions) {
                            let r = row + dr;
                            let c = col + dc;
                            let hasOpponentDisk = false;

                            // 相手のディスクが連続しているか確認
                            while (this.isValidPosition(r, c) && this.board[r][c] === opponent) {
                                hasOpponentDisk = true;
                                r += dr;
                                c += dc;
                            }

                            // 自分のディスクで挟んでいるか確認
                            if (hasOpponentDisk && this.isValidPosition(r, c) && this.board[r][c] === this.currentPlayer) {
                                isValidMove = true;
                                break;
                            }
                        }

                        if (isValidMove) {
                            validMoves.push([row, col]);
                        }
                    }
                }

                return validMoves;
            }

            // 手を実行し、裏返されるディスクを返す
            applyMove(row, col) {
                if (this.board[row][col] !== ".") {
                    return { valid: false, flipped: [] };
                }

                const opponent = this.currentPlayer === "B" ? "W" : "B";
                const directions = [
                    [-1, -1], [-1, 0], [-1, 1],
                    [0, -1],           [0, 1],
                    [1, -1],  [1, 0],  [1, 1]
                ];

                let isValidMove = false;
                const flipped = [];

                // 各方向をチェック
                for (const [dr, dc] of directions) {
                    const toFlip = [];
                    let r = row + dr;
                    let c = col + dc;

                    // 相手のディスクが連続しているか確認
                    while (this.isValidPosition(r, c) && this.board[r][c] === opponent) {
                        toFlip.push([r, c]);
                        r += dr;
                        c += dc;
                    }

                    // 自分のディスクで挟んでいるか確認
                    if (toFlip.length > 0 && this.isValidPosition(r, c) && this.board[r][c] === this.currentPlayer) {
                        isValidMove = true;
                        flipped.push(...toFlip);
                    }
                }

                // 有効な手の場合、盤面を更新
                if (isValidMove) {
                    this.board[row][col] = this.currentPlayer;

                    for (const [r, c] of flipped) {
                        this.board[r][c] = this.currentPlayer;
                    }

                    this.lastMove = [row, col];
                    this.flippedDisks = flipped;
                    return { valid: true, flipped };
                }

                return { valid: false, flipped: [] };
            }

            // 次のプレイヤーに交代
            switchPlayer() {
                this.currentPlayer = this.currentPlayer === "B" ? "W" : "B";
            }

            // ゲーム終了判定
            checkGameOver() {
                // 盤面が全て埋まっている場合
                let isBoardFull = true;
                for (let row = 0; row < 4; row++) {
                    for (let col = 0; col < 4; col++) {
                        if (this.board[row][col] === ".") {
                            isBoardFull = false;
                            break;
                        }
                    }
                    if (!isBoardFull) break;
                }

                // 両プレイヤーが打てる手があるか確認
                const blackMoves = this.getValidMovesForPlayer("B");
                const whiteMoves = this.getValidMovesForPlayer("W");

                if (isBoardFull || (blackMoves.length === 0 && whiteMoves.length === 0)) {
                    this.gameOver = true;
                }

                return this.gameOver;
            }

            // 特定のプレイヤーの有効な手を取得
            getValidMovesForPlayer(player) {
                const originalPlayer = this.currentPlayer;
                this.currentPlayer = player;
                const moves = this.getValidMoves();
                this.currentPlayer = originalPlayer;
                return moves;
            }

            // テスト用のAI戦略
            getAIMove() {
                const validMoves = this.getValidMoves();
                if (validMoves.length === 0) return null;

                // 簡易的な戦略：角を優先、次に多くの石を裏返せる手を選択
                const corners = [[0, 0], [0, 3], [3, 0], [3, 3]];

                // コーナーがあれば最優先
                for (const corner of corners) {
                    for (const move of validMoves) {
                        if (move[0] === corner[0] && move[1] === corner[1]) {
                            return move;
                        }
                    }
                }

                // 最も多くの石を裏返せる手を選択
                let bestMove = validMoves[0];
                let maxFlips = 0;

                for (const [row, col] of validMoves) {
                    // 一時的に手を適用して裏返る石の数を確認
                    const boardCopy = this.board.map(row => [...row]);
                    const { flipped } = this.applyMove(row, col);

                    // 盤面を元に戻す
                    this.board = boardCopy;

                    if (flipped.length > maxFlips) {
                        maxFlips = flipped.length;
                        bestMove = [row, col];
                    }
                }

                return bestMove;
            }
        }

        // UI関連の機能を管理するクラス
        class GameUI {
            constructor() {
                this.gameState = new GameState();
                this.boardElement = document.getElementById('game-board');
                this.currentPlayerElement = document.getElementById('current-player');
                this.turnCountElement = document.getElementById('turn-count');
                this.blackCountElement = document.getElementById('black-count');
                this.whiteCountElement = document.getElementById('white-count');
                this.gameLogElement = document.getElementById('game-log');
                this.gameOverPanel = document.getElementById('game-over-panel');
                this.winnerTextElement = document.getElementById('winner-text');
                this.finalBlackCountElement = document.getElementById('final-black-count');
                this.finalWhiteCountElement = document.getElementById('final-white-count');
                this.debugModeCheckbox = document.getElementById('debug-mode');

                // ボタンイベントの設定
                document.getElementById('btn-ai-move').addEventListener('click', () => this.executeAIMove());
                document.getElementById('btn-restart').addEventListener('click', () => this.restartGame());
                document.getElementById('btn-new-game').addEventListener('click', () => this.restartGame());
                this.debugModeCheckbox.addEventListener('change', () => {
                    this.gameState.debugMode = this.debugModeCheckbox.checked;
                    this.log(`デバッグモード: ${this.gameState.debugMode ? '有効' : '無効'}`);
                });

                // セルクリックイベントの設定
                this.setupCellClickEvents();

                // 初期盤面の表示
                this.updateBoard();
                this.updateGameInfo();
                this.showValidMoves();

                // ログの初期化
                this.log('ミニオセロ（4x4）ゲームを開始します...');
            }

            // セルクリックイベントを設定
            setupCellClickEvents() {
                const cells = document.querySelectorAll('.cell');
                cells.forEach(cell => {
                    cell.addEventListener('click', () => {
                        if (this.gameState.gameOver) return;

                        const row = parseInt(cell.dataset.row);
                        const col = parseInt(cell.dataset.col);

                        // 有効な手かチェック
                        const validMoves = this.gameState.getValidMoves();
                        const isValidMove = validMoves.some(move => move[0] === row && move[1] === col);

                        if (isValidMove) {
                            this.executeMove(row, col);
                        }
                    });
                });
            }

            // 盤面を更新
            updateBoard() {
                const cells = document.querySelectorAll('.cell');
                cells.forEach(cell => {
                    // 既存のディスクをクリア
                    cell.innerHTML = '';

                    const row = parseInt(cell.dataset.row);
                    const col = parseInt(cell.dataset.col);

                    if (this.gameState.board[row][col] === "B") {
                        const disk = document.createElement('div');
                        disk.className = 'disk black';

                        // 最後に裏返されたディスクにはハイライトを適用
                        if (this.gameState.flippedDisks.some(([r, c]) => r === row && c === col)) {
                            disk.classList.add('highlight');
                        }

                        cell.appendChild(disk);
                    } else if (this.gameState.board[row][col] === "W") {
                        const disk = document.createElement('div');
                        disk.className = 'disk white';

                        // 最後に裏返されたディスクにはハイライトを適用
                        if (this.gameState.flippedDisks.some(([r, c]) => r === row && c === col)) {
                            disk.classList.add('highlight');
                        }

                        cell.appendChild(disk);
                    }
                });
            }

            // 有効な手を表示
            showValidMoves() {
                // 既存の有効な手のマークをクリア
                const validMoveMarkers = document.querySelectorAll('.valid-move');
                validMoveMarkers.forEach(marker => marker.remove());

                const validMoves = this.gameState.getValidMoves();

                validMoves.forEach(([row, col]) => {
                    const cell = document.querySelector(`.cell[data-row="${row}"][data-col="${col}"]`);
                    const marker = document.createElement('div');
                    marker.className = 'valid-move';
                    cell.appendChild(marker);
                });
            }

            // ゲーム情報の更新
            updateGameInfo() {
                // ターン数
                this.turnCountElement.textContent = this.gameState.turn;

                // 現在のプレイヤー
                this.currentPlayerElement.textContent = this.gameState.currentPlayer === "B" ? "黒" : "白";
                this.currentPlayerElement.className = `current-player ${this.gameState.currentPlayer === "B" ? "black-player" : "white-player"}`;

                // ディスク数
                const { blackCount, whiteCount } = this.gameState.countDisks();
                this.blackCountElement.textContent = blackCount;
                this.whiteCountElement.textContent = whiteCount;
            }

            // 手を実行
            executeMove(row, col) {
                const playerStr = this.gameState.currentPlayer === "B" ? "黒" : "白";
                this.log(`${playerStr}の手: (${row}, ${col})`);

                // デバッグモードの場合、詳細を表示
                if (this.gameState.debugMode) {
                    this.log(`検証: プレイヤー ${this.gameState.currentPlayer} が (${row}, ${col}) に打つ場合`);
                }

                const { valid, flipped } = this.gameState.applyMove(row, col);

                if (valid) {
                    this.log(`有効な手です。${flipped.length}個のディスクが裏返りました。`);

                    if (this.gameState.debugMode) {
                        this.log(`裏返された石の位置: ${JSON.stringify(flipped)}`);
                    }

                    // 盤面を更新
                    this.updateBoard();

                    // プレイヤーを交代
                    this.gameState.switchPlayer();
                    this.gameState.turn++;

                    // ゲーム情報を更新
                    this.updateGameInfo();

                    // パスの確認
                    const validMoves = this.gameState.getValidMoves();
                    if (validMoves.length === 0) {
                        const currentPlayerStr = this.gameState.currentPlayer === "B" ? "黒" : "白";
                        this.log(`${currentPlayerStr}は打てる手がないためパスします`);

                        this.gameState.passCount++;

                        // 両プレイヤーが連続してパスした場合
                        if (this.gameState.passCount >= 2) {
                            this.handleGameOver();
                        } else {
                            this.gameState.switchPlayer();
                            this.updateGameInfo();
                        }
                    } else {
                        this.gameState.passCount = 0;
                    }

                    // ゲーム終了チェック
                    if (this.gameState.checkGameOver()) {
                        this.handleGameOver();
                    } else {
                        // 有効な手を更新
                        this.showValidMoves();
                    }
                } else {
                    this.log(`無効な手です。`);
                }
            }

            // AIの手を実行
            executeAIMove() {
                if (this.gameState.gameOver) return;

                const move = this.gameState.getAIMove();
                if (move) {
                    const [row, col] = move;
                    this.executeMove(row, col);
                } else {
                    const currentPlayerStr = this.gameState.currentPlayer === "B" ? "黒" : "白";
                    this.log(`${currentPlayerStr}は打てる手がないためパスします`);

                    this.gameState.passCount++;

                    // 両プレイヤーが連続してパスした場合
                    if (this.gameState.passCount >= 2) {
                        this.handleGameOver();
                    } else {
                        this.gameState.switchPlayer();
                        this.updateGameInfo();
                        this.showValidMoves();
                    }
                }
            }

            // ゲーム終了処理
            handleGameOver() {
                const { blackCount, whiteCount } = this.gameState.countDisks();

                this.log('\n===== ゲーム終了 =====');
                this.log(`黒: ${blackCount} 石`);
                this.log(`白: ${whiteCount} 石`);

                let winnerText = '';
                if (blackCount > whiteCount) {
                    winnerText = '黒の勝ち！';
                } else if (whiteCount > blackCount) {
                    winnerText = '白の勝ち！';
                } else {
                    winnerText = '引き分け！';
                }

                this.log(winnerText);

                // ゲーム終了パネルを表示
                this.finalBlackCountElement.textContent = blackCount;
                this.finalWhiteCountElement.textContent = whiteCount;
                this.winnerTextElement.textContent = winnerText;
                this.gameOverPanel.style.display = 'block';

                this.gameState.gameOver = true;
            }

            // ゲームをリスタート
            restartGame() {
                this.gameState = new GameState();
                this.gameState.debugMode = this.debugModeCheckbox.checked;

                // UIを更新
                this.updateBoard();
                this.updateGameInfo();
                this.showValidMoves();

                // ゲーム終了パネルを非表示
                this.gameOverPanel.style.display = 'none';

                // ログをクリア
                this.gameLogElement.innerHTML = '';
                this.log('ミニオセロ（4x4）ゲームを開始します...');
            }

            // ログにメッセージを追加
            log(message) {
                const logEntry = document.createElement('div');
                logEntry.textContent = message;
                this.gameLogElement.appendChild(logEntry);

                // 自動スクロール
                this.gameLogElement.scrollTop = this.gameLogElement.scrollHeight;
            }
        }

        // ゲーム開始
        document.addEventListener('DOMContentLoaded', () => {
            new GameUI();
        });
    </script>
</body>
</html>
```