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.
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.
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;
}
vmin fa que el tauler s'adapti
automàticament a la dimensió més petita de la finestra, ideal per a mòbils.
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 };
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);
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();
}
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;
}
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');
}
}
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();
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>
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.