Tutorial canvas 2D - Cómo hacer un juego en javascript 3ª parte

Created at: 2023-02-12

Bienvenidos al remake de la tercera 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 vimos cómo crear las entidades que necesita el juego mediante herencia de los atributos y métodos en común, y cómo empezar a dibujar el juego en la pantalla. Es hora de hacer que los aliens y el jugador se muevan un poco.

Tutorial 03

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 tiempo delta

Para empezar a mover las entidades por el mapa, vamos a tener que introducir una técnica muy usada: El tiempo (o sincronización) delta. Los novatos normalmente cometen el error de animar las entidades con un valor constante, por ejemplo, 10 píxeles a la izquierda. Entonces, en cada vuelta del bucle, las entidades siempre se moverán 10 píxeles. Así es como se hacían muchos videojuegos antiguos, como en los 486 o los primeros Pentium I. Ese bucle, además del movimiento, también se encargará de dibujar en pantalla, detectará colisiones, y demás tareas de la lógica del juego. En último lugar, hará un sleep o algo parecido, para hacer que el juego funciona a 30 o 40 FPS. Por ejemplo, si queremos que el juego corra a 30 FPS, el sleep será de 1000ms / 30FPS = 34 ms. Pero ese método presentaba un problema muy importante: Los ordenadores potentes ejecutarán la lógica del juego más rápidamente. Imaginemos que un ordenador potente ejecuta la lógica en 3 milisegundos, y uno malo en 10 milisegundos. Entonces los FPS reales del ordenador bueno serán 1000ms / (34ms + 3ms) = 27 FPS, y el ordenador malo 1000ms / (34ms + 10ms) = 22 FPS. Una diferencia notable de rendimiento: como la velocidad a la que se desplazan las entidades en cada FPS es constante, en el ordenador malo, el juego irá más lento. En el ejemplo, si hay 5 FPS de diferencia, y la entidad se movía a 10px por FPS, en el ordenador bueno se habrá movido 50 píxeles más por segundo que en el ordenador malo.

¿Cómo podemos hacer que el juego corra más o menos igual en toda clase de PC's? Tiempo delta al rescate.

El tiempo delta consiste en guardar el número de milisegundos transcurrido desde la última ejecución del bucle en una variable. Las entidades, en vez de moverse una cantidad fija en cada vuelta del bucle, lo que hacen es moverse una cantidad concreta de píxeles por segundo. Al multiplicar el tiempo transcurrido desde la última vuelta por la velocidad de la entidad, obtenemos los píxeles que debe moverse realmente la entidad en esa vuelta. Con esta simple técnica, podemos garantizar que el juego funcionará a la misma velocidad en toda clase de ordenadores, sean potentes o no. En los ordenadores potentes, la animación se verá más fluída porque serán capaces de dibujár más FPS's que en los ordenadores menos potentes, pero la experiencia del juego, al menos en cuanto a la respuesta de nuestras acciones, será similar.

class DeltaTime {
    #time;

    constructor() {
        this.reset();
    }

    reset() {
        this.#time = this.#now();
    }

    tick(max) {
        let now = this.#now();
        let delta = Math.min(now - this.#time, max);
        this.#time = now;

        return delta;
    }

    #now() {
        return Date.now() / 1000;
    }
}

La implementación final no tiene mucho misterio. Para facilitarlos los cálculos mentales, trabajaremos en segundos. Cuando la clase comience a funcionar, establecemos la hora actual. Y cuando queramos calcular el tiempo delta transcurrido, usamos su método tick, para hacer el sencillo cálculo. Para evitar un molesto efecto secundario, usaremos un argumento max al llamar este método. La explicación es que si tenemos corriendo el juego en una pestaña del navegador, y cambiamos de pestaña, el tiempo continuará transcurriendo, pero el navegador dejará de ejecutar el código del juego. Cuando el usuario vuelva a la pestaña, el navegador reiniciará la ejecución del juego y calcularía un tiempo delta exagerado, haciendo que las entidades se muevan de golpe decenas de miles de píxeles y desapareciendo completamente de la pantalla (u otros efectos inesperados dependiendo de tu juego). Para reducir este golpe, le seteamos un valor máximo de tiempo delta. Si se supera, te devolverá este máximo controlado.

El movimiento de los aliens

Los aliens tienen un movimiento particular. Van de derecha a izquierda, y cuando llegan al límite, bajan un poco y cambian de dirección. Y lo hacen todos a la vez. ¿Cómo podemos representar esta lógica en el juego? Pues repartiendo las responsabilidades entre varias clases.

Todas las entidades pueden moverse, no solo los aliens. Por tanto, el lugar ideal para meter los atributos y métodos relacionados con el movimiento básico es en la clase padre Entity. El código que sea idéntico a lo que ya vimos en el tutorial anterior, no lo incluiré:

'use strict'
class Entity {
    // ...
    #dx;
    #dy;
    // ...

    constructor(x, y, dx, dy, width, height, color) {
        // ...
        this.#dx = dx;
        this.#dy = dy;
        // ...
    }

    set x(x) {
        this.#x = x;
    }

    set y(y) {
        this.#y = y;
    }

    get dx() {
        return this.#dx;
    }

    set dx(dx) {
        this.#dx = dx;
    }

    get dy() {
        return this.#dy;
    }

    set dy(dy) {
        this.#dy = dy;
    }

    move(delta) {
        this.#x += Math.round(delta * this.#dx);
        this.#y += Math.round(delta * this.#dy);
    }
}

Junto con los atributos que ya teníamos, declaramos dos más: dx y dy. Contendrán la velocidad en píxeles por segundo que tiene la entidad en el eje X y en el eje Y. Pueden ser valores positivos o negativos. Si por ejemplo, dx es positivo, el atributo x crecerá, lo que quiere decir, que nos estamos moviendo a la derecha. Si es negativo, restará, por lo que nos moveremos a la izquierda. Con y igual, pero hacia abajo y hacia arriba, según sume o reste. Y para poder ser modificados desde las clases hijas, cada uno de estos atributos tendrá su propio método set.

El método move que las moverá, recibirá el tiempo delta en segundos transcurrido desde el último movimiento, asi que calcular la distancia a la que debe moverse cada coordenada de la entidad es trivial.

'use strict'
class Alien extends Entity {
    constructor(x, y) {
        super(x, y, 75, 0, 40, 25, 'white');
    }

    get isNearTheSide() {
        return this.dx < 0 && this.x < 10 || this.dx > 0 && this.x > 790 - this.width;
    }

    changeDirection() {
        this.dx *= -1;
        this.y += 10;
    }
}

Los aliens se mueven inicialmente hacia la derecha. Así que como dx inicial le asigno por ejemplo una velocidad de 75 píxeles por segundo. Cuando alcanzan el límite del mapa, descienden un poquito y se mueven en sentido contrario. Con el método changeDirection, aplicamos dicho cambio. Es importante notar que aunque parezca que estamos seteando directamente el atributo dx de la clase padre Entity, en realidad estamos haciendo uso de su método set, por lo que podríamos, si lo necesitáramos, controlar esta escritura. Además tenemos un método getter isNearTheSide para saber si un alien está pegado a uno de los bordes, para hacer lo del cambio de sentido. Notar que la comprobación mira el sentido en el que va a moverse el alien, guardado en dx. La idea es evitar que posibles aliens que estén justo debajo vuelvan a detectar que se está cerca del borde y provoquen un nuevo y equivocado cambio de dirección, ya que el que primero lo comunique, ejecutaría el changeDirection de todos los aliens, invirtiendo así su dx.

Pero un momento. ¿Quién comprueba si el alien ha llegado al límite? U otro problema. ¿Cómo podemos hacer que todos los aliens cambien de dirección en bloque?

Esa lógica la controlará el bucle principal de nuestra clase Game.

'use strict'
class Game {
    // ...
    #deltaTime;

    constructor() {
        // ...
        this.#deltaTime = new DeltaTime();
    }

    // ...

    init() {
        // ...
        this.#deltaTime.reset();
        this.#loop();
    }

    #loop() {
        this.#gameLogic();
        this.#update();
        this.#render();
        window.requestAnimationFrame(() => this.#loop());
    }

    #gameLogic() {
        this.#alienLogic();
        // ...
    }

    #alienLogic() {
        if (this.#aliens.filter(alien => alien.isNearTheSide).length > 0) {
            this.#aliens.forEach(alien => alien.changeDirection());
        }
    }
    
    // ...

    #update() {
        let delta = this.#deltaTime.tick(0.1);
        this.#aliens.forEach(alien => alien.move(delta));
    }

    // ...
}

Hemos dividido la gestión del juego en tres pasos: Calcular la lógica (órdenes del jugador, colisiones, etc), realizar los movimientos, y dibujar las entidades en pantalla. Los métodos equivalentes son gameLogic, update, y render. Todos estos métodos serán llamados desde el bucle principal del juego, llamado loop, que de momento iterará infinitamente. Para conseguir un bucle infinito en javascript, sin bloquear el navegador (olvídate de un while (true) { }), tenemos las opciones de usar un setInterval, setTimeout, y la mejor de todas las opciones, window.requestAnimationFrame. Esta última tiene la ventaja de que optimiza el control del bucle infinito, evitando que el navegador colapse, y pausando dicho bucle si entiende que el usuario que está navegando, ha cambiado de pestaña o no está usando el navegador. Este efecto secundario es el motivo que explicamos antes de tener que pasarle un valor máximo a la clase encargada de calcular el tiempo delta. En un ordenador decente, el juego debería funcionar entre 0.01 y 0.012 segundos de tiempo delta, o lo que es lo mismo, entre 83 y 100 FPS. Seteándolo a 0.1, debería de ser suficiente, ya que sería imposible que el tiempo delta "real" superara ese máximo durante un funcionamiento normal.

Volvamos a los aliens. Su lógica consiste en cambiar de dirección y bajar un poco cuando alcanzan uno de los límites del mapa. Encapsulo toda esa lógica condicional en el método alienLogic, y hago que gameLogic lo use para evitar tener un método demasiado complicado de leer. Nota que cuando un alien cualquiera alcance el límite, alien.isNearTheLimite, provocará el alien.changeDirection a todos los demás.

Con este chequeo, ya tenemos la velocidad adecuada seteada en cada alien. Ahora toca moverlos, para que aplique esa velocidad, a su coordenada correspondiente. El método update se encargará de todo. Lo primero que hace es calcular el tiempo delta, que como dijimos anteriormente, consiste en calcular el tiempo transcurrido desde la última ejecución del bucle principal. A continuación, me basta con ejecutar el move de cada alien pasándole el tiempo delta.

Y con ésto, de momento ya tenemos todos los aliens bailando por la pantalla. Cuando lleguen a la zona del jugador, continuarán su viaje hasta el infinito, porque no tenemos ningún código que lo evite. Ya llegaremos a eso en un próximo tutorial.

Veámos que pasa con el jugador.

Eventos de teclado y notificaciones de movimiento

Como te puedes imaginar, es una entidad especial. La nave del jugador responde a sus órdenes, recibidas a través del teclado. ¿Qué quiere decir esto? Acertaste, que necesitamos una clase Keyboard que se coma el marrón para no ensuciar el resto de mi precioso juego.

Vale la pena pararse un momento para pensar cómo podemos gestionar los eventos de teclado, antes de continuar implementando el código. ¿Qué opciones tenemos para gestionar este problema de la entrada de datos? Se me ocurren dos formas: Por notificación, o por reacción. Entender esta diferencia es clave para poder implementar la forma que más nos convenga.

  • Gestión por reacción: Consiste en hacer que la lógica asociada a una tecla (o al ratón) se dispare en cuanto se pulse la tecla. Si eres programador web, esta es la forma natural en la que has estado usando los eventos de teclado y ratón todo el tiempo. En cuanto el usuario hace click en un botón, el formulario es enviado. Cuando el usuario presiona ENTER en un input de texto, se ejecuta la lógica de la búsqueda.
  • Gestión por notificación: Consiste en guardar en algún tipo de estructura de datos si una tecla está siendo pulsada o no, para que algún otro trozo de código que esté interesado, pueda saber el estado de la tecla que le interese, y ejecutar entonces la lógica.
  • Gestión por notificaciones encoladas. Es una forma más complicada de gestión, y consiste en almacenar todas las teclas que vaya presionando el usuario. Cuando el bucle principal necesite consultar el teclado, tiene que estar preparado para poder ejecutar muchas acciones posibles dependiendo de las teclas pulsadas y el orden en el que se ha producido.

¿Cuál es entonces el mejor enfoque para la gestión de eventos de teclado? Pues esto es algo que depende totalmente de tu intención. Veamos qué sucede en nuestro juego. Si usáramos la gestión por reacción, tendríamos un grave problema: Imagina que atamos la lógica de mover el jugador cuando el usuario presiona alguna de las flechas derecha-izquierda. Imagina que cada movimiento son 10 píxeles. Si el jugador es capaz de presionar diez veces la tecla en un segundo, el jugador se movería de golpe 100 píxeles. Creo que esto arruinaría la experiencia del juego. Otro problema es cómo controlar si el usuario mantiene pulsado la flecha. Si me suscribo al evento keypress del navegador, ¿Cuántas veces va a ser disparada la lógica cada segundo?. Otro problema relacionado. Si mis entidades tienen velocidades máximas, ¿cómo controlaría que el jugador no se ha movido demasiado, si pulsa muchas veces las teclas para moverse? Por todas estas razones, descartaría este tipo de gestión de teclado, al menos para este pequeño juego.

Veámos ahora que pasaría si usáramos la gestión por notificación. Al presionar una tecla, guardo en un diccionario un true. Al soltarla, un false. En el bucle principal del juego, podría preguntarle al teclado... ¿Quiere el jugador moverse hacia la izquierda? O lo que es lo mismo, ¿está siendo pulsada la tecla flecha izquierda en este mismo momento? Y así, sin importar la cantidad de veces que el usuario pulse la tecla, o la mantenga presionada, el juego tendrá siempre el mismo comportamiento. Sin embargo, con esta sencilla implementación hay un pequeño problema (nada es gratis en esta vida). Si el usuario no está presionando la tecla justamente en el momento en el que juego lo comprueba, se perdería la posible pulsación del usuario. Pero en el mundo real, una pulsación rápida de tecla de un usuario dura alrededor de 20 ms, y si el juego corre a 100 FPS, para que se perdiera, debería de durar menos de 10 ms y producirse justamente en un "hueco".

Acerca de las notificaciones encoladas es el más preciso, pero también el más difícil de implementar. En mi juego no necesito tanta exhaustividad, asi que ni me he planteado implementarlo.

Así que ¿cuál elegimos? Si en tu juego cada pulsación de tecla importa, y es un juego por turnos, o en tiempo real pero poco dinámico, es probable que te interese el enfoque reactivo. Si por el contrario, sí que es en tiempo real y muy dinámico, entonces quizás te funcionaría mejor que optar por el encolado. En mi caso, que tengo un juego con mucho movimiento pero con pocas teclas y un tiempo delta muy pequeño, la solución más simple es el enfoque por notificación, ya que es poco probable que se pierdan pulsaciones al ejecutarse en método loop muchísimas veces por segundo. Por supuesto, en el caso de necesitarlo, se podría crear un control híbrido tomando ideas de todos los enfoques.

'use strict'
class Keyboard {
    #pressed;

    constructor() {
        this.#pressed = {};
        document.body.addEventListener('keydown', evt => this.#pressed[evt.code] = true);
        document.body.addEventListener('keyup', evt => this.#pressed[evt.code] = false);
    }

    is(key) {
        return !!this.#pressed[key];
    }

    static get Key() {
        return {
            ENTER: "Enter",
            LEFT: "ArrowLeft",
            RIGHT: "ArrowRight",
            SPACE: "Space"
        }
    }
}

La clase es bastante sencilla. Declara un enumerador con los codes de cada carácter del teclado, para que luego sea fácil usarlos y no tener que harcodear su string en todas partes. De esta manera, referencia una tecla sería escribir Keyboard.Key.SPACE. Para detectar la pulsación de teclas, la idea es meter en un array asociativo gigante si una tecla concreta está siendo pulsada en el momento de preguntar mediante el método is. Ese array asociativo, que contiene un diccionario de booleanos, es el atributo pressed. Escuchando los eventos keydown y keyup, que son disparados al pulsar y al soltar una tecla, resolvemos el problema. Notar que aunque en el enumerador solo he declarado las teclas que usa mi juego, en el mapa pressed en realidad se están almacenando todas.

El jugador

En el space invaders, el jugador es el principal interesado en escuchar los eventos del teclado, como es natural. La nave del jugador puede moverse de derecha a izquierda y disparar. De momento, implementaremos el movimiento.

'use strict'
class Player extends Entity {
    #velocity;

    constructor(x, y) {
        super(x, y, 0, 0, 40, 25, 'green');
        this.#velocity = 120;
    }

    stop() {
        this.dx = 0;
    }

    toRight() {
        if (this.x < 790 - this.width) {
            this.dx = this.#velocity;
        }
    }

    toLeft() {
        if (this.x > 10) {
            this.dx = -this.#velocity;
        }
    }
}

Al igual que con los aliens, le metemos a la clase Player los métodos necesarios para gestionar su movimiento. El jugador puede moverse a la derecha y moverse a la izquierda, a una velocidad determinada, y siempre que no esté ya en los límites del mapa. Al igual que con los aliens, me basta indicar su dx para que el método move del padre haga lo que tenga que hacer. Pero hay una novedad: Si el jugador no pulsa ninguna tecla, la nave debe detenerse. Para ello hemos creado el método stop, que setea la velocidad de movimiento en el eje X a cero, lo cual a su vez nos obliga a mantener la velocidad almacenada en otro atributo velocity.

Como detalle a destacar, siempre que puedo intento seguir el principio tell don't ask, que consiste en no hagas malabares para calcular si la clase A puede usar un método de la clase B, según el estado de B. Es mejor que A llame siempre al método de B, y que sea este método de B el que rechace, no haciendo nada, dicha petición. Tenemos dos ejemplos claros en los métodos toRight y toLeft. Si la nave no puede moverse, no se moverá. La clase juego no sabe nada de esta lógica, solo sabe que si la tecla adecuada está siendo pulsada, debe decirle a la entidad jugador que se mueva.

Veámos ahora qué cambia en la clase principal del juego.

'use strict'
class Game {
    // ...
    #keyboard;

    constructor() {
        // ...
        this.#keyboard = new Keyboard();
    }

    // ...
    #gameLogic() {
        // ...
        this.#playerMovement();
    }

    #playerMovement() {
        this.#player.stop();
        if (this.#keyboard.is(Keyboard.Key.LEFT)) {
            this.#player.toLeft();
        }
        if (this.#keyboard.is(Keyboard.Key.RIGHT)) {
            this.#player.toRight();
        }
    }

    #update() {
        let delta = this.#deltaTimer.tick(0.1);
        // ...
        this.#player.move(delta);
    }
}

Como se puede ver, poca cosa. Es la magia de repartir la funcionalidad entre las distintas clases, y tener un diseño previo decente. En la lógica del juego, ahora también estará atento al teclado, chequeando si la tecla esperada está siendo pulsada en ese momento. Si lo está, se procederá indicarle a la clase Player cómo tiene que moverse en la próxima llamada a su move, que se hará en update, como con los aliens. Para resetear el posible movimiento anterior que pudiera tener el jugador, playerMovement empieza por un stop, como dijimos antes. Es una ñapa que no me gusta demasiado, seguro que pensando un poco más, encontraría una forma más elegante de parar al jugador, pero tampoco es algo que me quite el sueño.

Y ya está bien por el momento, si ejecutamos el juego en su estado actual, conseguiremos que las naves alienígenas se muevan en zigzag desde arriba hasta desaparecer por abajo de la pantalla; y que la nave protagonista responda a las pulsaciones de teclas que haga el jugador. Nos queda poder disparar a nuestro gusto, que los disparos destruyan a las naves alienígenas (lo que nos servirá para introducirnos en el maravilloso mundo de la detección de colisiones), y saber cuándo se ha acabado la partida para mostrar algún texto en la pantalla. Todo esto será el motivo de un próximo capítulo.