468 lines
13 KiB
JavaScript
468 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
// Configuración del tablero y fichas
|
|
const ROWS = 6;
|
|
const COLS = 7;
|
|
const HUMAN = '🟡';
|
|
const AI = '❌';
|
|
|
|
// Referencias DOM
|
|
const boardDiv = document.getElementById('board');
|
|
const statusDiv = document.getElementById('status');
|
|
const restartBtn = document.getElementById('restart-btn');
|
|
const difficultySelect = document.getElementById('difficulty');
|
|
const scoreHumanSpan = document.getElementById('score-human');
|
|
const scoreAiSpan = document.getElementById('score-ai');
|
|
const scoreDrawSpan = document.getElementById('score-d');
|
|
|
|
// Estado del juego
|
|
let board = Array.from({ length: ROWS }, () => Array(COLS).fill(''));
|
|
let gameOver = false;
|
|
let currentPlayer = HUMAN;
|
|
let aiDepth = 3; // por defecto "normal"
|
|
let scores = { human: 0, ai: 0, D: 0 };
|
|
|
|
// Utilidades de almacenamiento
|
|
function loadScores() {
|
|
try {
|
|
const raw = localStorage.getItem('connect4_scores');
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed && typeof parsed === 'object') {
|
|
scores.human = Number(parsed.human) || 0;
|
|
scores.ai = Number(parsed.ai) || 0;
|
|
scores.D = Number(parsed.D) || 0;
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// Ignorar errores de almacenamiento/parsing
|
|
}
|
|
}
|
|
function saveScores() {
|
|
try {
|
|
localStorage.setItem('connect4_scores', JSON.stringify(scores));
|
|
} catch (_) {
|
|
// Ignorar errores de almacenamiento
|
|
}
|
|
}
|
|
function updateScoreUI() {
|
|
if (scoreHumanSpan) scoreHumanSpan.textContent = String(scores.human);
|
|
if (scoreAiSpan) scoreAiSpan.textContent = String(scores.ai);
|
|
if (scoreDrawSpan) scoreDrawSpan.textContent = String(scores.D);
|
|
}
|
|
|
|
// Inicialización del DOM del tablero (reutiliza nodos para rendimiento)
|
|
function initBoardDom() {
|
|
const totalCells = ROWS * COLS;
|
|
if (boardDiv.children.length === totalCells) {
|
|
// Reutilizar botones ya creados
|
|
Array.from(boardDiv.children).forEach((c) => {
|
|
c.classList.remove('win');
|
|
c.textContent = '';
|
|
c.removeEventListener('click', onCellClick);
|
|
c.addEventListener('click', onCellClick);
|
|
c.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') onCellClick(e);
|
|
});
|
|
});
|
|
boardDiv.setAttribute('aria-disabled', 'false');
|
|
return;
|
|
}
|
|
|
|
boardDiv.textContent = '';
|
|
for (let r = 0; r < ROWS; r++) {
|
|
for (let c = 0; c < COLS; c++) {
|
|
const cell = document.createElement('button');
|
|
cell.type = 'button';
|
|
cell.className = 'cell';
|
|
cell.setAttribute('data-row', String(r));
|
|
cell.setAttribute('data-col', String(c));
|
|
cell.setAttribute('role', 'gridcell');
|
|
cell.setAttribute('aria-label', `Fila ${r + 1}, Columna ${c + 1}`);
|
|
cell.addEventListener('click', onCellClick);
|
|
cell.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') onCellClick(e);
|
|
});
|
|
boardDiv.appendChild(cell);
|
|
}
|
|
}
|
|
boardDiv.setAttribute('aria-disabled', 'false');
|
|
}
|
|
|
|
// Render del tablero (sin reconstruir DOM)
|
|
function render() {
|
|
for (let r = 0; r < ROWS; r++) {
|
|
for (let c = 0; c < COLS; c++) {
|
|
const idx = r * COLS + c;
|
|
const el = boardDiv.children[idx];
|
|
if (!el) continue;
|
|
el.textContent = board[r][c];
|
|
|
|
// Indicador de clic solo en la fila más baja disponible de cada columna durante turno humano
|
|
if (!gameOver && currentPlayer === HUMAN && board[r][c] === '') {
|
|
const lowestRow = getLowestEmptyRow(c);
|
|
el.style.cursor = lowestRow === r ? 'pointer' : 'default';
|
|
} else {
|
|
el.style.cursor = 'default';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function setStatus(msg) {
|
|
statusDiv.textContent = msg;
|
|
}
|
|
|
|
// Lógica de movimiento
|
|
function getLowestEmptyRow(col) {
|
|
for (let r = ROWS - 1; r >= 0; r--) {
|
|
if (board[r][col] === '') return r;
|
|
}
|
|
return -1;
|
|
}
|
|
function isDraw() {
|
|
// Si la fila superior está llena, no hay movimientos posibles
|
|
return board[0].every((cell) => cell !== '');
|
|
}
|
|
|
|
// Comprobación de ganador; devuelve posiciones ganadoras para destacar
|
|
function checkWinnerPositions(b, player) {
|
|
// Horizontal
|
|
for (let r = 0; r < ROWS; r++) {
|
|
for (let c = 0; c < COLS - 3; c++) {
|
|
if (b[r][c] === player && b[r][c + 1] === player && b[r][c + 2] === player && b[r][c + 3] === player) {
|
|
return [[r, c], [r, c + 1], [r, c + 2], [r, c + 3]];
|
|
}
|
|
}
|
|
}
|
|
// Vertical
|
|
for (let c = 0; c < COLS; c++) {
|
|
for (let r = 0; r < ROWS - 3; r++) {
|
|
if (b[r][c] === player && b[r + 1][c] === player && b[r + 2][c] === player && b[r + 3][c] === player) {
|
|
return [[r, c], [r + 1, c], [r + 2, c], [r + 3, c]];
|
|
}
|
|
}
|
|
}
|
|
// Diagonal descendente \
|
|
for (let r = 0; r < ROWS - 3; r++) {
|
|
for (let c = 0; c < COLS - 3; c++) {
|
|
if (b[r][c] === player && b[r + 1][c + 1] === player && b[r + 2][c + 2] === player && b[r + 3][c + 3] === player) {
|
|
return [[r, c], [r + 1, c + 1], [r + 2, c + 2], [r + 3, c + 3]];
|
|
}
|
|
}
|
|
}
|
|
// Diagonal ascendente /
|
|
for (let r = 3; r < ROWS; r++) {
|
|
for (let c = 0; c < COLS - 3; c++) {
|
|
if (b[r][c] === player && b[r - 1][c + 1] === player && b[r - 2][c + 2] === player && b[r - 3][c + 3] === player) {
|
|
return [[r, c], [r - 1, c + 1], [r - 2, c + 2], [r - 3, c + 3]];
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function checkWinner(player) {
|
|
return !!checkWinnerPositions(board, player);
|
|
}
|
|
|
|
function highlightWin(positions) {
|
|
if (!positions) return;
|
|
positions.forEach(([r, c]) => {
|
|
const idx = r * COLS + c;
|
|
const el = boardDiv.children[idx];
|
|
if (el) el.classList.add('win');
|
|
});
|
|
}
|
|
|
|
function endGame(result, winPositions) {
|
|
gameOver = true;
|
|
boardDiv.setAttribute('aria-disabled', 'true');
|
|
|
|
if (result === HUMAN) {
|
|
setStatus('¡Has ganado! 🎉');
|
|
scores.human += 1;
|
|
highlightWin(winPositions);
|
|
} else if (result === AI) {
|
|
setStatus('¡La máquina gana! 🤖');
|
|
scores.ai += 1;
|
|
highlightWin(winPositions);
|
|
} else {
|
|
setStatus('¡Empate!');
|
|
scores.D += 1;
|
|
}
|
|
|
|
updateScoreUI();
|
|
saveScores();
|
|
}
|
|
|
|
// Interacción usuario
|
|
function onCellClick(e) {
|
|
const target = e.currentTarget;
|
|
const row = Number(target.getAttribute('data-row'));
|
|
const col = Number(target.getAttribute('data-col'));
|
|
if (!Number.isInteger(row) || !Number.isInteger(col)) return;
|
|
|
|
if (gameOver || currentPlayer !== HUMAN) return;
|
|
const r = getLowestEmptyRow(col);
|
|
if (r === -1) return; // columna llena
|
|
|
|
board[r][col] = HUMAN;
|
|
render();
|
|
|
|
const winPos = checkWinnerPositions(board, HUMAN);
|
|
if (winPos) {
|
|
endGame(HUMAN, winPos);
|
|
return;
|
|
}
|
|
if (isDraw()) {
|
|
endGame('D', null);
|
|
return;
|
|
}
|
|
|
|
currentPlayer = AI;
|
|
setStatus('Turno de la máquina (❌)');
|
|
setTimeout(machineMove, 220);
|
|
}
|
|
|
|
// IA Minimax con evaluación heurística y profundidad limitada
|
|
function validMoves(b) {
|
|
const cols = [];
|
|
for (let c = 0; c < COLS; c++) {
|
|
if (b[0][c] === '') cols.push(c);
|
|
}
|
|
return cols;
|
|
}
|
|
function applyMove(b, col, player) {
|
|
for (let r = ROWS - 1; r >= 0; r--) {
|
|
if (b[r][col] === '') {
|
|
b[r][col] = player;
|
|
return r;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
function undoMove(b, col) {
|
|
// Quita la ficha más alta en la columna
|
|
for (let r = 0; r < ROWS; r++) {
|
|
if (b[r][col] !== '') {
|
|
b[r][col] = '';
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function evaluateWindow(cells) {
|
|
let aiCount = 0, humanCount = 0, empty = 0;
|
|
for (const v of cells) {
|
|
if (v === AI) aiCount++;
|
|
else if (v === HUMAN) humanCount++;
|
|
else empty++;
|
|
}
|
|
// Bloques con ambas fichas no suman
|
|
if (aiCount > 0 && humanCount > 0) return 0;
|
|
if (aiCount > 0) {
|
|
if (aiCount === 1) return 2;
|
|
if (aiCount === 2) return 10;
|
|
if (aiCount === 3) return 50;
|
|
if (aiCount === 4) return 100000;
|
|
}
|
|
if (humanCount > 0) {
|
|
if (humanCount === 1) return -2;
|
|
if (humanCount === 2) return -10;
|
|
if (humanCount === 3) return -55; // penaliza más
|
|
if (humanCount === 4) return -100000;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function evaluateBoard(b) {
|
|
let score = 0;
|
|
// Centrismo: favorece columnas centrales (mejor conectividad)
|
|
const centerCol = Math.floor(COLS / 2);
|
|
let centerCount = 0;
|
|
for (let r = 0; r < ROWS; r++) {
|
|
if (b[r][centerCol] === AI) centerCount++;
|
|
}
|
|
score += centerCount * 3;
|
|
|
|
// Horizontal
|
|
for (let r = 0; r < ROWS; r++) {
|
|
for (let c = 0; c < COLS - 3; c++) {
|
|
const window = [b[r][c], b[r][c + 1], b[r][c + 2], b[r][c + 3]];
|
|
score += evaluateWindow(window);
|
|
}
|
|
}
|
|
// Vertical
|
|
for (let c = 0; c < COLS; c++) {
|
|
for (let r = 0; r < ROWS - 3; r++) {
|
|
const window = [b[r][c], b[r + 1][c], b[r + 2][c], b[r + 3][c]];
|
|
score += evaluateWindow(window);
|
|
}
|
|
}
|
|
// Diagonal descendente \
|
|
for (let r = 0; r < ROWS - 3; r++) {
|
|
for (let c = 0; c < COLS - 3; c++) {
|
|
const window = [b[r][c], b[r + 1][c + 1], b[r + 2][c + 2], b[r + 3][c + 3]];
|
|
score += evaluateWindow(window);
|
|
}
|
|
}
|
|
// Diagonal ascendente /
|
|
for (let r = 3; r < ROWS; r++) {
|
|
for (let c = 0; c < COLS - 3; c++) {
|
|
const window = [b[r][c], b[r - 1][c + 1], b[r - 2][c + 2], b[r - 3][c + 3]];
|
|
score += evaluateWindow(window);
|
|
}
|
|
}
|
|
return score;
|
|
}
|
|
|
|
function minimax(b, depth, maximizing, alpha, beta) {
|
|
// Terminal: victoria/derrota/empate
|
|
const aiWin = checkWinnerPositions(b, AI);
|
|
const humanWin = checkWinnerPositions(b, HUMAN);
|
|
if (aiWin) return 1000000 + depth; // ganar rápido
|
|
if (humanWin) return -1000000 - depth; // perder tarde
|
|
if (validMoves(b).length === 0 || depth === 0) {
|
|
return evaluateBoard(b);
|
|
}
|
|
|
|
if (maximizing) {
|
|
let best = -Infinity;
|
|
// Ordenar movimientos por proximidad al centro
|
|
const moves = validMoves(b).sort((a, d) => Math.abs(a - Math.floor(COLS / 2)) - Math.abs(d - Math.floor(COLS / 2)));
|
|
for (const col of moves) {
|
|
applyMove(b, col, AI);
|
|
const val = minimax(b, depth - 1, false, alpha, beta);
|
|
undoMove(b, col);
|
|
best = Math.max(best, val);
|
|
alpha = Math.max(alpha, best);
|
|
if (beta <= alpha) break; // poda
|
|
}
|
|
return best;
|
|
} else {
|
|
let best = Infinity;
|
|
const moves = validMoves(b).sort((a, d) => Math.abs(a - Math.floor(COLS / 2)) - Math.abs(d - Math.floor(COLS / 2)));
|
|
for (const col of moves) {
|
|
applyMove(b, col, HUMAN);
|
|
const val = minimax(b, depth - 1, true, alpha, beta);
|
|
undoMove(b, col);
|
|
best = Math.min(best, val);
|
|
beta = Math.min(beta, best);
|
|
if (beta <= alpha) break; // poda
|
|
}
|
|
return best;
|
|
}
|
|
}
|
|
|
|
function getBestMove(b, depth) {
|
|
let bestScore = -Infinity;
|
|
let bestCol = null;
|
|
|
|
// Heurística inicial: priorizar centro si vacío
|
|
const center = Math.floor(COLS / 2);
|
|
if (b[0][center] === '') {
|
|
bestCol = center;
|
|
}
|
|
|
|
const moves = validMoves(b);
|
|
for (const col of moves) {
|
|
applyMove(b, col, AI);
|
|
const score = minimax(b, depth - 1, false, -Infinity, Infinity);
|
|
undoMove(b, col);
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
bestCol = col;
|
|
}
|
|
}
|
|
// safety: si no hay mejor, primera válida
|
|
if (bestCol === null && moves.length > 0) bestCol = moves[0];
|
|
return bestCol;
|
|
}
|
|
|
|
function machineMove() {
|
|
if (gameOver || currentPlayer !== AI) return;
|
|
|
|
const col = getBestMove(board, aiDepth);
|
|
if (col === null) {
|
|
// Sin movimientos válidos: empate
|
|
endGame('D', null);
|
|
return;
|
|
}
|
|
const row = getLowestEmptyRow(col);
|
|
if (row === -1) {
|
|
// Columna llena por carrera, elegir siguiente válida
|
|
const altMoves = validMoves(board).filter((c) => c !== col);
|
|
if (!altMoves.length) {
|
|
endGame('D', null);
|
|
return;
|
|
}
|
|
const alt = altMoves[0];
|
|
const altRow = getLowestEmptyRow(alt);
|
|
if (altRow === -1) {
|
|
endGame('D', null);
|
|
return;
|
|
}
|
|
board[altRow][alt] = AI;
|
|
} else {
|
|
board[row][col] = AI;
|
|
}
|
|
|
|
render();
|
|
|
|
const winPos = checkWinnerPositions(board, AI);
|
|
if (winPos) {
|
|
endGame(AI, winPos);
|
|
return;
|
|
}
|
|
if (isDraw()) {
|
|
endGame('D', null);
|
|
return;
|
|
}
|
|
|
|
currentPlayer = HUMAN;
|
|
setStatus('Tu turno (🟡)');
|
|
}
|
|
|
|
function difficultyToDepth(val) {
|
|
switch (val) {
|
|
case 'facil': return 1;
|
|
case 'dificil': return 4;
|
|
case 'normal':
|
|
default: return 3;
|
|
}
|
|
}
|
|
|
|
function startGame() {
|
|
// Configurar dificultad
|
|
if (difficultySelect) {
|
|
aiDepth = difficultyToDepth(difficultySelect.value);
|
|
} else {
|
|
aiDepth = 3;
|
|
}
|
|
|
|
// Reset estado
|
|
board = Array.from({ length: ROWS }, () => Array(COLS).fill(''));
|
|
gameOver = false;
|
|
currentPlayer = HUMAN;
|
|
|
|
initBoardDom();
|
|
// Limpiar resaltados previos
|
|
Array.from(boardDiv.children).forEach((c) => c.classList.remove('win'));
|
|
render();
|
|
|
|
setStatus('Tu turno (🟡)');
|
|
boardDiv.setAttribute('aria-disabled', 'false');
|
|
}
|
|
|
|
// Listeners
|
|
restartBtn.addEventListener('click', startGame);
|
|
if (difficultySelect) {
|
|
difficultySelect.addEventListener('change', () => {
|
|
startGame();
|
|
});
|
|
}
|
|
|
|
// Init
|
|
loadScores();
|
|
updateScoreUI();
|
|
startGame(); |