363 lines
10 KiB
JavaScript
363 lines
10 KiB
JavaScript
'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');
|
|
|
|
// 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 = []; // 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++) {
|
|
bricks[c] = [];
|
|
for (let r = 0; r < brickRowCount; r++) {
|
|
bricks[c][r] = { x: 0, y: 0, status: 1 };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reinicio y arranque de partida
|
|
function startGame() {
|
|
// 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 = String(score);
|
|
bestScoreSpan.textContent = String(getBest(getCurrentDifficulty()));
|
|
gameOver = false;
|
|
gameOverDiv.textContent = '';
|
|
|
|
// Preparar ladrillos (precalcular posiciones para rendimiento)
|
|
setupBricks();
|
|
precomputeBrickPositions();
|
|
|
|
// Listeners
|
|
attachListeners();
|
|
|
|
// Animación
|
|
if (animId) cancelAnimationFrame(animId);
|
|
animId = requestAnimationFrame(draw);
|
|
}
|
|
|
|
// 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.fillStyle = "#f9d923";
|
|
ctx.fill();
|
|
ctx.closePath();
|
|
}
|
|
|
|
function drawPaddle() {
|
|
ctx.beginPath();
|
|
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 { x: bx, y: by } = bricks[c][r];
|
|
ctx.beginPath();
|
|
ctx.rect(bx, by, brickWidth, brickHeight);
|
|
ctx.fillStyle = "#393e46";
|
|
ctx.strokeStyle = "#f9d923";
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.closePath();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
|
|
// Actualizar pala por teclado
|
|
updatePaddleByKeyboard();
|
|
|
|
// Colisiones
|
|
const hitBrick = collisionWithBricks();
|
|
if (hitBrick) {
|
|
// Si hubo victoria, el bucle se detuvo
|
|
return;
|
|
}
|
|
const endOrWall = collisionWallsAndPaddle();
|
|
if (endOrWall) {
|
|
return;
|
|
}
|
|
|
|
// Avance de la bola
|
|
x += dx;
|
|
y += dy;
|
|
|
|
if (!gameOver) {
|
|
animId = requestAnimationFrame(draw);
|
|
}
|
|
}
|
|
|
|
// Inicio
|
|
startGame(); |