'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();