こんにちは!今回は4x4のミニオセロゲームをHTML、CSS、JavaScriptのみで実装する方法をご紹介します。盤面が小さいミニオセロは通常の8x8オセロよりも戦略が単純でわかりやすく、JavaScriptの学習にもぴったりです。
この記事では以下の内容について説明します:
- ゲームの要件と仕様
- 設計方針(オブジェクト指向アプローチ)
- 実装の詳細
- ユーザーインターフェースの構築
- AI(コンピュータ) の実装方法
完成イメージ
完成したゲームでは以下の機能が実現できます:
- 4x4のオセロ盤面
- 黒と白の石を交互に置ける
- 有効な手の表示
- 石を裏返すアニメーション
- シンプルなコンピュータ対戦
- ゲームログの表示
- デバッグモード
ゲームの要件と仕様
基本要件
- 4x4のゲーム盤を作成
- プレイヤーが交互に石を置ける
- オセロのルールに従って石を裏返す
- 有効な手がない場合はパス
- ゲーム終了時に勝者を表示
追加機能
- 有効な手を視覚的に表示
- コンピュータプレイヤーの実装
- ゲームログの表示
- デバッグモード
- ゲーム状態の表示(現在のプレイヤー、石の数など)
技術仕様
- フレームワークなしの純粋なHTML/CSS/JavaScript
- オブジェクト指向設計(クラスベース)
- レスポンシブデザイン
- モダンなCSSアニメーション
設計方針
ゲームは主に2つのクラスで構成されています:
- GameState - ゲームのロジックと状態を管理
- GameUI - ユーザーインターフェースと表示を管理
この分離によって、ゲームのロジックとUIを独立して扱うことができ、コードの保守性が向上します。
GameStateクラス
GameStateクラスはゲームの状態を管理し、以下の責任を持ちます:
- ゲーム盤の状態管理
- 有効な手の計算
- 石を置いた時の処理
- プレイヤーの切り替え
- ゲーム終了条件のチェック
- コンピュータプレイヤーの戦略実装
GameUIクラス
GameUIクラスはユーザーインターフェースを管理し、以下の責任を持ちます:
- DOM要素の取得と操作
- ユーザー入力のハンドリング
- 盤面の視覚的な更新
- ゲーム情報の表示更新
- ゲームログの管理
実装の詳細
1. ゲーム盤の表現
ゲーム盤は4x4の二次元配列で表現しています:
this.board = [
[".", ".", ".", "."],
[".", "W", "B", "."],
[".", "B", "W", "."],
[".", ".", ".", "."]
];
-
"."
- 空のセル -
"B"
- 黒石 -
"W"
- 白石
初期状態では、中央の4マスに黒と白の石が交互に配置されています。
2. 有効な手の計算
有効な手を計算するアルゴリズムは以下の通りです:
- 空のセルごとに8方向(上、下、左、右、斜め)をチェック
- 各方向で、相手の石が連続しているかを確認
- 相手の石の先に自分の石があるかを確認
- 条件を満たす場合、そのセルは有効な手となる
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. 石を置いた時の処理
石を置いた時の処理は以下の手順で行います:
- 指定された位置に石を置く
- 8方向それぞれで、挟まれる相手の石を特定
- 挟まれた石を裏返す
- 裏返された石の位置を記録(アニメーション用)
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. コンピュータプレイヤーの実装
コンピュータプレイヤーは簡単な戦略を持っています:
- 角の位置があれば最優先で選択(角は戦略的に有利な位置)
- それ以外は、最も多くの石を裏返せる手を選択
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の実装では、以下の主要な機能を提供しています:
- 盤面の表示と更新
- 有効な手の視覚的表示
- ゲーム情報の更新(現在のプレイヤー、石の数など)
- ユーザー入力の処理
- AIの手の実行
- ゲームログの表示
例えば、盤面の更新処理は次のように実装されています:
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テクニックを使用しています:
- Flexboxとグリッドレイアウト
- 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学習の良い題材になると思います!
参考リンク
実装例
<!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>