Mejoras y optimizaciones en general.

This commit is contained in:
2025-10-03 00:05:08 +02:00
parent bd76741bd2
commit d1a7442ffa
32 changed files with 3336 additions and 783 deletions

View File

@@ -1,151 +1,468 @@
'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');
let board, gameOver, currentPlayer;
const difficultySelect = document.getElementById('difficulty');
const scoreHumanSpan = document.getElementById('score-human');
const scoreAiSpan = document.getElementById('score-ai');
const scoreDrawSpan = document.getElementById('score-d');
function initGame() {
board = Array.from({length: ROWS}, () => Array(COLS).fill(''));
gameOver = false;
currentPlayer = HUMAN;
statusDiv.textContent = "Tu turno (🟡)";
renderBoard();
// 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);
}
function renderBoard() {
boardDiv.innerHTML = '';
// Creamos todas las celdas primero
for (let row = 0; row < ROWS; row++) {
for (let col = 0; col < COLS; col++) {
const cellDiv = document.createElement('div');
cellDiv.className = 'cell';
cellDiv.textContent = board[row][col];
// Si es turno humano y no está terminado el juego
if (!gameOver && currentPlayer === HUMAN && board[row][col] === '') {
let lowestRow = getLowestEmptyRow(col);
if (lowestRow === row) {
cellDiv.style.cursor = "pointer";
}
// 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';
}
// El evento click siempre llama para la columna
cellDiv.onclick = () => {
if (!gameOver && currentPlayer === HUMAN) {
playerMove(col);
}
};
boardDiv.appendChild(cellDiv);
}
}
}
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 playerMove(col) {
if (gameOver || currentPlayer !== HUMAN) return;
let row = getLowestEmptyRow(col);
if (row === -1) return; // columna llena
board[row][col] = HUMAN;
renderBoard();
if (checkWinner(HUMAN)) {
statusDiv.textContent = "¡Has ganado! 🎉";
gameOver = true;
return;
}
if (isFull()) {
statusDiv.textContent = "¡Empate!";
gameOver = true;
return;
}
currentPlayer = AI;
statusDiv.textContent = "Turno de la máquina (❌)";
setTimeout(machineMove, 450);
function isDraw() {
// Si la fila superior está llena, no hay movimientos posibles
return board[0].every((cell) => cell !== '');
}
function machineMove() {
if (gameOver || currentPlayer !== AI) return;
// IA sencilla: elige columna aleatoria libre
let validCols = [];
for (let c = 0; c < COLS; c++) {
if (board[0][c] === '') validCols.push(c);
}
if (validCols.length === 0) return;
let col = validCols[Math.floor(Math.random() * validCols.length)];
let row = getLowestEmptyRow(col);
board[row][col] = AI;
renderBoard();
if (checkWinner(AI)) {
statusDiv.textContent = "¡La máquina gana! 🤖";
gameOver = true;
return;
}
if (isFull()) {
statusDiv.textContent = "¡Empate!";
gameOver = true;
return;
}
currentPlayer = HUMAN;
statusDiv.textContent = "Tu turno (🟡)";
}
function isFull() {
return board[0].every(cell => cell !== '');
}
function checkWinner(player) {
// 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 (
board[r][c] === player &&
board[r][c + 1] === player &&
board[r][c + 2] === player &&
board[r][c + 3] === player
) return true;
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 (
board[r][c] === player &&
board[r + 1][c] === player &&
board[r + 2][c] === player &&
board[r + 3][c] === player
) return true;
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 (
board[r][c] === player &&
board[r + 1][c + 1] === player &&
board[r + 2][c + 2] === player &&
board[r + 3][c + 3] === player
) return true;
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 (
board[r][c] === player &&
board[r - 1][c + 1] === player &&
board[r - 2][c + 2] === player &&
board[r - 3][c + 3] === player
) return true;
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 false;
return null;
}
function checkWinner(player) {
return !!checkWinnerPositions(board, player);
}
restartBtn.onclick = initGame;
initGame();
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();