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

Created at: 2023-03-11

Bienvenidos al remake de la sexta 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 cambiamos el aspecto gráfico del juego usando sprites, y en este artículo cerraremos la serie añadiendo sonidos.

Tutorial 05

Aquí puedes correr la versión del juego que vamos a comentar en este artículo. El juego inicia en cuanto se abre la página, pero veo que al menos el navegador Chrome tira un warning avisando que no reproducirá sonidos hasta que el usuario no interactúe con lá página. Es decir, no escucharás el sonido hasta que empieces a jugar. Se arregla fácil haciendo que el juego arranque cuando se presione algún botón, pero para este tutorial lo voy a dejar así.

Aquí tienes el enlace al repositorio con todo el código de esta serie de tutoriales.

El almacén de sonidos

Como te puedes imaginar por el título de esta sección, lo primero que se me ha pasado por la cabeza, y finalmente he implementado, es cargar y usar los sonidos de forma similar a lo que hice con las imágenes. Javascript tiene un api de audio bastante completo, aunque yo voy a tirar otra vez por la forma que me ha parecido más sencilla.

'use strict'
class Sound {
    #audio;

    constructor(src) {
        this.#audio = new Audio();
        this.#audio.src = src;
    }

    play() {
        this.#audio.play();
    }

    stop() {
        this.#audio.pause();
        this.#audio.currentTime = 0;
    }
}

Creo una clase Sound, para esconder un poco la magia del objeto Audio del API de javascript.

'use strict'
class SoundStore {
    #sounds;

    constructor() {
        this.#sounds = {};
    }

    get(src) {
        let sound = this.#sounds[src];
		if (!sound) {
            sound = new Sound(src);
            this.#sounds[src] = sound;
        }
		
		return sound;
    }
}

Y esto es el almacén de sonidos, que es un clon de nuestro anterior almacén de imágenes, pero esta vez crea instancias de tipo Sound. Al igual que las imágenes, es mejor precargar estos recursos antes de iniciar el juego, pero para este tutorial lo voy a seguir manteniendo así.

Reproduciendo sonidos

Ahora únicamente nos falta encontrar el sitio adecuado para reproducir cada sonido. Veámoslos caso a caso:

Sonido de disparo

Después de pensarlo un rato, he optado en reproducir el sonido vía eventos cuando sea posible. Por tanto, me interesa crear otro evento más:

'use strict'
class EventDispatcher {
    // ...

    static get Event() {
        return {
            // ...
            PLAYER_SHOT : 'player_shot'
        };
    }
}

Que se lanzará cuando el jugador dispare:

'use strict'
class Player extends Entity {
    // ...

    shot() {
        if (this.#deltaTime.isElapsed(this.#shotDelay)) {
            this.#deltaTime.reset();
            this.#eventDispatcher.add(EventDispatcher.Event.PLAYER_SHOT);
            return new Shot(Math.round(this.x + this.width / 2), this.y, this.#shotSprite)
        }

        return null;
    }
}

Y que será gestionado por nuestro método privado applyEvents, reproduciendo el sonido pertinente. Además, también tenemos que instanciar el nuevo almacén:

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

    constructor(id) {
        // ...
        this.#soundStore = new SoundStore();
    }

    // ...

    #applyEvents() {
        this.#eventDispatcher.events.forEach(
            event => {
                switch(event) {
                    // ...
                    case EventDispatcher.Event.PLAYER_SHOT:
                        this.#soundStore.get("sound/shot.wav").play();
                }
            }
        );
    }
}

Otra posibilidad habría sido reproducirlo en el método Game.playerLogic, en aquel if que miraba que cuando el jugador presionara la tecla SPACE, intentaba crear una instancia de disparo y la metía en su listado. Pero el enfoque vía evento me ha gustado más porque no quería meter lógica de sonido en dicho sitio, y además como veremos a continuación, la mayoría de los sonidos están relacionados con los otros eventos.

Sonido de explosión

Este sonido ha sido bastante más directo. Ya teníamos un evento que controlaba la muerte de un alien. Ahora también reproduce su sonido.

'use strict'
class Game {
    // ...

    #applyEvents() {
        this.#eventDispatcher.events.forEach(
            event => {
                switch(event) {
                    // ...
                    case EventDispatcher.Event.ALIEN_KILLED:
                        // ...
                        this.#soundStore.get("sound/kill.wav").play();
                        break;
                    // ...
                }
            }
        );
    }
}

Sonido de muerte del jugador

Otro sonido fácil de meter, porque ya teníamos el evento correspondiente.

'use strict'
class Game {
    // ...

    #applyEvents() {
        this.#eventDispatcher.events.forEach(
            event => {
                switch(event) {
                    case EventDispatcher.Event.PLAYER_KILLED:
                        // ...
                        this.#soundStore.get("sound/defeat.wav").play();
                        break;
                    // ...
                }
            }
        );
    }
}

Sonido de victoria

Cuando comprobamos la condición de victoria, ahora también reproducidos el sonido relacionado. Como el sonido tiene duración, y no me interesa que siga sonando si el usuario presiona ENTER para continuar con otra ronda, hay que pararlo.

'use strict'
class Game {
    // ...

    #winLogic() {
        if (this.#keyboard.is(Keyboard.Key.ENTER)) {
            this.#soundStore.get("sound/victory.wav").stop();
            this.#reload();
        }
    }

    #applyEvents() {
        this.#eventDispatcher.events.forEach(
            event => {
                switch(event) {
                    case EventDispatcher.Event.ALIEN_KILLED:
                        // ...
                        this.#aliens = this.#aliens.filter(alien => alien.isAlive);
                        if (this.#aliens.length === 0 && this.#player.alive) {
                            this.#status = Game.Status.WIN;
                            this.#soundSequence.stop();
                            this.#soundStore.get("sound/victory.wav").play();
                        }
                        // ...
                        break;
                    // ...
                }
            }
        );
    }
}

Sonido de fondo

Y por último, el sonido más problemático de meter, aunque tampoco demasiado. Por si no lo recuerdas, el Space Invaders original tenía un ritmo compuesto por cuatro notas que se reproducía contínuamente, y cada vez más rápido, provocando un efecto de agobio en el jugador. Tras darle alguna vuelta, he optado por meter su lógica en una nueva clase.

'use strict'
class SoundSequence {
    #sounds;
    #initialDelay;
    #delay;
    #deltaTime;
    #currentIndex;

    constructor(sounds, delay) {
        this.#sounds = sounds;
        this.#initialDelay = delay;
        this.#deltaTime = new DeltaTime();
        this.#reset();
    }

    play() {
        if (this.#deltaTime.isElapsed(this.#delay)) {
            this.#deltaTime.reset();
            this.#sounds[this.#currentIndex].play();
            this.#currentIndex = (this.#currentIndex + 1) % this.#sounds.length;
        }
    }

    decreaseDelay(quantity) {
        this.#delay = Math.max(0.1, this.#delay - quantity);
    }

    stop() {
        this.#sounds.forEach(sound => sound.stop());
        this.#reset();
    }

    #reset() {
        this.#delay = this.#initialDelay;
        this.#currentIndex = 0;
    }
}

La clase contendrá los cuatro sonidos, pero serán gestionados más o menos de forma transparente. El juego sabe que debe de llamar a play en cada iteración, y esta clase ya decidirá si debe reproducir el sonido, y cuál de ellos. Como puedes ver, internamente hago uso una vez más de nuestro querido DeltaTime, y de aquella forma de iterar circularmente sobre arrays que te comenté en el artículo anterior. La idea es muy parecida a lo que hacíamos para seleccionar la imagen correcta del sprite a dibujar. La novedad es que ahora la velocidad con la que va cambiando el sonido debe de ir incrementándose poco a poco, asi que he optado por añadir el método decreaseDelay para que sea nuestra instancia de Game quien elija a qué ritmo hacerlo. Cuando el juego empiece una nueva ronda, me interesa volver al delay original y a la primera nota, resultado que conseguiremos al llamar a stop.

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

    constructor(id) {
        // ...
        this.#soundSequence = new SoundSequence(
            [
                this.#soundStore.get("sound/invasion1.wav"),
                this.#soundStore.get("sound/invasion2.wav"),
                this.#soundStore.get("sound/invasion3.wav"),
                this.#soundStore.get("sound/invasion4.wav")
            ],
            0.7
        );
        // ...
    }

    // ...


    #gameLogic() {
        // ...
        this.#soundSequence.play();
    }

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

    #applyEvents() {
        this.#eventDispatcher.events.forEach(
            event => {
                switch(event) {
                    case EventDispatcher.Event.ALIEN_KILLED:
                        // ...
                        this.#aliens = this.#aliens.filter(alien => alien.isAlive);
                        if (this.#aliens.length === 0 && this.#player.alive) {
                            // ...
                            this.#soundSequence.stop();
                        }
                        // ...
                        break;
                    case EventDispatcher.Event.PLAYER_KILLED:
                        // ...
                        this.#soundSequence.stop();
                        break;
                    // ...case
                }
            }
        );
    }
}

En el constructor de la clase principal del juego instancio nuestra nueva secuencia de sonido, y la ponemos en marcha en el bucle principal del juego. Cuando uno de los aliens llegue al borde del mapa y se disponga a hacer el cambio de sentido, aceleramos un poco el ritmo de la secuencia. Y para terminar, cuando el usuario palme o venza, paramos el sonido para que no se solape con el que se va a reproducir por tal hito.

Y esto es todo amigos. De forma resumida hemos tocado cada uno de los principales palos relacionados con el desarrollo amateur de juegos. Si echas de menos algún punto extra, o tienes dudas acerca de alguna parte de estos artículos, te agradecería un montón que dejes un comentario.

Por otro lado, tengo una espinita clavada con un tema que ya mencioné en un artículo anterior. Mucha lógica del juego está fuertemente atada a los valores harcodeados relacionados con el tamaño actual del mapa, haciéndo bastante complicado modificar el aspecto del juego. Me pensaré si soluciono este problema en un último artículo de la serie, para conseguir por ejemplo que el juego sea capaz de adaptarse al tamaño de tu pantalla, o dejarlo como contenido para otra posible serie de tutoriales.

¡Visita el blog de vez en cuando si estás intrigado en saber lo que voy a hacer!