Botones
Este es el paso 10 de los 11 del tutorial para crear un juego Breakout con JavaScript puro. En lugar de empezar el juego de inmediato, podemos dejar esa decisión al jugador añadiendo un botón "Start" que pueda pulsar. Veamos cómo hacerlo.
Nuevas variables
Necesitaremos una variable booleana que indique si el juego ha empezado y un AbortController para quitar los detectores de eventos del botón cuando empiece. Añade estas líneas debajo de tus otras variables de nivel superior:
let playing = false;
const buttonControls = new AbortController();
Añadir el botón al juego
Podemos cargar la hoja de sprites del botón igual que cargamos la animación de tambaleo de la pelota. Añade una clase Button después de las demás clases de objetos del juego:
class Button extends GameObject {
size = { w: 120, h: 40 };
frame = 0;
constructor(url, ctx) {
super(url, ctx);
this.pos = { x: ctx.canvas.width / 2, y: ctx.canvas.height / 2 };
}
}
El botón está centrado en el canvas. Su propiedad frame selecciona la imagen normal (0), al pasar el puntero por encima (1) o pulsada (2).
El método draw() calcula la columna y la fila del fotograma en la hoja de sprites y dibuja solo ese fotograma, como hicimos con la pelota.
class Button extends GameObject {
// …
draw() {
const columns = Math.floor(this.asset.width / this.size.w);
const { left, top } = this.hitbox;
this.ctx.drawImage(
this.asset,
(this.frame % columns) * this.size.w,
Math.floor(this.frame / columns) * this.size.h,
this.size.w,
this.size.h,
left,
top,
this.size.w,
this.size.h,
);
}
}
Crea el botón debajo de las definiciones de la pelota, la paleta y los ladrillos:
const startButton = new Button("img/button.png", ctx);
También tienes que descargar la hoja de sprites del botón y guardarla en tu directorio /img.
En update(), añade lo siguiente después de drawStatus() para mostrar el botón hasta que empiece el juego:
if (!playing) {
startButton.draw();
}
Gestionar la entrada del botón
Gestionar la entrada del botón es un poco engorroso, porque trabajamos con un canvas en lugar de con elementos HTML nativos. Tenemos que implementar a mano la comprobación del área de impacto, la animación y la gestión de eventos.
El método containsPointer() convierte las coordenadas del puntero en coordenadas del canvas y después comprueba si están dentro del área de impacto:
class Button extends GameObject {
// …
containsPointer(event) {
const bounds = this.ctx.canvas.getBoundingClientRect();
const x =
((event.clientX - bounds.left) * this.ctx.canvas.width) / bounds.width;
const y =
((event.clientY - bounds.top) * this.ctx.canvas.height) / bounds.height;
const { left, right, top, bottom } = this.hitbox;
return x >= left && x <= right && y >= top && y <= bottom;
}
}
Añade una función initButtonControls() al final del script, que registrará los detectores de eventos de puntero que controlan el botón. Todo lo que sigue en esta sección, salvo que se indique lo contrario, se añadirá al cuerpo de esta función.
function initButtonControls() {
// Añade el código aquí
}
Empieza definiendo options, que contiene la signal que se usa para quitar estos detectores una vez que empieza el juego. Registra también el ID del puntero para que solo el puntero que pulsó el botón pueda activarlo.
const options = { signal: buttonControls.signal };
let pressedPointer = null;
Salir del botón restablece el índice del fotograma (lo que devuelve el botón a su aspecto predeterminado). Soltar una pulsación fuera del botón la cancela. Estos gestos se pueden hacer de varias formas, así que añádelos como funciones reutilizables.
function resetFrame(event) {
if (pressedPointer === null || pressedPointer === event.pointerId) {
startButton.frame = 0;
}
}
function cancelPress(event) {
if (event.pointerId === pressedPointer) {
pressedPointer = null;
resetFrame(event);
}
}
A continuación, define el manejador de pointermove. Su función es establecer el índice del fotograma (es decir, cambiar el aspecto del botón) si el puntero está encima. Si ya hay un puntero pulsado, los demás punteros se ignoran y no cuentan como un paso por encima adicional.
canvas.addEventListener(
"pointermove",
(event) => {
if (pressedPointer !== null && pressedPointer !== event.pointerId) {
return;
}
if (startButton.containsPointer(event)) {
startButton.frame = pressedPointer === null ? 1 : 2;
} else {
resetFrame(event);
}
},
options,
);
A continuación, define el manejador de pointerdown. Su función también es establecer el índice del fotograma con el aspecto de pulsado, y además registrar pressedPointer. Esto solo ocurre si no hay ningún otro puntero pulsado en ese momento y, en el caso de un ratón o un dispositivo similar, si se trata de un clic izquierdo. La captura del puntero permite que el canvas siga recibiendo eventos de puntero aunque el puntero salga de sus límites.
canvas.addEventListener(
"pointerdown",
(event) => {
if (
event.button !== 0 ||
pressedPointer !== null ||
!startButton.containsPointer(event)
) {
return;
}
pressedPointer = event.pointerId;
startButton.frame = 2;
canvas.setPointerCapture(event.pointerId);
},
options,
);
A continuación, define el manejador de pointerup. Su función es iniciar realmente el juego, pero solo si el puntero se soltó mientras seguía encima del botón; de lo contrario, la pulsación se considera cancelada.
canvas.addEventListener(
"pointerup",
(event) => {
if (event.pointerId !== pressedPointer) {
return;
}
if (startButton.containsPointer(event)) {
pressedPointer = null;
startGame();
} else {
cancelPress(event);
}
},
options,
);
Por último, que el puntero salga del canvas, un gesto nativo de cancelación del puntero o la pérdida de la captura del puntero establecida por el detector de pointerdown deberían provocar el restablecimiento o la cancelación. Estos eventos no se filtran por el área de impacto, porque también tienen que limpiar las pulsaciones que salen del botón.
canvas.addEventListener("pointerleave", resetFrame, options);
canvas.addEventListener("pointercancel", cancelPress, options);
canvas.addEventListener("lostpointercapture", cancelPress, options);
Registramos estos detectores solo después de cargar los recursos. Reemplaza la llamada a preload existente por la siguiente, que también carga la imagen del botón:
Promise.all(
[ball, paddle, ...bricks, startButton].map((obj) => obj.preload()),
).then(() => {
ball.pos.x = paddle.pos.x;
ball.pos.y = paddle.hitbox.top - ball.size.h / 2;
initButtonControls();
requestAnimationFrame(update);
});
Empezar el juego
Ahora tenemos que definir la función startGame() a la que hace referencia el código anterior:
function startGame() {
buttonControls.abort();
ball.vel = { x: 150, y: -150 };
playing = true;
lastTimestamp = null;
}
Cuando se pulsa el botón, quitamos todos los detectores del botón, establecemos la velocidad inicial de la pelota y ponemos la propiedad playing en true. También restablecemos lastTimestamp para que la primera actualización de movimiento no incluya el tiempo anterior a soltar el botón.
Para terminar esta sección, vuelve a tu clase Ball, busca la línea vel = { x: 150, y: -150 } y reemplázala por vel = { x: 0, y: 0 }. ¡Solo quieres que la pelota se mueva cuando se pulse el botón, no antes!
Mantener la paleta quieta antes de que empiece el juego
Funciona como esperábamos, pero todavía podemos mover la paleta cuando el juego aún no ha empezado, y eso queda un poco raro. Para evitarlo, podemos aprovechar la propiedad playing y hacer que la paleta solo se pueda mover cuando el juego haya empezado. Para ello, añade !playing a la condición de guarda del detector de pointermove de la paleta que ya existe, así:
if (!playing || paddle.size.w === undefined) {
return;
}
Así, la paleta queda inmóvil después de que todo se haya cargado y preparado, pero antes de que empiece el juego de verdad.
Compara tu código
Esto es lo que deberías tener hasta ahora, funcionando en vivo. Para ver su código fuente, haz clic en el botón "Play".
Próximos pasos
Lo último que haremos en esta serie de artículos es hacer que la jugabilidad sea aún más interesante añadiendo algo de aleatoriedad a la forma en que la pelota rebota en la paleta.