En Mi Local Funciona

Technical thoughts, stories and ideas

Phaser 3: Mi primer juego HTML5/JS (Parte 3)

Publicado por Jesús García Navarro el

UI DevelopmentPhaser 3HTML5

Y continuamos una más vez con la serie de posts sobre cómo desarrollar un videojuego completo con Phaser3. En el post anterior aprendimos a crear enemigos, implementamos el sistema de colisiones y le aplicamos sonidos y música de fondo al juego.

¿Qué vamos a ver en el presente post?

El presente post va a ser un poco más ambicioso que los anteriores y trataremos los siguientes puntos:

  • Creación de clases.
  • Mostrar textos en pantalla.
  • Delay.
  • Solapamientos.
  • Pausar y reiniciar la escena.

Para explicar estas características de "Phaser 3" separaremos nuestros sprites en diferentes clases JS, crearemos un nuevo enemigo, añadiremos las vidas de nuestro personaje, también la necesidad de recargar nuestro disparo, puntuación, un powerup para disparar doble, invencibilidad durante unos segundos tras ser golpeados y reiniciar la escena tras el fatídico e indeseado "Game Over".

Resultado final

En la parte inferior del post he embebido un "codesandbox" para que podáis ver el código, trastear con él y probar en vivo el juego. Incluso desde este enlace podéis jugar:
Mi primer Juego (Parte 3).

Nota: He desactivado la música de fondo en el ejemplo.

Creando clases

Hasta ahora todo el código del juego lo teníamos dentro del archivo "Firstscene.js" y podemos observar cómo el código se va haciendo cada vez más grande y poco comprensible, así que, aplicando los buenos criterios y prácticas de programación, vamos a dividir los elementos del juego en clases diferentes.

Lo primero que haremos es crearnos un directorio llamado "classes" y dentro de ella los archivos que contendrán las clases de nuestro juego:

alt

Donde bacterium.js será nuestro nuevo enemigo, bullet.js el disparo, player.js nuestro personaje, porwerup.js el poder de doble disparo y viurs.js nuestro conocido enemigo del post anterior.

Por resumir, separaremos nuestros sprites, hojas de sprites o grupos en clases independientes para una correcta reutilización.

Nota: También hemos agrupado las imágenes y sonidos en diferentes carpetas dentro de assets. Y como siempre las incorporamos en el método preload():

this.load.path = './assets/';

    // LOAD IMAGES AND SPRITES
    this.load.image('background', 'backgrounds/background.png')
        .image("bullet", "sprites/bullet.png")
        .image("virus", "sprites/virus.png")
        .image("bacterium", "sprites/bacterium.png")
        .image('life', "sprites/life.png")
        .image('soap', 'sprites/soap.png')
        .image('reload', 'sprites/reload.png')
        .image('powerup', 'sprites/powerup.png')
        .spritesheet('doggysprite', 'sprites/doggysprite.png',
            { frameWidth: 50, frameHeight: 66 }
        );

Notad que hemos añadidos nuevos sprites como "bacterium" o "life" que luego veremos para qué los necesitamos.

Para aprender a crear las clases, vamos a hacerlo detalladamente con la clase bacterium.js que es el nuevo enemigo que crearemos en este post. Para las demás clases solo trasladaríamos el código desde el archivo "Firstscene.js" hacia sus respectiva clases. Aún así, detallaremos los pequeños ajustes realizados en las mejoras del juego.

Player.js

Tal y como hemos comentando, traspasamos la lógica de nuestro personaje principal a una clase propia, quedando de la siguiente manera:

export default class Player extends Phaser.Physics.Arcade.Sprite {
    constructor(scene, x, y, sprite) {
        super(scene, x, y, sprite); 
        this.scene = scene;
        this.scene.add.existing(this);
        this.scene.physics.world.enable(this);

        this.init();
        this.animatePlayer();   
    }

    init(){
        this
        .setBounce(0.2)
        .setCollideWorldBounds(true)
        .setGravityY(300)
        .setDepth(2)
        .body.setSize(35,66,35,30); // custom mask => setSize(width, height, XinSprite, YinSprite)
    }

    animatePlayer() {
        this.anims.create({
            key: 'left',
            frames: this.anims.generateFrameNumbers('doggysprite', { start: 0, end: 3 }),
            frameRate: 10,
            repeat: -1

        });

        this.anims.create({
            key: 'turn',
            frames: [{ key: 'doggysprite', frame: 4 }],
            frameRate: 20,
            // delay: 1.1
        });

        this.anims.create({
            key: 'right',
            frames: this.anims.generateFrameNumbers('doggysprite', { start: 5, end: 8 }),
            frameRate: 10,
            repeat: -1
        });

    }
}

Esta clase hereda de Phaser.Physics.Arcade.Sprite, así los objetos tendrán las propiedades y métodos de los sprites incluido las físicas. El constructor debe recibir la escena, y en nuestro caso la posición x e y deseada del sprite. Nosotros también le hemos añadido un parámetro que recibe la imagen (hoja de sprite) a dibujar. Además se hace clase exportable con "export", para que pueda ser importada desde la escena principal.

Es muy importante no olvidar añadir a la escena y a las físicas estos nuevos objetos:

    this.scene = scene;
    this.scene.add.existing(this);
    this.scene.physics.world.enable(this);

Como podemos observar, se ha trasladado la animación y movimiento del personaje, invocándolos desde el constructor, así como el establecimiento de sus propiedades.

Destacando la propiedad size que se usa para adaptar mejor la máscara de colisiones de nuestro sprite.

  • body.setSize(35,66,35,30);

Bacterium.js

Un concepto muy importante a tener en cuenta es que dependiendo de si es un sprite o un grupo las clases se extenderá de objetos diferentes.

Este sería nuestro nuevo enemigo:

alt

Vector de Personaje creado por brgfx - www.freepik.es

La diferencia con respecto al "virus" es que va a necesitar varios golpes para desaparecer y que además le dotaremos de física para que su dirección cambie cuando colisione con los disparos.

Así, en el archivo bacterium.js codificamos lo siguiente:

export default class Bacterium extends Phaser.Physics.Arcade.Group {
    constructor(physicsWorld, scene) {
        super(physicsWorld, scene);
    }

    newItem(){
        this.create(
                    Phaser.Math.Between(0, this.scene.scale.width), 80, 'bacterium')
                    .setActive(true)
                    .setVisible(true)
                    .setGravityY(400)
                    .setCollideWorldBounds(true)
                    .setDepth(2)
                    .setCircle(32)
                    .setBounce(1, 1)
                    .setVelocityX((Phaser.Math.Between(0, 1) ? 180 : -180))
                    .hitsToKill = 4;
    }
}

Lo interesante de esta clase es que hereda de Phaser.Physics.Arcade.Group, es decir ya se establece como grupo e incluso con físicas. Su constructor debe recibir las físicas del juego así como la escena, para recibir su contexto y poder trabajar con sus items.

En esta clase implementamos el método newItem(), similar a las futuras clases, y en ella es donde crearemos y estableceremos las propiedades del objeto. En este caso lo más reseñable es que hemos añadido un atributo nuevo hitsToKill para llevar el control de las veces que necesita ser golpeado nuestro enemigo hasta desaparecer.

Virus.js

Esta clase es igual que la clase bacterium.js pero con los valores propios del virus ya vistos en la entrega anterior.

    export default class Virus extends Phaser.Physics.Arcade.Group {
        constructor(physicsWorld, scene) {
            super(physicsWorld, scene);

        }

        newItem(){
            this.create(
                        Phaser.Math.Between(0, this.scene.scale.width), 20, 'virus')
                        .setActive(true)
                        .setVisible(true)
                        .setGravityY(300)
                        .setCollideWorldBounds(true)
                        .setDepth(2)
                        .setCircle(45)
                        .setBounce(1, 1)
                        .setVelocityX((Phaser.Math.Between(0, 1) ? 100 : -100))
                        .hitsToKill = 1;
        }    
    }

En este caso, este virus desaparecerá con solo un disparo. .hitsToKill = 1;

Bullet.js

}

export default class Bullet extends Phaser.Physics.Arcade.Group {
    constructor(physicsWorld, scene) {
        super(physicsWorld, scene);

        this.scene = scene;
    }

    newItem(x = 17, y = 30) {
        var item = this.create(this.scene.player.x + x, this.scene.player.y - y, 'bullet')
            .setActive(true)
            .setVisible(true)
            .setDepth(2);
        item.body.velocity.y = -200;
        item.outOfBoundsKill = true;
    }

}

Esta clase también es similar a la anterior, ya que es un grupo de sprites. Lo más reseñable de esta clase es que hemos dejado preparado el método newItem para que reciba la posición desde donde nace el disparo con respecto al jugador. (Hemos hecho esto porque luego obtendremos un powerup para disparar dos burbujas a la vez).

Powerup.js

Vamos a crear un nuevo elemento en pantalla, que será una burbuja, cuyo comportamiento será parecido al de los enemigos pero que contendrá una recompensa, en este caso será la capacidad de disparar doble durante un número determinado de balas.

alt

Vector de Agua creado por macrovector - www.freepik.es

    export default class Powerup extends Phaser.Physics.Arcade.Group {
        constructor(physicsWorld, scene) {
            super(physicsWorld, scene);
        }

        newItem(){
            this.create(
                        Phaser.Math.Between(0, this.scene.scale.width), 20, 'powerup')
                        .setActive(true)
                        .setVisible(true)
                        .setGravityY(50)
                        .setCollideWorldBounds(true)
                        .setDepth(2)
                        .setCircle(25)
                        .setBounce(1, 1)
                        .setVelocityX((Phaser.Math.Between(0, 1) ? 100 : -100))
                        .hitsToKill = 1;
        }
    }

Explicación de la lógica de la escena

Ya tenemos definidas las clases que intervendrán en nuestra escena. Ahora solo queda aplicarlas a la lógica del juego. Para comprender todo mejor, vamos a ir explicando paso a paso la clase:

Firstscene.js

Lo primero es importar todas las clases que vamos a necesitar:

    import Bacterium from "../clasess/bacterium.js";
    import Player from "../clasess/player.js";
    import Virus from "../clasess/virus.js";
    import Bullet from "../clasess/bullet.js";
    import Powerup from "../clasess/powerup.js";

Ahora dentro de la clase class Firstscene extends Phaser.Scene { } iremos creando los diferentes métodos preestablecidos y propios:

constructor() {
    super('Firstscene');
}

init() {
    this.respawn = 0;
    this.respawnInterval = 3000;
    this.scoreText = "";
    this.score = 0;
    this.lifesCounter = 3;
    this.lifesText = "";
    this.newLife = 250; // Nueva Vida cada X puntuación
    this.enemiesGlobalCounter = 0;
    this.invincible = false;
    this.ammo = 30;
    this.ammoText = "";
    this.powerupCounter = 0;

}

En el método init() iremos definiendo las variables a usar, que son fácilmente entendibles según su nombre.

Ahora en preload() como hemos comentado anteriormente, cargaremos todos los assets necesarios de nuestra escena.

preload() {

    this.load.path = './assets/';

    // LOAD IMAGES AND SPRITES
    this.load.image('background', 'backgrounds/background.png')
        .image("bullet", "sprites/bullet.png")
        .image("virus", "sprites/virus.png")
        .image("bacterium", "sprites/bacterium.png")
        .image('life', "sprites/life.png")
        .image('soap', 'sprites/soap.png')
        .image('reload', 'sprites/reload.png')
        .image('powerup', 'sprites/powerup.png')
        .spritesheet('doggysprite', 'sprites/doggysprite.png',
            { frameWidth: 50, frameHeight: 66 }
        );

    // LOAD AUDIOS
    this.load.audio('pop', ['sounds/pop.wav'])
        .audio('shot', ['sounds/shot.wav'])
        .audio('killed', ['sounds/killed.wav'])
        .audio('rebound', ['sounds/rebound.wav'])
        .audio('bgmusic', ['sounds/bgmusic.mp3']);
}

A destacar que esta vez hemos indicado el directorio desde donde partirán todos los assets:

  • this.load.path = './assets/';

En el métodod create() es donde añadiremos estos assets a la escena:

create() {

    // TEXTS
    this.scoreText = this.add.text(this.sys.game.canvas.width / 2 - 65, 0, 'SCORE: ' + this.score, { fontStyle: 'strong', font: '19px Arial', fill: '#6368BC' });
    this.scoreText.setDepth(1);
    this.lifesText = this.add.text(50, 10, 'X ' + this.lifesCounter, { fontStyle: 'strong', align: 'right', font: '24px Arial', fill: 'beige' });
    this.lifesText.setDepth(1);
    this.ammoText = this.add.text(this.sys.game.canvas.width - 150, 10, 'AMMO: ' + this.ammo, { fontStyle: 'strong', align: 'right', font: '24px Arial', fill: 'beige' });
    this.ammoText.setDepth(1);


    // CREATE AUDIOS
    this.popSound = this.sound.add('pop');
    this.shotSound = this.sound.add('shot');
    this.killedSound = this.sound.add('killed');
    this.reboundSound = this.sound.add('rebound');

    // BACKGROUND MUSIC
    this.backgroundMusic = this.sound.add('bgmusic');
    this.backgroundMusic.loop = true;
    this.backgroundMusic.play();

    // CREATE KEYBOARD CURSOS
    this.cursors = this.input.keyboard.createCursorKeys();

    // CREATE SPRITES
    this.background = this.add.image(this.sys.game.canvas.width / 2, this.sys.game.canvas.height / 2, 'background');
    this.lifeSprite = this.add.image(30, 18, 'life').setDepth(1);
    this.soapImage = this.physics.add.image(40, this.sys.game.canvas.height - 30, 'soap').setActive(true).setDepth(1).setVisible(false);
    this.reloadImage = this.add.image(50, this.sys.game.canvas.height - 80, 'reload');
    this.reloadImage.setVisible(false);

    // PLAYER
    this.player = new Player(this, this.sys.game.canvas.width / 2, this.sys.game.canvas.height, 'doggysprite');


    // GROUPS
    this.virusGroup = new Virus(this.physics.world, this);
    this.bacteriumGroup = new Bacterium(this.physics.world, this);
    this.bulletsGroup = new Bullet(this.physics.world, this);
    this.powerupGroup = new Powerup(this.physics.world, this);


    // ADD COLIDERS BETWEEN SPRITES        
    this.physics.add.overlap(this.player, [this.virusGroup, this.bacteriumGroup, this.powerupGroup], this.hitPlayer, null, this);
    this.physics.add.collider(this.bulletsGroup, [this.virusGroup, this.bacteriumGroup], this.hitEnemies, null, this);
    this.physics.add.collider(this.bulletsGroup,  this.powerupGroup, this.hitPowerup, null, this);
    this.physics.add.overlap(this.player, this.soapImage, this.reloadAmmo, null, this);

}

Cabe detenernos en varios puntos:

  • Hemos generado objetos de nuestras clases

Por ejemplo:

// GROUPS
    this.virusGroup = new Virus(this.physics.world, this);
  • Hemos añadido los textos a la escena

Por ejemplo:

    // TEXTS
    this.scoreText = this.add.text(this.sys.game.canvas.width / 2 - 65, 0, 'SCORE: ' + this.score, { fontStyle: 'strong', font: '19px Arial', fill: '#6368BC' });
    this.scoreText.setDepth(1);
    this.lifesText = this.add.text(50, 10, 'X ' + this.lifesCounter, { fontStyle: 'strong', align: 'right', font: '24px Arial', fill: 'beige' });

Simplemente con this.add.text() conseguimos posicionar los textos en pantalla y dotarlos de estilos. Tabmién la imagen de nuestro protagonista para que se entienda que son las vidas:

    this.lifeSprite = this.add.image(30, 18, 'life').setDepth(1);

Nota: Hay que establecer correctamente la profundidad de cada sprite con .setDepth()

alt
  • Hemos añadido solapamiento

Aquí hemos optimizado el código, agrupando en un vector el conjunto de assets que interaccionan con otros.

    // ADD COLIDERS BETWEEN SPRITES        
    this.physics.add.overlap(this.player, [this.virusGroup, this.bacteriumGroup, this.powerupGroup], this.hitPlayer, null, this);
    this.physics.add.collider(this.bulletsGroup, [this.virusGroup, this.bacteriumGroup], this.hitEnemies, null, this);
    this.physics.add.collider(this.bulletsGroup,  this.powerupGroup, this.hitPowerup, null, this);
    this.physics.add.overlap(this.player, this.soapImage, this.reloadAmmo, null, this);

Y aparte, hemos utilizado overlap en vez de collider, la diferencia radica en que no se produce choque entre los assets, sino que con overlap también se detecta la colisión pero no se produce fricción entre los assets implicados, uno pasaría por encima del otro.

Pasamos ahora a detallar el método update():

update(time, delta) {

    // INPUT CONTROL
    if (this.input.keyboard.checkDown(this.cursors.space, 250)) {
        this.player.setVelocity(0, 0)
            .anims.play('turn');
        this.fire();

    }
    else if (this.cursors.left.isDown) {
        this.player.setVelocityX(-160)
            .anims.play('left', true);
    }
    else if (this.cursors.right.isDown) {
        this.player.setVelocityX(160)
            .anims.play('right', true);
    }
    else {
        this.player.setVelocityX(0)
            .anims.play('turn');
    }

    //  ENEMIES RESPAWN CONTROL AFTER GAME OVER
    if (time > this.respawnInterval && this.respawn == 0) {
        this.respawn = Math.trunc(time);
    }

    if (time > this.respawn) {

        // POWERUP
        if (this.enemiesGlobalCounter % 15 == 0 && this.enemiesGlobalCounter != 0) {
            this.powerupGroup.newItem();
        }

        if (this.enemiesGlobalCounter % 5 == 0 && this.enemiesGlobalCounter != 0) {

            if (this.respawnInterval > 600) {
                this.respawnInterval -= 100;
            }

            this.addEnemy(0);
        }
        else {
            this.addEnemy(1);
        }
        this.respawn += this.respawnInterval;
    }
}

La parte de INPUTS CONTROL es la misma que la del post anterior, controla el movimiento e invoca el disparo de nuestro personaje.

El siguiente bloque de RESPAWN, controla la aparición de enemigos. El primer If controla la reaparición una vez que se ha reseteado la escena, y el siguiente gran bloque If controla la creación del powerup, llamando al método de la clase powerup.js con this.powerupGroup.newItem(). Luego controlamos la aparición de las dos clases de enemigos invocando a un método propio this.addEnemy(x), donde se le pasará un parámetro para indicar qué enemigo generar, (lo veremos más adelante).

Ahora veremos los métodos creados:

Añadiendo los enemigos

Este sería el método que crearía los objetos de los distintos enemigos:

addEnemy(type) {
    this.reboundSound.play();
    this.enemiesGlobalCounter++;

    switch (type) {
        case 0:
            this.bacteriumGroup.newItem();
            break;
        default:
            this.virusGroup.newItem();
    }
}

Colisión entre el disparo y los enemigos

Como podemos comprobar, lo primero que hacemos es destruir la bala (burbuja) y restarle los golpes restantes que le quedan al enemigo. Ya cuando llega a 0, destruimos también el enemigo de la escena, aumentamos y actualizamos el marcador e incluso añadimos una vida extra.

hitEnemies(bullet, enemy) {
    bullet.setVisible(false);
    bullet.setActive(false);
    bullet.destroy();

    enemy.hitsToKill--;

    if (enemy.hitsToKill == 0) {
        enemy.destroy();
        this.popSound.play();
        this.score += 10;
        this.scoreText.setText('SCORE: ' + this.score);

        if (this.score % this.newLife == 0) {
            this.lifesCounter++;
            this.lifesText.setText('X ' + this.lifesCounter);
        }
    }
}

PowerUp

Este método es muy sencillo, simplemente si le disparamos a la burbuja del PowerUp, llamamos al método hitEnemies() para que haga la funcionalidad anterior y añadimos 10 disparos dobles a consumir.

hitPowerup(bullet, bubble) {
    this.hitEnemies(bullet, bubble);
    this.powerupCounter = 10;
}

Colisión entre enemigo y jugador

En este método, una vez se produzca la colisión, descontaremos una vida a nuestro personaje, actualizaremos dicho texto en pantalla, destruiremos al enemigo y emitiremos el sonido de colisión.

hitPlayer(player, enemy) {

    if (!this.invincible) {
        this.invincible = true;
        this.killedSound.play();
        this.lifesCounter--;
        this.lifesText.setText('X ' + this.lifesCounter);
        enemy.destroy();
        player.setTint(0x1abc9c);
        this.time.addEvent({
            delay: 1000,
            callback: () => {
                this.invincible = false;
                player.clearTint();
            }
        });

        if (this.lifesCounter < 0) {
            alert("GAME OVER");
            this.virusGroup.clear(true, true); // clear( [removeFromScene] [, destroyChild])
            this.bacteriumGroup.clear(true, true);
            this.bulletsGroup.clear(true, true);
            this.scene.restart();
        }

    }
}

Si las vidas llegan a 0, aparece la alerta de "GAME OVER" y se reinicia la escena.

Otra característica que hemos añadido al juego es que al ser golpeados, método hitPlayer(), obtengamos un segundo de invencibilidad para que no podamos ser golpeados por otro enemigo. Lo mostramos en pantalla cambiando el color de nuestro personaje principal durante ese periodo de tiempo con esta funcionalidad:

        this.invincible = true;
        player.setTint(0x1abc9c);
        this.time.addEvent({
            delay: 1000,
            callback: () => {
                this.invincible = false;
                player.clearTint();
            }
        });

Recarga y doble disparos

Con este método realizamos el disparo o el doble disparo. Hemos incorporado un contador, para que nuestro personaje solo pueda disparar 30 veces. Una vez terminado tendrá que recargar cogiendo de la pantalla el dispensador de jabón, que aparecerá aleatoriamente una vez se quede el protagonista sin balas.

alt

Vector de Diseño creado por freepik - www.freepik.es

Si anteriormente ha disparado a un "Powerup", se detendrá el contador y se generarán los dobles disparos hasta un máximo de 10.

fire() {
    if (this.ammo >= 1 && this.powerupCounter === 0) {
        this.bulletsGroup.newItem();
        this.shotSound.play();
        this.ammo--;
        this.ammoText.setText('AMMO: ' + this.ammo);
    }

    if (this.ammo == 0 && this.powerupCounter === 0) {
        this.reloadImage.setVisible(true).setActive(true);
        this.soapImage.setVisible(true).setActive(true);
    }

    if (this.powerupCounter > 0){
        this.bulletsGroup.newDoubleItem();
        this.shotSound.play();
        this.powerupCounter--;
    }


}

Así quedaría el método reload()

reloadAmmo() {

    if (this.ammo === 0) {
        this.ammo = 30;
        var randomX = Phaser.Math.Between(40, this.sys.game.canvas.width - 50);
        this.reloadImage.setX(randomX).setActive(false).setVisible(false);
        this.soapImage.setX(randomX).setActive(false).setVisible(false);
        this.ammoText.setText('AMMO: ' + this.ammo);
    }

}

Codesandbox Phaser3

Os dejo todo el código en "codesandbox" para que podáis verlo y modificarlo en vivo:

Continuará...

Hasta aquí el presente post. Como podéis observar el juego va ganando en complejidad. En las siguientes entregas veremos:

  • Pantalla de Game Over.
  • Pantalla de presentación.
  • Nuevas pantallas.
  • Jefe final.
  • Explosión del enemigo.

El código actual del juego lo tenéis en GitHub:

¡Síguenos en Twitter para estar al día de próximas entregas!

Enlaces de interés