Tutorial canvas 2D - Cómo hacer un juego en javascript 5ª parte
Created at: 2023-03-03
Bienvenidos al remake de la quinta 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 terminamos de darle forma al juego, añadiendo disparos y controlando el final del juego con una GUI muy básica. Ahora vamos a intentar mejorar un poco su aspecto gráfico. En concreto, usaré algunos de los recursos del Space Invaders original, para que así, además de gastar mi tiempo escribiendo unos tutoriales que nadie lee, pueda también acabar en la cárcel por una denuncia de Atari por violación de copyrigth.

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.
El almacén de imágenes
Como salta a la vista, uno de los grandes cambios que le hemos hecho al juego es la inclusión de imágenes animadas sustituyendo a los horribles rectángulos blancos de las versiones anteriores. El primer problema que se plantea es ¿cómo cargo dichas imágenes?. En javascript, las imágenes, como cualquier otro elemento del HTML, son objetos; en particular, el objeto Image. En esencia bastaría con hacer un new Image()
, asignándoselo a alguna variable, y especificarle una ruta en su atributo src
. Luego, para dibujarla, el contexto 2D del canvas dispone de funciones para renderizar imágenes de forma bastante parecida a cómo dibujábamos cuadrados, como veremos después.
Pero ¿dónde metemos todo el código? Vayámos por partes. En primer lugar, necesitamos cargar las imágenes. Es evidente que muchas de las imágenes se van a reusar, hay muchos aliens del mismo tipo, y por supuesto, la imagen de cada disparo es la misma. Sería un desperdicio de memoria instanciar múltiples veces las mismas imágenes, asi que creo que una buena forma de empezar sería creando un almacén de imágenes a la que delegar esta tarea.
'use strict'
class ImageStore {
#images;
constructor() {
this.#images = {};
}
get(src) {
let img = this.#images[src];
if (!img) {
img = new Image();
img.src = src;
this.#images[src] = img;
}
return img;
}
}
Nuestro almacén de imágenes tiene poca chicha. Alguien le pedirá que le devuelva una imagen a partir de su src
. Si la tiene cacheada, la devuelve. Si no, la crea, la cachea, y la devuelve.
Mundo Sprite
Las imágenes tienen su propio nombre en el mundo del desarrollo de juegos, que seguramente ya hayas escuchado antes: Sprites. Javascript puede dibujar en un canvas un montón de formatos diferentes, como JPG, BMP, TFF, GIF, PNG, etc, pero los tres más usados son GIF, porque puede contener animaciones y suele tener un peso (bytes) pequeño; JPG porque es el formato con mejor compresión, aunque no permite transparencias; y PNG, que es otro formato comprimido, aunque no tanto como JPG, pero que sí acepta transparencias.
El tema de las transparencias es algo a tener en cuenta. Si tus sprites son propensos a dibujarse en el juego unos encima de otros, necesitarás usar un formato que acepte transparencias, y comunicarle de alguna manera a javascript que unas zonas de la imagen son transparentes, es decir, que no deben ser dibujadas. Por ejemplo, GIF y PNG necesitan que al crealos les indiques un color. Ese color es el que se utilizará como transparente. Sin embargo, yo he elegido el formato JPG, porque la imagen apenas tiene espacios transparentes, es decir, la figura ocupa casi toda la imagen. Su fondo es negro, como el color de fondo de mi videojuego. Y además, en caso de colisiones entre disparos y aliens, ambas entidades desaparecerán inmediatamente, asi que no se notará el feo efecto de superposición de dos imágenes en JPG.
Si mi videojuego sí permitiera que dos entidades estuvieran una encima de otra, o quisiera cambiar el color de fondo por otro distinto al color de fondo del JPG, estaría obligado a usar PNG. Mi recomendación es que si puedes, uses este último para evitar todos estos posibles problemas.
No obstante, me gustaría insistir en un posible problema. Cuando dibujábamos cuadrados, la colisión se calculaba exactamente con el mismo tamaño de ese cuadrado. Sin embargo cuando dibujes una imagen en su lugar, si la figura del alien no llena completamente la imagen, es decir, si hay demasiado espacio alrededor del alien, cuando un alien choque contra la nave, pasará lo que expliqué con el ejemplo de la moto en el primer artículo: En realidad colisionarán las esquinas de la imagen, de color negro, y no las figuras contenidas en la imagen. Mis imágenes de alien tienen muy poco espacio alrededor del alien, asi que es un defecto que voy a pasar por alto, ya que solucionarlo requeriría recortar más la imagen, o mantener otro cuadrado algo más pequeño sobre el que calcular las colisiones.

Otra característica de los sprites es que también sirven para generar animaciones. Si muestras varias imágenes de forma consecutiva, con ligeros cambios entre cada una de ellas, darán sensación de animación. Por ejemplo, en la imagen de la izquierda, tenemos los sprites de Mario, a lo largo de sus primeros juegos. Los sprites suelen ir todos en un mismo archivo. Luego, en el código, le tenemos que indicar qué trozito de imagen, de forma rectangular, tenemos que coger para dibujar la entidad en un momento dado. Unas milésimas de segundo después, habrá que dibujar el siguiente sprite, y así se da la sensación de que la imagen está animada. Los sprites se suelen utilizar sobre todo para hacer las animaciones de los personajes (el protagonista y sus enemigos) cuando caminan, saltan, disparan, se agachan, etc. También se usan para los elementos móviles del escenario, como las antorchas que arden, puertas que pueden abrirse, elementos que el personaje puede activar, etc.
Como en nuestro juego sólo hay un par de sprites para algunas de las entidades, formando una animación muy simple, he optado por meterlos en archivos separados. Así no tengo que comprobar con un editor de imágenes en qué coordenadas y longitudes tengo que trocear el archivo para extraerle los diferentes sprites que contiene. Pero sin duda, cuando se tienen un montón de sprites para un mismo personaje, es mucho más cómodo ensamblarlos todos en una sola imagen. El motivo principal es que por cada imagen a descargar, el navegador ha de crear y procesar una petición al servidor, y después descargar el archivo. Si lo metemos todo en una sola imagen, solo tendrá que realizar una petición al servidor, con el ahorro de tiempo que eso supone.
Un detalle importante que he de comentar antes de entrar en materia, es que deberíamos precargar las imágenes a utilizar en el juego, aunque yo no lo he hecho. He preferido ignorar totalmente el tiempo de descarga de las imágenes, así que es posible que el juego empiece a funcionar, y no se vea nada en la pantalla, a causa de que la imagen a dibujar aún no se ha descargado del todo. Lo he hecho así porque he utilizado muy pocas imágenes, y que dichas imágenes son muy ligeras, con lo que deberían descargarse muy rápidamente. Pero cuando tu juego requiera de muchas imágenes, o cuando las imágenes sean pesadas, es buena idea pre-cargarlas antes de ejecutar el juego.
Una imagen se empieza a descargar en cuanto le asignas una ruta, es decir, cuando le asignas a un objeto Image
su atributo src
. Uno de los métodos del objeto Image es onload
, que se disparará cuando la imagen se haya descargado completamente, aunque no la muestres en ningún sitio. Aprovechando sus onload, llamando a alguna función global que controlara el número de imágenes descargadas, podrías saber cuándo arrancar el juego. Es posible que en un futuro incierto utilize la precarga de imágenes en algún tutorial.
Bueno, al lío. ¿Qué tipo de sprites necesito? Imagina el juego Super Mario Bros. Mario puede moverse hacia los lados, o saltar, o incluso a veces, disparar. Cada una de estas acciones tiene una animación, o secuencia de sprites, diferente. Asi que a la hora de dibujar el sprite tienes que saber qué acción está ejecutando el personaje. Sin embargo, en mi juego de space invaders, el jugador no tiene ninguna animación, y los aliens y los disparos siempre muestran la misma animación sencilla en bucle. Por lo tanto, me ahorro bastante lógica de cómo dibujar el sprite.
La forma más directa que se me ha ocurrido es esta implementación:
'use strict'
class Sprite {
#images;
#timeBetweenTransitions;
#currentIndex;
#deltaTime;
constructor(images, timeBetweenTransitions) {
this.#images = images;
this.#timeBetweenTransitions = timeBetweenTransitions;
this.#currentIndex = 0;
this.#deltaTime = new DeltaTime();
}
get image() {
if (this.#images.length === 1) {
return this.#images[0];
}
if (this.#deltaTime.isElapsed(this.#timeBetweenTransitions)) {
this.#deltaTime.reset();
this.#currentIndex = (this.#currentIndex + 1) % this.#images.length;
}
return this.#images[this.#currentIndex];
}
}
Almaceno una lista con todas las imágenes que conforman la animación, y le asigno un tiempo para transicionar a la siguiente. Con el método isElapsed
del ya conocido DeltaTimer
es una operación sencilla. Si el listado de imágenes solo contiene una, la devolvemos siempre sin calcular nada.
Toma nota de estas expresiones, porque son muy útiles para iterar circularmente sobre listas:
let nextIndex = (currentIndex + 1) % list.length;
let previousIndex = (currentIndex + list.length - 1) % list.length;
Cambios en las entidades
Bueno, llegó el momento de empezar a ver los cambios que le hemos tenido que hacer a la entidad. Como siempre, mostraré los atributos y métodos modificados o nuevos. Empezaremos por supuesto por la clase padre:
'use strict'
class Entity {
// ...
#sprite;
constructor(x, y, dx, dy, width, height, sprite) {
// ...
this.#sprite = sprite;
}
// ...
get sprite() {
return this.#sprite;
}
}
La entidad es prácticamente la misma. Únicamente hemos sustituído el atributo color
de la versión anterior por el nuevo sprite
.
'use strict'
class Alien extends Entity {
constructor(x, y, sprite, eventDispatcher) {
super(
x,
y,
75,
0,
40,
25,
sprite
);
this.#eventDispatcher = eventDispatcher;
}
// ...
}
A la clase Alien
ahora le llega el sprite desde fuera. No quiero que su constructor necesite usar la dependencia del almacén de imágenes.
'use strict'
class Player extends Entity {
// ...
#shotSprite;
constructor(x, y, sprite, shotSprite, eventDispatcher) {
super(x, y, 0, 0, 40, 25, sprite);
// ...
this.#shotSprite = shotSprite;
}
// ...
shot() {
if (this.#deltaTime.isElapsed(this.#shotDelay)) {
this.#deltaTime.reset();
return new Shot(Math.round(this.x + this.width / 2), this.y, this.#shotSprite)
}
return null;
}
}
El jugador también tiene que recibir su sprite, pero también el sprite del disparo, para que cuando necesite instanciarlo, pueda pasárselo. Para ello, simplemente lo metemos en un nuevo atributo shotSprite
.
A continuación, tenemos que modificar la construcción de las entidades:
'use strict'
class Game {
// ...
#reload() {
this.#player = new Player(
370,
550,
new Sprite([this.#imageStore.get("img/player.jpg")]),
new Sprite(
[
this.#imageStore.get("img/shota.jpg"),
this.#imageStore.get("img/shotb.jpg")
],
0.1
),
this.#eventDispatcher
);
this.#shots = [];
this.#aliens = [];
for (let row = 0; row < 5; row++) {
for (let column = 0; column < 12; column++) {
let alienSprite;
switch(row) {
case 0:
alienSprite = new Sprite(
[
this.#imageStore.get("img/alien1a.jpg"),
this.#imageStore.get("img/alien1b.jpg")
],
0.2
);
break;
case 1:
case 2:
alienSprite = new Sprite(
[
this.#imageStore.get("img/alien2a.jpg"),
this.#imageStore.get("img/alien2b.jpg")
],
0.25
);
break;
default:
alienSprite = new Sprite(
[
this.#imageStore.get("img/alien3a.jpg"),
this.#imageStore.get("img/alien3b.jpg")
],
0.3
);
}
this.#aliens.push(new Alien(100 + column * 50,50 + row * 30, alienSprite,this.#eventDispatcher));
}
}
// ...
}
}
Me siento más cómodo si la clase Game
es la única que usa ImageStore
para cargar imágenes. La lógica es prácticamente la misma, salvo que ahora tiene que pasarle el sprite a cada entidad.
¿Y cómo se dibuja la imagen?
'use strict'
class Render2D {
// ...
drawEntity(entity) {
this.#ctx.drawImage(entity.sprite.image, entity.x, entity.y, entity.width, entity.height);
}
}
Poca cosa, como puedes ver. Hay que utilizar el método drawImage, idéntico al anterior fillRect
del contexto 2d del canvas. El getter image
del sprite hará la magia de seleccionar la imagen que deba renderizarse en ese momento, como hemos visto antes.
Y con esto, ya tenemos nuestro juego con unos gráficos algo más decentes.
Mejorando el aspecto
Cuando la bala impacta en el alien, ambos desaparecen de golpe, lo cual me parece un efecto un poco feo. Vamos a intentar meter un efecto de explosión, para darle algo más de alegría al asunto. Una explosión será otra entidad, ya que es dibujable, que debería aparecer en exactamente la misma posición donde estaba el alien que acaba de palmar, y debería mostrarse poco tiempo. ¿Cómo podemos hacer esto? Si has seguido todos los tutoriales, espero que ya te estés imaginando lo que voy a mostrar a continuación.
'use strict'
class Explosion extends Entity {
#timeToDissapear;
#deltaTimer;
constructor(x, y, sprite) {
super(x, y, 0, 0, 40, 25, sprite);
this.#timeToDissapear = 0.1;
this.#deltaTimer = new DeltaTime();
}
move(delta) {
super.move(delta);
if (this.#deltaTimer.isElapsed(this.#timeToDissapear)) {
this.kill();
}
}
}
La explosión, como dije, será otra entidad. No se mueve, asi que sus dx
y dy
serán cero, y tiene el mismo tamaño que el alien, para que cuando aparezca, no quede raro. Y para que desaparezca al poco tiempo, digamos en 0.1
segundos, uso otro DeltaTimer
en su método move
para matarlo. Es raro llamar kill
a hacer desaparecer una explosión, pero no me compensa renombrar ahora todos los usos de este método para ponerle un nombre mejor.
Ahora, en la clase Game
, gestionamos su lógica:
'use strict'
class Game {
// ...
#explosions;
// ...
#reload() {
// ...
this.#explosions = [];
}
#gameLogic() {
// ...
this.#explosionLogic();
}
#explosionLogic() {
this.#explosions = this.#explosions.filter(explosion => explosion.isAlive);
}
#update() {
let delta = this.#deltaTime.tick(0.1);
// ...
this.#explosions.forEach(explosion => explosion.move(delta));
}
#render() {
// ...
this.#explosions.forEach(explosion => this.#renderer.drawEntity(explosion));
// ...
}
#applyEvents() {
this.#eventDispatcher.events.forEach(
event => {
switch(event) {
case EventDispatcher.Event.ALIEN_KILLED:
this.#score += 10;
let deathAlines = this.#aliens.filter(alien => !alien.isAlive);
this.#aliens = this.#aliens.filter(alien => alien.isAlive);
if (this.#aliens.length === 0) {
this.#status = Game.Status.WIN;
}
deathAlines.forEach(deathAlien => this.#explosions.push(
new Explosion(
deathAlien.x,
deathAlien.y,
new Sprite([this.#imageStore.get("img/explosion.jpg")]),
)
));
break;
// ...
}
}
);
}
}
Como podemos ver, creamos un nuevo listado de explosiones, y gestionamos su lógica de forma idéntica al resto. Lo único particular es cuándo creamos nuevas explosiones. Como tenemos un trozo de código que sabe que un alien acaba de morir, creo que es el lugar perfecto para crear la explosión. Nuestro sistema de eventos es muy simple, nos dice que "algún alien ha muerto", pero no cuál. Así que una de dos, o modifico el sistema de eventos para hacer que el evento sea un objeto más rico, y que contenga el alien que acaba de palmar, o lo que finalmente he hecho, recupero todos los posibles aliens muertos y añado una explosión por cada uno de ellos. Si hiciera el juego de cero, creo que implementaría la primera solución, la de crear eventos ricos, que me parece mucho más interesante y flexible si el juego siguiera creciendo.
Como curiosidad, el dibujado de las explosiones lo he puesto antes que el dibujado de los aliens. Ya que la explosión no se mueve, creo que es mejor que los aliens vivos se dibujen encima para evitar un efecto visual raro.
Y ésto es todo, ya tenemos un sistema de explosiones que hace el juego un poco más bonito.