Tutorial canvas 2D - Cómo hacer un juego en javascript 4ª parte
Created at: 2023-02-24
Bienvenidos al remake de la cuarta parte de cómo realizar un pequeño juego en JavaScript, más concretamente el tan viejo como popular Space Invaders. En el capítulo anterior nos centramos en darle animaciones a las entidades para que se movieran por la pantalla, lo que nos introdujo el concepto de tiempo delta y el bucle principal del juego. También aprendimos una forma de detectar y utilizar los eventos de teclado, por medio de notificaciones para mover al jugador. En este tutorial completaremos el juego. Nos quedan por hacer varias cosas: Permitir que el jugador pueda disparar, para lo que usaremos una nueva clase que represente a un disparo. Los disparos pueden destruir naves alienígenas, lo que requerirá ser capaces de detectar las colisiones entre estas entidades. Y por último, queremos introducir algún tipo de mecánica para gestionar el progreso del juego, como la puntación y el número de vidas restantes.

Aquí puedes correr la versión del juego que vamos a comentar en este artículo.
Aquí tienes el enlace al repositorio con todo el código de esta serie de tutoriales.
Detección de colisiones
Llegó la hora de acoplar un cañón a nuestra nave para empezar a liquidar extraterrestres. Un videojuego donde no puedes matar nada que se mueva, no es un videojuego de verdad (salvo que sea de fútbol). Este requisito viene acompañado de algunas complicaciones. ¿Cómo representamos cada disparo? ¿Dónde los guardamos? ¿Cómo sabemos si un disparo le atiza a algún alien? ¿Cómo le decimos a ese alien que fenezca? ¿Cómo sabemos cuándo un alien choca con el jugador y se lo carga? Bien, con la programación orientada a objetos y un par de ideas, podemos resolver estos problemas de forma más o menos organizada.
Algo fundamental para detectar la colisión entre dos entidades del juego es implementar algún algoritmo sencillo y rápido de ejecutar, ya que es una tarea que se va a estar ejecutando contínuamente. Hay un montón de literatura en inglés acerca de este controvertido tema, con análisis matemáticos que demuestran que uno es más preciso que otro, pero que el otro es más rápido, etc. Y como dije en el primer tutorial, la forma de representar de las entidades a base de polígonos es crucial para elegir un buen y eficiente método de colisión. Para un primer videojuego, yo he optado por el algoritmo más sencillo de todos: Detección de colisiones mediante rectángulos que no pueden rotar. Además voy a usar el mismo cuadrado que se usa para dibujar cada entidad, para detectar sus colisiones. Otros juegos más complicados en 2D, que usan imágenes, suelen tener dos estructuras de datos dentro de las clases que representan a las entidades, pero que se usarán para propósitos diferentes: Una de ellas, normalmente una imagen, será que lo que se pinte en el canvas. La otra se usará para las físicas, y a menudo consiste en una lista de rectángulos y/o círculos y/o triángulos cuyo tamaño y disposición se aproximan al contorno real de la figura dibujable.
Todo esto es muy bonito, ¿pero dónde programamos la detección de colisiones? Bueno, como la clase Game
es la que contiene una lista con todas las entidades, parece que es el lugar más factible para escribir un nuevo método que realice esos cálculos. Pues no. Bueno, sí, y no. En cada vuelta del bucle principal del juego, efectívamente debemos de introducir algún nuevo código que compruebe si están ocurriendo colisiones entre las entidades, pero como he repetido varias veces, todas las cosas relacionadas con el funcionamiento de una clase, deben ir dentro de esa clase. Y como todas las entidades deben comprobar si están colisionando, el lugar ideal es meter el nuevo método en la clase padre Entity
al contener la información requerida:
class Entity {
// ...
inCollisionWith(other) {
if (!this.isAlive || !other.isAlive) {
return false;
}
if (this.#x + this.#width < other.x) {
return false;
}
if (this.#y + this.#height < other.y) {
return false;
}
if (this.#x > other.x + other.width) {
return false;
}
if (this.#y > other.y + other.height) {
return false;
}
return true;
}
}

Como podemos ver, el algoritmo es tremendamente sencillo. Dos rectángulos no están en colisión si:
- Si el valor de la coordenada
x
en cualquiera de los vértices derechos de A es menor que el valorx
de cualquiera de los vértices izquierdos de B. - Si el valor de la coordenada
y
en cualquiera de los vértices inferiores de A es menor que el valory
de cualquiera de los vértices superiores de B. - Si el valor de la coordenada
x
en cualquiera de los vértices izquierdos de A es mayor que el valor dex
en cualquiera de los vértices derechos de B. - Si el valor de la coordenada
y
en cualquiera de los vértices superiores de A es mayor que el valor dey
en cualquiera de los vértices inferiores de B.
En cualquier otro caso, los rectángulos están en colisión.
Ahora bien. Si un alien choca con una bala, o con un jugador, ambas entidades deben desaparecer. ¿Cómo gestionamos eso? La forma por la que optado es la más directa: Le he metido un atributo nuevo a las entidades: alive
, un booleano para indicar si la entidad está vivo o no. Y para evitar choques con fantasmas, he añadido otra condición para que solo puedan colisionar dos entidades vivas.
class Entity {
//...
#alive;
constructor(x, y, dx, dy, width, height, color) {
// ...
this.#alive = true;
}
get isAlive() {
return this.#alive;
}
kill() {
this.#alive = false;
}
}
Como se puede ver, hay poco misterio. Inicialmente la entidad está viva, hasta que alguien decide matarla con kill
.
Veámos cómo por ejemplo el alien gestiona sus colisiones.
'use strict'
class Alien extends Entity {
// ...
inCollisionWith(anything) {
if (super.inCollisionWith(anything)) {
this.kill();
anything.kill();
}
}
kill() {
super.kill();
// ...
}
}
Alguien le enviará al alien, otra entidad (un disparo, o el propio jugador) con la que detectar la colisión. Si colisionan, ambos palman. De momento, el kill
del alien se limita a llamar al método equivalente de la clase padre, pero esta es la forma de hacer que el alien tenga un comportamiento extra cuando muera, como veremos más adelante, y con qué finalidad.
Pim pam pum
Llegó el ansiado momento de disparar. Empezamos por la clase Shot
, que representará un disparo (y que quizá debí llamar bullet...). Agarraos los machos que ahora viene lo duro...
'use strict'
class Shot extends Entity {
constructor(x, y) {
super(x, y, 0, -400, 2, 5, 'yellow');
}
get isAlive() {
return super.isAlive && this.y > 0;
}
}
Siendo defraudarte, el disparo no tiene nada especial. Esta entidad se moverá de abajo hacia arriba. Eso quiere decir que su coordenada y
decrecerá. Digamos que a una velocidad de 400 píxeles por segundo. También necesitamos cambiar la detección de cuándo el disparo no está vivo, si no impacta contra ningún alien. En ese caso no merece la pena seguir moviendo, dibujando y comprobando colisiones de un disparo que se ha salido del mapa por la parte superior.
Bien, veámos ahora cómo se dispara. Espero que hayas pensado lo mismo que yo: si dispara el jugador, él debería crear el disparo.
'use strict'
class Player extends Entity {
//...
#shotDelay;
#deltaTime;
constructor(x, y) {
//...
this.#shotDelay = 1;
this.#deltaTime = new DeltaTime();
}
shot() {
if (this.#deltaTime.isElapsed(this.#shotDelay)) {
this.#deltaTime.reset();
return new Shot(Math.round(this.x + this.width / 2), this.y)
}
return null;
}
// ...
}
Esta clase se encarga de instanciar el disparo, e interesa que la haga la clase Player
porque ella debería saber en qué posición crearlo. El sitio natural es desde la parte superior de su centro de gravedad. Pero no solo basta con crear un disparo. Hay que poner límite al número de disparos que el jugador puede realizar, o el juego no tendría ningún tipo de reto. ¿Tal vez un disparo por segundo como máximo le complique la vida? Probémoslo. Cuando alguien intente que el jugador dispare, le devolveremos el disparo recién creado, o un null
si en esa petición el jugador no puede disparar.
Para comprobar si ha pasado ese segundo, añadiendo le método isElapsed
a la clase que ya teníamos DeltaTimer
, resolvemos la papeleta.
class DeltaTime {
// ...
isElapsed(time) {
let now = this.#now();
return time <= (now - this.#time)
}
}
Ahora veámos quién da la orden de apretar el gatillo. Espero que lo hayas imaginado.
class Game {
// ...
#gameLogic() {
this.#shotsLogic();
// ...
this.#playerLogic();
}
#shotsLogic() {
this.#shots.forEach(shot => this.#aliens.forEach(alien => alien.inCollisionWith(shot)));
this.#shots = this.#shots.filter(shot => shot.isAlive);
}
#playerLogic() {
// ...
if (this.#keyboard.is(Keyboard.Key.SPACE)) {
let shot = this.#player.shot();
if (shot) {
this.#shots.push(shot);
}
}
// ...
this.#aliens.forEach(alien => alien.inCollisionWith(this.#player));
}
#update() {
let delta = this.#deltaTime.tick(0.1);
// ...
this.#shots.forEach(shot => shot.move(delta));
}
#render() {
// ...
this.#shots.forEach(shot => this.#renderer.entity(shot));
// ...
}
}
Hemos metido un array de disparos, en shots
. Cuando el jugador apriete la tecla espacio
ordenará al jugador que dispare. Si este puede, devolverá el disparo, que meteremos en dicho array. Luego ya el flujo habitual. Se calculará su lógica, se moverá, y se renderizará como los demás.
La lógica del disparo, del método shotsLogic
, aparenta ser más compleja de lo que en realidad hace. Cuando te acostumbres a leer funciones flecha y a los métodos funcionales que traen los arrays en javascript, ya no te parecerán extrañas. Simplemente hace lo siguiente, por línea:
- Por cada disparo y alien se compara si están en colisión llamando al método
inCollisionWith
de la claseAlien
que ya vimos. Por si no lo recuerdas, lo que hacía si ambas entidades estaban en colisión, era matarlas llamando a sus respectivos métodoskill
. - Limpiamos los disparos que no están vivos. Es decir, que han impactado contra algún alien o se han salido del mapa por la parte superior.
Por otro lado, el método playerLogic
será quien se encargue de gestionar las peticiones de disparo del jugador si se ha pulsado la tecla espacio
, y de las posibles colisiones con los aliens que le costará una vida.
Por supuesto, cada disparo también necesita ir actualizándolo con el tiempo delta, y renderizándolo en pantalla.
No era para tanto, ¿verdad?
¿Eventos!
Ya tenemos la mecánica del juego implementada. Los aliens bailan, el jugador dispara, los aliens explotan. ¿Qué falta? Pues dos aspectos igual de importantes. Algún sistema de puntuaciones, y que la muerte del jugador tenga algún tipo de castigo.
El juego tiene dos tipos de finales:
- El jugador choca con un alien: Derrota.
- El jugador destruye todos los aliens: Victoria.
En el caso de la victoria, dejaremos que el jugador pueda reiniciar otra partida, conservando su puntuación, y el número de vidas restantes que le queden. Y si pierde, le restaremos una vida. Si no le quedan, le mostramos el típico mensaje de game over y la opción de empezar una nueva partida.
Hemos hecho referencia a dos sistemas nuevos: Vidas del jugador, y su puntuación. Te podrás imaginar que el sistema de puntuación debería programarse dentro del código que detecta colisiones entre los disparos y los aliens, pero si recuerdas el cómo lo hemos programado, solo consistía en iterar sobre los disparos y los aliens comprobando si chocaban. Y no parece buena idea hacer que las clases de Alien
o Shot
tengan esta responsabilidad. Por cuestión de sencillez, es indispensable diseñar el juego con la idea de que cada clase que lo componen sepa lo menos posible del resto, aunque a veces es inevitable que algunas conozcan demasiado, como en nuestro caso la clase Game
. Sin embargo, jamás debería permitir que las entidades tuviera una referencia al juego, provocando siempre problemáticas dependencias circulares.
Hay unas cuantas formas de gestionar este problema, pero antes de reinventar la rueda, pensémos en qué contexto estamos: Un navegador web. ¿Cómo transmite javascript la información de las acciones que ocurren en cada elemento HTML? ¡Por eventos! ¿Por qué no usar eventos en mi juego? Probemos a ver qué sale. Veámos otra vez la clase Alien
class Alien extends Entity {
// ...
#eventDispatcher;
constructor(x, y, eventDispatcher) {
// ...
this.#eventDispatcher = eventDispatcher;
}
// ...
kill() {
super.kill();
this.#eventDispatcher.add(EventDispatcher.Event.ALIEN_KILLED);
}
}
Le declaramos un atributo nuevo, una instancia de la clase llamada EventDispatcher
, que usamos para declarar un evento llamado EventDispatcher.Event.ALIEN_KILLED
que se añadirá cuando un alien muera. ¿Cómo es un event dispatcher?
'use strict'
class EventDispatcher {
#events;
constructor() {
this.#events = [];
}
add(event) {
this.#events.push(event);
}
get events() {
const events = [...this.#events];
this.#clear();
return events;
}
#clear() {
this.#events = [];
}
static get Event() {
return {
ALIEN_KILLED : 'alien_killed',
PLAYER_KILLED : 'player_killed'
};
}
}
Pues de momento poca cosa. Quizá en un tutorial evolucione, o quizá desaparezca y rehaga los eventos de otra forma. Pero en esta primera aproximación solo se encargará de ir acumulando los eventos que se produzcan en cualquier sitio, para que luego el juego los procese. De momento me basta almacenar eventos en formato string, pero la puerta está abierta a que algún día se pueda enriquecer dichos eventos mutándolos a objetos que además contengan más información, como una referencia a la entidad que los tira, o algún dato importante como la posición donde estaba, etcétera.
Es interesante notar que el método get para recuperar eventos, los limpia para que en las llamadas sucesivas, no esté contínuamente devolviendo los mismos eventos.
class Game {
/// ...
#score;
#lives;
#eventDispatcher;
constructor(id) {
// ...
this.#eventDispatcher = new EventDispatcher();
}
#init() {
this.#restart();
this.#loop();
}
#restart() {
this.#score = 0;
this.#lives = 2;
this.#reload();
}
#reload() {
this.#player = new Player(370, 550, this.#eventDispatcher);
this.#shots = [];
this.#aliens = [];
for (let row = 0; row < 5; row++) {
for (let column = 0; column < 12; column++) {
this.#aliens.push(
new Alien(100 + column * 50, 50 + row * 30, this.#eventDispatcher)
);
}
}
this.#status = Game.Status.RUNNING;
}
// ...
#applyEvents() {
this.#eventDispatcher.events.forEach(
event => {
switch(event) {
case EventDispatcher.Event.ALIEN_KILLED:
this.#score += 10;
this.#aliens = this.#aliens.filter(alien => alien.isAlive);
if (this.#aliens.length === 0 && this.#player.isAlive) {
this.#status = Game.Status.WIN;
}
break;
case EventDispatcher.Event.PLAYER_KILLED:
this.#lives--;
this.#status = this.#lives > 0 ? Game.Status.CONTINUE : Game.Status.GAME_OVER;
break;
}
}
);
}
static get Status() {
return {
WIN : 'win',
CONTINUE : 'continue',
GAME_OVER : 'game_over',
RUNNING : 'running'
}
}
}
Como puedes ver, hemos cambiado un poco la forma de la clase GAME
. Además de instanciar el nuevo EventDispatcher
en su constructor
, hemos declarado dos atributos que gestionarán la puntuación score
y el número de vidas del jugador lives
. Como consecuencia de que ahora una partida tiene varios posibles finales, que mencionamos antes, hemos dividido el método init
en dos métodos más, restart
y reload
, para empezar una partida nueva, o reiniciar el nivel según el caso sin resetear puntuación y vidas restantes. La lógica que seguiremos es muy sencilla. Si el jugador gana, o muere pero le quedan vidas, tiramos el reload
para comenzar de nuevo. Si el jugador muere y no tiene vidas, se reinicia el juego con un restart
.
También metemos el event dispatcher al jugador y a cada alien para que puedan enviarle sus posibles eventos. Y por último, algún lugar del código se encargará de llamar al nuevo método applyEvents
que se encargará de ejecutar las acciones pertinentes según el tipo de evento que acaba de recolectar.
La gestión de los eventos es bastante sencilla. Cuando un alien muera, sumaremos puntuación, eliminamos el alien de la lista, y comprobaremos si queda alguno vivo. Si no es así, y el jugador sigue vivo (quizá haya chocado contra el último alien y en realidad debería morir), pondremos el status
del juego a Game.Status.WIN
. Y si el jugador ha muerto, pues entonces lo ponemos a Game.Status.CONTINUE
o a Game.Status.GAME_OVER
dependiendo de si al jugador le quedan vidas o no. Como se puede ver en el método reload
, el status
inicial es Game.Status.RUNNING
.
Bueno, los eventos nos han servido para cambiar el estado de un juego. ¿Cómo afecta esto a la lógica? Pues veámos por fin la versión final de Game
.
class Game {
// ...
#loop() {
switch(this._status) {
case Game.Status.RUNNING:
this.#gameLogic();
break;
case Game.Status.WIN:
this.#winLogic();
break;
case Game.Status.CONTINUE:
this.#continueLogic();
break;
case Game.Status.GAME_OVER:
this.#gameoverLogic();
}
this.#render();
window.requestAnimationFrame(() => this.#loop());
}
#gameLogic() {
this.#shotsLogic();
this.#alienLogic();
this.#playerLogic();
this.#update();
this.#applyEvents();
}
// ...
#winLogic() {
if (this.#keyboard.is(Keyboard.Key.ENTER)) {
this.#reload();
}
}
#continueLogic() {
if (this.#keyboard.is(Keyboard.Key.ENTER)) {
this.#reload();
}
}
#gameoverLogic() {
if (this.#keyboard.is(Keyboard.Key.ENTER)) {
this.#restart();
}
}
#render() {
// ...
this.#renderer.gui(this.#score, this.#lives);
switch(this.#status) {
case Game.Status.CONTINUE:
this.#renderer.menu('You are death, but you have more lives.', 'Press <ENTER> to continue the game...');
break;
case Game.Status.GAME_OVER:
this.#renderer.menu('Game over!', 'Press <ENTER> to start a new game...');
break;
case Game.Status.WIN:
this.#renderer.menu('Congratulations! You defeated the invasion!', 'Press <ENTER> to start new level...');
break;
}
}
// ...
}
El loop
gestionará los estados, llamando a su respectivo método particular. Los estados nuevos se quedan a la espera de que el usuario pulse la tecla Keyboard.Key.ENTER
para reiniciar la partida. Y el método render
se encargará de dibujar en pantalla el texto adecuado según el estado actual.
class Render2D {
// ...
gui(score, lives) {
this.#ctx.fillStyle = "white";
this.#ctx.font = "bold 20px monospace";
this.#ctx.fillText(score + " Score", 20, 30);
this.#ctx.fillText(lives + " Lives", this.#canvas.width - 110, 30);
}
menu(title, subtitle) {
this.#ctx.fillStyle = "red";
this.#ctx.font = "bold 30px monospace";
let titleWidth = this.#ctx.measureText(title).width;
this.#ctx.fillText(
title,
(this.#canvas.width - titleWidth) / 2,
this.#canvas.height / 2 - 30,
);
this.#ctx.font = "bold 20px monospace";
let subtitleWidth = this.#ctx.measureText(subtitle).width;
this.#ctx.fillText(
subtitle,
(this.#canvas.width - subtitleWidth) / 2,
this.#canvas.height / 2 + 5,
);
}
}
Los nuevos métodos de Render2D
no tienen tampoco mucho misterio. Escriben texto en pantalla, con un color, fuente y tamaño especificado. El método menu
además calcula el ancho que tendrá el texto, para intentar centrarlo en la pantalla. Pero nada especialmente complicado si echáis mano a un buen tutorial de las instrucciones de dibujo que tiene el contexto 2d del canvas.
Por último, para hacer ligeramente más complicado el juego, haremos que los aliens incremente un poco, un 2.5%
cada vez, la velocidad de movimiento cuando cambien de sentido. No es más que tocar el método correspondiente del alien.
class Alien extends Entity {
// ...
changeDirection() {
this.dx *= -1.025;
// ...
}
// ...
}
Y se acabó, ya tenemos nuestro primer videojuego de Space Invaders, muy sencillito y sin ningún alarde gráfico, pero por algo teníamos que empezar. Aún queda mucho que aprender, como usar imágenes en vez de rectángulos, hacer que esas imágenes estén animadas, introducir efectos sonoros... ¡El único límite es tu imaginación o tus habilidades para plagiar!