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

@@ -3,12 +3,28 @@
<head>
<meta charset="UTF-8">
<title>4 en Raya vs Máquina</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<h1>4 en Raya vs Máquina</h1>
<div id="board"></div>
<div id="status"></div>
<h1 id="title">4 en Raya vs Máquina</h1>
<div id="controls">
<label for="difficulty">Dificultad IA:</label>
<select id="difficulty">
<option value="facil">Fácil (profundidad 1)</option>
<option value="normal" selected>Normal (profundidad 3)</option>
<option value="dificil">Difícil (profundidad 4)</option>
</select>
</div>
<div id="board" role="grid" aria-labelledby="title"></div>
<div id="status" aria-live="polite"></div>
<div id="score">
Marcador — Tú: <span id="score-human">0</span> · Máquina: <span id="score-ai">0</span> · Empates: <span id="score-d">0</span>
</div>
<button id="restart-btn">Reiniciar</button>
<script src="script.js"></script>
</body>

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

View File

@@ -129,4 +129,56 @@ h1 {
.cell {
font-size: 2em;
}
}
/* Controles de IA y marcador */
#controls {
margin: 1rem auto 0.5rem auto;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
#controls select#difficulty {
font-size: 1em;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid #ddd;
}
#score {
margin: 0.6rem auto 0.4rem auto;
font-size: 1em;
color: #444;
}
/* Botones-celda accesibles */
.cell {
border: none;
outline: none;
}
.cell:focus-visible {
box-shadow: 0 0 0 4px rgba(21, 101, 192, 0.35);
}
/* Resaltado de combinación ganadora */
.win {
background: #c8e6ff;
box-shadow: 0 0 0 2px rgba(61, 90, 254, 0.25), 0 2px 10px rgba(61, 90, 254, 0.16);
}
@media (prefers-color-scheme: dark) {
#controls select#difficulty {
background: #20233a;
color: #eaeaf0;
border-color: #2c3252;
}
#score {
color: #a0a3b0;
}
.cell:focus-visible {
box-shadow: 0 0 0 4px rgba(61, 90, 254, 0.35);
}
.win {
background: #172441;
box-shadow: 0 0 0 2px rgba(61, 90, 254, 0.35), 0 2px 12px rgba(61, 90, 254, 0.22);
}
}