囲碁ウェブアプリケーションの解説

はじめに

今回は当サイトのホームにある囲碁のコードを解説していきます。このコードは、HTMLとJavaScriptで構築された基本的なウェブベースの囲碁ゲームを実装しています。囲碁は、二人のプレイヤーが交互に石を置き、盤上の「陣地」をより多く確保することを目指す古代からの戦略ボードゲームです。このアプリケーションでは、プレイヤーが交互に盤上に黒と白の石を置き、標準的な囲碁ルールに従って対戦することができます。

盤面の構造と視覚要素

HTMLとCSSの構成

コードは、19x19のグリッドで表現される囲碁盤を作成します。盤面は、以下の視覚要素で構成されています:

  1. 盤面の背景: 木目調の背景色(#DEB887)で、伝統的な囲碁盤の見た目を再現
  2. グリッド線: 縦横に配置された黒い線で19x19のグリッドを形成
  3. 星(碁盤の目印): 特定の交点に配置された黒い点(hoshiクラス)
  4. : 黒と白の円形の石(グラデーション効果付き)

CSSは、盤面の木目調の背景、影効果、格子線、星の配置、そして石の見た目を定義しています。特に石には、立体感を出すためのグラデーションと影効果が施されています。

/* 囲碁盤全体のコンテナ - 570px × 570px のグリッドを作成 */
#board {
    position: relative;
    width: 570px; /* 30px * 19 = 570px */
    height: 570px;
    background-color: #DEB887; /* 囲碁盤の木の色 */
    border: 2px solid #8b4513; /* 縁取りの色 */
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); /* 影効果で立体感を出す */
    display: grid;
    grid-template-columns: repeat(19, 30px); /* 19列のグリッド */
    grid-template-rows: repeat(19, 30px); /* 19行のグリッド */
}

/* 盤面上の線の共通スタイル */
.line {
    position: absolute;
    background-color: black;
}

/* 横線のスタイル - 幅は盤面全体、左右に15pxのマージン */
.horizontal {
    width: calc(100% - 30px); /* 線が飛び出さないように調整 */
    height: 1px; /* 線の太さ */
    left: 15px; /* 左側での位置調整 */
}

/* 縦線のスタイル - 高さは盤面全体、上下に15pxのマージン */
.vertical {
    height: calc(100% - 30px); /* 線が飛び出さないように調整 */
    width: 1px; /* 線の太さ */
    top: 15px; /* 上側での位置調整 */
}

/* 星(碁盤の目印)のスタイル - 8px × 8pxの黒い円 */
.hoshi {
    position: absolute;
    width: 8px;
    height: 8px;
    background-color: black;
    border-radius: 50%; /* 円形にする */
}

/* 各セルのスタイル - クリック可能な30px × 30pxの領域 */
.cell {
    width: 30px;
    height: 30px;
    cursor: pointer; /* クリック可能なことを示すカーソル */
    position: relative;
}

/* 石の共通スタイル - 28px × 28pxの円形 */
.stone {
    width: 28px;
    height: 28px;
    border-radius: 50%; /* 円形にする */
    position: absolute;
    top: 1px;
    left: 1px;
    box-shadow: 2px 2px 2px rgba(0,0,0,0.3); /* 影効果で立体感を出す */
    z-index: 1; /* 線よりも前面に表示 */
}

/* 黒石のスタイル - 中心から外側へのグラデーションで立体感を出す */
.black {
    background: radial-gradient(circle at 30% 30%, #666, #000);
}

/* 白石のスタイル - 中心から外側へのグラデーションで立体感を出す */
.white {
    background: radial-gradient(circle at 30% 30%, #fff, #ccc);
}

盤面の初期化

JavaScriptコードでは、盤面を初期化するために以下の処理を行っています:

// 定数定義
const BOARD_SIZE = 19; // 囲碁盤のサイズ(19×19)
const CELL_SIZE = 30;  // 各セルのサイズ(30px)
const board = document.getElementById('board'); // 盤面要素
// その他のUI要素とゲーム状態の初期化
const passButton = document.getElementById('passButton');
const resignButton = document.getElementById('resignButton');
const scoreButton = document.getElementById('scoreButton');
const statusDiv = document.getElementById('status');
const scoreDiv = document.getElementById('score');
let currentPlayer = 'black'; // 初期プレイヤーは黒
let gameBoard = Array(BOARD_SIZE).fill().map(() => Array(BOARD_SIZE).fill(null)); // 盤面状態の初期化
let koPoint = null; // コウ点の初期化
let passCount = 0; // パスカウントの初期化
let capturedStones = { black: 0, white: 0 }; // 捕獲した石のカウント
let moveHistory = []; // 手の履歴

// 盤面の作成(セルを追加)
for (let i = 0; i < BOARD_SIZE; i++) {
    for (let j = 0; j < BOARD_SIZE; j++) {
        const cell = document.createElement('div'); // 新しいセル要素を作成
        cell.className = 'cell'; // セルのクラスを設定
        cell.dataset.row = i; // データ属性に行番号を保存
        cell.dataset.col = j; // データ属性に列番号を保存
        cell.addEventListener('click', () => placeStone(cell)); // クリックイベントを設定
        board.appendChild(cell); // 盤面にセルを追加
    }
}

// 囲碁盤の線を追加
for (let i = 0; i < BOARD_SIZE; i++) {
    // 横線を作成
    const horizontalLine = document.createElement('div');
    horizontalLine.className = 'line horizontal';
    horizontalLine.style.top = `${i * CELL_SIZE + CELL_SIZE / 2}px`; // 線の位置を設定
    board.appendChild(horizontalLine); // 盤面に線を追加

    // 縦線を作成
    const verticalLine = document.createElement('div');
    verticalLine.className = 'line vertical';
    verticalLine.style.left = `${i * CELL_SIZE + CELL_SIZE / 2}px`; // 線の位置を設定
    board.appendChild(verticalLine); // 盤面に線を追加
}

// 星(hoshi)を追加 - 伝統的な星の位置(3-3, 3-9, 3-15, 9-3, 9-9, 9-15, 15-3, 15-9, 15-15)
const HOSHI_POSITIONS = [3, 9, 15]; // 星の位置(インデックス)
HOSHI_POSITIONS.forEach(row => {
    HOSHI_POSITIONS.forEach(col => {
        const hoshi = document.createElement('div'); // 星要素を作成
        hoshi.className = 'hoshi'; // 星のクラスを設定
        hoshi.style.top = `${row * CELL_SIZE + CELL_SIZE / 2 - 4}px`; // 星の位置を設定(中心補正)
        hoshi.style.left = `${col * CELL_SIZE + CELL_SIZE / 2 - 4}px`; // 星の位置を設定(中心補正)
        board.appendChild(hoshi); // 盤面に星を追加
    });
});

ゲームロジック

盤面状態の保存と復元(「待った」機能)

// 盤面の状態を保存する関数 - 待った(取り消し)機能のため
function saveBoardState() {
    // 現在の盤面状態、プレイヤー、捕獲石、コウ点を履歴に保存
    moveHistory.push({
        board: gameBoard.map(row => [...row]), // 2次元配列の深いコピー
        currentPlayer: currentPlayer, // 現在のプレイヤー
        capturedStones: {...capturedStones}, // 捕獲石数のコピー
        koPoint: koPoint ? [...koPoint] : null // コウ点のコピー
    });
    document.getElementById('undo-button').disabled = false; // 待ったボタンを有効化
}

// 「待った」(手の取り消し)機能
function undoMove() {
    if (moveHistory.length === 0) return; // 履歴がなければ何もしない
    
    // 最後に保存した状態を取り出す
    const lastState = moveHistory.pop();
    gameBoard = lastState.board; // 盤面を復元
    currentPlayer = lastState.currentPlayer; // プレイヤーを復元
    capturedStones = lastState.capturedStones; // 捕獲石数を復元
    koPoint = lastState.koPoint; // コウ点を復元
    
    refreshBoard(); // 盤面を再描画
    updateStatus(); // ステータス表示を更新
    
    // 履歴がなくなったら待ったボタンを無効化
    document.getElementById('undo-button').disabled = moveHistory.length === 0;
    passCount = 0; // パスカウントをリセット
}

// 盤面を現在の状態に合わせて再描画する関数
function refreshBoard() {
    const cells = board.querySelectorAll('.cell'); // すべてのセルを取得
    cells.forEach(cell => {
        const row = parseInt(cell.dataset.row); // セルの行番号
        const col = parseInt(cell.dataset.col); // セルの列番号
        cell.innerHTML = ''; // セル内の石を削除
        if (gameBoard[row][col]) { // セルに石がある場合
            const stone = document.createElement('div'); // 石要素を作成
            stone.className = `stone ${gameBoard[row][col]}`; // 石のクラスを設定
            cell.appendChild(stone); // セルに石を追加
        }
    });
}

石の配置と捕獲ロジック

// 石を配置する関数 - セルがクリックされたときに呼び出される
function placeStone(cell) {
    const row = parseInt(cell.dataset.row); // クリックされたセルの行番号
    const col = parseInt(cell.dataset.col); // クリックされたセルの列番号

    // セルが空でコウ違反でない場合のみ配置可能
    if (gameBoard[row][col] === null && !isKoViolation(row, col)) {
        saveBoardState(); // 現在の状態を保存(待った用)

        // 石要素を作成して配置
        const stone = document.createElement('div');
        stone.className = `stone ${currentPlayer}`; // 現在のプレイヤーの石
        cell.appendChild(stone);

        // 盤面データを更新
        gameBoard[row][col] = currentPlayer;
        passCount = 0; // パスカウントをリセット

        // 石を配置したことによる敵石の捕獲処理
        const capturedGroups = captureStones(row, col);
        
        // 自殺手の場合(配置した石に呼吸点がなく、かつ敵石を取れない場合)は石を配置できない
        if (capturedGroups.length === 0 && hasNoLiberties(row, col)) {
            moveHistory.pop(); // 保存した状態を削除
            cell.removeChild(stone); // 石を削除
            gameBoard[row][col] = null; // 盤面データを元に戻す
            return;
        }

        updateKoPoint(capturedGroups); // コウ点を更新
        switchPlayer(); // プレイヤーを切り替え
        updateStatus(); // ステータス表示を更新
    }
}

// 石を置いた際に周囲の敵の石を捕獲する関数
function captureStones(row, col) {
    const directions = [[-1, 0], [1, 0], [0, -1], [0, 1]]; // 上下左右の方向
    const opponent = currentPlayer === 'black' ? 'white' : 'black'; // 敵プレイヤー
    const capturedGroups = []; // 捕獲されたグループのリスト

    // 上下左右の隣接セルをチェック
    for (const [dx, dy] of directions) {
        const newRow = row + dx;
        const newCol = col + dy;

        // 隣接セルが有効で敵の石がある場合
        if (isValidPosition(newRow, newCol) && gameBoard[newRow][newCol] === opponent) {
            const group = getGroup(newRow, newCol); // 敵の石のグループを取得
            // グループに呼吸点がない場合は捕獲
            if (hasNoLiberties(newRow, newCol)) {
                removeGroup(group); // グループを盤面から削除
                capturedGroups.push(group); // 捕獲グループに追加
                capturedStones[currentPlayer] += group.length; // 捕獲石数をカウント
            }
        }
    }

    return capturedGroups;
}

// 石のグループを取得する関数(深さ優先探索)
function getGroup(row, col) {
    const color = gameBoard[row][col]; // 石の色
    const group = []; // グループの石のリスト
    const visited = new Set(); // 訪問済みのセル

    // 深さ優先探索の関数
    function dfs(r, c) {
        // 無効な位置、異なる色、または訪問済みの場合は終了
        if (!isValidPosition(r, c) || gameBoard[r][c] !== color || visited.has(`${r},${c}`)) {
            return;
        }

        visited.add(`${r},${c}`); // 訪問済みに追加
        group.push([r, c]); // グループに追加

        // 上下左右を再帰的に探索
        dfs(r-1, c); // 上
        dfs(r+1, c); // 下
        dfs(r, c-1); // 左
        dfs(r, c+1); // 右
    }

    dfs(row, col); // 探索開始
    return group;
}

// グループに呼吸点(空きセル)がないかチェックする関数
function hasNoLiberties(row, col) {
    const group = getGroup(row, col); // 石のグループを取得
    // グループ内のどの石も呼吸点(隣接する空きセル)がない場合はtrue
    return !group.some(([r, c]) => hasAdjacencyLiberties(r, c));
}

// 個々の石に呼吸点があるかチェックする関数
function hasAdjacencyLiberties(row, col) {
    const directions = [[-1, 0], [1, 0], [0, -1], [0, 1]]; // 上下左右の方向
    // 隣接するセルのいずれかが空いているかチェック
    return directions.some(([dx, dy]) => {
        const newRow = row + dx;
        const newCol = col + dy;
        return isValidPosition(newRow, newCol) && gameBoard[newRow][newCol] === null;
    });
}

// 捕獲されたグループを盤面から削除する関数
function removeGroup(group) {
    for (const [row, col] of group) {
        gameBoard[row][col] = null; // 盤面データから石を削除
        const cell = board.children[row * BOARD_SIZE + col]; // 該当するセル要素
        cell.innerHTML = ''; // セルから石要素を削除
    }
}

// 位置が盤面内かチェックする関数
function isValidPosition(row, col) {
    return row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
}

コウのルール実装

// コウ違反をチェックする関数
function isKoViolation(row, col) {
    // コウ点が設定されていて、配置しようとしている位置がコウ点と一致する場合はtrue
    return koPoint && koPoint[0] === row && koPoint[1] === col;
}

// コウ点を更新する関数
function updateKoPoint(capturedGroups) {
    // ちょうど1つの石を捕獲した場合のみコウ点を設定
    if (capturedGroups.length === 1 && capturedGroups[0].length === 1) {
        koPoint = capturedGroups[0][0]; // 捕獲した石の位置をコウ点に設定
    } else {
        koPoint = null; // それ以外の場合はコウ点なし
    }
}

プレイヤー切り替えとステータス更新

// プレイヤーを切り替える関数
function switchPlayer() {
    currentPlayer = currentPlayer === 'black' ? 'white' : 'black';
}

// ゲームステータス表示を更新する関数
function updateStatus() {
    statusDiv.textContent = `現在の手番: ${currentPlayer === 'black' ? '黒' : '白'} | 黒の捕獲: ${capturedStones.black} | 白の捕獲: ${capturedStones.white}`;
}

ゲームコントロールのイベントハンドラ

// パスボタンのイベントハンドラ
passButton.addEventListener('click', () => {
    saveBoardState(); // 状態を保存
    passCount++; // パスカウントを増やす
    // 2回連続でパスした場合はゲーム終了
    if (passCount === 2) {
        endGame("両プレイヤーがパスしたため、得点計算を行います。");
    } else {
        switchPlayer(); // プレイヤーを切り替え
        updateStatus(); // ステータス表示を更新
    }
});

// 投了ボタンのイベントハンドラ
resignButton.addEventListener('click', () => {
    document.getElementById('undo-button').disabled = true; // 待ったボタンを無効化
    // 現在のプレイヤーが投了したメッセージを表示
    endGame(`${currentPlayer === 'black' ? '黒' : '白'}プレイヤーが投了しました。${currentPlayer === 'black' ? '白' : '黒'}の勝利です。`);
});

// 終局(得点計算)ボタンのイベントハンドラ
scoreButton.addEventListener('click', () => {
    calculateScore(); // 得点計算を実行
});

// 待ったボタンのイベントハンドラ
document.getElementById('undo-button').addEventListener('click', undoMove);

ゲーム終了と得点計算

// ゲームを終了する関数
function endGame(message) {
    statusDiv.textContent = message; // メッセージを表示
    board.style.pointerEvents = 'none'; // 盤面のクリックを無効化
    passButton.disabled = true; // パスボタンを無効化
    resignButton.disabled = true; // 投了ボタンを無効化
    document.getElementById('undo-button').disabled = true; // 待ったボタンを無効化
    calculateScore(); // 得点計算を実行
}

// 得点計算の関数
function calculateScore() {
    const territory = { black: 0, white: 0 }; // 各プレイヤーのテリトリー
    const visited = new Set(); // 訪問済みのセル

    // 盤面全体をスキャン
    for (let row = 0; row < BOARD_SIZE; row++) {
        for (let col = 0; col < BOARD_SIZE; col++) {
            // 空のセルでまだ訪問していない場合
            if (gameBoard[row][col] === null && !visited.has(`${row},${col}`)) {
                // フラッドフィルでテリトリーを特定
                const [color, size] = floodFillTerritory(row, col, visited);
                // テリトリーが特定のプレイヤーのものである場合
                if (color) {
                    territory[color] += size; // そのプレイヤーのテリトリーに加算
                }
            }
        }
    }

    // 得点計算:テリトリー + 捕獲した石の数 (+ 白の場合はコミ)
    const blackScore = territory.black + capturedStones.black;
    const whiteScore = territory.white + capturedStones.white + 6.5; // コミ6.5点

    // 得点を表示
    scoreDiv.textContent = `最終得点 - 黒: ${blackScore} | 白: ${whiteScore} | ${blackScore > whiteScore ? '黒' : '白'}の勝利`;
}

// テリトリーを特定するフラッドフィルアルゴリズム
function floodFillTerritory(row, col, visited) {
    const queue = [[row, col]]; // 探索キュー
    const territory = []; // テリトリーとなるセル
    const borders = new Set(); // 領域の境界となる石の色

    // 幅優先探索
    while (queue.length > 0) {
        const [r, c] = queue.shift(); // キューから取り出し
        // 無効な位置または訪問済みの場合はスキップ
        if (!isValidPosition(r, c) || visited.has(`${r},${c}`)) continue;

        visited.add(`${r},${c}`); // 訪問済みに追加
        if (gameBoard[r][c] === null) { // 空のセルの場合
            territory.push([r, c]); // テリトリーに追加
            // 上下左右を探索キューに追加
            queue.push([r-1, c], [r+1, c], [r, c-1], [r, c+1]);
        } else { // 石がある場合
            borders.add(gameBoard[r][c]); // 境界の色を記録
        }
    }

    // 境界が1種類の色のみの場合、その色のテリトリーとなる
    if (borders.size === 1) {
        return [[...borders][0], territory.length];
    }
    // 境界が複数の色の場合、中立地帯となる
    return [null, 0];
}

まとめ

このウェブベースの囲碁ゲームは、以下の囲碁のルールと機能を実装しています:

  • 19x19の標準的な盤面
  • 石の配置と捕獲
  • 呼吸点(liberty)のルール
  • コウのルール
  • テリトリーの計算
  • コミ(6.5ポイント)
  • パス、投了、待った機能

このコードは、HTMLとJavaScriptだけで完全な囲碁ゲームを実装する方法を示しています。さらなる改良として、AI対戦相手の追加、ゲーム記録の保存と再生、複数のプレイヤー間でのオンラインプレイなどが考えられます。

非常な雑な解説でしたがここまで見てくださりありがとうございました!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です