♟️ Aprenent HTML, CSS i JavaScript creant el joc de Go

Tutorial pas a pas · Tauler 9×9 · Dos jugadors · Captura de pedres · Totalment responsiu

El Go és un joc de tauler d'origen xinès on dos jugadors col·loquen pedres (negres i blanques) alternativament sobre les interseccions d'una quadrícula. L'objectiu és controlar més territori que l'oponent. En aquest tutorial aprendràs a crear-ne una versió bàsica amb HTML, CSS i JavaScript, amb captura de pedres i funcionament tant en ordinador com en mòbil.

Pas 1: Estructura HTML bàsica

Comencem amb l'esquelet del document. La meta etiqueta viewport és fonamental perquè el joc sigui responsiu. Dins del body posem un div per al tauler i un altre per mostrar les pedres capturades.

<!DOCTYPE html>
<html lang="ca">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>Go Game</title>
</head>
<body>
    <div id="go-board"></div>
    <div id="captured-counts">
        <span id="black-captured">Pedres negres capturades: 0</span>
        <span id="white-captured">Pedres blanques capturades: 0</span>
    </div>
    <script src="go.js"></script>
</body>
</html>

El div#go-board serà el contenidor del tauler i l'omplirem dinàmicament amb JavaScript. L'element #captured-counts mostrarà el recompte de pedres capturades per cada jugador.

Pas 2: Estils CSS responsius

El tauler es crea amb CSS Grid de 9 columnes. Cada cel·la és una intersecció on es pot col·locar una pedra. Les pedres són cercles creats amb border-radius: 50%. Afegim també estils per al cos de la pàgina i per als comptadors de pedres capturades.

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}
body {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 100vh;
    background-color: #f5f0e8;
    font-family: Arial, sans-serif;
    padding: 10px;
}
#go-board {
    display: grid;
    grid-template-columns: repeat(9, 1fr);
    grid-template-rows: repeat(9, 1fr);
    gap: 1px;
    background-color: #000;
    border: 2px solid #333;
    width: 90vmin;
    max-width: 450px;
    height: 90vmin;
    max-height: 450px;
    margin: 20px auto;
}
.stone {
    background-color: #dcb35c;
    border: none;
    padding: 0;
    margin: 0;
    cursor: pointer;
    box-sizing: border-box;
    transition: background-color 0.2s;
    position: relative;
}
.stone::after {
    content: '';
    display: block;
    padding-bottom: 100%;
}
.black {
    background: radial-gradient(circle at 35% 35%, #555, #111);
    border-radius: 50%;
}
.white {
    background: radial-gradient(circle at 35% 35%, #fff, #ccc);
    border-radius: 50%;
}
#captured-counts {
    margin-top: 10px;
    text-align: center;
    font-size: clamp(14px, 3vw, 18px);
}
#captured-counts span {
    display: inline-block;
    margin: 0 15px;
    font-weight: bold;
}
#turn-indicator {
    text-align: center;
    font-size: clamp(16px, 4vw, 20px);
    margin-top: 5px;
    font-weight: bold;
}
button {
    display: block;
    margin: 10px auto;
    padding: 8px 24px;
    font-size: 16px;
    cursor: pointer;
    border-radius: 6px;
    border: 1px solid #333;
    background: #fff;
}
💡 Consell: La unitat vmin fa que el tauler s'adapti automàticament a la dimensió més petita de la finestra, ideal per a mòbils.

Pas 3: Variables globals i inicialització

Definim les variables principals: la mida del tauler (boardSize = 9), el jugador actual (currentPlayer), la matriu del joc (gameBoard) i l'objecte que porta el recompte de pedres capturades.

const boardContainer = document.getElementById('go-board');
const boardSize = 9;
let currentPlayer = 'black';
let gameBoard = [];
let capturedStones = { black: 0, white: 0 };

Pas 4: Creació dinàmica del tauler

Amb un bucle doble creem cada cel·la (div.stone), li assignem coordenades data-row i data-col, i la guardem a la matriu gameBoard. Després afegim un únic event listener al contenidor del tauler per gestionar tots els clics (delegació d'esdeveniments).

for (let i = 0; i < boardSize; i++) {
    gameBoard.push([]);
    for (let j = 0; j < boardSize; j++) {
        const stone = document.createElement('div');
        stone.className = 'stone';
        stone.dataset.row = i;
        stone.dataset.col = j;
        boardContainer.appendChild(stone);
        gameBoard[i].push(stone);
    }
}
boardContainer.addEventListener('click', handleStoneClick);

Pas 5: Gestió del clic – col·locar una pedra

Quan es fa clic sobre una cel·la buida, s'hi col·loca una pedra del color del jugador actual. Després es comprova si hi ha pedres enemigues per capturar, es canvia de torn i s'actualitzen els comptadors.

function handleStoneClick(event) {
    const stone = event.target;
    if (!stone.classList.contains('stone') ||
        stone.classList.contains('black') ||
        stone.classList.contains('white')) {
        return; // No fem res si no és una cel·la buida
    }
    placeStone(stone);
}

function placeStone(stone) {
    stone.classList.add(currentPlayer);
    const row = parseInt(stone.dataset.row);
    const col = parseInt(stone.dataset.col);

    // Comprovar captures
    captureStones(row, col);

    // Canviar de torn
    currentPlayer = currentPlayer === 'black' ? 'white' : 'black';
    updateTurnIndicator();
    updateCapturedCounts();
}

Pas 6: Algorisme de captura de pedres

Aquesta és la part més important del joc. Quan es col·loca una pedra, hem de comprovar si algun grup de pedres enemigues s'ha quedat sense llibertats (interseccions buides adjacents). Per fer-ho, utilitzem un algorisme de flood fill (DFS amb pila) sobre els veïns de la pedra col·locada.

function captureStones(row, col) {
    const opponentColor = currentPlayer === 'black' ? 'white' : 'black';
    let captured = false;

    // Obtenir les 4 cel·les veïnes (dins dels límits)
    function getNeighbors(r, c) {
        return [
            [r - 1, c], [r + 1, c], [r, c - 1], [r, c + 1]
        ].filter(([nr, nc]) =>
            nr >= 0 && nr < boardSize && nc >= 0 && nc < boardSize
        );
    }

    // Comprova si un grup té llibertats (flood fill)
    function hasLiberties(r, c, colorToCheck) {
        const visited = new Set();
        const stack = [[r, c]];
        while (stack.length > 0) {
            const [cr, cc] = stack.pop();
            const key = cr + ',' + cc;
            if (visited.has(key)) continue;
            visited.add(key);
            const stone = gameBoard[cr][cc];
            // Si trobem una cel·la buida, el grup té llibertats
            if (!stone.classList.contains('black') &&
                !stone.classList.contains('white')) {
                return true;
            }
            // Si és del mateix color, seguim explorant
            if (stone.classList.contains(colorToCheck)) {
                getNeighbors(cr, cc).forEach(([nr, nc]) => {
                    if (!visited.has(nr + ',' + nc)) {
                        stack.push([nr, nc]);
                    }
                });
            }
        }
        return false;
    }

    // Elimina totes les pedres d'un grup capturat
    function removeCapturedStones(r, c, colorToRemove) {
        const visited = new Set();
        const stack = [[r, c]];
        while (stack.length > 0) {
            const [cr, cc] = stack.pop();
            const key = cr + ',' + cc;
            if (visited.has(key)) continue;
            visited.add(key);
            const stone = gameBoard[cr][cc];
            if (stone.classList.contains(colorToRemove)) {
                stone.classList.remove('black', 'white');
                capturedStones[colorToRemove]++;
                getNeighbors(cr, cc).forEach(([nr, nc]) => {
                    if (!visited.has(nr + ',' + nc)) {
                        stack.push([nr, nc]);
                    }
                });
            }
        }
    }

    // Per a cada veí del color contrari, comprovem si té llibertats
    getNeighbors(row, col).forEach(([nr, nc]) => {
        const neighbor = gameBoard[nr][nc];
        if (neighbor.classList.contains(opponentColor) &&
            !hasLiberties(nr, nc, opponentColor)) {
            captured = true;
            removeCapturedStones(nr, nc, opponentColor);
        }
    });

    return captured;
}
⚠️ Important: En una implementació completa també caldria comprovar que la pròpia pedra no se suïcidi (quedar-se sense llibertats) i gestionar la regla del ko. Aquí fem una versió simplificada.

Pas 7: Actualització dels comptadors i del torn

Cada cop que es fa una jugada, actualitzem els textos que mostren el nombre de pedres capturades i indiquem de qui és el torn.

function updateCapturedCounts() {
    document.getElementById('black-captured').textContent =
        'Pedres negres capturades: ' + capturedStones.black;
    document.getElementById('white-captured').textContent =
        'Pedres blanques capturades: ' + capturedStones.white;
}

function updateTurnIndicator() {
    const indicator = document.getElementById('turn-indicator');
    if (indicator) {
        indicator.textContent = 'Torn: ' +
            (currentPlayer === 'black' ? 'Negres' : 'Blanques');
    }
}

Pas 8: Funció d'inicialització del joc

Per poder reiniciar la partida, necessitem una funció que netegi el tauler i restableixi totes les variables al seu estat inicial.

function initGame() {
    // Netejar el tauler
    gameBoard.forEach(row => {
        row.forEach(stone => {
            stone.className = 'stone';
        });
    });
    currentPlayer = 'black';
    capturedStones = { black: 0, white: 0 };
    updateCapturedCounts();
    updateTurnIndicator();
}

// Cridem initGame en carregar la pàgina
initGame();

Pas 9: Codi complet del joc

Aquí tens el codi complet del joc de Go. Copia'l i enganxa'l en un fitxer .html per provar-lo directament al teu navegador:

<!DOCTYPE html>
<html lang="ca">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>Go Game - 9×9</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            min-height: 100vh;
            background-color: #f5f0e8;
            font-family: Arial, sans-serif;
            padding: 10px;
            touch-action: manipulation;
        }
        h1 {
            margin-bottom: 5px;
            font-size: clamp(1.5rem, 5vw, 2rem);
            color: #333;
        }
        #go-board {
            display: grid;
            grid-template-columns: repeat(9, 1fr);
            grid-template-rows: repeat(9, 1fr);
            gap: 1px;
            background-color: #000;
            border: 3px solid #333;
            width: 90vmin;
            max-width: 450px;
            height: 90vmin;
            max-height: 450px;
            margin: 10px auto;
            border-radius: 4px;
        }
        .stone {
            background-color: #dcb35c;
            border: none;
            padding: 0;
            margin: 0;
            cursor: pointer;
            box-sizing: border-box;
            transition: background-color 0.2s;
            position: relative;
        }
        .stone::after {
            content: '';
            display: block;
            padding-bottom: 100%;
        }
        .black {
            background: radial-gradient(circle at 35% 35%, #555, #111);
            border-radius: 50%;
        }
        .white {
            background: radial-gradient(circle at 35% 35%, #fff, #ccc);
            border-radius: 50%;
        }
        #captured-counts {
            margin-top: 5px;
            text-align: center;
            font-size: clamp(14px, 3vw, 18px);
        }
        #captured-counts span {
            display: inline-block;
            margin: 0 15px;
            font-weight: bold;
        }
        #turn-indicator {
            text-align: center;
            font-size: clamp(16px, 4vw, 20px);
            margin-top: 5px;
            font-weight: bold;
            color: #333;
        }
        button {
            display: block;
            margin: 8px auto;
            padding: 10px 28px;
            font-size: 16px;
            cursor: pointer;
            border-radius: 6px;
            border: 1px solid #333;
            background: #fff;
            font-weight: bold;
        }
    </style>
</head>
<body>
    <h1>♟️ Joc de Go (9×9)</h1>
    <div id="turn-indicator">Torn: Negres</div>
    <div id="go-board"></div>
    <div id="captured-counts">
        <span id="black-captured">Pedres negres capturades: 0</span>
        <span id="white-captured">Pedres blanques capturades: 0</span>
    </div>
    <button id="restart-btn">Reiniciar partida</button>

    <script>
        const boardContainer = document.getElementById('go-board');
        const boardSize = 9;
        let currentPlayer = 'black';
        let gameBoard = [];
        let capturedStones = { black: 0, white: 0 };

        // Crear el tauler
        for (let i = 0; i < boardSize; i++) {
            gameBoard.push([]);
            for (let j = 0; j < boardSize; j++) {
                const stone = document.createElement('div');
                stone.className = 'stone';
                stone.dataset.row = i;
                stone.dataset.col = j;
                boardContainer.appendChild(stone);
                gameBoard[i].push(stone);
            }
        }
        boardContainer.addEventListener('click', handleStoneClick);

        function handleStoneClick(event) {
            const stone = event.target;
            if (!stone.classList.contains('stone') ||
                stone.classList.contains('black') ||
                stone.classList.contains('white')) {
                return;
            }
            placeStone(stone);
        }

        function placeStone(stone) {
            stone.classList.add(currentPlayer);
            const row = parseInt(stone.dataset.row);
            const col = parseInt(stone.dataset.col);
            captureStones(row, col);
            currentPlayer = currentPlayer === 'black' ? 'white' : 'black';
            updateTurnIndicator();
            updateCapturedCounts();
        }

        function captureStones(row, col) {
            const opponentColor = currentPlayer === 'black' ? 'white' : 'black';
            let captured = false;

            function getNeighbors(r, c) {
                return [
                    [r - 1, c], [r + 1, c], [r, c - 1], [r, c + 1]
                ].filter(([nr, nc]) =>
                    nr >= 0 && nr < boardSize && nc >= 0 && nc < boardSize
                );
            }

            function hasLiberties(r, c, colorToCheck) {
                const visited = new Set();
                const stack = [[r, c]];
                while (stack.length > 0) {
                    const [cr, cc] = stack.pop();
                    const key = cr + ',' + cc;
                    if (visited.has(key)) continue;
                    visited.add(key);
                    const stone = gameBoard[cr][cc];
                    if (!stone.classList.contains('black') &&
                        !stone.classList.contains('white')) {
                        return true;
                    }
                    if (stone.classList.contains(colorToCheck)) {
                        getNeighbors(cr, cc).forEach(([nr, nc]) => {
                            if (!visited.has(nr + ',' + nc)) {
                                stack.push([nr, nc]);
                            }
                        });
                    }
                }
                return false;
            }

            function removeCapturedStones(r, c, colorToRemove) {
                const visited = new Set();
                const stack = [[r, c]];
                while (stack.length > 0) {
                    const [cr, cc] = stack.pop();
                    const key = cr + ',' + cc;
                    if (visited.has(key)) continue;
                    visited.add(key);
                    const stone = gameBoard[cr][cc];
                    if (stone.classList.contains(colorToRemove)) {
                        stone.classList.remove('black', 'white');
                        capturedStones[colorToRemove]++;
                        getNeighbors(cr, cc).forEach(([nr, nc]) => {
                            if (!visited.has(nr + ',' + nc)) {
                                stack.push([nr, nc]);
                            }
                        });
                    }
                }
            }

            getNeighbors(row, col).forEach(([nr, nc]) => {
                const neighbor = gameBoard[nr][nc];
                if (neighbor.classList.contains(opponentColor) &&
                    !hasLiberties(nr, nc, opponentColor)) {
                    captured = true;
                    removeCapturedStones(nr, nc, opponentColor);
                }
            });

            return captured;
        }

        function updateCapturedCounts() {
            document.getElementById('black-captured').textContent =
                'Pedres negres capturades: ' + capturedStones.black;
            document.getElementById('white-captured').textContent =
                'Pedres blanques capturades: ' + capturedStones.white;
        }

        function updateTurnIndicator() {
            const indicator = document.getElementById('turn-indicator');
            if (indicator) {
                indicator.textContent = 'Torn: ' +
                    (currentPlayer === 'black' ? 'Negres' : 'Blanques');
            }
        }

        function initGame() {
            gameBoard.forEach(row => {
                row.forEach(stone => {
                    stone.className = 'stone';
                });
            });
            currentPlayer = 'black';
            capturedStones = { black: 0, white: 0 };
            updateCapturedCounts();
            updateTurnIndicator();
        }

        document.getElementById('restart-btn').addEventListener('click', initGame);

        // Inicialitzar el joc
        initGame();
    </script>
</body>
</html>

🎮 Prova el joc aquí mateix!

Juga contra un amic en el mateix dispositiu. Feu clic sobre una intersecció buida per col·locar-hi una pedra. Les pedres enemigues sense llibertats es capturen automàticament. Funciona amb ratolí i pantalla tàctil.