Mejoras y optimizaciones en general.
This commit is contained in:
@@ -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;
|
||||
}
|
Reference in New Issue
Block a user