Mejoras y optimizaciones en general.
This commit is contained in:
@@ -3,12 +3,27 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Tres 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>Tres en Raya vs Máquina</h1>
|
||||
<div id="board"></div>
|
||||
<div id="status"></div>
|
||||
<h1 id="title">Tres en Raya vs Máquina</h1>
|
||||
|
||||
<div id="controls">
|
||||
<label for="player-side">Tu ficha:</label>
|
||||
<select id="player-side">
|
||||
<option value="X" selected>X</option>
|
||||
<option value="O">O</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-player">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>
|
||||
|
@@ -1,122 +1,307 @@
|
||||
'use strict';
|
||||
|
||||
// Referencias DOM
|
||||
const boardDiv = document.getElementById('board');
|
||||
const statusDiv = document.getElementById('status');
|
||||
const restartBtn = document.getElementById('restart-btn');
|
||||
let board, gameOver;
|
||||
const playerSideSelect = document.getElementById('player-side');
|
||||
const scorePlayerSpan = document.getElementById('score-player');
|
||||
const scoreAiSpan = document.getElementById('score-ai');
|
||||
const scoreDrawSpan = document.getElementById('score-d');
|
||||
|
||||
function initGame() {
|
||||
board = Array(9).fill('');
|
||||
gameOver = false;
|
||||
statusDiv.textContent = "Tu turno (X)";
|
||||
renderBoard();
|
||||
// Constantes
|
||||
const WIN_COMBOS = [
|
||||
[0, 1, 2], [3, 4, 5], [6, 7, 8], // Filas
|
||||
[0, 3, 6], [1, 4, 7], [2, 5, 8], // Columnas
|
||||
[0, 4, 8], [2, 4, 6] // Diagonales
|
||||
];
|
||||
|
||||
let board = Array(9).fill('');
|
||||
let gameOver = false;
|
||||
let playerSide = 'X';
|
||||
let aiSide = 'O';
|
||||
let scores = { player: 0, ai: 0, D: 0 };
|
||||
|
||||
function loadScores() {
|
||||
try {
|
||||
const raw = localStorage.getItem('ttt_ai_scores');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
scores.player = Number(parsed.player) || 0;
|
||||
scores.ai = Number(parsed.ai) || 0;
|
||||
scores.D = Number(parsed.D) || 0;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignorar errores
|
||||
}
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
boardDiv.innerHTML = '';
|
||||
board.forEach((cell, idx) => {
|
||||
const cellDiv = document.createElement('div');
|
||||
cellDiv.className = 'cell';
|
||||
cellDiv.textContent = cell;
|
||||
cellDiv.onclick = () => handlePlayerMove(idx);
|
||||
boardDiv.appendChild(cellDiv);
|
||||
function saveScores() {
|
||||
try {
|
||||
localStorage.setItem('ttt_ai_scores', JSON.stringify(scores));
|
||||
} catch (_) {
|
||||
// Ignorar errores
|
||||
}
|
||||
}
|
||||
|
||||
function updateScoreUI() {
|
||||
if (scorePlayerSpan) scorePlayerSpan.textContent = String(scores.player);
|
||||
if (scoreAiSpan) scoreAiSpan.textContent = String(scores.ai);
|
||||
if (scoreDrawSpan) scoreDrawSpan.textContent = String(scores.D);
|
||||
}
|
||||
|
||||
function initBoardDom() {
|
||||
// Si ya existen 9 hijos, reutilizarlos (mejor rendimiento)
|
||||
if (boardDiv.children.length === 9) {
|
||||
Array.from(boardDiv.children).forEach(c => {
|
||||
c.classList.remove('win');
|
||||
c.textContent = '';
|
||||
c.setAttribute('aria-label', 'Casilla');
|
||||
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 idx = 0; idx < 9; idx++) {
|
||||
const cell = document.createElement('button');
|
||||
cell.type = 'button';
|
||||
cell.className = 'cell';
|
||||
cell.setAttribute('data-idx', String(idx));
|
||||
cell.setAttribute('role', 'gridcell');
|
||||
cell.setAttribute('aria-label', `Casilla ${idx + 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');
|
||||
}
|
||||
|
||||
function render() {
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const el = boardDiv.children[i];
|
||||
if (!el) continue;
|
||||
el.textContent = board[i];
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(msg) {
|
||||
statusDiv.textContent = msg;
|
||||
}
|
||||
|
||||
function getWinCombo(b, player) {
|
||||
for (const combo of WIN_COMBOS) {
|
||||
const [a, m, c] = combo;
|
||||
if (b[a] === player && b[m] === player && b[c] === player) {
|
||||
return combo;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isDraw(b) {
|
||||
return b.every(cell => cell !== '') &&
|
||||
!getWinCombo(b, 'X') && !getWinCombo(b, 'O');
|
||||
}
|
||||
|
||||
function highlightCombo(combo) {
|
||||
if (!combo) return;
|
||||
combo.forEach(i => {
|
||||
const el = boardDiv.children[i];
|
||||
if (el) el.classList.add('win');
|
||||
});
|
||||
}
|
||||
|
||||
function handlePlayerMove(idx) {
|
||||
if (gameOver || board[idx] !== '') return;
|
||||
board[idx] = 'X';
|
||||
renderBoard();
|
||||
if (checkWinner('X')) {
|
||||
statusDiv.textContent = `¡Tú ganas! 🎉`;
|
||||
gameOver = true;
|
||||
function endGame(result, combo) {
|
||||
gameOver = true;
|
||||
boardDiv.setAttribute('aria-disabled', 'true');
|
||||
|
||||
if (result === playerSide) {
|
||||
setStatus('¡Tú ganas! 🎉');
|
||||
scores.player += 1;
|
||||
highlightCombo(combo);
|
||||
} else if (result === aiSide) {
|
||||
setStatus('¡La máquina gana! 🤖');
|
||||
scores.ai += 1;
|
||||
highlightCombo(combo);
|
||||
} else {
|
||||
setStatus('¡Empate!');
|
||||
scores.D += 1;
|
||||
}
|
||||
|
||||
updateScoreUI();
|
||||
saveScores();
|
||||
}
|
||||
|
||||
function onCellClick(e) {
|
||||
const target = e.currentTarget;
|
||||
const idx = Number(target.getAttribute('data-idx'));
|
||||
|
||||
if (gameOver || !Number.isInteger(idx)) return;
|
||||
// Solo permitir movimiento del jugador cuando sea su turno
|
||||
const turn = nextTurn(board);
|
||||
if (turn !== playerSide) return;
|
||||
if (board[idx] !== '') return;
|
||||
|
||||
// Movimiento jugador
|
||||
board[idx] = playerSide;
|
||||
render();
|
||||
|
||||
// Comprobar fin
|
||||
const combo = getWinCombo(board, playerSide);
|
||||
if (combo) {
|
||||
endGame(playerSide, combo);
|
||||
return;
|
||||
}
|
||||
if (isDraw()) {
|
||||
statusDiv.textContent = "¡Empate!";
|
||||
gameOver = true;
|
||||
if (isDraw(board)) {
|
||||
endGame('D', null);
|
||||
return;
|
||||
}
|
||||
statusDiv.textContent = "Turno de la máquina (O)";
|
||||
setTimeout(machineMove, 400);
|
||||
|
||||
// Turno de la máquina
|
||||
setStatus(`Turno de la máquina (${aiSide})`);
|
||||
setTimeout(machineMove, 350);
|
||||
}
|
||||
|
||||
function nextTurn(b) {
|
||||
const xCount = b.filter(v => v === 'X').length;
|
||||
const oCount = b.filter(v => v === 'O').length;
|
||||
// En Tic-Tac-Toe empieza 'X'; si hay igual cantidad, le toca a 'X', si X > O, le toca 'O'
|
||||
return xCount === oCount ? 'X' : 'O';
|
||||
}
|
||||
|
||||
// Minimax con poda alfa-beta
|
||||
function minimax(b, depth, isMaximizing, alpha, beta) {
|
||||
const aiWin = getWinCombo(b, aiSide);
|
||||
const playerWin = getWinCombo(b, playerSide);
|
||||
|
||||
if (aiWin) return 10 - depth;
|
||||
if (playerWin) return depth - 10;
|
||||
if (isDraw(b)) return 0;
|
||||
|
||||
if (isMaximizing) {
|
||||
let best = -Infinity;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
if (b[i] === '') {
|
||||
b[i] = aiSide;
|
||||
const score = minimax(b, depth + 1, false, alpha, beta);
|
||||
b[i] = '';
|
||||
best = Math.max(best, score);
|
||||
alpha = Math.max(alpha, best);
|
||||
if (beta <= alpha) break;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
} else {
|
||||
let best = Infinity;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
if (b[i] === '') {
|
||||
b[i] = playerSide;
|
||||
const score = minimax(b, depth + 1, true, alpha, beta);
|
||||
b[i] = '';
|
||||
best = Math.min(best, score);
|
||||
beta = Math.min(beta, best);
|
||||
if (beta <= alpha) break;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
|
||||
function machineMove() {
|
||||
if (gameOver) return;
|
||||
|
||||
// Si no es turno de la máquina, salir
|
||||
const turn = nextTurn(board);
|
||||
if (turn !== aiSide) return;
|
||||
|
||||
// Elegir mejor jugada con Minimax
|
||||
let bestScore = -Infinity;
|
||||
let move = null;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
if (board[i] === '') {
|
||||
board[i] = 'O';
|
||||
let score = minimax(board, 0, false);
|
||||
board[i] = '';
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
move = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (move !== null) board[move] = 'O';
|
||||
renderBoard();
|
||||
if (checkWinner('O')) {
|
||||
statusDiv.textContent = `¡La máquina gana! 🤖`;
|
||||
gameOver = true;
|
||||
return;
|
||||
}
|
||||
if (isDraw()) {
|
||||
statusDiv.textContent = "¡Empate!";
|
||||
gameOver = true;
|
||||
return;
|
||||
}
|
||||
statusDiv.textContent = "Tu turno (X)";
|
||||
}
|
||||
|
||||
// Algoritmo Minimax
|
||||
function minimax(newBoard, depth, isMaximizing) {
|
||||
if (checkWinnerOnBoard(newBoard, 'O')) return 10 - depth;
|
||||
if (checkWinnerOnBoard(newBoard, 'X')) return depth - 10;
|
||||
if (newBoard.every(cell => cell !== '')) return 0;
|
||||
|
||||
if (isMaximizing) {
|
||||
let bestScore = -Infinity;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
if (newBoard[i] === '') {
|
||||
newBoard[i] = 'O';
|
||||
let score = minimax(newBoard, depth + 1, false);
|
||||
newBoard[i] = '';
|
||||
bestScore = Math.max(score, bestScore);
|
||||
}
|
||||
}
|
||||
return bestScore;
|
||||
// Pequeña heurística: si está libre, prioriza centro (4) en primera jugada
|
||||
if (board.every(c => c === '')) {
|
||||
move = 4; // centro
|
||||
} else {
|
||||
let bestScore = Infinity;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
if (newBoard[i] === '') {
|
||||
newBoard[i] = 'X';
|
||||
let score = minimax(newBoard, depth + 1, true);
|
||||
newBoard[i] = '';
|
||||
bestScore = Math.min(score, bestScore);
|
||||
if (board[i] === '') {
|
||||
board[i] = aiSide;
|
||||
const score = minimax(board, 0, false, -Infinity, Infinity);
|
||||
board[i] = '';
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
move = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestScore;
|
||||
// Si no encontró nada (no debería), elige primera libre
|
||||
if (move === null) move = board.findIndex(c => c === '');
|
||||
}
|
||||
|
||||
if (move !== null) {
|
||||
board[move] = aiSide;
|
||||
}
|
||||
render();
|
||||
|
||||
// Comprobar fin
|
||||
const combo = getWinCombo(board, aiSide);
|
||||
if (combo) {
|
||||
endGame(aiSide, combo);
|
||||
return;
|
||||
}
|
||||
if (isDraw(board)) {
|
||||
endGame('D', null);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(`Tu turno (${playerSide})`);
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
// Lado del jugador según selector
|
||||
if (playerSideSelect && (playerSideSelect.value === 'X' || playerSideSelect.value === 'O')) {
|
||||
playerSide = playerSideSelect.value;
|
||||
} else {
|
||||
playerSide = 'X';
|
||||
}
|
||||
aiSide = playerSide === 'X' ? 'O' : 'X';
|
||||
|
||||
// Reset
|
||||
board = Array(9).fill('');
|
||||
gameOver = false;
|
||||
initBoardDom();
|
||||
Array.from(boardDiv.children).forEach(c => c.classList.remove('win'));
|
||||
render();
|
||||
|
||||
// Si empieza la IA (jugador eligió 'O'), IA mueve primero
|
||||
const turn = nextTurn(board);
|
||||
if (turn === aiSide) {
|
||||
setStatus(`Turno de la máquina (${aiSide})`);
|
||||
setTimeout(machineMove, 300);
|
||||
} else {
|
||||
setStatus(`Tu turno (${playerSide})`);
|
||||
}
|
||||
}
|
||||
|
||||
function checkWinner(player) {
|
||||
return checkWinnerOnBoard(board, player);
|
||||
// Listeners
|
||||
restartBtn.addEventListener('click', startGame);
|
||||
if (playerSideSelect) {
|
||||
playerSideSelect.addEventListener('change', () => {
|
||||
// Reiniciar al cambiar de lado
|
||||
startGame();
|
||||
});
|
||||
}
|
||||
|
||||
function checkWinnerOnBoard(b, player) {
|
||||
const wins = [
|
||||
[0,1,2],[3,4,5],[6,7,8],
|
||||
[0,3,6],[1,4,7],[2,5,8],
|
||||
[0,4,8],[2,4,6]
|
||||
];
|
||||
return wins.some(combo => combo.every(i => b[i] === player));
|
||||
}
|
||||
|
||||
function isDraw() {
|
||||
return board.every(cell => cell !== '') && !checkWinner('X') && !checkWinner('O');
|
||||
}
|
||||
|
||||
restartBtn.onclick = initGame;
|
||||
|
||||
initGame();
|
||||
// Init
|
||||
loadScores();
|
||||
updateScoreUI();
|
||||
startGame();
|
@@ -119,4 +119,56 @@ h1 {
|
||||
.cell {
|
||||
font-size: 3em;
|
||||
}
|
||||
}
|
||||
/* Controles y marcador */
|
||||
#controls {
|
||||
margin: 1rem auto 0.5rem auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
#controls select#player-side {
|
||||
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(52, 152, 219, 0.35);
|
||||
}
|
||||
|
||||
/* Resaltado de combinación ganadora */
|
||||
.win {
|
||||
background: #eaf7ff;
|
||||
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#player-side {
|
||||
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);
|
||||
}
|
||||
}
|
@@ -3,12 +3,27 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Tres en Raya</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Tres en Raya (Tic Tac Toe)</h1>
|
||||
<div id="board"></div>
|
||||
<div id="status"></div>
|
||||
<h1 id="title">Tres en Raya (Tic Tac Toe)</h1>
|
||||
|
||||
<div id="controls">
|
||||
<label for="first-player">Primer jugador:</label>
|
||||
<select id="first-player">
|
||||
<option value="X" selected>X</option>
|
||||
<option value="O">O</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="board" role="grid" aria-labelledby="title"></div>
|
||||
<div id="status" aria-live="polite"></div>
|
||||
|
||||
<div id="score">
|
||||
Marcador — X: <span id="score-x">0</span> · O: <span id="score-o">0</span> · Empates: <span id="score-d">0</span>
|
||||
</div>
|
||||
|
||||
<button id="restart-btn">Reiniciar</button>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
|
@@ -1,52 +1,184 @@
|
||||
'use strict';
|
||||
|
||||
const boardDiv = document.getElementById('board');
|
||||
const statusDiv = document.getElementById('status');
|
||||
const restartBtn = document.getElementById('restart-btn');
|
||||
let board, currentPlayer, gameOver;
|
||||
const firstPlayerSelect = document.getElementById('first-player');
|
||||
const scoreXSpan = document.getElementById('score-x');
|
||||
const scoreOSpan = document.getElementById('score-o');
|
||||
const scoreDrawSpan = document.getElementById('score-d');
|
||||
|
||||
function initGame() {
|
||||
board = Array(9).fill('');
|
||||
currentPlayer = 'X';
|
||||
gameOver = false;
|
||||
statusDiv.textContent = "Turno de " + currentPlayer;
|
||||
renderBoard();
|
||||
}
|
||||
const WIN_COMBOS = [
|
||||
[0, 1, 2], [3, 4, 5], [6, 7, 8], // Filas
|
||||
[0, 3, 6], [1, 4, 7], [2, 5, 8], // Columnas
|
||||
[0, 4, 8], [2, 4, 6] // Diagonales
|
||||
];
|
||||
|
||||
function renderBoard() {
|
||||
boardDiv.innerHTML = '';
|
||||
board.forEach((cell, idx) => {
|
||||
const cellDiv = document.createElement('div');
|
||||
cellDiv.className = 'cell';
|
||||
cellDiv.textContent = cell;
|
||||
cellDiv.onclick = () => handleCellClick(idx);
|
||||
boardDiv.appendChild(cellDiv);
|
||||
});
|
||||
}
|
||||
let board = Array(9).fill('');
|
||||
let currentPlayer = 'X';
|
||||
let gameOver = false;
|
||||
let scores = { X: 0, O: 0, D: 0 };
|
||||
|
||||
function handleCellClick(idx) {
|
||||
if (gameOver || board[idx] !== '') return;
|
||||
board[idx] = currentPlayer;
|
||||
renderBoard();
|
||||
if (checkWinner(currentPlayer)) {
|
||||
statusDiv.textContent = `¡${currentPlayer} gana! 🎉`;
|
||||
gameOver = true;
|
||||
} else if (board.every(cell => cell !== '')) {
|
||||
statusDiv.textContent = "¡Empate!";
|
||||
gameOver = true;
|
||||
} else {
|
||||
currentPlayer = currentPlayer === 'X' ? 'O' : 'X';
|
||||
statusDiv.textContent = "Turno de " + currentPlayer;
|
||||
function loadScores() {
|
||||
try {
|
||||
const raw = localStorage.getItem('ttt_scores');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
scores.X = Number(parsed.X) || 0;
|
||||
scores.O = Number(parsed.O) || 0;
|
||||
scores.D = Number(parsed.D) || 0;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignorar errores de parsing o acceso
|
||||
}
|
||||
}
|
||||
|
||||
function checkWinner(player) {
|
||||
const winCases = [
|
||||
[0,1,2],[3,4,5],[6,7,8], // Filas
|
||||
[0,3,6],[1,4,7],[2,5,8], // Columnas
|
||||
[0,4,8],[2,4,6] // Diagonales
|
||||
];
|
||||
return winCases.some(combo => combo.every(i => board[i] === player));
|
||||
function saveScores() {
|
||||
try {
|
||||
localStorage.setItem('ttt_scores', JSON.stringify(scores));
|
||||
} catch (_) {
|
||||
// Ignorar errores de almacenamiento
|
||||
}
|
||||
}
|
||||
|
||||
restartBtn.onclick = initGame;
|
||||
function updateScoreUI() {
|
||||
if (scoreXSpan) scoreXSpan.textContent = String(scores.X);
|
||||
if (scoreOSpan) scoreOSpan.textContent = String(scores.O);
|
||||
if (scoreDrawSpan) scoreDrawSpan.textContent = String(scores.D);
|
||||
}
|
||||
|
||||
initGame();
|
||||
function initBoardDom() {
|
||||
// Crear la cuadrícula de 3x3 solo si no existe
|
||||
if (boardDiv.children.length === 9) {
|
||||
// limpiar estado de clases win previas
|
||||
Array.from(boardDiv.children).forEach(c => c.classList.remove('win'));
|
||||
return;
|
||||
}
|
||||
boardDiv.textContent = '';
|
||||
for (let idx = 0; idx < 9; idx++) {
|
||||
const cell = document.createElement('button');
|
||||
cell.type = 'button';
|
||||
cell.className = 'cell';
|
||||
cell.setAttribute('data-idx', String(idx));
|
||||
cell.setAttribute('aria-label', `Casilla ${idx + 1}`);
|
||||
cell.setAttribute('role', 'gridcell');
|
||||
cell.addEventListener('click', onCellClick);
|
||||
cell.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onCellClick(e);
|
||||
});
|
||||
boardDiv.appendChild(cell);
|
||||
}
|
||||
boardDiv.setAttribute('aria-disabled', 'false');
|
||||
}
|
||||
|
||||
function render() {
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const cellEl = boardDiv.children[i];
|
||||
if (!cellEl) continue;
|
||||
cellEl.textContent = board[i];
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(msg) {
|
||||
statusDiv.textContent = msg;
|
||||
}
|
||||
|
||||
function checkWinner(player) {
|
||||
for (const combo of WIN_COMBOS) {
|
||||
const [a, b, c] = combo;
|
||||
if (board[a] === player && board[b] === player && board[c] === player) {
|
||||
return { win: true, combo };
|
||||
}
|
||||
}
|
||||
return { win: false, combo: null };
|
||||
}
|
||||
|
||||
function highlightCombo(combo) {
|
||||
if (!combo) return;
|
||||
combo.forEach(i => {
|
||||
const el = boardDiv.children[i];
|
||||
if (el) el.classList.add('win');
|
||||
});
|
||||
}
|
||||
|
||||
function endGame(result, combo) {
|
||||
gameOver = true;
|
||||
boardDiv.setAttribute('aria-disabled', 'true');
|
||||
|
||||
if (result === 'X' || result === 'O') {
|
||||
setStatus(`¡${result} gana! 🎉`);
|
||||
scores[result] += 1;
|
||||
highlightCombo(combo);
|
||||
} else {
|
||||
setStatus('¡Empate!');
|
||||
scores.D += 1;
|
||||
}
|
||||
|
||||
updateScoreUI();
|
||||
saveScores();
|
||||
}
|
||||
|
||||
function onCellClick(e) {
|
||||
const target = e.currentTarget;
|
||||
const idx = Number(target.getAttribute('data-idx'));
|
||||
|
||||
if (gameOver || !Number.isInteger(idx)) return;
|
||||
if (board[idx] !== '') return;
|
||||
|
||||
board[idx] = currentPlayer;
|
||||
render();
|
||||
|
||||
const { win, combo } = checkWinner(currentPlayer);
|
||||
if (win) {
|
||||
endGame(currentPlayer, combo);
|
||||
return;
|
||||
}
|
||||
|
||||
// Comprueba empate
|
||||
if (board.every(cell => cell !== '')) {
|
||||
endGame('D', null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cambiar turno
|
||||
currentPlayer = currentPlayer === 'X' ? 'O' : 'X';
|
||||
setStatus(`Turno de ${currentPlayer}`);
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
board = Array(9).fill('');
|
||||
gameOver = false;
|
||||
|
||||
// Determinar primer jugador desde el selector si existe
|
||||
if (firstPlayerSelect && (firstPlayerSelect.value === 'X' || firstPlayerSelect.value === 'O')) {
|
||||
currentPlayer = firstPlayerSelect.value;
|
||||
} else {
|
||||
currentPlayer = 'X';
|
||||
}
|
||||
|
||||
boardDiv.setAttribute('aria-disabled', 'false');
|
||||
initBoardDom();
|
||||
// Remover cualquier resaltado previo
|
||||
Array.from(boardDiv.children).forEach(c => c.classList.remove('win'));
|
||||
render();
|
||||
setStatus(`Turno de ${currentPlayer}`);
|
||||
}
|
||||
|
||||
// Listeners
|
||||
restartBtn.addEventListener('click', startGame);
|
||||
if (firstPlayerSelect) {
|
||||
firstPlayerSelect.addEventListener('change', () => {
|
||||
// Solo afecta si la partida no ha empezado o si no ha terminado aún
|
||||
if (!gameOver && board.every(v => v === '')) {
|
||||
currentPlayer = firstPlayerSelect.value;
|
||||
setStatus(`Turno de ${currentPlayer}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Init
|
||||
loadScores();
|
||||
updateScoreUI();
|
||||
startGame();
|
@@ -118,4 +118,56 @@ h1 {
|
||||
.cell {
|
||||
font-size: 3em;
|
||||
}
|
||||
}
|
||||
/* Controles de partida y marcador */
|
||||
#controls {
|
||||
margin: 1rem auto 0.5rem auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
#controls select#first-player {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Ajustes para botones-celda (accesibilidad) */
|
||||
.cell {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
.cell:focus-visible {
|
||||
box-shadow: 0 0 0 4px rgba(52, 152, 219, 0.35);
|
||||
}
|
||||
|
||||
/* Resaltado de combinación ganadora */
|
||||
.win {
|
||||
background: #eaf7ff;
|
||||
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#first-player {
|
||||
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);
|
||||
}
|
||||
}
|
@@ -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>
|
||||
|
@@ -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();
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -3,18 +3,39 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Adivina el Número</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>¡Adivina el número!</h1>
|
||||
<div id="game-box">
|
||||
<p>Estoy pensando en un número entre 1 y 100.</p>
|
||||
<p>¿Puedes adivinarlo en 7 intentos?</p>
|
||||
<input id="guess-input" type="number" min="1" max="100" placeholder="Tu número" />
|
||||
<button id="guess-btn">Adivinar</button>
|
||||
<button id="restart-btn" class="hidden">Jugar de nuevo</button>
|
||||
<div id="info"></div>
|
||||
<h1 id="title">¡Adivina el número!</h1>
|
||||
<div id="game-box" role="group" aria-labelledby="title">
|
||||
<p>Estoy pensando en un número entre 1 y <span id="range-max">100</span>.</p>
|
||||
<p>¿Puedes adivinarlo en <span id="attempts-total">7</span> intentos?</p>
|
||||
|
||||
<label for="difficulty">Dificultad:</label>
|
||||
<select id="difficulty">
|
||||
<option value="normal" selected>Normal (1-100, 7 intentos)</option>
|
||||
<option value="facil">Fácil (1-50, 10 intentos)</option>
|
||||
<option value="dificil">Difícil (1-200, 7 intentos)</option>
|
||||
<option value="extremo">Extremo (1-1000, 10 intentos)</option>
|
||||
</select>
|
||||
|
||||
<div class="input-row">
|
||||
<label for="guess-input" class="sr-only">Tu número</label>
|
||||
<input id="guess-input" type="number" min="1" max="100" placeholder="Tu número" inputmode="numeric" />
|
||||
<button id="guess-btn">Adivinar</button>
|
||||
<button id="restart-btn" class="hidden" aria-live="polite">Jugar de nuevo</button>
|
||||
</div>
|
||||
|
||||
<div id="error" class="error" aria-live="polite"></div>
|
||||
<div id="info" aria-live="polite"></div>
|
||||
<div id="attempts">Intentos restantes: <span id="attempts-left">7</span></div>
|
||||
<div id="best">Mejor marca: <span id="best-score">—</span></div>
|
||||
|
||||
<div id="history">
|
||||
<h2>Historial</h2>
|
||||
<ul id="history-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
|
@@ -1,55 +1,156 @@
|
||||
let randomNumber;
|
||||
let attemptsLeft = 7;
|
||||
'use strict';
|
||||
|
||||
const DIFFICULTIES = {
|
||||
normal: { max: 100, attempts: 7 },
|
||||
facil: { max: 50, attempts: 10 },
|
||||
dificil: { max: 200, attempts: 7 },
|
||||
extremo: { max: 1000, attempts: 10 }
|
||||
};
|
||||
|
||||
let randomNumber = 0;
|
||||
let attemptsLeft = 0;
|
||||
let attemptsTotal = 0;
|
||||
let maxNumber = 100;
|
||||
let history = [];
|
||||
let currentDifficulty = 'normal';
|
||||
|
||||
// DOM refs
|
||||
const guessInput = document.getElementById('guess-input');
|
||||
const guessBtn = document.getElementById('guess-btn');
|
||||
const restartBtn = document.getElementById('restart-btn');
|
||||
const infoDiv = document.getElementById('info');
|
||||
const attemptsSpan = document.getElementById('attempts-left');
|
||||
const difficultySelect = document.getElementById('difficulty');
|
||||
const errorDiv = document.getElementById('error');
|
||||
const rangeMaxSpan = document.getElementById('range-max');
|
||||
const attemptsTotalSpan = document.getElementById('attempts-total');
|
||||
const bestScoreSpan = document.getElementById('best-score');
|
||||
const historyList = document.getElementById('history-list');
|
||||
|
||||
function getBest(difficulty) {
|
||||
const key = `adivina_best_${difficulty}`;
|
||||
const v = localStorage.getItem(key);
|
||||
return v ? parseInt(v, 10) : null;
|
||||
}
|
||||
|
||||
function setBest(difficulty, attemptsUsed) {
|
||||
const prev = getBest(difficulty);
|
||||
if (prev === null || attemptsUsed < prev) {
|
||||
localStorage.setItem(`adivina_best_${difficulty}`, String(attemptsUsed));
|
||||
bestScoreSpan.textContent = String(attemptsUsed);
|
||||
}
|
||||
}
|
||||
|
||||
function updateBestDisplay() {
|
||||
const best = getBest(currentDifficulty);
|
||||
bestScoreSpan.textContent = best !== null ? String(best) : '—';
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
// Safe clear
|
||||
while (historyList.firstChild) historyList.removeChild(historyList.firstChild);
|
||||
history.forEach(item => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = `${item.guess} → ${item.hint}`;
|
||||
historyList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
randomNumber = Math.floor(Math.random() * 100) + 1;
|
||||
attemptsLeft = 7;
|
||||
attemptsSpan.textContent = attemptsLeft;
|
||||
currentDifficulty = (difficultySelect && difficultySelect.value) || 'normal';
|
||||
const conf = DIFFICULTIES[currentDifficulty] || DIFFICULTIES.normal;
|
||||
|
||||
maxNumber = conf.max;
|
||||
attemptsTotal = conf.attempts;
|
||||
attemptsLeft = attemptsTotal;
|
||||
randomNumber = Math.floor(Math.random() * maxNumber) + 1;
|
||||
history = [];
|
||||
|
||||
// Update UI
|
||||
if (rangeMaxSpan) rangeMaxSpan.textContent = String(maxNumber);
|
||||
if (attemptsTotalSpan) attemptsTotalSpan.textContent = String(attemptsTotal);
|
||||
attemptsSpan.textContent = String(attemptsLeft);
|
||||
errorDiv.textContent = '';
|
||||
infoDiv.textContent = '';
|
||||
renderHistory();
|
||||
updateBestDisplay();
|
||||
|
||||
// Reset input/button state
|
||||
guessInput.value = '';
|
||||
guessInput.disabled = false;
|
||||
guessInput.setAttribute('min', '1');
|
||||
guessInput.setAttribute('max', String(maxNumber));
|
||||
guessBtn.disabled = false;
|
||||
restartBtn.classList.add('hidden');
|
||||
guessInput.focus();
|
||||
}
|
||||
|
||||
function checkGuess() {
|
||||
const guess = Number(guessInput.value);
|
||||
errorDiv.textContent = '';
|
||||
|
||||
if (guess < 1 || guess > 100 || isNaN(guess)) {
|
||||
infoDiv.textContent = "Por favor ingresa un número válido entre 1 y 100.";
|
||||
const raw = guessInput.value.trim();
|
||||
if (raw === '') {
|
||||
errorDiv.textContent = 'Introduce un número.';
|
||||
return;
|
||||
}
|
||||
|
||||
const guess = Number(raw);
|
||||
if (!Number.isFinite(guess) || !Number.isInteger(guess)) {
|
||||
errorDiv.textContent = 'El valor debe ser un número entero.';
|
||||
return;
|
||||
}
|
||||
if (guess < 1 || guess > maxNumber) {
|
||||
errorDiv.textContent = `El número debe estar entre 1 y ${maxNumber}.`;
|
||||
return;
|
||||
}
|
||||
|
||||
attemptsLeft--;
|
||||
attemptsSpan.textContent = attemptsLeft;
|
||||
attemptsSpan.textContent = String(attemptsLeft);
|
||||
|
||||
if (guess === randomNumber) {
|
||||
infoDiv.textContent = "¡Correcto! 🎉 Has adivinado el número.";
|
||||
endGame(true);
|
||||
} else if (attemptsLeft === 0) {
|
||||
infoDiv.textContent = '¡Correcto! 🎉 Has adivinado el número.';
|
||||
const usedAttempts = attemptsTotal - attemptsLeft;
|
||||
history.push({ guess, hint: '✅ Correcto' });
|
||||
renderHistory();
|
||||
endGame(true, usedAttempts);
|
||||
return;
|
||||
}
|
||||
|
||||
const hint = guess < randomNumber ? '⬇️ Bajo' : '⬆️ Alto';
|
||||
infoDiv.textContent = guess < randomNumber
|
||||
? 'Demasiado bajo. Intenta nuevamente.'
|
||||
: 'Demasiado alto. Intenta nuevamente.';
|
||||
history.push({ guess, hint });
|
||||
renderHistory();
|
||||
|
||||
if (attemptsLeft === 0) {
|
||||
infoDiv.textContent = `¡Oh no! Te quedaste sin intentos. El número era ${randomNumber}.`;
|
||||
endGame(false);
|
||||
} else if (guess < randomNumber) {
|
||||
infoDiv.textContent = "Demasiado bajo. Intenta nuevamente.";
|
||||
} else {
|
||||
infoDiv.textContent = "Demasiado alto. Intenta nuevamente.";
|
||||
endGame(false, attemptsTotal);
|
||||
}
|
||||
}
|
||||
|
||||
function endGame(won) {
|
||||
function endGame(won, usedAttempts) {
|
||||
guessInput.disabled = true;
|
||||
guessBtn.disabled = true;
|
||||
restartBtn.classList.remove('hidden');
|
||||
|
||||
if (won) {
|
||||
setBest(currentDifficulty, usedAttempts);
|
||||
}
|
||||
}
|
||||
|
||||
guessBtn.onclick = checkGuess;
|
||||
restartBtn.onclick = startGame;
|
||||
guessInput.onkeydown = (e) => { if (e.key === "Enter") checkGuess(); };
|
||||
// Listeners (avoid inline handlers)
|
||||
guessBtn.addEventListener('click', checkGuess);
|
||||
restartBtn.addEventListener('click', startGame);
|
||||
guessInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') checkGuess();
|
||||
});
|
||||
guessInput.addEventListener('input', () => {
|
||||
errorDiv.textContent = '';
|
||||
});
|
||||
if (difficultySelect) {
|
||||
difficultySelect.addEventListener('change', startGame);
|
||||
}
|
||||
|
||||
// Init
|
||||
startGame();
|
@@ -1,43 +1,106 @@
|
||||
:root {
|
||||
--bg: #f7f7f7;
|
||||
--text: #222;
|
||||
--muted: #888;
|
||||
--card-bg: #ffffff;
|
||||
--shadow: 0 0 8px #bbb;
|
||||
--accent: #2196f3;
|
||||
--accent-hover: #1769aa;
|
||||
--accent-contrast: #ffffff;
|
||||
--error: #c62828;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #f7f7f7;
|
||||
font-family: Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Montserrat', Arial, sans-serif;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 40px;
|
||||
color: #222;
|
||||
margin-top: 20px;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#game-box {
|
||||
background: white;
|
||||
background: var(--card-bg);
|
||||
width: 350px;
|
||||
margin: 50px auto;
|
||||
max-width: 94vw;
|
||||
margin: 30px auto;
|
||||
padding: 30px 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 8px #bbb;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
width: 100px;
|
||||
width: 120px;
|
||||
font-size: 1.1em;
|
||||
padding: 6px;
|
||||
margin-right: 12px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="number"]:focus-visible {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.25);
|
||||
}
|
||||
|
||||
select#difficulty {
|
||||
font-size: 1em;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
margin: 8px 0 14px 0;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 1em;
|
||||
padding: 7px 20px;
|
||||
padding: 9px 22px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
border-radius: 44px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
transition: transform .12s, box-shadow .16s, background .16s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1769aa;
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px rgba(33,150,243,0.3);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(33,150,243,0.35);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#error {
|
||||
color: var(--error);
|
||||
font-size: 0.95em;
|
||||
min-height: 1.6em;
|
||||
}
|
||||
|
||||
#info {
|
||||
@@ -48,21 +111,93 @@ button:hover {
|
||||
|
||||
#attempts {
|
||||
margin-top: 8px;
|
||||
color: #888;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#best {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#history {
|
||||
margin-top: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#history h2 {
|
||||
font-size: 1.05em;
|
||||
margin: 8px 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#history-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px dashed #ddd;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
#history-list li {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dashed #eee;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute !important;
|
||||
height: 1px; width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
#game-box {
|
||||
padding: 24px 14px;
|
||||
}
|
||||
.input-row {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 728px) {
|
||||
body,
|
||||
#game-box,
|
||||
input[type="number"],
|
||||
button,
|
||||
#info,
|
||||
#attempts {
|
||||
width: 95%;
|
||||
font-size: 130%;
|
||||
#game-box { width: 450px; }
|
||||
body { font-size: 110%; }
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f1222;
|
||||
--text: #eaeaf0;
|
||||
--muted: #a0a3b0;
|
||||
--card-bg: #171a2e;
|
||||
--shadow: 0 0 0 rgba(0,0,0,0);
|
||||
--accent: #3d5afe;
|
||||
--accent-hover: #0a2459;
|
||||
--accent-contrast: #ffffff;
|
||||
--error: #ef5350;
|
||||
}
|
||||
#game-box {
|
||||
box-shadow: 0 6px 24px rgba(61,90,254,0.12);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
input[type="number"], select#difficulty {
|
||||
background: #20233a;
|
||||
color: var(--text);
|
||||
border-color: #2c3252;
|
||||
}
|
||||
#history-list {
|
||||
border-top-color: #2c3252;
|
||||
}
|
||||
#history-list li {
|
||||
border-bottom-color: #2c3252;
|
||||
}
|
||||
}
|
@@ -3,18 +3,30 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Empareja la Bandera</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Empareja la Bandera</h1>
|
||||
<h1 id="title">Empareja la Bandera</h1>
|
||||
<p>Haz clic en una bandera y después en el país correspondiente. ¿Puedes emparejar todas?</p>
|
||||
|
||||
<div id="controls">
|
||||
<label for="pairs-count">Número de parejas:</label>
|
||||
<select id="pairs-count">
|
||||
<option value="6">6</option>
|
||||
<option value="8">8</option>
|
||||
<option value="12" selected>12</option>
|
||||
<option value="16">16</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="score">Aciertos: <span id="score-value"></span> / <span id="score-total"></span></div>
|
||||
<div id="game">
|
||||
<div class="flags" id="flags"></div>
|
||||
<div class="countries" id="countries"></div>
|
||||
<div id="game" role="group" aria-labelledby="title">
|
||||
<div class="flags" id="flags" aria-label="Banderas" role="list"></div>
|
||||
<div class="countries" id="countries" aria-label="Países" role="list"></div>
|
||||
</div>
|
||||
<button id="restart-btn">Reiniciar</button>
|
||||
<div id="status"></div>
|
||||
<div id="status" aria-live="polite"></div>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
@@ -1,3 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
// Lista completa con país y siglas según ISO 3166-1 alpha-2
|
||||
const countryList = [
|
||||
{ name: "España", code: "ES" }, { name: "Francia", code: "FR" }, { name: "Alemania", code: "DE" }, { name: "Italia", code: "IT" },
|
||||
@@ -13,88 +15,119 @@ const countryList = [
|
||||
function getFlagEmoji(code) {
|
||||
// Las banderas se generan a partir de letras usando unicode, no por siglas textuales
|
||||
// Solo funcionan para países con código alpha-2 y script latino
|
||||
if (!code || code.length !== 2) return "";
|
||||
if (!code || code.length !== 2) return '';
|
||||
return String.fromCodePoint(
|
||||
...code.toUpperCase().split("").map(c => 0x1F1E6 + c.charCodeAt(0) - 65)
|
||||
...code.toUpperCase().split('').map(c => 0x1F1E6 + c.charCodeAt(0) - 65)
|
||||
);
|
||||
}
|
||||
|
||||
// Para elegir N países aleatorios distintos con bandera
|
||||
// Elegir N países aleatorios distintos con bandera, sin mutar la lista original
|
||||
function pickRandomCountries(list, n) {
|
||||
const pool = list.slice(); // copia de seguridad
|
||||
const chosen = [];
|
||||
const used = {};
|
||||
while (chosen.length < n && list.length > 0) {
|
||||
let idx = Math.floor(Math.random() * list.length);
|
||||
let c = list[idx];
|
||||
let flag = getFlagEmoji(c.code);
|
||||
if (flag && !used[c.code]) {
|
||||
chosen.push({name: c.name, code: c.code, flag});
|
||||
used[c.code] = true;
|
||||
const used = new Set();
|
||||
while (chosen.length < n && pool.length > 0) {
|
||||
const idx = Math.floor(Math.random() * pool.length);
|
||||
const c = pool[idx];
|
||||
const flag = getFlagEmoji(c.code);
|
||||
if (flag && !used.has(c.code)) {
|
||||
chosen.push({ name: c.name, code: c.code, flag });
|
||||
used.add(c.code);
|
||||
}
|
||||
list.splice(idx,1);
|
||||
// Eliminar del pool para evitar repetir
|
||||
pool.splice(idx, 1);
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
let flagsDiv = document.getElementById('flags');
|
||||
let countriesDiv = document.getElementById('countries');
|
||||
let statusDiv = document.getElementById('status');
|
||||
let scoreSpan = document.getElementById('score-value');
|
||||
let scoreTotal = document.getElementById('score-total');
|
||||
let restartBtn = document.getElementById('restart-btn');
|
||||
// Referencias DOM
|
||||
const flagsDiv = document.getElementById('flags');
|
||||
const countriesDiv = document.getElementById('countries');
|
||||
const statusDiv = document.getElementById('status');
|
||||
const scoreSpan = document.getElementById('score-value');
|
||||
const scoreTotal = document.getElementById('score-total');
|
||||
const restartBtn = document.getElementById('restart-btn');
|
||||
const pairsSelect = document.getElementById('pairs-count');
|
||||
|
||||
let pairs = [], flags = [], countries = [], selectedFlag = null, selectedCountry = null, score = 0, totalPairs = 12;
|
||||
// Estado
|
||||
let pairs = [];
|
||||
let flags = [];
|
||||
let countries = [];
|
||||
let selectedFlag = null;
|
||||
let selectedCountry = null;
|
||||
let score = 0;
|
||||
let totalPairs = 12;
|
||||
|
||||
// Utilidades
|
||||
function shuffle(arr) {
|
||||
for (let i = arr.length-1; i>0; i--) {
|
||||
const j = Math.floor(Math.random() * (i+1));
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
let fullList = countryList.slice(); // copia
|
||||
pairs = pickRandomCountries(fullList, totalPairs);
|
||||
flags = pairs.map(o=>o);
|
||||
countries = pairs.map(o=>o);
|
||||
shuffle(flags); shuffle(countries);
|
||||
score = 0;
|
||||
scoreSpan.textContent = score;
|
||||
scoreTotal.textContent = pairs.length;
|
||||
selectedFlag = selectedCountry = null;
|
||||
renderFlags();
|
||||
renderCountries();
|
||||
statusDiv.textContent = 'Empareja todas las banderas con su país';
|
||||
function setStatus(msg) {
|
||||
statusDiv.textContent = msg;
|
||||
}
|
||||
|
||||
// Renderizado
|
||||
function renderFlags() {
|
||||
flagsDiv.innerHTML = '';
|
||||
// Limpieza segura
|
||||
flagsDiv.textContent = '';
|
||||
flags.forEach((p, i) => {
|
||||
const d = document.createElement('div');
|
||||
const d = document.createElement('button');
|
||||
d.type = 'button';
|
||||
d.className = 'flag';
|
||||
d.textContent = p.flag;
|
||||
d.setAttribute('tabindex', 0);
|
||||
d.onclick = () => selectFlag(i);
|
||||
d.textContent = p.flag; // seguro (caracter unicode)
|
||||
d.setAttribute('tabindex', '0');
|
||||
d.setAttribute('role', 'listitem');
|
||||
d.setAttribute('aria-label', `Bandera de ${p.name}`);
|
||||
d.setAttribute('aria-disabled', p.matched ? 'true' : 'false');
|
||||
d.setAttribute('aria-selected', selectedFlag === i ? 'true' : 'false');
|
||||
|
||||
if (p.matched) d.classList.add('matched');
|
||||
if (selectedFlag === i) d.classList.add('selected');
|
||||
|
||||
d.addEventListener('click', () => selectFlag(i));
|
||||
d.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectFlag(i);
|
||||
}
|
||||
});
|
||||
|
||||
flagsDiv.appendChild(d);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCountries() {
|
||||
countriesDiv.innerHTML = '';
|
||||
countriesDiv.textContent = '';
|
||||
countries.forEach((p, i) => {
|
||||
const d = document.createElement('div');
|
||||
const d = document.createElement('button');
|
||||
d.type = 'button';
|
||||
d.className = 'country';
|
||||
d.textContent = p.name;
|
||||
d.setAttribute('tabindex', 0);
|
||||
d.onclick = () => selectCountry(i);
|
||||
d.textContent = p.name; // seguro
|
||||
d.setAttribute('tabindex', '0');
|
||||
d.setAttribute('role', 'listitem');
|
||||
d.setAttribute('aria-label', `País ${p.name}`);
|
||||
d.setAttribute('aria-disabled', p.matched ? 'true' : 'false');
|
||||
d.setAttribute('aria-selected', selectedCountry === i ? 'true' : 'false');
|
||||
|
||||
if (p.matched) d.classList.add('matched');
|
||||
if (selectedCountry === i) d.classList.add('selected');
|
||||
|
||||
d.addEventListener('click', () => selectCountry(i));
|
||||
d.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectCountry(i);
|
||||
}
|
||||
});
|
||||
|
||||
countriesDiv.appendChild(d);
|
||||
});
|
||||
}
|
||||
|
||||
// Lógica selección
|
||||
function selectFlag(i) {
|
||||
if (flags[i].matched) return;
|
||||
selectedFlag = i;
|
||||
@@ -110,32 +143,81 @@ function selectCountry(i) {
|
||||
}
|
||||
|
||||
function checkMatch() {
|
||||
if (selectedFlag === null || selectedCountry === null) return;
|
||||
|
||||
const flagObj = flags[selectedFlag];
|
||||
const countryObj = countries[selectedCountry];
|
||||
|
||||
if (!flagObj || !countryObj) return;
|
||||
|
||||
if (flagObj.code === countryObj.code) {
|
||||
flags[selectedFlag].matched = true;
|
||||
countries[selectedCountry].matched = true;
|
||||
score++;
|
||||
scoreSpan.textContent = score;
|
||||
statusDiv.textContent = '¡Correcto!';
|
||||
scoreSpan.textContent = String(score);
|
||||
setStatus('¡Correcto!');
|
||||
renderFlags();
|
||||
renderCountries();
|
||||
if (score === pairs.length) {
|
||||
statusDiv.textContent = '¡Has emparejado todas las banderas! 🎉';
|
||||
setStatus('¡Has emparejado todas las banderas! 🎉');
|
||||
}
|
||||
} else {
|
||||
statusDiv.textContent = 'No es correcto, intenta otra vez.';
|
||||
setStatus('No es correcto, intenta otra vez.');
|
||||
// Reset de selección tras breve pausa
|
||||
setTimeout(() => {
|
||||
statusDiv.textContent = '';
|
||||
selectedFlag = selectedCountry = null;
|
||||
setStatus('');
|
||||
selectedFlag = null;
|
||||
selectedCountry = null;
|
||||
renderFlags();
|
||||
renderCountries();
|
||||
}, 850);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset selección tras acierto
|
||||
selectedFlag = null;
|
||||
selectedCountry = null;
|
||||
}
|
||||
|
||||
restartBtn.onclick = startGame;
|
||||
// Inicio/reinicio
|
||||
function startGame() {
|
||||
// Leer número de parejas desde selector, con límites
|
||||
const desired = pairsSelect && Number(pairsSelect.value) ? Number(pairsSelect.value) : 12;
|
||||
totalPairs = Math.max(4, Math.min(desired, countryList.length));
|
||||
|
||||
const fullList = countryList.slice(); // copia
|
||||
pairs = pickRandomCountries(fullList, totalPairs);
|
||||
// Si no se pudieron escoger las deseadas (por restricciones), ajustar total
|
||||
if (pairs.length < totalPairs) {
|
||||
totalPairs = pairs.length;
|
||||
}
|
||||
|
||||
flags = pairs.map(o => ({ ...o }));
|
||||
countries = pairs.map(o => ({ ...o }));
|
||||
|
||||
shuffle(flags);
|
||||
shuffle(countries);
|
||||
|
||||
score = 0;
|
||||
scoreSpan.textContent = String(score);
|
||||
scoreTotal.textContent = String(pairs.length);
|
||||
selectedFlag = null;
|
||||
selectedCountry = null;
|
||||
|
||||
renderFlags();
|
||||
renderCountries();
|
||||
setStatus('Empareja todas las banderas con su país');
|
||||
|
||||
// Enfocar primera bandera para accesibilidad
|
||||
const firstFlag = flagsDiv.querySelector('.flag');
|
||||
if (firstFlag) firstFlag.focus();
|
||||
}
|
||||
|
||||
// Listeners (evitar handlers inline)
|
||||
restartBtn.addEventListener('click', startGame);
|
||||
if (pairsSelect) {
|
||||
pairsSelect.addEventListener('change', startGame);
|
||||
}
|
||||
|
||||
// Init
|
||||
startGame();
|
@@ -73,4 +73,68 @@ h1 {
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.flags, .countries { grid-template-columns: 1fr;}
|
||||
}
|
||||
/* Controles y accesibilidad */
|
||||
#controls {
|
||||
margin: 1rem auto 0.5rem auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
#controls select#pairs-count {
|
||||
font-size: 1em;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.flag, .country {
|
||||
outline: none;
|
||||
}
|
||||
.flag:focus-visible, .country:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgba(35, 105, 143, 0.35);
|
||||
}
|
||||
.flag[aria-disabled="true"], .country[aria-disabled="true"] {
|
||||
cursor: default;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Modo oscuro básico */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #0f1222;
|
||||
color: #eaeaf0;
|
||||
}
|
||||
#score, #status {
|
||||
color: #a0a3b0;
|
||||
}
|
||||
.flag, .country {
|
||||
background: #20233a;
|
||||
color: #eaeaf0;
|
||||
border-color: #2c3252;
|
||||
box-shadow: 0 2px 10px rgba(61, 90, 254, 0.12);
|
||||
}
|
||||
.flag.selected, .country.selected {
|
||||
background: #1e3a8a;
|
||||
border-color: #3d5afe;
|
||||
box-shadow: 0 0 0 2px #3d5afe;
|
||||
}
|
||||
.flag.matched, .country.matched {
|
||||
background: #1b5e20;
|
||||
color: #d7ffd9;
|
||||
border-color: #66bb6a;
|
||||
box-shadow: 0 0 0 2px #66bb6a;
|
||||
}
|
||||
#restart-btn {
|
||||
background: #3d5afe;
|
||||
}
|
||||
#restart-btn:hover {
|
||||
background: #0a2459;
|
||||
}
|
||||
#controls select#pairs-count {
|
||||
background: #20233a;
|
||||
color: #eaeaf0;
|
||||
border-color: #2c3252;
|
||||
}
|
||||
}
|
@@ -3,12 +3,31 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Buscaminas</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Buscaminas</h1>
|
||||
<div id="status"></div>
|
||||
<div id="board"></div>
|
||||
<h1 id="title">Buscaminas</h1>
|
||||
|
||||
<div id="controls">
|
||||
<label for="size">Tamaño:</label>
|
||||
<select id="size">
|
||||
<option value="8">8x8</option>
|
||||
<option value="10" selected>10x10</option>
|
||||
<option value="12">12x12</option>
|
||||
</select>
|
||||
<label for="mines">Minas:</label>
|
||||
<select id="mines">
|
||||
<option value="10">10</option>
|
||||
<option value="15" selected>15</option>
|
||||
<option value="20">20</option>
|
||||
<option value="25">25</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="status" aria-live="polite"></div>
|
||||
<div id="counters">🚩 Restantes: <span id="flags-left">0</span></div>
|
||||
<div id="board" role="grid" aria-labelledby="title"></div>
|
||||
<button id="restart-btn">Reiniciar</button>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
|
@@ -1,43 +1,181 @@
|
||||
const SIZE = 10;
|
||||
const MINES = 15;
|
||||
'use strict';
|
||||
|
||||
// Configuración por defecto (se puede cambiar desde los selectores del UI)
|
||||
const DEFAULT_SIZE = 10;
|
||||
const DEFAULT_MINES = 15;
|
||||
|
||||
// Referencias DOM
|
||||
const boardDiv = document.getElementById('board');
|
||||
const statusDiv = document.getElementById('status');
|
||||
const restartBtn = document.getElementById('restart-btn');
|
||||
let board, revealed, flagged, gameOver, cellsRevealed;
|
||||
const sizeSelect = document.getElementById('size');
|
||||
const minesSelect = document.getElementById('mines');
|
||||
const flagsLeftSpan = document.getElementById('flags-left');
|
||||
|
||||
function initGame() {
|
||||
board = Array.from({length: SIZE}, () => Array(SIZE).fill(0));
|
||||
revealed = Array.from({length: SIZE}, () => Array(SIZE).fill(false));
|
||||
flagged = Array.from({length: SIZE}, () => Array(SIZE).fill(false));
|
||||
gameOver = false;
|
||||
cellsRevealed = 0;
|
||||
statusDiv.textContent = 'Haz clic izquierdo para revelar, clic derecho para marcar.';
|
||||
placeMines();
|
||||
calculateNumbers();
|
||||
renderBoard();
|
||||
// Estado
|
||||
let SIZE = DEFAULT_SIZE;
|
||||
let MINES = DEFAULT_MINES;
|
||||
let board = []; // matriz de valores: 'M' para mina o 0..8 número de minas adyacentes
|
||||
let revealed = []; // matriz booleana: celdas reveladas
|
||||
let flagged = []; // matriz booleana: celdas marcadas
|
||||
let gameOver = false;
|
||||
let cellsRevealed = 0;
|
||||
let flagsLeft = 0;
|
||||
let firstClickMade = false;
|
||||
|
||||
// Utilidades
|
||||
function setStatus(msg) {
|
||||
statusDiv.textContent = msg;
|
||||
}
|
||||
function updateFlagsLeft() {
|
||||
let count = 0;
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
if (flagged[r][c]) count++;
|
||||
}
|
||||
}
|
||||
flagsLeft = Math.max(0, MINES - count);
|
||||
if (flagsLeftSpan) flagsLeftSpan.textContent = String(flagsLeft);
|
||||
}
|
||||
function inBounds(r, c) {
|
||||
return r >= 0 && r < SIZE && c >= 0 && c < SIZE;
|
||||
}
|
||||
|
||||
function placeMines() {
|
||||
let placed = 0;
|
||||
while (placed < MINES) {
|
||||
const r = Math.floor(Math.random() * SIZE);
|
||||
const c = Math.floor(Math.random() * SIZE);
|
||||
if (board[r][c] !== 'M') {
|
||||
board[r][c] = 'M';
|
||||
placed++;
|
||||
// Inicializa estructuras y UI base
|
||||
function startGame() {
|
||||
// Leer valores desde selectores
|
||||
const parsedSize = sizeSelect ? Number(sizeSelect.value) : DEFAULT_SIZE;
|
||||
const parsedMines = minesSelect ? Number(minesSelect.value) : DEFAULT_MINES;
|
||||
SIZE = Number.isInteger(parsedSize) ? Math.max(6, Math.min(parsedSize, 18)) : DEFAULT_SIZE;
|
||||
MINES = Number.isInteger(parsedMines) ? Math.max(5, Math.min(parsedMines, SIZE * SIZE - 1)) : DEFAULT_MINES;
|
||||
|
||||
// Crear matrices
|
||||
board = Array.from({ length: SIZE }, () => Array(SIZE).fill(0));
|
||||
revealed = Array.from({ length: SIZE }, () => Array(SIZE).fill(false));
|
||||
flagged = Array.from({ length: SIZE }, () => Array(SIZE).fill(false));
|
||||
gameOver = false;
|
||||
firstClickMade = false;
|
||||
cellsRevealed = 0;
|
||||
|
||||
// Contador de banderas y estado
|
||||
updateFlagsLeft();
|
||||
setStatus('Haz clic izquierdo o Enter para revelar, clic derecho, F o Espacio para marcar banderas.');
|
||||
|
||||
// Ajustar grid dinámico
|
||||
boardDiv.style.gridTemplateColumns = `repeat(${SIZE}, 1fr)`;
|
||||
boardDiv.setAttribute('aria-disabled', 'false');
|
||||
|
||||
// Construir o reutilizar DOM del tablero
|
||||
initBoardDom();
|
||||
render();
|
||||
}
|
||||
|
||||
// Crear/reutilizar celdas del tablero con listeners accesibles
|
||||
function initBoardDom() {
|
||||
const totalCells = SIZE * SIZE;
|
||||
// Si el número de hijos no coincide, reconstruimos
|
||||
if (boardDiv.children.length !== totalCells) {
|
||||
boardDiv.textContent = '';
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; 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}`);
|
||||
// Listeners accesibles
|
||||
cell.addEventListener('click', onLeftClick);
|
||||
cell.addEventListener('contextmenu', onRightClick);
|
||||
cell.addEventListener('keydown', (e) => {
|
||||
// Enter o NumpadEnter para revelar
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onLeftClick(e);
|
||||
}
|
||||
// Espacio o F para marcar
|
||||
if (e.key === ' ' || e.key === 'Spacebar' || e.key.toLowerCase() === 'f') {
|
||||
e.preventDefault();
|
||||
onRightClick(e);
|
||||
}
|
||||
});
|
||||
boardDiv.appendChild(cell);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reutilizar: limpiar clases y re-enganchar listeners
|
||||
Array.from(boardDiv.children).forEach((cell) => {
|
||||
cell.classList.remove('revealed', 'mine', 'flag');
|
||||
cell.textContent = '';
|
||||
cell.removeEventListener('click', onLeftClick);
|
||||
cell.removeEventListener('contextmenu', onRightClick);
|
||||
cell.addEventListener('click', onLeftClick);
|
||||
cell.addEventListener('contextmenu', onRightClick);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Renderiza las celdas según estado interno sin reconstruir DOM
|
||||
function render() {
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
const idx = r * SIZE + c;
|
||||
const cell = boardDiv.children[idx];
|
||||
if (!cell) continue;
|
||||
|
||||
// Reset estilos base
|
||||
cell.classList.remove('revealed', 'mine', 'flag');
|
||||
|
||||
if (revealed[r][c]) {
|
||||
cell.classList.add('revealed');
|
||||
// contenido según valor
|
||||
if (board[r][c] === 'M') {
|
||||
cell.classList.add('mine');
|
||||
cell.textContent = '💣';
|
||||
} else if (board[r][c] > 0) {
|
||||
cell.textContent = String(board[r][c]);
|
||||
} else {
|
||||
cell.textContent = '';
|
||||
}
|
||||
cell.setAttribute('aria-disabled', 'true');
|
||||
} else if (flagged[r][c]) {
|
||||
cell.classList.add('flag');
|
||||
cell.textContent = '🚩';
|
||||
cell.setAttribute('aria-disabled', 'false');
|
||||
} else {
|
||||
cell.textContent = '';
|
||||
cell.setAttribute('aria-disabled', 'false');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Colocar minas, evitando la celda del primer clic (primera jugada segura)
|
||||
function placeMines(excludeR, excludeC) {
|
||||
let placed = 0;
|
||||
const used = new Set();
|
||||
while (placed < MINES) {
|
||||
const r = Math.floor(Math.random() * SIZE);
|
||||
const c = Math.floor(Math.random() * SIZE);
|
||||
const key = r * SIZE + c;
|
||||
if ((r === excludeR && c === excludeC) || used.has(key) || board[r][c] === 'M') continue;
|
||||
board[r][c] = 'M';
|
||||
used.add(key);
|
||||
placed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calcula números adyacentes para cada celda
|
||||
function calculateNumbers() {
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
if (board[r][c] === 'M') continue;
|
||||
let count = 0;
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
for (let j = -1; j <= 1; j++) {
|
||||
let nr = r+i, nc = c+j;
|
||||
if (nr >= 0 && nr < SIZE && nc >=0 && nc < SIZE && board[nr][nc] === 'M') count++;
|
||||
for (let dr = -1; dr <= 1; dr++) {
|
||||
for (let dc = -1; dc <= 1; dc++) {
|
||||
const nr = r + dr, nc = c + dc;
|
||||
if (inBounds(nr, nc) && board[nr][nc] === 'M') count++;
|
||||
}
|
||||
}
|
||||
board[r][c] = count;
|
||||
@@ -45,90 +183,111 @@ function calculateNumbers() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
boardDiv.innerHTML = '';
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.classList.add('cell');
|
||||
if (revealed[r][c]) {
|
||||
cell.classList.add('revealed');
|
||||
if (board[r][c] === 'M') {
|
||||
cell.classList.add('mine');
|
||||
cell.textContent = '💣';
|
||||
} else if (board[r][c] > 0) {
|
||||
cell.textContent = board[r][c];
|
||||
} else {
|
||||
cell.textContent = '';
|
||||
}
|
||||
} else if (flagged[r][c]) {
|
||||
cell.classList.add('flag');
|
||||
cell.textContent = '🚩';
|
||||
} else {
|
||||
cell.textContent = '';
|
||||
}
|
||||
// Click izquierdo
|
||||
cell.onmousedown = (e) => {
|
||||
if (gameOver) return;
|
||||
if (e.button === 0) revealCell(r, c);
|
||||
else if (e.button === 2) toggleFlag(r, c);
|
||||
};
|
||||
cell.oncontextmenu = (e) => e.preventDefault();
|
||||
boardDiv.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Revela una celda y expande si es 0 mediante BFS
|
||||
function revealCell(r, c) {
|
||||
if (!inBounds(r, c)) return;
|
||||
if (revealed[r][c] || flagged[r][c]) return;
|
||||
|
||||
revealed[r][c] = true;
|
||||
cellsRevealed++;
|
||||
|
||||
if (board[r][c] === 'M') {
|
||||
endGame(false);
|
||||
renderBoard();
|
||||
render();
|
||||
return;
|
||||
} else if (board[r][c] === 0) {
|
||||
// Revela recursivo
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
for (let j = -1; j <= 1; j++) {
|
||||
let nr = r+i, nc = c+j;
|
||||
if (nr >= 0 && nr < SIZE && nc >= 0 && nc < SIZE) {
|
||||
if (!revealed[nr][nc]) revealCell(nr, nc);
|
||||
}
|
||||
|
||||
if (board[r][c] === 0) {
|
||||
const queue = [[r, c]];
|
||||
while (queue.length) {
|
||||
const [cr, cc] = queue.shift();
|
||||
for (let dr = -1; dr <= 1; dr++) {
|
||||
for (let dc = -1; dc <= 1; dc++) {
|
||||
const nr = cr + dr, nc = cc + dc;
|
||||
if (inBounds(nr, nc) && !revealed[nr][nc] && !flagged[nr][nc]) {
|
||||
revealed[nr][nc] = true;
|
||||
cellsRevealed++;
|
||||
if (board[nr][nc] === 0) queue.push([nr, nc]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkWin();
|
||||
renderBoard();
|
||||
}
|
||||
|
||||
// Alterna bandera en una celda (si no está revelada)
|
||||
function toggleFlag(r, c) {
|
||||
if (!inBounds(r, c)) return;
|
||||
if (revealed[r][c]) return;
|
||||
flagged[r][c] = !flagged[r][c];
|
||||
renderBoard();
|
||||
updateFlagsLeft();
|
||||
}
|
||||
|
||||
// Comprueba victoria (todas las celdas no-mina están reveladas)
|
||||
function checkWin() {
|
||||
if (cellsRevealed === SIZE*SIZE - MINES) {
|
||||
if (cellsRevealed === SIZE * SIZE - MINES) {
|
||||
endGame(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Finaliza el juego (muestra minas si pierdes)
|
||||
function endGame(won) {
|
||||
gameOver = true;
|
||||
// Revela todas las minas si perdiste
|
||||
boardDiv.setAttribute('aria-disabled', 'true');
|
||||
|
||||
if (!won) {
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
if (board[r][c] === 'M') revealed[r][c] = true;
|
||||
}
|
||||
}
|
||||
statusDiv.textContent = "¡BOOM! Has perdido 💣";
|
||||
setStatus('¡BOOM! Has perdido 💣');
|
||||
} else {
|
||||
statusDiv.textContent = "¡Felicidades! Has ganado 🎉";
|
||||
setStatus('¡Felicidades! Has ganado 🎉');
|
||||
}
|
||||
}
|
||||
|
||||
restartBtn.onclick = initGame;
|
||||
// Listeners por celda
|
||||
function onLeftClick(e) {
|
||||
if (gameOver) return;
|
||||
const target = e.currentTarget;
|
||||
const r = Number(target.getAttribute('data-row'));
|
||||
const c = Number(target.getAttribute('data-col'));
|
||||
if (!Number.isInteger(r) || !Number.isInteger(c)) return;
|
||||
|
||||
initGame();
|
||||
// Primera jugada segura: colocamos minas después del primer clic
|
||||
if (!firstClickMade) {
|
||||
placeMines(r, c);
|
||||
calculateNumbers();
|
||||
firstClickMade = true;
|
||||
}
|
||||
|
||||
revealCell(r, c);
|
||||
render();
|
||||
}
|
||||
|
||||
function onRightClick(e) {
|
||||
e.preventDefault();
|
||||
if (gameOver) return;
|
||||
const target = e.currentTarget;
|
||||
const r = Number(target.getAttribute('data-row'));
|
||||
const c = Number(target.getAttribute('data-col'));
|
||||
if (!Number.isInteger(r) || !Number.isInteger(c)) return;
|
||||
|
||||
toggleFlag(r, c);
|
||||
render();
|
||||
}
|
||||
|
||||
// Eventos globales
|
||||
restartBtn.addEventListener('click', startGame);
|
||||
if (sizeSelect) {
|
||||
sizeSelect.addEventListener('change', startGame);
|
||||
}
|
||||
if (minesSelect) {
|
||||
minesSelect.addEventListener('change', startGame);
|
||||
}
|
||||
|
||||
// Init
|
||||
startGame();
|
@@ -146,4 +146,77 @@ h1 {
|
||||
}
|
||||
}
|
||||
|
||||
/* :::::::::::::::::::::::::::::: */
|
||||
/* :::::::::::::::::::::::::::::: */
|
||||
/* Controles (tamaño/minas) y contador de banderas */
|
||||
#controls {
|
||||
margin: 0.8rem auto 0.4rem auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
#controls select {
|
||||
font-size: 1em;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
#counters {
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* Accesibilidad: foco visible en celdas */
|
||||
.cell {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
.cell:focus-visible {
|
||||
box-shadow: 0 0 0 4px rgba(215, 38, 61, 0.35);
|
||||
}
|
||||
|
||||
/* Estado deshabilitado para botón reinicio */
|
||||
#restart-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Modo oscuro mejorado */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #0f1222;
|
||||
color: #eaeaf0;
|
||||
}
|
||||
#board {
|
||||
background: #182a46;
|
||||
box-shadow: 0 6px 24px rgba(61, 90, 254, 0.12);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.cell {
|
||||
background: #20233a;
|
||||
color: #eaeaf0;
|
||||
box-shadow: 0 2px 10px rgba(61, 90, 254, 0.12);
|
||||
}
|
||||
.cell.revealed { background: #264b74; }
|
||||
.cell.mine { background: #7c1f2a; color: #fff; }
|
||||
.cell.flag { background: #8a6d28; color: #ffd54f; }
|
||||
#status, #counters {
|
||||
color: #a0a3b0;
|
||||
}
|
||||
#restart-btn {
|
||||
background: #3d5afe;
|
||||
}
|
||||
#restart-btn:hover {
|
||||
background: #0a2459;
|
||||
}
|
||||
#controls select {
|
||||
background: #20233a;
|
||||
color: #eaeaf0;
|
||||
border-color: #2c3252;
|
||||
}
|
||||
.cell:focus-visible {
|
||||
box-shadow: 0 0 0 4px rgba(61, 90, 254, 0.35);
|
||||
}
|
||||
}
|
65
index.html
65
index.html
@@ -6,14 +6,20 @@
|
||||
<meta name='description' content='Página web de juegos online de Fernando Méndez.'>
|
||||
<title>FerMdez - Games</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://fonts.googleapis.com/css?family=Montserrat:700,400&display=swap" rel="stylesheet">
|
||||
<meta name="theme-color" content="#3d5afe">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap" rel="stylesheet">
|
||||
<link rel='icon' href='./media/favicon.ico' sizes='192x192' />
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name='keywords' content='fermdez, juegos, games, mini-juegos, fermdez juegos, fermdez games'/>
|
||||
<meta property='og:type' content='website' />
|
||||
<meta property='og:site_name' content='FerMdez' />
|
||||
<meta property='og:title' content='Fernando Méndez' />
|
||||
<meta property='og:description' content='Página web de juegos online de Fernando Méndez.' />
|
||||
<meta property='og:image' content='https://games.fermdez.net/media/favicon.ico' />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' 'unsafe-inline'; connect-src 'self'; base-uri 'self'; form-action 'self'">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta property='og:url' content='https://games.fermdez.net/' />
|
||||
<style>
|
||||
body {
|
||||
@@ -157,7 +163,7 @@
|
||||
<body>
|
||||
<header>
|
||||
<h1>Fermdez - Juegos</h1>
|
||||
<p>¡Prueba estos mini juegos creados por <a href="https://fermdez.net/" target="_blank">Fernando Méndez</a>!<br />Elige, juega y supera tus récords.</p>
|
||||
<p>¡Prueba estos mini juegos creados por <a href="https://fermdez.net/" target="_blank" rel="noopener noreferrer">Fernando Méndez</a>!<br />Elige, juega y supera tus récords.</p>
|
||||
</header>
|
||||
<main>
|
||||
<div class="grid">
|
||||
@@ -165,7 +171,7 @@
|
||||
<div class="game-icon">🏓</div>
|
||||
<div class="game-title">Pong Clásico</div>
|
||||
<div class="game-desc">Juega al clásico Pong contra la máquina usando las flechas.</div>
|
||||
<a class="play-btn" href="pong/" target="_blank">Jugar
|
||||
<a class="play-btn" href="pong/" target="_blank" rel="noopener noreferrer">Jugar
|
||||
<svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -173,7 +179,7 @@
|
||||
<div class="game-icon">🔲</div>
|
||||
<div class="game-title">Simon Dice</div>
|
||||
<div class="game-desc">Recuerda y repite la secuencia de colores para avanzar de nivel.</div>
|
||||
<a class="play-btn" href="simon-dice/" target="_blank">Jugar
|
||||
<a class="play-btn" href="simon-dice/" target="_blank" rel="noopener noreferrer">Jugar
|
||||
<svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -181,7 +187,7 @@
|
||||
<div class="game-icon">🧮</div>
|
||||
<div class="game-title">Buscaminas</div>
|
||||
<div class="game-desc">Marca las minas y evita explotarlas en el tablero.</div>
|
||||
<a class="play-btn" href="buscaminas/" target="_blank">Jugar
|
||||
<a class="play-btn" href="buscaminas/" target="_blank" rel="noopener noreferrer">Jugar
|
||||
<svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -189,7 +195,7 @@
|
||||
<div class="game-icon">🃏</div>
|
||||
<div class="game-title">Juego de Memoria</div>
|
||||
<div class="game-desc">Descubre todas las parejas de cartas y ejercita tu memoria.</div>
|
||||
<a class="play-btn" href="memoria/" target="_blank">Jugar
|
||||
<a class="play-btn" href="memoria/" target="_blank" rel="noopener noreferrer">Jugar
|
||||
<svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -197,7 +203,7 @@
|
||||
<div class="game-icon">🃏</div>
|
||||
<div class="game-title">Juego de Memoria Avanzado</div>
|
||||
<div class="game-desc">Descubre todas las parejas de cartas y ejercita tu memoria.</div>
|
||||
<a class="play-btn" href="memoria-v2/" target="_blank">Jugar
|
||||
<a class="play-btn" href="memoria-v2/" target="_blank" rel="noopener noreferrer">Jugar
|
||||
<svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -205,7 +211,7 @@
|
||||
<div class="game-icon">⭕❌</div>
|
||||
<div class="game-title">3 en Raya</div>
|
||||
<div class="game-desc">Tres en línea clásico: reta a un amigo.</div>
|
||||
<a class="play-btn" href="3-en-raya/" target="_blank">Jugar
|
||||
<a class="play-btn" href="3-en-raya/" target="_blank" rel="noopener noreferrer">Jugar
|
||||
<svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -213,7 +219,7 @@
|
||||
<div class="game-icon">⭕❌</div>
|
||||
<div class="game-title">3 en Raya vs Máquina</div>
|
||||
<div class="game-desc">Tres en línea clásico: reta a una IA invencible.</div>
|
||||
<a class="play-btn" href="3-en-raya-computer/" target="_blank">Jugar
|
||||
<a class="play-btn" href="3-en-raya-computer/" target="_blank" rel="noopener noreferrer">Jugar
|
||||
<svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -221,7 +227,7 @@
|
||||
<div class="game-icon">🔵🔴🔵🔴</div>
|
||||
<div class="game-title">4 en Raya vs Máquina</div>
|
||||
<div class="game-desc">Conecta 4 fichas antes que la máquina en este clásico de estrategia.</div>
|
||||
<a class="play-btn" href="4-en-raya/" target="_blank">Jugar
|
||||
<a class="play-btn" href="4-en-raya/" target="_blank" rel="noopener noreferrer">Jugar
|
||||
<svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -229,7 +235,7 @@
|
||||
<div class="game-icon">🧩</div>
|
||||
<div class="game-title">Puzzle de Números</div>
|
||||
<div class="game-desc">Resuelve el clásico puzzle de 15 piezas deslizantes.</div>
|
||||
<a class="play-btn" href="puzle-numeros/" target="_blank">Jugar
|
||||
<a class="play-btn" href="puzle-numeros/" target="_blank" rel="noopener noreferrer">Jugar
|
||||
<svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -237,7 +243,7 @@
|
||||
<div class="game-icon">🔢</div>
|
||||
<div class="game-title">Adivina el Número</div>
|
||||
<div class="game-desc">Resuelve el número que ha pensado la máquina en menos de 7 intentos.</div>
|
||||
<a class="play-btn" href="puzle-numeros/" target="_blank">Jugar
|
||||
<a class="play-btn" href="adivina/" target="_blank" rel="noopener noreferrer">Jugar
|
||||
<svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -245,7 +251,7 @@
|
||||
<div class="game-icon">🐍</div>
|
||||
<div class="game-title">Snake Game</div>
|
||||
<div class="game-desc">Haz crecer la serpiente comiendo puntos, ¡no choques con la pared!</div>
|
||||
<a class="play-btn" href="serpiente/" target="_blank">Jugar
|
||||
<a class="play-btn" href="serpiente/" target="_blank" rel="noopener noreferrer">Jugar
|
||||
<svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -253,7 +259,7 @@
|
||||
<div class="game-icon">🧱</div>
|
||||
<div class="game-title">Rompe Ladrillos</div>
|
||||
<div class="game-desc">Rompe todos los ladrillos controlando la pala y la bola.</div>
|
||||
<a class="play-btn" href="ladrillos" target="_blank">Jugar
|
||||
<a class="play-btn" href="ladrillos/" target="_blank" rel="noopener noreferrer">Jugar
|
||||
<svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -261,7 +267,7 @@
|
||||
<div class="game-icon">⏱️</div>
|
||||
<div class="game-title">Carrera de Reacción</div>
|
||||
<div class="game-desc">Haz clic cuando la pantalla se ponga verde, ¡mide tu tiempo de reacción!</div>
|
||||
<a class="play-btn" href="reflejos/" target="_blank">Jugar
|
||||
<a class="play-btn" href="reflejos/" target="_blank" rel="noopener noreferrer">Jugar
|
||||
<svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -269,7 +275,7 @@
|
||||
<div class="game-icon">🦦</div>
|
||||
<div class="game-title">Atrapa el Topo</div>
|
||||
<div class="game-desc">Haz clic en el topo cuando aparezca y suma puntos.</div>
|
||||
<a class="play-btn" href="topo/" target="_blank">Jugar
|
||||
<a class="play-btn" href="topo/" target="_blank" rel="noopener noreferrer">Jugar
|
||||
<svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -277,9 +283,34 @@
|
||||
<div class="game-icon">🇪🇸</div>
|
||||
<div class="game-title">Banderas</div>
|
||||
<div class="game-desc">Empereja la bandera con su país correspondiente.</div>
|
||||
<a class="play-btn" href="banderas/" target="_blank">Jugar
|
||||
<a class="play-btn" href="banderas/" target="_blank" rel="noopener noreferrer">Jugar
|
||||
<svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(reg => {
|
||||
// Forzar activación inmediata si hay un SW en espera
|
||||
if (reg.waiting) {
|
||||
reg.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||
}
|
||||
// Detectar nuevas actualizaciones
|
||||
reg.addEventListener('updatefound', () => {
|
||||
const newSW = reg.installing;
|
||||
if (newSW) {
|
||||
newSW.addEventListener('statechange', () => {
|
||||
if (newSW.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
console.log('Nueva versión del Service Worker instalada');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(err => console.error('Fallo al registrar el Service Worker:', err));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
@@ -3,15 +3,30 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Breakout</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Rompe Ladrillos</h1>
|
||||
<p>Usa el ratón para mover el bloque. Rompe todos los ladrillos para ganar.</p>
|
||||
<div id="controls">
|
||||
<label for="difficulty">Dificultad:</label>
|
||||
<select id="difficulty">
|
||||
<option value="easy">Fácil</option>
|
||||
<option value="normal" selected>Normal</option>
|
||||
<option value="hard">Difícil</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<canvas id="gameCanvas" width="540" height="480"></canvas>
|
||||
<div id="score">Puntuación: <span id="score-value">0</span></div>
|
||||
|
||||
<div id="score">
|
||||
Puntuación: <span id="score-value">0</span>
|
||||
· Mejor: <span id="best-score">0</span>
|
||||
</div>
|
||||
|
||||
<button id="restart-btn">Reiniciar</button>
|
||||
<div id="game-over-message"></div>
|
||||
<div id="game-over-message" aria-live="polite"></div>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
@@ -1,77 +1,196 @@
|
||||
'use strict';
|
||||
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const scoreSpan = document.getElementById('score-value');
|
||||
const bestScoreSpan = document.getElementById('best-score');
|
||||
const restartBtn = document.getElementById('restart-btn');
|
||||
const gameOverDiv = document.getElementById('game-over-message');
|
||||
const difficultySelect = document.getElementById('difficulty');
|
||||
|
||||
const ballRadius = 8;
|
||||
let x, y, dx, dy;
|
||||
const paddleHeight = 12, paddleWidth = 75;
|
||||
let paddleX;
|
||||
const brickRowCount = 5, brickColumnCount = 7;
|
||||
// Parámetros por dificultad
|
||||
const DIFFICULTIES = {
|
||||
easy: { ballSpeed: 2.6, paddleWidth: 95, brickRows: 4, brickCols: 6 },
|
||||
normal: { ballSpeed: 3.2, paddleWidth: 80, brickRows: 5, brickCols: 7 },
|
||||
hard: { ballSpeed: 4.0, paddleWidth: 70, brickRows: 6, brickCols: 8 }
|
||||
};
|
||||
|
||||
// Estado del juego
|
||||
let ballRadius = 8;
|
||||
let x = 0, y = 0, dx = 0, dy = 0;
|
||||
let paddleHeight = 12, paddleWidth = DIFFICULTIES.normal.paddleWidth;
|
||||
let paddleX = 0;
|
||||
|
||||
let brickRowCount = DIFFICULTIES.normal.brickRows;
|
||||
let brickColumnCount = DIFFICULTIES.normal.brickCols;
|
||||
const brickWidth = 60, brickHeight = 20, brickPadding = 10, brickOffsetTop = 30, brickOffsetLeft = 30;
|
||||
let bricks = [];
|
||||
let score, gameOver;
|
||||
let bricks = []; // matriz [col][row] con {x,y,status}
|
||||
let score = 0, gameOver = false;
|
||||
let animId = null;
|
||||
|
||||
// Controles/Listeners
|
||||
let mouseMoveHandlerBound = null;
|
||||
let keyDownHandlerBound = null;
|
||||
let keyUpHandlerBound = null;
|
||||
let keysPressed = { left: false, right: false };
|
||||
|
||||
// Utilidades de almacenamiento
|
||||
function bestKey(diff) {
|
||||
return `breakout_best_${diff}`;
|
||||
}
|
||||
function getBest(diff) {
|
||||
const v = localStorage.getItem(bestKey(diff));
|
||||
return v ? parseInt(v, 10) : 0;
|
||||
}
|
||||
function setBest(diff, val) {
|
||||
const prev = getBest(diff);
|
||||
if (val > prev) {
|
||||
localStorage.setItem(bestKey(diff), String(val));
|
||||
}
|
||||
bestScoreSpan.textContent = String(getBest(diff));
|
||||
}
|
||||
|
||||
// Lectura dificultad actual
|
||||
function getCurrentDifficulty() {
|
||||
const v = difficultySelect && difficultySelect.value ? difficultySelect.value : 'normal';
|
||||
return DIFFICULTIES[v] ? v : 'normal';
|
||||
}
|
||||
function getCurrentConfig() {
|
||||
return DIFFICULTIES[getCurrentDifficulty()];
|
||||
}
|
||||
|
||||
// Inicializa ladrillos según dificultad
|
||||
function setupBricks() {
|
||||
bricks = [];
|
||||
for(let c=0; c<brickColumnCount; c++) {
|
||||
for (let c = 0; c < brickColumnCount; c++) {
|
||||
bricks[c] = [];
|
||||
for(let r=0; r<brickRowCount; r++) {
|
||||
bricks[c][r] = { x:0, y:0, status:1 };
|
||||
for (let r = 0; r < brickRowCount; r++) {
|
||||
bricks[c][r] = { x: 0, y: 0, status: 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reinicio y arranque de partida
|
||||
function startGame() {
|
||||
x = canvas.width/2;
|
||||
y = canvas.height-30;
|
||||
dx = 3;
|
||||
dy = -3;
|
||||
paddleX = (canvas.width-paddleWidth)/2;
|
||||
// Configuración por dificultad
|
||||
const conf = getCurrentConfig();
|
||||
paddleWidth = conf.paddleWidth;
|
||||
brickRowCount = conf.brickRows;
|
||||
brickColumnCount = conf.brickCols;
|
||||
|
||||
// Estado base
|
||||
x = canvas.width / 2;
|
||||
y = canvas.height - 60;
|
||||
const speed = conf.ballSpeed;
|
||||
dx = speed; // velocidad horizontal inicial
|
||||
dy = -speed; // velocidad vertical inicial
|
||||
paddleX = (canvas.width - paddleWidth) / 2;
|
||||
score = 0;
|
||||
scoreSpan.textContent = score;
|
||||
scoreSpan.textContent = String(score);
|
||||
bestScoreSpan.textContent = String(getBest(getCurrentDifficulty()));
|
||||
gameOver = false;
|
||||
gameOverDiv.textContent = '';
|
||||
|
||||
// Preparar ladrillos (precalcular posiciones para rendimiento)
|
||||
setupBricks();
|
||||
document.addEventListener("mousemove", mouseMoveHandler);
|
||||
draw();
|
||||
precomputeBrickPositions();
|
||||
|
||||
// Listeners
|
||||
attachListeners();
|
||||
|
||||
// Animación
|
||||
if (animId) cancelAnimationFrame(animId);
|
||||
animId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
function mouseMoveHandler(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
let relativeX = e.clientX - rect.left;
|
||||
if(relativeX > 0 && relativeX < canvas.width) {
|
||||
paddleX = relativeX - paddleWidth/2;
|
||||
if(paddleX < 0) paddleX = 0;
|
||||
if(paddleX + paddleWidth > canvas.width) paddleX = canvas.width - paddleWidth;
|
||||
}
|
||||
}
|
||||
|
||||
function collisionDetection() {
|
||||
for(let c=0; c<brickColumnCount; c++) {
|
||||
for(let r=0; r<brickRowCount; r++) {
|
||||
const b = bricks[c][r];
|
||||
if(b.status == 1) {
|
||||
if(x > b.x && x < b.x+brickWidth && y > b.y && y < b.y+brickHeight) {
|
||||
dy = -dy;
|
||||
b.status = 0;
|
||||
score++;
|
||||
scoreSpan.textContent = score;
|
||||
if(score == brickRowCount*brickColumnCount) {
|
||||
gameOver = true;
|
||||
gameOverDiv.textContent = "¡Ganaste! 🏆";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Precalcular posiciones de ladrillos una vez
|
||||
function precomputeBrickPositions() {
|
||||
for (let c = 0; c < brickColumnCount; c++) {
|
||||
for (let r = 0; r < brickRowCount; r++) {
|
||||
const brickX = (c * (brickWidth + brickPadding)) + brickOffsetLeft;
|
||||
const brickY = (r * (brickHeight + brickPadding)) + brickOffsetTop;
|
||||
bricks[c][r].x = brickX;
|
||||
bricks[c][r].y = brickY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listeners seguros (evitar duplicados)
|
||||
function attachListeners() {
|
||||
// Ratón
|
||||
if (mouseMoveHandlerBound) {
|
||||
document.removeEventListener('mousemove', mouseMoveHandlerBound);
|
||||
}
|
||||
mouseMoveHandlerBound = function (e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
let relativeX = e.clientX - rect.left;
|
||||
if (relativeX > 0 && relativeX < canvas.width) {
|
||||
paddleX = relativeX - paddleWidth / 2;
|
||||
clampPaddle();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousemove', mouseMoveHandlerBound, { passive: true });
|
||||
|
||||
// Teclado
|
||||
if (keyDownHandlerBound) window.removeEventListener('keydown', keyDownHandlerBound);
|
||||
if (keyUpHandlerBound) window.removeEventListener('keyup', keyUpHandlerBound);
|
||||
|
||||
keyDownHandlerBound = function (e) {
|
||||
if (e.key === 'ArrowLeft' || e.key === 'Left') keysPressed.left = true;
|
||||
if (e.key === 'ArrowRight' || e.key === 'Right') keysPressed.right = true;
|
||||
};
|
||||
keyUpHandlerBound = function (e) {
|
||||
if (e.key === 'ArrowLeft' || e.key === 'Left') keysPressed.left = false;
|
||||
if (e.key === 'ArrowRight' || e.key === 'Right') keysPressed.right = false;
|
||||
};
|
||||
window.addEventListener('keydown', keyDownHandlerBound);
|
||||
window.addEventListener('keyup', keyUpHandlerBound);
|
||||
|
||||
// Botón reinicio
|
||||
restartBtn.removeEventListener('click', onRestart);
|
||||
restartBtn.addEventListener('click', onRestart);
|
||||
|
||||
// Cambio de dificultad reinicia partida
|
||||
if (difficultySelect) {
|
||||
difficultySelect.removeEventListener('change', onDifficultyChange);
|
||||
difficultySelect.addEventListener('change', onDifficultyChange);
|
||||
}
|
||||
}
|
||||
|
||||
function detachListeners() {
|
||||
if (mouseMoveHandlerBound) {
|
||||
document.removeEventListener('mousemove', mouseMoveHandlerBound);
|
||||
mouseMoveHandlerBound = null;
|
||||
}
|
||||
if (keyDownHandlerBound) {
|
||||
window.removeEventListener('keydown', keyDownHandlerBound);
|
||||
keyDownHandlerBound = null;
|
||||
}
|
||||
if (keyUpHandlerBound) {
|
||||
window.removeEventListener('keyup', keyUpHandlerBound);
|
||||
keyUpHandlerBound = null;
|
||||
}
|
||||
restartBtn.removeEventListener('click', onRestart);
|
||||
if (difficultySelect) difficultySelect.removeEventListener('change', onDifficultyChange);
|
||||
}
|
||||
|
||||
function onRestart() {
|
||||
detachListeners();
|
||||
startGame();
|
||||
}
|
||||
function onDifficultyChange() {
|
||||
onRestart();
|
||||
}
|
||||
|
||||
function clampPaddle() {
|
||||
if (paddleX < 0) paddleX = 0;
|
||||
if (paddleX + paddleWidth > canvas.width) paddleX = canvas.width - paddleWidth;
|
||||
}
|
||||
|
||||
// Dibujo de elementos
|
||||
function drawBall() {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, ballRadius, 0, Math.PI*2);
|
||||
ctx.arc(x, y, ballRadius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#f9d923";
|
||||
ctx.fill();
|
||||
ctx.closePath();
|
||||
@@ -79,22 +198,19 @@ function drawBall() {
|
||||
|
||||
function drawPaddle() {
|
||||
ctx.beginPath();
|
||||
ctx.rect(paddleX, canvas.height-paddleHeight-5, paddleWidth, paddleHeight);
|
||||
ctx.rect(paddleX, canvas.height - paddleHeight - 5, paddleWidth, paddleHeight);
|
||||
ctx.fillStyle = "#e94560";
|
||||
ctx.fill();
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function drawBricks() {
|
||||
for(let c=0; c<brickColumnCount; c++) {
|
||||
for(let r=0; r<brickRowCount; r++) {
|
||||
if(bricks[c][r].status == 1) {
|
||||
const brickX = (c*(brickWidth+brickPadding))+brickOffsetLeft;
|
||||
const brickY = (r*(brickHeight+brickPadding))+brickOffsetTop;
|
||||
bricks[c][r].x = brickX;
|
||||
bricks[c][r].y = brickY;
|
||||
for (let c = 0; c < brickColumnCount; c++) {
|
||||
for (let r = 0; r < brickRowCount; r++) {
|
||||
if (bricks[c][r].status === 1) {
|
||||
const { x: bx, y: by } = bricks[c][r];
|
||||
ctx.beginPath();
|
||||
ctx.rect(brickX, brickY, brickWidth, brickHeight);
|
||||
ctx.rect(bx, by, brickWidth, brickHeight);
|
||||
ctx.fillStyle = "#393e46";
|
||||
ctx.strokeStyle = "#f9d923";
|
||||
ctx.fill();
|
||||
@@ -105,43 +221,143 @@ function drawBricks() {
|
||||
}
|
||||
}
|
||||
|
||||
// Control de pala por teclado (suave)
|
||||
function updatePaddleByKeyboard() {
|
||||
const conf = getCurrentConfig();
|
||||
const step = Math.max(4, conf.ballSpeed * 2.2);
|
||||
if (keysPressed.left) {
|
||||
paddleX -= step;
|
||||
} else if (keysPressed.right) {
|
||||
paddleX += step;
|
||||
}
|
||||
clampPaddle();
|
||||
}
|
||||
|
||||
// Colisión círculo-rectángulo (ball vs rect)
|
||||
function circleRectCollision(cx, cy, radius, rx, ry, rw, rh) {
|
||||
// Punto más cercano del rect al círculo
|
||||
const nearestX = Math.max(rx, Math.min(cx, rx + rw));
|
||||
const nearestY = Math.max(ry, Math.min(cy, ry + rh));
|
||||
const dx = cx - nearestX;
|
||||
const dy = cy - nearestY;
|
||||
return (dx * dx + dy * dy) <= (radius * radius);
|
||||
}
|
||||
|
||||
function collisionWithBricks() {
|
||||
for (let c = 0; c < brickColumnCount; c++) {
|
||||
for (let r = 0; r < brickRowCount; r++) {
|
||||
const b = bricks[c][r];
|
||||
if (b.status !== 1) continue;
|
||||
|
||||
if (circleRectCollision(x, y, ballRadius, b.x, b.y, brickWidth, brickHeight)) {
|
||||
// Determinar lado de impacto para invertir adecuadamente
|
||||
// Calculamos centro del brick
|
||||
const bxCenter = b.x + brickWidth / 2;
|
||||
const byCenter = b.y + brickHeight / 2;
|
||||
const dxCenter = x - bxCenter;
|
||||
const dyCenter = y - byCenter;
|
||||
|
||||
if (Math.abs(dxCenter) > Math.abs(dyCenter)) {
|
||||
dx = -dx; // impacto predominantemente lateral
|
||||
} else {
|
||||
dy = -dy; // impacto predominantemente superior/inferior
|
||||
}
|
||||
|
||||
b.status = 0;
|
||||
score++;
|
||||
scoreSpan.textContent = String(score);
|
||||
|
||||
// Victoria si todos eliminados
|
||||
if (score === brickRowCount * brickColumnCount) {
|
||||
onWin();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function collisionWallsAndPaddle() {
|
||||
// Paredes laterales
|
||||
if (x + dx > canvas.width - ballRadius || x + dx < ballRadius) {
|
||||
dx = -dx;
|
||||
}
|
||||
// Techo
|
||||
if (y + dy < ballRadius) {
|
||||
dy = -dy;
|
||||
} else if (y + dy > canvas.height - ballRadius - paddleHeight - 5) {
|
||||
// Posible contacto con la pala
|
||||
if (x > paddleX && x < paddleX + paddleWidth) {
|
||||
// Rebote con ángulo según sitio de impacto en la pala
|
||||
const conf = getCurrentConfig();
|
||||
const hitPos = (x - (paddleX + paddleWidth / 2)) / (paddleWidth / 2); // -1..1
|
||||
const maxAngle = Math.PI / 3; // 60°
|
||||
const angle = hitPos * maxAngle;
|
||||
const speed = conf.ballSpeed;
|
||||
dx = speed * Math.sin(angle);
|
||||
dy = -Math.abs(speed * Math.cos(angle)); // hacia arriba
|
||||
|
||||
// Evitar quedarse pegado a la pala
|
||||
y = canvas.height - ballRadius - paddleHeight - 6;
|
||||
} else if (y + dy > canvas.height - ballRadius) {
|
||||
// Fuera por abajo: Game Over
|
||||
onGameOver();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onWin() {
|
||||
gameOver = true;
|
||||
gameOverDiv.textContent = "¡Ganaste! 🏆";
|
||||
setBest(getCurrentDifficulty(), score);
|
||||
stopLoop();
|
||||
}
|
||||
|
||||
function onGameOver() {
|
||||
gameOver = true;
|
||||
gameOverDiv.textContent = "¡Game Over! 😢";
|
||||
stopLoop();
|
||||
}
|
||||
|
||||
function stopLoop() {
|
||||
if (animId) cancelAnimationFrame(animId);
|
||||
animId = null;
|
||||
detachListeners();
|
||||
}
|
||||
|
||||
// Bucle principal
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
drawBricks();
|
||||
drawBall();
|
||||
drawPaddle();
|
||||
collisionDetection();
|
||||
|
||||
// Rebote en las paredes
|
||||
if(x + dx > canvas.width-ballRadius || x + dx < ballRadius) {
|
||||
dx = -dx;
|
||||
// Actualizar pala por teclado
|
||||
updatePaddleByKeyboard();
|
||||
|
||||
// Colisiones
|
||||
const hitBrick = collisionWithBricks();
|
||||
if (hitBrick) {
|
||||
// Si hubo victoria, el bucle se detuvo
|
||||
return;
|
||||
}
|
||||
if(y + dy < ballRadius) {
|
||||
dy = -dy;
|
||||
} else if(y + dy > canvas.height-ballRadius-paddleHeight-5) {
|
||||
// Rebote en la paleta
|
||||
if(x > paddleX && x < paddleX + paddleWidth) {
|
||||
dy = -dy;
|
||||
} else if (y + dy > canvas.height-ballRadius) {
|
||||
// GAME OVER
|
||||
gameOver = true;
|
||||
gameOverDiv.textContent = "¡Game Over! 😢";
|
||||
return;
|
||||
}
|
||||
const endOrWall = collisionWallsAndPaddle();
|
||||
if (endOrWall) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Avance de la bola
|
||||
x += dx;
|
||||
y += dy;
|
||||
|
||||
if(!gameOver) {
|
||||
requestAnimationFrame(draw);
|
||||
if (!gameOver) {
|
||||
animId = requestAnimationFrame(draw);
|
||||
}
|
||||
}
|
||||
|
||||
restartBtn.onclick = function() {
|
||||
document.removeEventListener("mousemove", mouseMoveHandler);
|
||||
startGame();
|
||||
};
|
||||
|
||||
// Inicio
|
||||
startGame();
|
@@ -112,4 +112,43 @@ canvas {
|
||||
#restart-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
/* Controles (selector de dificultad) */
|
||||
#controls {
|
||||
margin: clamp(0.5rem, 2vw, 1rem) auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
#controls select#difficulty {
|
||||
font-size: clamp(0.9rem, 2vw, 1.1rem);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid rgba(255,255,255,0.18);
|
||||
background: var(--color-canvas-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
#controls select#difficulty:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.35);
|
||||
}
|
||||
|
||||
/* Accesibilidad: foco visible en botón reinicio */
|
||||
#restart-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.35);
|
||||
}
|
||||
|
||||
/* Estado deshabilitado para el botón reinicio */
|
||||
#restart-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Ajuste de separación del marcador con mejor puntuación */
|
||||
#score {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
}
|
@@ -3,17 +3,29 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Juego de Memoria</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Juego de Memoria Avanzado</h1>
|
||||
<h1 id="title">Juego de Memoria Avanzado</h1>
|
||||
<p>Haz clic sobre las cartas para descubrir y encontrar las parejas.</p>
|
||||
<div id="hud">
|
||||
<span id="moves"></span>
|
||||
<span id="timer"></span>
|
||||
|
||||
<div id="controls">
|
||||
<label for="difficulty">Dificultad:</label>
|
||||
<select id="difficulty">
|
||||
<option value="facil">Fácil (6 parejas)</option>
|
||||
<option value="normal" selected>Normal (12 parejas)</option>
|
||||
<option value="dificil">Difícil (18 parejas)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="game-board"></div>
|
||||
<div id="status"></div>
|
||||
|
||||
<div id="hud">
|
||||
<span id="moves" aria-live="polite"></span>
|
||||
<span id="timer" aria-live="polite"></span>
|
||||
<span id="best"></span>
|
||||
</div>
|
||||
<div id="game-board" role="grid" aria-labelledby="title"></div>
|
||||
<div id="status" aria-live="polite"></div>
|
||||
<button id="restart-btn">Reiniciar</button>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
|
@@ -1,67 +1,209 @@
|
||||
const symbols = [
|
||||
'use strict';
|
||||
|
||||
// Símbolos del juego (pool amplio para distintas dificultades)
|
||||
const SYMBOLS = [
|
||||
'🐶','🌸','⚽','🍕','🎲','🌞','🚗','🍩',
|
||||
'⭐','🚀','🎮','💎'
|
||||
'⭐','🚀','🎮','💎','🐱','🍔','🍟','🎧',
|
||||
'🍓','🍍','🥝','🍇','🍒','🍉','🍊','🧩',
|
||||
'🎯','🪙','🧠','🦄','🦊','🦁','🐼','🐸',
|
||||
'🏀','🏐','🎳','🎹','🎻','🥁','🎺','🎷'
|
||||
];
|
||||
let cards = [];
|
||||
|
||||
// Configuración por dificultad
|
||||
const DIFFICULTIES = {
|
||||
facil: { pairs: 6, maxMoves: 40, timeSec: 90 },
|
||||
normal: { pairs: 12, maxMoves: 60, timeSec: 120 },
|
||||
dificil: { pairs: 18, maxMoves: 85, timeSec: 180 }
|
||||
};
|
||||
|
||||
// Estado
|
||||
let deck = []; // símbolos duplicados y mezclados
|
||||
let firstCard = null;
|
||||
let secondCard = null;
|
||||
let lockBoard = false;
|
||||
let matches = 0;
|
||||
let moves = 0;
|
||||
const maxMoves = 45;
|
||||
let timer = 120; // segundos
|
||||
let timer = 0;
|
||||
let timerInterval = null;
|
||||
const boardDiv = document.getElementById("game-board");
|
||||
const statusDiv = document.getElementById("status");
|
||||
const restartBtn = document.getElementById("restart-btn");
|
||||
const movesSpan = document.getElementById("moves");
|
||||
const timerSpan = document.getElementById("timer");
|
||||
let totalPairs = DIFFICULTIES.normal.pairs;
|
||||
let maxMoves = DIFFICULTIES.normal.maxMoves;
|
||||
|
||||
// DOM
|
||||
const boardDiv = document.getElementById('game-board');
|
||||
const statusDiv = document.getElementById('status');
|
||||
const restartBtn = document.getElementById('restart-btn');
|
||||
const movesSpan = document.getElementById('moves');
|
||||
const timerSpan = document.getElementById('timer');
|
||||
const difficultySelect = document.getElementById('difficulty');
|
||||
const bestSpan = document.getElementById('best');
|
||||
|
||||
// Utils
|
||||
function shuffle(array) {
|
||||
for(let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i+1));
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
}
|
||||
function pickSymbols(n) {
|
||||
const pool = SYMBOLS.slice();
|
||||
shuffle(pool);
|
||||
return pool.slice(0, n);
|
||||
}
|
||||
function bestKey(diff) {
|
||||
return `memoryv2_best_${diff}`;
|
||||
}
|
||||
function getBest(diff) {
|
||||
const v = localStorage.getItem(bestKey(diff));
|
||||
return v ? parseInt(v, 10) : null;
|
||||
}
|
||||
function setBestIfBetter(diff, movesUsed, timeLeft) {
|
||||
// Métrica compuesta: menor movimientos prioriza, y mayor tiempo restante también importa
|
||||
// Guardamos mejor (menores movimientos) y desempate por mayor tiempo restante
|
||||
const key = bestKey(diff);
|
||||
const prevRaw = localStorage.getItem(key);
|
||||
let prev = prevRaw ? JSON.parse(prevRaw) : null;
|
||||
const current = { moves: movesUsed, timeLeft: timeLeft };
|
||||
if (!prev || current.moves < prev.moves || (current.moves === prev.moves && current.timeLeft > prev.timeLeft)) {
|
||||
localStorage.setItem(key, JSON.stringify(current));
|
||||
}
|
||||
prev = JSON.parse(localStorage.getItem(key));
|
||||
if (bestSpan) {
|
||||
if (prev) bestSpan.textContent = `Mejor: ${prev.moves} mov · ${prev.timeLeft}s`;
|
||||
else bestSpan.textContent = 'Mejor: —';
|
||||
}
|
||||
}
|
||||
function updateBestDisplay(diff) {
|
||||
const prevRaw = localStorage.getItem(bestKey(diff));
|
||||
if (!bestSpan) return;
|
||||
if (!prevRaw) {
|
||||
bestSpan.textContent = 'Mejor: —';
|
||||
} else {
|
||||
const prev = JSON.parse(prevRaw);
|
||||
bestSpan.textContent = `Mejor: ${prev.moves} mov · ${prev.timeLeft}s`;
|
||||
}
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
matches = 0;
|
||||
moves = 0;
|
||||
timer = 120;
|
||||
function updateHUD() {
|
||||
if (movesSpan) movesSpan.textContent = `Movimientos: ${moves} / ${maxMoves}`;
|
||||
if (timerSpan) timerSpan.textContent = `Tiempo: ${timer}s`;
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
}
|
||||
}
|
||||
function startTimer() {
|
||||
stopTimer();
|
||||
timerInterval = setInterval(() => {
|
||||
timer--;
|
||||
updateHUD();
|
||||
if (timer <= 0) {
|
||||
endGame(false, '¡Se acabó el tiempo!');
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function gridColumnsFor(totalCards) {
|
||||
// Determina columnas para una rejilla compacta
|
||||
if (totalCards <= 12) return 4;
|
||||
if (totalCards <= 16) return 4;
|
||||
if (totalCards <= 24) return 6;
|
||||
if (totalCards <= 36) return 6;
|
||||
return 6;
|
||||
}
|
||||
|
||||
// Render tarjeta
|
||||
function createCard(symbol, idx) {
|
||||
const card = document.createElement('button');
|
||||
card.type = 'button';
|
||||
card.className = 'card';
|
||||
card.dataset.index = String(idx);
|
||||
card.dataset.symbol = symbol;
|
||||
card.textContent = '';
|
||||
card.setAttribute('aria-label', 'Carta');
|
||||
card.setAttribute('aria-disabled', 'false');
|
||||
|
||||
card.addEventListener('click', () => flipCard(card));
|
||||
card.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
flipCard(card);
|
||||
}
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function resetTurn() {
|
||||
firstCard = null;
|
||||
secondCard = null;
|
||||
lockBoard = false;
|
||||
statusDiv.textContent = '';
|
||||
updateHUD();
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
cards = [...symbols, ...symbols];
|
||||
shuffle(cards);
|
||||
boardDiv.innerHTML = '';
|
||||
cards.forEach((symbol, idx) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "card";
|
||||
card.dataset.index = idx;
|
||||
card.dataset.symbol = symbol;
|
||||
card.textContent = '';
|
||||
card.onclick = () => flipCard(card);
|
||||
boardDiv.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function unflip(el) {
|
||||
if (!el) return;
|
||||
el.classList.remove('flipped');
|
||||
el.textContent = '';
|
||||
el.setAttribute('aria-disabled', 'false');
|
||||
}
|
||||
|
||||
// Inicio/reinicio
|
||||
function startGame() {
|
||||
// Leer dificultad
|
||||
const diff = (difficultySelect && difficultySelect.value) || 'normal';
|
||||
const conf = DIFFICULTIES[diff] || DIFFICULTIES.normal;
|
||||
totalPairs = conf.pairs;
|
||||
maxMoves = conf.maxMoves;
|
||||
timer = conf.timeSec;
|
||||
|
||||
// Reiniciar estado
|
||||
matches = 0;
|
||||
moves = 0;
|
||||
firstCard = null;
|
||||
secondCard = null;
|
||||
lockBoard = false;
|
||||
if (statusDiv) statusDiv.textContent = '';
|
||||
updateHUD();
|
||||
updateBestDisplay(diff);
|
||||
|
||||
// Construir deck
|
||||
const chosen = pickSymbols(totalPairs);
|
||||
deck = [...chosen, ...chosen];
|
||||
shuffle(deck);
|
||||
|
||||
// Render tablero
|
||||
boardDiv.textContent = '';
|
||||
const totalCards = deck.length;
|
||||
const cols = gridColumnsFor(totalCards);
|
||||
boardDiv.style.gridTemplateColumns = `repeat(${cols}, minmax(45px, 1fr))`;
|
||||
|
||||
deck.forEach((symbol, idx) => {
|
||||
const card = createCard(symbol, idx);
|
||||
boardDiv.appendChild(card);
|
||||
});
|
||||
|
||||
// Foco inicial y arranque de tiempo
|
||||
const first = boardDiv.querySelector('.card');
|
||||
if (first) first.focus();
|
||||
startTimer();
|
||||
}
|
||||
|
||||
// Lógica de flip
|
||||
function flipCard(card) {
|
||||
if (lockBoard) return;
|
||||
if (card.classList.contains('flipped') || card.classList.contains('matched')) return;
|
||||
|
||||
card.classList.add('flipped');
|
||||
card.textContent = card.dataset.symbol;
|
||||
card.setAttribute('aria-disabled', 'true');
|
||||
|
||||
if (!firstCard) {
|
||||
firstCard = card;
|
||||
return; // ¡Esperamos por la segunda carta!
|
||||
return;
|
||||
}
|
||||
|
||||
if (card === firstCard) return;
|
||||
|
||||
secondCard = card;
|
||||
lockBoard = true;
|
||||
moves++;
|
||||
@@ -77,55 +219,40 @@ function flipCard(card) {
|
||||
firstCard.classList.add('hide');
|
||||
secondCard.classList.add('hide');
|
||||
resetTurn();
|
||||
// Verifica victoria DESPUÉS de ocultar
|
||||
if (matches === symbols.length) {
|
||||
clearInterval(timerInterval);
|
||||
if (matches === totalPairs) {
|
||||
// Victoria
|
||||
stopTimer();
|
||||
statusDiv.textContent = `¡Felicidades! Lo lograste en ${moves} movimientos y te sobraron ${timer} segs 🎉`;
|
||||
lockBoard = true; // Deshabilita el tablero tras terminar
|
||||
setBestIfBetter((difficultySelect && difficultySelect.value) || 'normal', moves, timer);
|
||||
lockBoard = true;
|
||||
}
|
||||
}, 800);
|
||||
|
||||
}, 600);
|
||||
} else {
|
||||
// No es PAR
|
||||
setTimeout(() => {
|
||||
firstCard.classList.remove('flipped');
|
||||
secondCard.classList.remove('flipped');
|
||||
firstCard.textContent = '';
|
||||
secondCard.textContent = '';
|
||||
unflip(firstCard);
|
||||
unflip(secondCard);
|
||||
resetTurn();
|
||||
}, 900);
|
||||
}, 700);
|
||||
}
|
||||
|
||||
// DERROTA: por movimientos
|
||||
// Derrota por límite de movimientos
|
||||
if (moves >= maxMoves) {
|
||||
endGame(false, "Has alcanzado el límite de movimientos. ¡Inténtalo otra vez!");
|
||||
endGame(false, 'Has alcanzado el límite de movimientos. ¡Inténtalo otra vez!');
|
||||
}
|
||||
}
|
||||
|
||||
function updateHUD() {
|
||||
movesSpan.textContent = `Movimientos: ${moves} / ${maxMoves}`;
|
||||
timerSpan.textContent = `Tiempo: ${timer}s`;
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
timer--;
|
||||
updateHUD();
|
||||
if (timer <= 0) {
|
||||
endGame(false, "¡Se acabó el tiempo!");
|
||||
}
|
||||
}
|
||||
|
||||
function resetTurn() {
|
||||
firstCard = null;
|
||||
secondCard = null;
|
||||
lockBoard = false;
|
||||
}
|
||||
|
||||
function endGame(win, msg) {
|
||||
lockBoard = true;
|
||||
clearInterval(timerInterval);
|
||||
stopTimer();
|
||||
statusDiv.textContent = msg;
|
||||
}
|
||||
|
||||
restartBtn.onclick = startGame;
|
||||
// Listeners
|
||||
restartBtn.addEventListener('click', startGame);
|
||||
if (difficultySelect) {
|
||||
difficultySelect.addEventListener('change', startGame);
|
||||
}
|
||||
|
||||
// Init
|
||||
startGame();
|
@@ -3,13 +3,29 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Juego de Memoria</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Juego de Memoria</h1>
|
||||
<h1 id="title">Juego de Memoria</h1>
|
||||
<p>Haz clic en las cartas para darles la vuelta y encuentra las parejas.</p>
|
||||
<div id="game-board"></div>
|
||||
<div id="info"></div>
|
||||
|
||||
<div id="controls">
|
||||
<label for="difficulty">Dificultad:</label>
|
||||
<select id="difficulty">
|
||||
<option value="facil">Fácil (6 parejas)</option>
|
||||
<option value="normal" selected>Normal (8 parejas)</option>
|
||||
<option value="dificil">Difícil (12 parejas)</option>
|
||||
<option value="extremo">Extremo (18 parejas)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="hud">
|
||||
Movimientos: <span id="moves">0</span> · Tiempo: <span id="time">0s</span> · Mejor: <span id="best-score">—</span>
|
||||
</div>
|
||||
|
||||
<div id="game-board" role="grid" aria-labelledby="title"></div>
|
||||
<div id="info" aria-live="polite"></div>
|
||||
<button id="reset-btn">Reiniciar</button>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
|
@@ -1,85 +1,228 @@
|
||||
const symbols = ['🍎','🍌','🍒','🍇','🍉','🍑','🍊','🍓'];
|
||||
let cards = [];
|
||||
'use strict';
|
||||
|
||||
// Símbolos disponibles (>= 18 únicos para niveles altos)
|
||||
const SYMBOLS = [
|
||||
'🍎','🍌','🍒','🍇','🍉','🍑','🍊','🍓',
|
||||
'🥝','🍍','🥥','🍐','🍈','🍏','🍔','🍕',
|
||||
'🎈','⭐','🌙','☀️','⚽','🏀','🎲','🎵',
|
||||
'🐶','🐱','🐼','🐸','🦊','🦁','🦄','🐯',
|
||||
'🚗','🚀','✈️','🚲','🏝️','🏰'
|
||||
];
|
||||
|
||||
const DIFFICULTIES = {
|
||||
facil: { pairs: 6 },
|
||||
normal: { pairs: 8 },
|
||||
dificil: { pairs: 12 },
|
||||
extremo: { pairs: 18 }
|
||||
};
|
||||
|
||||
// Estado del juego
|
||||
let deck = []; // array de símbolos duplicados y mezclados
|
||||
let firstCard = null;
|
||||
let secondCard = null;
|
||||
let lockBoard = false;
|
||||
let matches = 0;
|
||||
let moves = 0;
|
||||
let totalPairs = DIFFICULTIES.normal.pairs;
|
||||
|
||||
let timerId = null;
|
||||
let startTime = 0;
|
||||
|
||||
// DOM
|
||||
const gameBoard = document.getElementById('game-board');
|
||||
const infoDiv = document.getElementById('info');
|
||||
const resetBtn = document.getElementById('reset-btn');
|
||||
const difficultySelect = document.getElementById('difficulty');
|
||||
const movesSpan = document.getElementById('moves');
|
||||
const timeSpan = document.getElementById('time');
|
||||
const bestSpan = document.getElementById('best-score');
|
||||
|
||||
// Utils
|
||||
function shuffle(array) {
|
||||
// Fisher-Yates shuffle
|
||||
for(let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i+1));
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
}
|
||||
function pickSymbols(n) {
|
||||
const pool = SYMBOLS.slice();
|
||||
shuffle(pool);
|
||||
return pool.slice(0, n);
|
||||
}
|
||||
function formatTime(seconds) {
|
||||
const s = Math.max(0, Math.floor(seconds));
|
||||
return `${s}s`;
|
||||
}
|
||||
function bestKey(diff) {
|
||||
return `memory_best_time_${diff}`;
|
||||
}
|
||||
function getBest(diff) {
|
||||
const v = localStorage.getItem(bestKey(diff));
|
||||
return v ? parseInt(v, 10) : null;
|
||||
}
|
||||
function setBestIfBetter(diff, timeSec) {
|
||||
const prev = getBest(diff);
|
||||
if (prev === null || timeSec < prev) {
|
||||
localStorage.setItem(bestKey(diff), String(timeSec));
|
||||
}
|
||||
const best = getBest(diff);
|
||||
if (bestSpan) bestSpan.textContent = best !== null ? `${best}s` : '—';
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
stopTimer();
|
||||
startTime = Date.now();
|
||||
timerId = setInterval(() => {
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
if (timeSpan) timeSpan.textContent = formatTime(elapsed);
|
||||
}, 250);
|
||||
}
|
||||
function stopTimer() {
|
||||
if (timerId) {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function gridColumnsFor(totalCards) {
|
||||
// Aproxima columnas en función del total para formar rejilla compacta
|
||||
if (totalCards <= 12) return 4;
|
||||
if (totalCards <= 16) return 4;
|
||||
if (totalCards <= 24) return 6;
|
||||
if (totalCards <= 30) return 6;
|
||||
return 6;
|
||||
}
|
||||
|
||||
function setupBoard() {
|
||||
// Leer dificultad
|
||||
const diff = (difficultySelect && difficultySelect.value) || 'normal';
|
||||
const conf = DIFFICULTIES[diff] || DIFFICULTIES.normal;
|
||||
totalPairs = conf.pairs;
|
||||
|
||||
// Reiniciar estado
|
||||
matches = 0;
|
||||
moves = 0;
|
||||
firstCard = null;
|
||||
secondCard = null;
|
||||
lockBoard = false;
|
||||
infoDiv.textContent = '';
|
||||
// Duplica los símbolos y los mezcla
|
||||
cards = [...symbols, ...symbols];
|
||||
shuffle(cards);
|
||||
gameBoard.innerHTML = '';
|
||||
cards.forEach((symbol, idx) => {
|
||||
const cardEl = document.createElement('div');
|
||||
if (movesSpan) movesSpan.textContent = String(moves);
|
||||
if (infoDiv) infoDiv.textContent = '';
|
||||
|
||||
// Construir mazo
|
||||
const chosen = pickSymbols(totalPairs);
|
||||
deck = [...chosen, ...chosen];
|
||||
shuffle(deck);
|
||||
|
||||
// Render tablero accesible
|
||||
gameBoard.textContent = '';
|
||||
const totalCards = deck.length;
|
||||
const cols = gridColumnsFor(totalCards);
|
||||
gameBoard.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
|
||||
|
||||
deck.forEach((symbol, idx) => {
|
||||
const cardEl = document.createElement('button');
|
||||
cardEl.type = 'button';
|
||||
cardEl.className = 'card';
|
||||
cardEl.dataset.index = idx;
|
||||
cardEl.dataset.index = String(idx);
|
||||
cardEl.dataset.symbol = symbol;
|
||||
cardEl.textContent = '';
|
||||
cardEl.onclick = () => flipCard(cardEl);
|
||||
cardEl.setAttribute('role', 'gridcell');
|
||||
cardEl.setAttribute('aria-label', 'Carta de memoria');
|
||||
cardEl.setAttribute('aria-disabled', 'false');
|
||||
|
||||
cardEl.addEventListener('click', () => flipCard(cardEl));
|
||||
cardEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
flipCard(cardEl);
|
||||
}
|
||||
});
|
||||
|
||||
gameBoard.appendChild(cardEl);
|
||||
});
|
||||
|
||||
// Mostrar mejor tiempo
|
||||
const best = getBest(diff);
|
||||
if (bestSpan) bestSpan.textContent = best !== null ? `${best}s` : '—';
|
||||
|
||||
// Arrancar temporizador
|
||||
startTimer();
|
||||
|
||||
// Foco inicial
|
||||
const first = gameBoard.querySelector('.card');
|
||||
if (first) first.focus();
|
||||
}
|
||||
|
||||
function flipCard(cardEl) {
|
||||
if (lockBoard) return;
|
||||
if (cardEl.classList.contains('flipped') || cardEl.classList.contains('matched')) return;
|
||||
|
||||
// Voltear carta
|
||||
cardEl.classList.add('flipped');
|
||||
cardEl.textContent = cardEl.dataset.symbol;
|
||||
cardEl.setAttribute('aria-disabled', 'true');
|
||||
|
||||
if (!firstCard) {
|
||||
firstCard = cardEl;
|
||||
} else if(!secondCard && cardEl !== firstCard) {
|
||||
return;
|
||||
}
|
||||
if (!secondCard && cardEl !== firstCard) {
|
||||
secondCard = cardEl;
|
||||
lockBoard = true;
|
||||
moves++;
|
||||
if (movesSpan) movesSpan.textContent = String(moves);
|
||||
|
||||
if (firstCard.dataset.symbol === secondCard.dataset.symbol) {
|
||||
// ¡Es pareja!
|
||||
// Pareja encontrada
|
||||
firstCard.classList.add('matched');
|
||||
secondCard.classList.add('matched');
|
||||
matches++;
|
||||
resetFlipped(700);
|
||||
if (matches === symbols.length) {
|
||||
infoDiv.textContent = '¡Felicidades! Has encontrado todas las parejas 🎉';
|
||||
}
|
||||
} else {
|
||||
// No es pareja, voltea las cartas después de un momento
|
||||
setTimeout(() => {
|
||||
firstCard.classList.remove('flipped');
|
||||
secondCard.classList.remove('flipped');
|
||||
firstCard.textContent = '';
|
||||
secondCard.textContent = '';
|
||||
resetFlipped(0);
|
||||
}, 900);
|
||||
resetSelection();
|
||||
if (matches === totalPairs) {
|
||||
// Fin de partida
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
if (infoDiv) infoDiv.textContent = `¡Felicidades! 🎉 Parejas: ${totalPairs} · Movimientos: ${moves} · Tiempo: ${elapsed}s`;
|
||||
stopTimer();
|
||||
const diff = (difficultySelect && difficultySelect.value) || 'normal';
|
||||
setBestIfBetter(diff, elapsed);
|
||||
}
|
||||
}, 400);
|
||||
} else {
|
||||
// No es pareja, desvoltear tras un momento
|
||||
setTimeout(() => {
|
||||
unflip(firstCard);
|
||||
unflip(secondCard);
|
||||
resetSelection();
|
||||
}, 700);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetFlipped(delay) {
|
||||
setTimeout(() => {
|
||||
firstCard = null;
|
||||
secondCard = null;
|
||||
lockBoard = false;
|
||||
}, delay);
|
||||
function unflip(el) {
|
||||
if (!el) return;
|
||||
el.classList.remove('flipped');
|
||||
el.textContent = '';
|
||||
el.setAttribute('aria-disabled', 'false');
|
||||
}
|
||||
|
||||
resetBtn.onclick = setupBoard;
|
||||
function resetSelection() {
|
||||
firstCard = null;
|
||||
secondCard = null;
|
||||
lockBoard = false;
|
||||
}
|
||||
|
||||
// Listeners
|
||||
resetBtn.addEventListener('click', () => {
|
||||
stopTimer();
|
||||
setupBoard();
|
||||
});
|
||||
if (difficultySelect) {
|
||||
difficultySelect.addEventListener('change', () => {
|
||||
stopTimer();
|
||||
setupBoard();
|
||||
});
|
||||
}
|
||||
|
||||
// Init
|
||||
setupBoard();
|
@@ -143,4 +143,85 @@ h1 {
|
||||
.card { font-size: clamp(1rem, 20vw, 2rem); }
|
||||
}
|
||||
|
||||
/* ::::::::::::::::::::::::::: */
|
||||
/* ::::::::::::::::::::::::::: */
|
||||
/* Controles y HUD */
|
||||
#controls {
|
||||
margin: 1rem auto 0.5rem auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
#controls select#difficulty {
|
||||
font-size: 1em;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
#hud {
|
||||
font-size: 1rem;
|
||||
color: #364f6b;
|
||||
margin: 0.4rem 0 0.8rem 0;
|
||||
}
|
||||
|
||||
/* Accesibilidad: foco visible y estado deshabilitado */
|
||||
.card {
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
.card:focus-visible {
|
||||
box-shadow: 0 0 0 4px rgba(252, 81, 133, 0.35);
|
||||
}
|
||||
.card[aria-disabled="true"] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Ajustes de tablero para rejillas dinámicas desde JS */
|
||||
#game-board {
|
||||
/* JS establecerá grid-template-columns dinámicamente */
|
||||
}
|
||||
|
||||
/* Modo oscuro mejorado */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #0f1222;
|
||||
color: #eaeaf0;
|
||||
}
|
||||
h1, #info {
|
||||
color: #eaeaf0;
|
||||
}
|
||||
#hud {
|
||||
color: #a0a3b0;
|
||||
}
|
||||
#controls select#difficulty {
|
||||
background: #20233a;
|
||||
color: #eaeaf0;
|
||||
border-color: #2c3252;
|
||||
}
|
||||
#game-board {
|
||||
/* sin cambios, deja que JS decida columnas */
|
||||
}
|
||||
.card {
|
||||
background: #20233a;
|
||||
color: #eaeaf0;
|
||||
box-shadow: 0 2px 10px rgba(61, 90, 254, 0.12);
|
||||
}
|
||||
.card.flipped {
|
||||
background: #3d5afe;
|
||||
color: #fff;
|
||||
}
|
||||
.card.matched {
|
||||
background: #172441;
|
||||
color: #fff;
|
||||
opacity: 0.8;
|
||||
}
|
||||
#reset-btn {
|
||||
background: #3d5afe;
|
||||
}
|
||||
#reset-btn:hover {
|
||||
background: #0a2459;
|
||||
}
|
||||
.card:focus-visible {
|
||||
box-shadow: 0 0 0 4px rgba(61, 90, 254, 0.35);
|
||||
}
|
||||
}
|
@@ -3,20 +3,34 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Piedra, Papel o Tijera</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Piedra, Papel o Tijera</h1>
|
||||
<div id="scoreboard">
|
||||
<h1 id="title">Piedra, Papel o Tijera</h1>
|
||||
|
||||
<div id="controls">
|
||||
<label for="best-of">Partida:</label>
|
||||
<select id="best-of">
|
||||
<option value="1">A 1 ronda</option>
|
||||
<option value="3" selected>Mejor de 3</option>
|
||||
<option value="5">Mejor de 5</option>
|
||||
<option value="7">Mejor de 7</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="scoreboard" aria-live="polite">
|
||||
Tú: <span id="user-score">0</span> |
|
||||
Máquina: <span id="computer-score">0</span>
|
||||
Máquina: <span id="computer-score">0</span> · Ronda: <span id="round">1</span>/<span id="rounds-total">3</span>
|
||||
</div>
|
||||
<div id="choices">
|
||||
<button data-choice="piedra">🪨 Piedra</button>
|
||||
<button data-choice="papel">📄 Papel</button>
|
||||
<button data-choice="tijera">✂️ Tijera</button>
|
||||
|
||||
<div id="choices" role="group" aria-labelledby="title">
|
||||
<button data-choice="piedra" aria-label="Piedra">🪨 Piedra</button>
|
||||
<button data-choice="papel" aria-label="Papel">📄 Papel</button>
|
||||
<button data-choice="tijera" aria-label="Tijera">✂️ Tijera</button>
|
||||
</div>
|
||||
<div id="result"></div>
|
||||
|
||||
<div id="result" aria-live="polite"></div>
|
||||
<button id="reset-btn">Reiniciar</button>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
|
@@ -1,62 +1,151 @@
|
||||
'use strict';
|
||||
|
||||
const userScoreSpan = document.getElementById('user-score');
|
||||
const computerScoreSpan = document.getElementById('computer-score');
|
||||
const resultDiv = document.getElementById('result');
|
||||
const choiceButtons = document.querySelectorAll('#choices button');
|
||||
const resetBtn = document.getElementById('reset-btn');
|
||||
|
||||
let userScore = 0;
|
||||
let computerScore = 0;
|
||||
const bestSelect = document.getElementById('best-of');
|
||||
const roundSpan = document.getElementById('round');
|
||||
const roundsTotalSpan = document.getElementById('rounds-total');
|
||||
|
||||
const choices = ['piedra', 'papel', 'tijera'];
|
||||
|
||||
let userScore = 0;
|
||||
let computerScore = 0;
|
||||
let roundNumber = 1;
|
||||
let bestOf = 3;
|
||||
let targetWins = 2;
|
||||
let gameOver = false;
|
||||
|
||||
function computerPlay() {
|
||||
const idx = Math.floor(Math.random() * 3);
|
||||
const idx = Math.floor(Math.random() * choices.length);
|
||||
return choices[idx];
|
||||
}
|
||||
|
||||
function playRound(userChoice) {
|
||||
const computerChoice = computerPlay();
|
||||
|
||||
let resultMsg = `Tu elección: ${emoji(userChoice)} ${capitalize(userChoice)}<br>
|
||||
Máquina: ${emoji(computerChoice)} ${capitalize(computerChoice)}<br>`;
|
||||
|
||||
if (userChoice === computerChoice) {
|
||||
resultMsg += "<strong>¡Empate!</strong>";
|
||||
} else if (
|
||||
(userChoice === 'piedra' && computerChoice === 'tijera') ||
|
||||
(userChoice === 'papel' && computerChoice === 'piedra') ||
|
||||
(userChoice === 'tijera' && computerChoice === 'papel')
|
||||
) {
|
||||
userScore++;
|
||||
userScoreSpan.textContent = userScore;
|
||||
resultMsg += "<strong>¡Ganaste esta ronda! 🎉</strong>";
|
||||
} else {
|
||||
computerScore++;
|
||||
computerScoreSpan.textContent = computerScore;
|
||||
resultMsg += "<strong>La máquina gana esta ronda.</strong>";
|
||||
}
|
||||
|
||||
resultDiv.innerHTML = resultMsg;
|
||||
}
|
||||
|
||||
function emoji(choice) {
|
||||
if (choice === 'piedra') return '🪨';
|
||||
if (choice === 'papel') return '📄';
|
||||
if (choice === 'tijera') return '✂️';
|
||||
return '';
|
||||
}
|
||||
|
||||
function capitalize(word) {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}
|
||||
|
||||
choiceButtons.forEach(btn => {
|
||||
btn.onclick = () => playRound(btn.getAttribute('data-choice'));
|
||||
});
|
||||
function updateHUD() {
|
||||
if (userScoreSpan) userScoreSpan.textContent = String(userScore);
|
||||
if (computerScoreSpan) computerScoreSpan.textContent = String(computerScore);
|
||||
if (roundSpan) roundSpan.textContent = String(roundNumber);
|
||||
if (roundsTotalSpan) roundsTotalSpan.textContent = String(bestOf);
|
||||
}
|
||||
|
||||
resetBtn.onclick = () => {
|
||||
function setBestOf() {
|
||||
const v = bestSelect && bestSelect.value ? parseInt(bestSelect.value, 10) : 3;
|
||||
bestOf = Number.isInteger(v) ? Math.max(1, Math.min(v, 9)) : 3;
|
||||
targetWins = Math.floor(bestOf / 2) + 1;
|
||||
roundsTotalSpan.textContent = String(bestOf);
|
||||
resetGame(); // reinicia con la nueva configuración
|
||||
}
|
||||
|
||||
function endMatchIfNeeded() {
|
||||
// Fin anticipado si alguien alcanza las victorias necesarias
|
||||
if (userScore >= targetWins) {
|
||||
gameOver = true;
|
||||
resultDiv.textContent = `Has ganado la partida ${userScore}-${computerScore} (mejor de ${bestOf}). 🎉`;
|
||||
return true;
|
||||
}
|
||||
if (computerScore >= targetWins) {
|
||||
gameOver = true;
|
||||
resultDiv.textContent = `La máquina gana la partida ${computerScore}-${userScore} (mejor de ${bestOf}). 🤖`;
|
||||
return true;
|
||||
}
|
||||
// Fin por alcanzar el número máximo de rondas
|
||||
if (roundNumber > bestOf) {
|
||||
gameOver = true;
|
||||
if (userScore > computerScore) {
|
||||
resultDiv.textContent = `Has ganado la partida ${userScore}-${computerScore} (mejor de ${bestOf}). 🎉`;
|
||||
} else if (computerScore > userScore) {
|
||||
resultDiv.textContent = `La máquina gana la partida ${computerScore}-${userScore} (mejor de ${bestOf}). 🤖`;
|
||||
} else {
|
||||
resultDiv.textContent = `Empate global ${userScore}-${computerScore} (mejor de ${bestOf}).`;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function playRound(userChoice) {
|
||||
if (gameOver) return;
|
||||
|
||||
const computerChoice = computerPlay();
|
||||
|
||||
let msg = `Tu elección: ${emoji(userChoice)} ${capitalize(userChoice)}\n` +
|
||||
`Máquina: ${emoji(computerChoice)} ${capitalize(computerChoice)}\n`;
|
||||
|
||||
if (userChoice === computerChoice) {
|
||||
msg += 'Resultado: ¡Empate!';
|
||||
// Empate cuenta como ronda consumida
|
||||
roundNumber += 1;
|
||||
} else if (
|
||||
(userChoice === 'piedra' && computerChoice === 'tijera') ||
|
||||
(userChoice === 'papel' && computerChoice === 'piedra') ||
|
||||
(userChoice === 'tijera' && computerChoice === 'papel')
|
||||
) {
|
||||
userScore += 1;
|
||||
msg += 'Resultado: ¡Ganaste esta ronda! 🎉';
|
||||
// Avanza ronda tras cada ronda jugada
|
||||
roundNumber += 1;
|
||||
} else {
|
||||
computerScore += 1;
|
||||
msg += 'Resultado: La máquina gana esta ronda.';
|
||||
roundNumber += 1;
|
||||
}
|
||||
|
||||
// Mostrar resultado de la ronda (usamos textContent para evitar HTML)
|
||||
resultDiv.textContent = msg;
|
||||
|
||||
updateHUD();
|
||||
|
||||
// Comprobar fin anticipado o por límite de rondas
|
||||
if (endMatchIfNeeded()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function handleChoice(choice) {
|
||||
playRound(choice);
|
||||
}
|
||||
|
||||
function resetGame() {
|
||||
userScore = 0;
|
||||
computerScore = 0;
|
||||
userScoreSpan.textContent = userScore;
|
||||
computerScoreSpan.textContent = computerScore;
|
||||
roundNumber = 1;
|
||||
gameOver = false;
|
||||
resultDiv.textContent = '';
|
||||
};
|
||||
updateHUD();
|
||||
}
|
||||
|
||||
// Listeners (evitar handlers inline)
|
||||
choiceButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', () => handleChoice(btn.getAttribute('data-choice')));
|
||||
// Accesibilidad extra: Enter y Espacio activan el botón
|
||||
btn.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleChoice(btn.getAttribute('data-choice'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
resetBtn.addEventListener('click', resetGame);
|
||||
|
||||
if (bestSelect) {
|
||||
bestSelect.addEventListener('change', setBestOf);
|
||||
}
|
||||
|
||||
// Init
|
||||
setBestOf();
|
||||
updateHUD();
|
@@ -3,13 +3,25 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Pong Game</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Pong Clásico</h1>
|
||||
<h1 id="title">Pong Clásico</h1>
|
||||
<p>Usa las flechas arriba/abajo para mover tu pala. ¡No dejes que la bola pase!</p>
|
||||
<canvas id="pong" width="600" height="400"></canvas>
|
||||
<div id="score"></div>
|
||||
|
||||
<div id="controls">
|
||||
<label for="difficulty">Dificultad:</label>
|
||||
<select id="difficulty">
|
||||
<option value="facil">Fácil</option>
|
||||
<option value="normal" selected>Normal</option>
|
||||
<option value="dificil">Difícil</option>
|
||||
</select>
|
||||
<button id="pause-btn">Pausar</button>
|
||||
</div>
|
||||
|
||||
<canvas id="pong" width="600" height="400" aria-label="Tablero Pong"></canvas>
|
||||
<div id="score" aria-live="polite"></div>
|
||||
<button id="restart-btn">Reiniciar</button>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
|
387
pong/script.js
387
pong/script.js
@@ -1,125 +1,332 @@
|
||||
'use strict';
|
||||
|
||||
const canvas = document.getElementById('pong');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const scoreDiv = document.getElementById('score');
|
||||
const restartBtn = document.getElementById('restart-btn');
|
||||
const difficultySelect = document.getElementById('difficulty');
|
||||
const pauseBtn = document.getElementById('pause-btn');
|
||||
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const paddleHeight = 80, paddleWidth = 14;
|
||||
let playerY = h/2 - paddleHeight/2, aiY = h/2 - paddleHeight/2;
|
||||
|
||||
// Configuraciones por dificultad
|
||||
const DIFFICULTIES = {
|
||||
facil: { ballSpeed: 5.0, aiMaxSpeed: 4.0, paddleHeight: 95 },
|
||||
normal: { ballSpeed: 6.5, aiMaxSpeed: 5.5, paddleHeight: 80 },
|
||||
dificil: { ballSpeed: 8.0, aiMaxSpeed: 7.0, paddleHeight: 70 }
|
||||
};
|
||||
|
||||
// Estado
|
||||
let paddleHeight = DIFFICULTIES.normal.paddleHeight;
|
||||
const paddleWidth = 14;
|
||||
let playerY = h / 2 - paddleHeight / 2;
|
||||
let aiY = h / 2 - paddleHeight / 2;
|
||||
|
||||
let playerScore = 0, aiScore = 0;
|
||||
|
||||
let ball = {
|
||||
x: w/2, y: h/2,
|
||||
size: 12,
|
||||
speed: 5,
|
||||
velX: 5,
|
||||
velY: -4
|
||||
x: w / 2,
|
||||
y: h / 2,
|
||||
r: 12,
|
||||
baseSpeed: DIFFICULTIES.normal.ballSpeed,
|
||||
velX: DIFFICULTIES.normal.ballSpeed,
|
||||
velY: -4.0
|
||||
};
|
||||
|
||||
let up = false, down = false;
|
||||
let running = true;
|
||||
let rafId = null;
|
||||
let aiMaxSpeed = DIFFICULTIES.normal.aiMaxSpeed;
|
||||
|
||||
// Dibujo de todo (juego, paletas, bola, marcador)
|
||||
function draw() {
|
||||
// Utilidades
|
||||
function clamp(v, min, max) {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
function setDifficulty() {
|
||||
const diff = difficultySelect && difficultySelect.value ? difficultySelect.value : 'normal';
|
||||
const conf = DIFFICULTIES[diff] || DIFFICULTIES.normal;
|
||||
|
||||
paddleHeight = conf.paddleHeight;
|
||||
aiMaxSpeed = conf.aiMaxSpeed;
|
||||
ball.baseSpeed = conf.ballSpeed;
|
||||
|
||||
// Reposicionar palas con nueva altura
|
||||
playerY = clamp(playerY, 0, h - paddleHeight);
|
||||
aiY = clamp(aiY, 0, h - paddleHeight);
|
||||
|
||||
// Reiniciar bola con la velocidad base de la dificultad
|
||||
resetBall(true);
|
||||
updateScoreHUD();
|
||||
}
|
||||
|
||||
function updateScoreHUD() {
|
||||
scoreDiv.textContent = `Tú: ${playerScore} | Máquina: ${aiScore}`;
|
||||
}
|
||||
|
||||
function drawNet() {
|
||||
ctx.fillStyle = '#f2e9e4';
|
||||
for (let i = 15; i < h; i += 30) {
|
||||
ctx.fillRect(w / 2 - 2, i, 4, 14);
|
||||
}
|
||||
}
|
||||
|
||||
function drawScene() {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = "#f2e9e4";
|
||||
// Jugador (izq)
|
||||
|
||||
// Palas
|
||||
ctx.fillStyle = '#f2e9e4';
|
||||
ctx.fillRect(16, playerY, paddleWidth, paddleHeight);
|
||||
// AI (der)
|
||||
ctx.fillRect(w-16-paddleWidth, aiY, paddleWidth, paddleHeight);
|
||||
ctx.fillRect(w - 16 - paddleWidth, aiY, paddleWidth, paddleHeight);
|
||||
|
||||
// Bola
|
||||
ctx.beginPath();
|
||||
ctx.arc(ball.x, ball.y, ball.size, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = "#f6c90e";
|
||||
ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#f6c90e';
|
||||
ctx.fill();
|
||||
|
||||
// Red central
|
||||
for(let i=15;i<h;i+=30){
|
||||
ctx.fillRect(w/2-2,i,4,14);
|
||||
drawNet();
|
||||
|
||||
// HUD
|
||||
updateScoreHUD();
|
||||
}
|
||||
|
||||
function pauseGame() {
|
||||
if (!running) return;
|
||||
running = false;
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
if (pauseBtn) pauseBtn.textContent = 'Reanudar';
|
||||
}
|
||||
|
||||
function resumeGame() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
if (pauseBtn) pauseBtn.textContent = 'Pausar';
|
||||
rafId = requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
if (running) pauseGame();
|
||||
else resumeGame();
|
||||
}
|
||||
|
||||
function resetBall(hardReset = false) {
|
||||
ball.x = w / 2;
|
||||
ball.y = h / 2;
|
||||
const speed = ball.baseSpeed;
|
||||
ball.velX = (Math.random() > 0.5 ? speed : -speed);
|
||||
ball.velY = (Math.random() - 0.5) * speed * 1.2;
|
||||
|
||||
if (!hardReset) {
|
||||
pauseGame();
|
||||
setTimeout(() => resumeGame(), 700);
|
||||
}
|
||||
// Score
|
||||
scoreDiv.innerHTML = `<b>Tú:</b> ${playerScore} | <b>Máquina:</b> ${aiScore}`;
|
||||
}
|
||||
|
||||
function handlePlayerMovement() {
|
||||
const playerSpeed = 7.0;
|
||||
if (up) playerY -= playerSpeed;
|
||||
if (down) playerY += playerSpeed;
|
||||
playerY = clamp(playerY, 0, h - paddleHeight);
|
||||
}
|
||||
|
||||
function predictBallYAtX(targetX) {
|
||||
// Predicción simple: extrapola y con rebotes verticales
|
||||
let px = ball.x;
|
||||
let py = ball.y;
|
||||
let vx = ball.velX;
|
||||
let vy = ball.velY;
|
||||
const r = ball.r;
|
||||
const maxSteps = 1000;
|
||||
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
// Avanza
|
||||
px += vx;
|
||||
py += vy;
|
||||
|
||||
// Rebote arriba/abajo
|
||||
if (py - r < 0) {
|
||||
py = r;
|
||||
vy = -vy;
|
||||
} else if (py + r > h) {
|
||||
py = h - r;
|
||||
vy = -vy;
|
||||
}
|
||||
|
||||
// Si hemos cruzado el targetX
|
||||
if ((vx > 0 && px + r >= targetX) || (vx < 0 && px - r <= targetX)) {
|
||||
return py;
|
||||
}
|
||||
|
||||
// Seguridad: si vx se hace cero (no debería), rompe
|
||||
if (Math.abs(vx) < 0.0001) break;
|
||||
}
|
||||
|
||||
return py;
|
||||
}
|
||||
|
||||
function handleAIMovement() {
|
||||
// IA solo sigue cuando la bola va hacia ella, si no recentra lentamente
|
||||
const aiCenter = aiY + paddleHeight / 2;
|
||||
let targetY;
|
||||
|
||||
if (ball.velX > 0) {
|
||||
// Objetivo es el centro de la pala cuando la bola llegue al borde derecho
|
||||
const targetX = w - 16 - paddleWidth - ball.r;
|
||||
targetY = predictBallYAtX(targetX);
|
||||
} else {
|
||||
// Bola alejándose: recentrar
|
||||
targetY = h / 2;
|
||||
}
|
||||
|
||||
const desired = targetY - aiCenter;
|
||||
const step = clamp(desired, -aiMaxSpeed, aiMaxSpeed);
|
||||
aiY += step;
|
||||
aiY = clamp(aiY, 0, h - paddleHeight);
|
||||
}
|
||||
|
||||
function collideBallWithWalls() {
|
||||
// Arriba/abajo
|
||||
if (ball.y - ball.r < 0) {
|
||||
ball.y = ball.r;
|
||||
ball.velY *= -1;
|
||||
} else if (ball.y + ball.r > h) {
|
||||
ball.y = h - ball.r;
|
||||
ball.velY *= -1;
|
||||
}
|
||||
}
|
||||
|
||||
function collideBallWithPaddle(px, py, pw, ph, reverseXSign) {
|
||||
// Chequeo de colisión círculo-rectángulo
|
||||
const nearestX = clamp(ball.x, px, px + pw);
|
||||
const nearestY = clamp(ball.y, py, py + ph);
|
||||
const dx = ball.x - nearestX;
|
||||
const dy = ball.y - nearestY;
|
||||
const dist2 = dx * dx + dy * dy;
|
||||
|
||||
if (dist2 <= ball.r * ball.r) {
|
||||
// Rebote con ángulo según punto de impacto relativo
|
||||
const hitPos = ((nearestY - py) - ph / 2) / (ph / 2); // -1..1 con referencia al centro
|
||||
const maxAngle = Math.PI / 3; // 60 grados
|
||||
const speed = Math.max(Math.abs(ball.velX), Math.abs(ball.velY), ball.baseSpeed);
|
||||
|
||||
const angle = hitPos * maxAngle;
|
||||
const newVX = speed * Math.cos(angle);
|
||||
const newVY = speed * Math.sin(angle);
|
||||
|
||||
ball.velX = reverseXSign ? -Math.abs(newVX) : Math.abs(newVX);
|
||||
ball.velY = newVY;
|
||||
|
||||
// Pequeño empujón para salir del rect
|
||||
ball.x += ball.velX * 0.2;
|
||||
ball.y += ball.velY * 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
function checkGoals() {
|
||||
// Gol IA (bola sale por izquierda)
|
||||
if (ball.x + ball.r < 0) {
|
||||
aiScore++;
|
||||
resetBall();
|
||||
return true;
|
||||
}
|
||||
// Gol jugador (bola sale por derecha)
|
||||
if (ball.x - ball.r > w) {
|
||||
playerScore++;
|
||||
resetBall();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function stepBall() {
|
||||
ball.x += ball.velX;
|
||||
ball.y += ball.velY;
|
||||
}
|
||||
|
||||
function gameLoop() {
|
||||
if (!running) return;
|
||||
|
||||
// Jugador
|
||||
if (up) playerY -= 7;
|
||||
if (down) playerY += 7;
|
||||
playerY = Math.max(0, Math.min(h-paddleHeight, playerY));
|
||||
// AI: sigue la bola
|
||||
if (aiY + paddleHeight/2 < ball.y - 12) aiY += 5;
|
||||
else if (aiY + paddleHeight/2 > ball.y + 12) aiY -= 5;
|
||||
aiY = Math.max(0, Math.min(h-paddleHeight, aiY));
|
||||
handlePlayerMovement();
|
||||
handleAIMovement();
|
||||
|
||||
// Bola
|
||||
ball.x += ball.velX;
|
||||
ball.y += ball.velY;
|
||||
stepBall();
|
||||
collideBallWithWalls();
|
||||
|
||||
// Colisión con pared arriba/abajo
|
||||
if (ball.y - ball.size < 0 || ball.y + ball.size > h) ball.velY *= -1;
|
||||
// Colisiones con palas
|
||||
// Jugador (izquierda)
|
||||
collideBallWithPaddle(16, playerY, paddleWidth, paddleHeight, false);
|
||||
// IA (derecha)
|
||||
collideBallWithPaddle(w - 16 - paddleWidth, aiY, paddleWidth, paddleHeight, true);
|
||||
|
||||
// Colisión con paleta jugador
|
||||
if (
|
||||
ball.x - ball.size < 16 + paddleWidth &&
|
||||
ball.y > playerY && ball.y < playerY + paddleHeight
|
||||
) {
|
||||
ball.velX = Math.abs(ball.velX);
|
||||
ball.velY += (Math.random() - 0.5) * 2.5;
|
||||
if (checkGoals()) {
|
||||
drawScene();
|
||||
return;
|
||||
}
|
||||
|
||||
// Colisión con paleta AI
|
||||
if (
|
||||
ball.x + ball.size > w-16-paddleWidth &&
|
||||
ball.y > aiY && ball.y < aiY + paddleHeight
|
||||
) {
|
||||
ball.velX = -Math.abs(ball.velX);
|
||||
ball.velY += (Math.random() - 0.5) * 2.5;
|
||||
}
|
||||
|
||||
// Gol jugador
|
||||
if (ball.x - ball.size < 0) {
|
||||
aiScore++;
|
||||
resetBall();
|
||||
}
|
||||
// Gol máquina
|
||||
if (ball.x + ball.size > w) {
|
||||
playerScore++;
|
||||
resetBall();
|
||||
}
|
||||
|
||||
draw();
|
||||
requestAnimationFrame(gameLoop);
|
||||
drawScene();
|
||||
rafId = requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
function resetBall() {
|
||||
ball.x = w/2; ball.y = h/2;
|
||||
ball.velX = (Math.random() > 0.5 ? 5 : -5);
|
||||
ball.velY = (Math.random() - 0.5) * 7;
|
||||
running = false;
|
||||
setTimeout(() => {
|
||||
running = true;
|
||||
requestAnimationFrame(gameLoop);
|
||||
}, 900);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'ArrowUp') up = true;
|
||||
if (e.key === 'ArrowDown') down = true;
|
||||
});
|
||||
document.addEventListener('keyup', e => {
|
||||
if (e.key === 'ArrowUp') up = false;
|
||||
if (e.key === 'ArrowDown') down = false;
|
||||
});
|
||||
|
||||
restartBtn.onclick = () => {
|
||||
playerScore = aiScore = 0;
|
||||
playerY = h/2 - paddleHeight/2;
|
||||
aiY = h/2 - paddleHeight/2;
|
||||
resetBall();
|
||||
function resetGame() {
|
||||
playerScore = 0;
|
||||
aiScore = 0;
|
||||
playerY = h / 2 - paddleHeight / 2;
|
||||
aiY = h / 2 - paddleHeight / 2;
|
||||
resetBall(true);
|
||||
running = true;
|
||||
requestAnimationFrame(gameLoop);
|
||||
};
|
||||
updateScoreHUD();
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
draw();
|
||||
gameLoop();
|
||||
function attachEvents() {
|
||||
// Teclado (evita scroll con flechas)
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
up = true;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
down = true;
|
||||
}
|
||||
});
|
||||
window.addEventListener('keyup', (e) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
up = false;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
down = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Pausa
|
||||
if (pauseBtn) {
|
||||
pauseBtn.addEventListener('click', togglePause);
|
||||
}
|
||||
|
||||
// Dificultad
|
||||
if (difficultySelect) {
|
||||
difficultySelect.addEventListener('change', () => {
|
||||
setDifficulty();
|
||||
});
|
||||
}
|
||||
|
||||
// Reinicio
|
||||
restartBtn.addEventListener('click', resetGame);
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
attachEvents();
|
||||
setDifficulty();
|
||||
drawScene();
|
||||
rafId = requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// Init
|
||||
startGame();
|
@@ -33,4 +33,53 @@ h1 {
|
||||
}
|
||||
#restart-btn:hover {
|
||||
background: #232946;
|
||||
}
|
||||
/* Controles y accesibilidad */
|
||||
#controls {
|
||||
margin: 0.8rem auto 0.4rem auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
#controls select#difficulty {
|
||||
font-size: 1em;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
background: #232946;
|
||||
color: #f2e9e4;
|
||||
}
|
||||
#pause-btn {
|
||||
font-size: 1em;
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #4a4e69;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
#pause-btn:hover {
|
||||
background: #232946;
|
||||
}
|
||||
|
||||
/* Foco accesible */
|
||||
#pause-btn:focus-visible,
|
||||
#restart-btn:focus-visible,
|
||||
#controls select#difficulty:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 4px rgba(246, 201, 14, 0.35);
|
||||
}
|
||||
|
||||
/* Estado deshabilitado para botones */
|
||||
#pause-btn:disabled,
|
||||
#restart-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Ajuste del marcador */
|
||||
#score {
|
||||
font-weight: 600;
|
||||
}
|
Reference in New Issue
Block a user