Plataformas con Phaser

Por 9.99€ al mes tendrás acceso completo a todos los cursos. Sin matrícula ni permanencia.

Los videojuegos de plaformas ven sus orígenes en la década de los 80. Space Panic (1980), Pitfall (1981, de la Atari 2600), Super Mario Bros (1985).

En un videojuego de plataformas típico, el jugador irá saltando de plataforma en plataforma hasta llegar a su destino. Puede haber distintos tipos de plataformas, enemigos, ataques, etc.

Pasos en la realización de un juego de plataformas con PhaserJS

Carga de estructura y fondo

Definimos la estructura global del juego y cargamos la imagen de fondo. Como siempre, en la escena Boot.js precargamos la imagen de fondo y en la escena Game.js la ubicamos en pantalla.

Cargar el mapa

En este juego, hemos creado un mapa utilizando la aplicación Tiled. En la escena de nuestro juego, cargamos el JSON generado con esta herramienta y cargamos la imagen tiles.png que será usado por dicho JSON para poner los cuadraditos de la escena en pantalla.

En el fichero JSON, collisionLayer es la capa en la que hemos definido las plataformas contra las que podrá colisionar el jugador y en las que se podrá apoyar.

class Game extends Phaser.Scene {
  preload() {
    this.load.tilemapTiledJSON("jsonLevel", "/assets/json-level.json");
    this.load.image("tilesetPNG", "/assets/tiles.png");
  }
  create(){
    const map = this.make.tilemap({ key: 'jsonLevel' });
    const tileset = map.addTilesetImage('nombreDelTilesetEnTiled', 'tilesetPNG');
    this.collisionLayer = map.createLayer('collisionLayer', tileset);

    // Esta línea hace un zoom para alejar, para que se vea el mapa al completo, una vez hayamos centrado el mapa en la posición correcta, podemos comentarla
    this.cameras.main.setZoom(0.5);
  }

Ubicar el punto de origen de la cámara en pantalla.

Es posible que querramos que la cámara comience en un punto diferente del que comienza por defecto. Para lograrlo, podemos utilizar la siguiente clase, que nos permitirá sobrevolar la escena, para decidir las coordenadas iniciales de la cámara.

export class DebugMapCamera {
  scene;
  controls;
  debugCoords;

  constructor(scene) {
    this.scene = scene;
    this.debugCoords = scene.add.text(0, 0, '').setScrollFactor(0);
    const cursors = scene.input.keyboard.createCursorKeys();
    this.controls = new Phaser.Cameras.Controls.FixedKeyControl({
      camera: scene.cameras.main,
      left: cursors.left,
      right: cursors.right,
      up: cursors.up,
      down: cursors.down,
      speed: 0.5
    });
   }

   update(time, delta) {
     this.controls.update(delta);
     this.debugCoords.setText([
       `scrollX: ${this.scene.cameras.main.scrollX}`,
       `scrollY: ${this.scene.cameras.main.scrollY}`
     ]);
   }
}

export default DebugMapCamera;

Y luego, dentro de la escena donde queremos fijar la cámara, usaremos:

create() {
  ...
  this.debugCamera = new DebugMapCamera(this);
}    

update(time, delta) {
  this.debugCamera.update(time, delta);
}

Con esto podremos mover la cámara utilizando los cursores del teclado y decidir donde queremos ubicarla:

this.cameras.main.setScroll(440, -304);

Cargar Player con el JSON de Tiled

Primero definiremos la clase del Player en un fichero externo, para que todo quede más ordenado. Esta clase tendrá el código necesario para crear un jugador y añadirlo a la escena. Este jugador, que hereda de la clase Sprite, tendrá físicas, así que deberemos activarlas en el main.js.

class Player extends Phaser.Physics.Arcade.Sprite {
  constructor(scene, x, y) {
    super(scene, x, y, 'player');
    scene.add.existing(this);
    scene.physics.add.existing(this);
    this.body.gravity.y = 500;
  }
}

export default Player;

En la clase Game, necesitaremos el siguiente método para buscar las coordenadas del player en el JSON de Tiled:

findObjectsByClassInObjectsLayer(classParam, tilemap) {
  const result = [];
  tilemap.objects.forEach(function (element) {
    if (element.name === 'objectsLayer') {
      element.objects.forEach(function (element2) {
        if (element2.type === classParam) {
          element2.y -= tilemap.tileHeight;
          result.push(element2);
        }
      });
    }
  });
  return result;
}

Finalmente, cargamos el player en la escena.

const playersFromTiled = this.findObjectsByClassInObjectsLayer('player', map);
this.player = new Player(this, playersFromTiled[0].x, playersFromTiled[0].y);

Hacer la capa collisionLayer colisionable con el player

// Hacemos que los tiles que pertenecen a la collisionLayer sean colisionables    
this.collisionLayer.setCollisionByExclusion([-1]);
// Establecemos la colisión entre el jugador y la collisionLayer
this.physics.add.collider(this.player, this.collisionLayer);

Cargar el resto de capas no colisionables (grassLayer y backgroundLayer)

El el fichero generado con Tiled, además de la capa collisionLayer que contiene los objetos colisionables, también tenemos otras dos capas que contienen elementos del decorado. La capa de la hierba (grassLayer) que pondremos por delante del personaje y la capa backgroundLayer, con el resto de tiles de la decoración.

create(){
  ...
  map.createLayer('grassLayer', tileset).setDepth(100);
  map.createLayer('backgroundLayer', tileset);
}

Ajustando la boundingbox del player

El spritesheet del player es un poco grande y eso hace que su area de colisión también lo sea. Para reducir este area de colisión, llamaremos al método setSize y le pasaremos como ancho el valor 90, que es la mitad del valor original

this.setSize(90, 180, true);

El jugador se mueve

En la clase Player vamos a integrar el código necesario para controlar su movimiento. Utilizaremos el objeto this.cursors dentro del método update para evaluar que tecla ha pulsado el usuario (la flecha de ir hacia la izquierda o la flecha de ir hacia la derecha). En función de la tecla pulsada llamaremos a la función leftWalk() o la función rightWalk(), que desplazará al jugador aplicándole una velocidad horizontal.

class Player extends Phaser.Physics.Arcade.Sprite {
  constructor(scene, x, y) {
    ...
    this.cursors = this.scene.input.keyboard.createCursorKeys();
  }

  update() {       
    if (this.cursors.left.isDown) {
      this.leftWalk()
    } else if (this.cursors.right.isDown) {
       this.rightWalk();
    }
  }

  leftWalk() {
    this.body.setVelocityX(-250);
  }

  rightWalk() {
    this.body.setVelocityX(250);
  }
}
class Game extends Phaser.Scene {
  ...
  update() {
    this.player.update();
  }
}

Animación del jugador

Cargamos la animación de caminar del Player…

/src/scenes/Boot.js

class Boot extends Phaser.Scene{
  create(){
    this.anims.create({
      key: 'walk',
      frames: this.anims.generateFrameNumbers('player', {start: 2, end: 5}),
      frameRate: 10,
      repeat: -1,
    });
  }
}

… Y en la clase del Player activamos dicha animación cuando el Player se mueva hacia los lados. También haremos un flip del spritesheet para que el Player siempre mire en la dirección en la que se está desplazando.

/src/characters/Player.js

leftWalk(){
    this.body.setVelocityX(-250);
    this.flipX = true;
    this.play('walk', true);
}

rightWalk(){
    this.body.setVelocityX(250);
    this.flipX = false;
    this.play('walk', true);
}

Reposo

Vamos a añadir al Player la animación de estar en reposo. Esto es una animación en la que se ve al Player respirando y que estará activa cuando el Player no se mueva en ninguna dirección.

Cargaremos la animación de estar en reposo del Player en la escena Boot.js a partir del spritesheet.

/src/scenes/Boot.js

this.anims.create({
      key: 'idle',
      frames: this.anims.generateFrameNumbers('player', { start: 0, end: 1 }),
      frameRate: 4,
      repeat: -1,
      });
  }

Tras cargar la animación de estar en reposo (dile), la activamos en la clase Player siempre y cuando no se esté moviendo.

/src/characers/Player.js

class Player extends Phaser.Physics.Arcade.Sprite {
  constructor(scene, x, y) {
    ...
    this.play('idle', true);
  } 

  update(){
    ...
    }else {
      this.idle();
    }
  }

  idle() {
    this.body.setVelocityX(0);
    this.play('idle', true);
  }
}

Saltar

Añadimos el método saltar a la clase Player. El jugador sólo debe poder saltar si está tocando el suelo. Para ello, cuando pulsemos la flecha de dirección hacia arriba, llamamos a la función jump que será la que lo evalúe.

class Player extends Phaser.Physics.Arcade.Sprite {
  constructor(scene, x, y) {
    this.onGround= false;
  }
	
  update(){
    this.onGround= this.body.onFloor();
    ...
    if (this.cursors.up.isDown) {
      this.jump();
    }
  }

  jump(){
    if(this.onGround){
      this.body.setVelocityY(-500);
    }
  }
}

Añadir nuevos controles para un segundo jugador (paso opcional)

Una variante que podemos añadir a nuestro videojuego es que haya dos jugadores. Incluso podríamos partir la pantalla y añadir una cámara que siga a cada uno de los dos jugadores. Para ello, cada jugador será controlado con unas teclas diferentes del teclado. A continuación tienes el código para las teclas que controlarán al segundo jugador.

this.cursors2 = scene.input.keyboard.addKeys({ up: 'W', left: 'A', down: 'S', right: 'D' });

Además, debemos tener en cuenta que los nombres de los recursos y de las animaciones no pueden estar repetidos.

  this.anims.create({
    key: 'walk2',
    frames: this.anims.generateFrameNumbers('run2', { start: 0, end: 13 }),
    frameRate: 30,
    repeat: -1,
  });

  this.anims.create({
    key: 'idle2',
    frames: this.anims.generateFrameNumbers('idle2', { start: 0, end: 17 }),
    frameRate: 30,
    repeat: -1,
  });

Animación de Caer

Vamos a activar una animación para el jugador para cuando no esté en contacto con el suelo, es decir, para cuando esté en la trayectoria ascendente o descendente de un salto.

Como utilizaremos una nueva animación, la cargamos dentro del método create de la clase Boot.js.

/src/scenes/Boot.js

this.anims.create({
      key: 'fall',
      frames: this.anims.generateFrameNumbers('player', {start: 6, end: 7}),
      frameRate: 7,
      repeat: -1,
    });

En la clase Player, activaremos todas las animaciones que teníamos (idle y caminar) siempre que el player esté en contacto con el suelo. Cuando el player no esté en contacto con el suelo, activaremos la animación de caer.

/src/scenes/Player.js

class Player extends Phaser.Physics.Arcade.Sprite {
  jump(){
    if(this.onGround){
      this.body.setVelocityY(-500);
      this.play('fall', true);
    }
  }

  leftWalk(){
    ...
    if(this.onGround)this.play('walk', true);
  }

  rightWalk(){
    ...
    if(this.onGround)this.play('walk', true);
  }

  idle() {
    ...
    if (this.onGround)this.play('idle', true);
  }
}

Controles visuales – poniendo los botones en pantalla

Para poder jugar desde un dispositivo móvil, definiremos la función visualControls, que colocará los controles visuales del jugador en escena.

El método setDepth hará que los controles siempre esten superpuestos al resto de elementos.

El método setScrollFactor hará que los botones no se desplacen aunque el jugador si lo haga.

visualControls(scene) {
  const leftbtn = scene.add.sprite(50, gameHeight - 30, 'arrow').setInteractive();
  leftbtn.setDepth(200);
  leftbtn.setScrollFactor(0, 0); // este método hará que cuando en los siguientes pasos hagamos que la cámara siga al jugador, el botón de desplazamiento no se desplace. Si no utilizásemos este método, cuando hagamos el pointerup, como este no se hace en la misma posición en la que hicimos el pointerdown, no funcionaría
  const rightbtn = scene.add.sprite(140, gameHeight - 30, 'arrow').setInteractive();
  rightbtn.flipX = true;
  rightbtn.setDepth(200);
  rightbtn.setScrollFactor(0, 0);
  const upbtn = scene.add.sprite(850, gameHeight - 30, 'arrow').setInteractive();
  upbtn.rotation = Math.PI/2;
  upbtn.setDepth(200);
  upbtn.setScrollFactor(0, 0);
}

Controles visuales – dando funcionalidad

Vamos a completar el código que definimos en el paso anterior y que añadía controles visuales al videojuego. A estos controles visuales vamos a añadir funcionalidad para controlar el moviento del jugador. De esta forma, cuando el jugador los pulse, modificaremos una propiedad data vinculada al propio Player, que almacenará información sobre los botones pulsados.

En el método update evaluaremos tanto los botones que el jugador ha pulsado como la información almacenada en data. En función de esto determinaremos el movimiento del jugador.

visualControls(scene) {
  ...
  this.setData('horizontalDirection', 0);
  this.setData('isJumping', false);

  leftbtn.on('pointerdown', () => {
    this.setData('horizontalDirection', Phaser.LEFT);
  });

  rightbtn.on('pointerdown', () => {
    this.setData('horizontalDirection', Phaser.RIGHT);
  });

  upbtn.on('pointerdown', () => {
    this.setData('isJumping', Phaser.UP);
  });

  leftbtn.on('pointerup', () => {
    this.setData('horizontalDirection', Phaser.NONE);
  });

  rightbtn.on('pointerup', () => {
    this.setData('horizontalDirection', Phaser.NONE);
  });

  upbtn.on('pointerup', () => {
    this.setData('isJumping', Phaser.NONE);
  });
}

update() {
  this.onGround = this.body.onFloor();
  if (this.cursors.left.isDown || this.getData('horizontalDirection') === Phaser.LEFT) {
    this.leftWalk();
  } else if (this.cursors.right.isDown || this.getData('horizontalDirection') === Phaser.RIGHT) {
    this.rightWalk();
  } else {
    this.idle();
  }
  if (this.cursors.up.isDown || this.getData('isJumping') === Phaser.UP) {
    this.jump();
  }
}

La cámara sigue al prota

Hasta ahora, aunque podíamos controlar al player, la camara no lo seguía y podía desaparecer por los límites de la pantalla. Con este código hacemos que la cámara se desplace y siga al jugador.

class Game extends Phaser.Scene {
  create() {
    ...
    this.cameras.main.setSize(gameWidth, gameHeight);
    this.cameras.main.startFollow(this.player);
  }

La cámara sigue al prota horizontalmente

En este juego concretamente no queremos que la cámara se desplace verticalemente. Sólo queremos que la cámara siga al jugador horizontalmente. Para limitar el movimiento horizontal de la cámara usaremos la línea this.cameras.main.scrollY = 0;

Eliminamos la línea:

// this.cameras.main.startFollow(this.player);
class Game extends Phaser.Scene {
  create(){
    this.cameras.main.scrollY = 0;
  }

  update(){
    ...
    this.cameras.main.scrollX = this.player.x - 400;
  }

El fondo se repite

Igual que hicimos en el juego del Flappy Bird, vamos a hacer que la imagen de fondo se repita horizontalmente. Esta imagen debe estar preparada para que los pixels de un de sus lados coincidan con los pixeles del otro lado.

Sustituímos la línea que carga el fondo
this.bg = this.add.tileSprite(gameWidth/2, gameHeight/2, gameWidth, gameHeight, 'back').setScrollFactor(0);

En el método update, desplazamos el fondo acorde con la posición del jugador

class Game extends Phaser.Scene {
  update() {
    this.bg.tilePositionX = this.player.x;

Método para la inserción de enemigos

El método insertBadGuys que vamos a definir y a usar, recibirá como parámetro de entrada, la escena en la que queremos cargar los enemigos, el array con las coordenadas de cada uno de los enemigos y el tipo de enemigo que vamos a cargar.

class Game extends Phaser.Scene {
  create() {
    ...
    const antsFromTiled = this.findObjectsByClassInObjectsLayer('antEnemy', map);
    const ants = this.insertBadGuys(this, antsFromTiled, AntEnemy);
  }

  insertBadGuys(scene, arrayDeMalos, type) {
    const enemies = scene.physics.add.group({classType: type, runChildUpdate: true, runChildCreate: true, gravityY:100}); // Cuando instanciamos un grupo, los parámetros de gravedad los ponemos en la creación del grupo en lugar de en el personaje individual
    for (let i = 0; i < arrayDeMalos.length; i++) {
      const malo = new type(scene, arrayDeMalos[i].x, arrayDeMalos[i].y);
      enemies.add(malo);
    }
    return enemies;
  }
}
class AntEnemy extends Phaser.Physics.Arcade.Sprite {
  constructor(scene, x, y) {
    super(scene, x, y, 'ant');
    scene.physics.add.collider(this, scene.collisionLayer);
    scene.add.existing(this);
  }

  static loadAssets(scene) {
    scene.load.spritesheet('ant', ant, { frameWidth: 192, frameHeight: 96 });
  }
}
export default AntEnemy;

Enemigo con animación

En el preload de la escena Boot cargaremos el spritesheet de la hormiga y en el create definiremos su animación de andar. Luego, en el constructor de la clase hormiga debemos activar esta animación.

this.anims.create({
    key: 'antWalk',
    frames: this.anims.generateFrameNumbers('ant', { start: 0, end: 3 }),
    frameRate: 7,
    repeat: -1,
});

Enemigo se mueve

Aunque en el paso anterior cagamos la animación de andar de la hormiga, esta todavía no se esta desplazando. Para lograrlo, le aplicaremos una velocidad en su método update. Dicha velocidad tendrá una magnitud, que llamaremos speed y una dirección, que llamaremos direction y podrá ser hacia la derecha o hacia la izquierda.

constructor(x, y, scene) {
  ...
  this.speed = 100;
  this.direction = 1;
}

update() {
  this.body.setVelocityX(this.direction* this.speed);
}

Enemigo inteligente

Vamos a darle un poco de cerebro a nuestra hormiga. Vamos a hacer que cuando la hormiga llegue al límite de un barranco se mueva en dirección opuesta. Para ello, crearemos la variable nextY que contendrá información del pixel que hay justo delante de la hormiga a ras de suelo. Si justo delante de la hormiga a ras de suelo no hay una plataforma colisionable, significará que hay un precipicio y la hormiga dará media vuelta (modificaremos la variable direction).

class AntEnemy extends Phaser.Physics.Arcade.Sprite {

  update(){
    ...
    const nextX = Math.floor(this.x / 32) + this.direction;
    let nextY = this.y + this.height / 2;
    nextY = Math.round(nextY / 32);
    const nextTile = this.scene.collisionLayer.hasTileAt(nextX, nextY);
    if (!nextTile && this.body.blocked.down) {
      this.direction *= -1;
    }
    if (this.direction > 0) {
      this.flipX = false;
    } else {
      this.flipX = true;
    }
  }
}

Player muere, Enemigo muere

Cuando el jugador colisione contra una hormiga llamaremos al método checkEnemy de la clase Player que podrá hacer dos cosas:

  • Si el jugador se encontraba en un movimiento descendente, será porque el jugador esta aplastando al enemigo, y en ese caso llamaremos a la función die del enemigo, que lo desactivará.
  • Si el jugador no se encontraba en un movimiento descendente, eliminaremos al desactivaremos al jugador.
class Game extends Phaser.Scene {
  ...
  create() {
    ...
    this.physics.add.overlap(this.player, ants, this.player.checkEnemy, null, this.player);
  }
class Player extends Phaser.Physics.Arcade.Sprite {
  checkEnemy(player, enemy) {
    //  El jugador está cayendo?
    if (this.body.velocity.y > 0) {
      enemy.die();
    } else {
      this.die();
    }
  }
  die() {
    this.disableBody();
  }
class AntEnemy extends Phaser.Physics.Arcade.Sprite {
  die() {
    this.disableBody();
  }

Enemigo explota

Vamos a completar el código que se ejecuta cuando un enemigo muere. Hasta ahora, sólo estabamos desactivando sus físicas. En este paso vamos a activar una animación de explosión que debemos haber cargado previamente en la clase Boot y vamos a añadir un listener que escucha cuando finaliza cualquier animación, y cuando termine la animación de explosión vamos a eliminar a la hormiga.

class AntEnemy extends Phaser.Physics.Arcade.Sprite {
  constructor(scene, x, y, sprite) {
    ...
    this.on('animationcomplete', this.animationComplete, this);
  }
		
  die() {
    this.disableBody();
    this.play('crashAnim');
  }

  animationComplete(animation, frame, sprite) {
    if (animation.key === 'crashAnim') {
      this.destroy();
    }
  }
}

Prota explota

Ahora programaremos la muerte del jugador. El código es similar al de la muerte de la hormiga. Utilizaremos la variable isAlive para detener todas las demás animaciones cuando el Player es colisionado.

class Player extends Phaser.Physics.Arcade.Sprite {
  constructor(scene, x, y) {
    ...
    this.isAlive = true;
    this.on('animationcomplete', this.animationComplete, this);
    this.anims.create({
      key: 'crashAnim',
      frames: this.anims.generateFrameNumbers('crash', { start: 0, end: 4 }),
      frameRate: 7
    });
  }

  die() {
    this.isAlive= false;
    this.disableBody();
    //Para que esta animación se ejecute es necesario haber puesto el código que evalúa this.estaVivo en el update, para que no se sigan ejecutando el resto de animaciones y comportamientos
    this.play('crashAnim');
  }

  animationComplete(animation, frame, sprite) {
    if (animation.key === 'crashAnim') {
      this.destroy();
    }
  }

  update() {
    if (this.isAlive) {
      this.onGround = this.body.onFloor();
      if (this.cursors.left.isDown || this.getData('horizontalDirection') === Phaser.LEFT) {
        this.leftWalk();
      } else if (this.cursors.right.isDown || this.getData('horizontalDirection') === Phaser.RIGHT) {
        this.rightWalk();
      } else {
        this.idle();
      }

      if (this.cursors.up.isDown || this.getData('isJumping') === Phaser.UP) {
        this.jump();
      }
    }
  }
}

Enemigo con Herencia

Creamos una nueva clase llamada Enemy de tal forma que AntEnemy herede de Enemy. Meteremos las partes del código de AntEnemy que son comunes a todos los enemigos a la clase Enemy.

Aunque cambiemos la estructura del código interno, en este punto el juego debe seguir haciendo lo mismo.

class Enemy extends Phaser.Phantysics.Arcade.Sprite{
  constructor(scene, x, y, sprite ) {
    super(scene, x, y, sprite);
    scene.physics.add.collider(this, scene.collisionLayer);
    scene.add.existing(this);
    this.speed = 100;
    this.direction = 1;
    this.on('animationcomplete', this.animationComplete, this);
  }

  animationComplete(animation, frame, sprite) {
    if (animation.key === 'crashAnim') {
      this.destroy();
    }
  }

  update() {
    this.body.setVelocityX(this.direction * this.speed);

    const nextX = Math.floor(this.x / 32) + this.direction;
    let nextY = this.y + this.height / 2;
    nextY = Math.round(nextY / 32);
    const nextTile = this.scene.collisionLayer.hasTileAt(nextX, nextY);
    if (!nextTile && this.body.blocked.down) {
      this.direction *= -1;
    }
    if (this.direction > 0) {
      this.flipX = false;
    } else {
      this.flipX = true;
    }
  }

  die() {
    this.disableBody();
    this.play('crashAnim');
  }
}

export default Enemy;
class AntEnemy extends Enemy {
  constructor(scene, x, y) {
    super(scene, x, y, 'ant');

    this.play('antWalk');
  }
}

export default AntEnemy;

Insertar Oruga

Vamos a programar un nuevo tipo de enemigo: la oruga. Realmente, el comportamiento de la oruga y de la hormiga es muy similar. Ambos bichos se desplazar horizontalmente hasta encontrarse con un precipicio. En ese momento darán media vuelta. Como hemos encapsulado este comportamiento en la clase Enemy, si la oruga hereda de Enemy, hará eso mismo.

class CaterpillarEnemy extends Enemy {
  constructor(scene, x, y) {
    super(scene, x, y, 'caterpillar');
    this.anims.create({
      key: 'caterpillarWalk',
      frames: this.anims.generateFrameNumbers('caterpillar', { start: 0, end: 3 }),
      frameRate: 7,
      repeat: -1,
    });
    this.play('caterpillarWalk');
  }
}

export default CaterpillarEnemy;

Utilizaremos de nuevo el método findObjectsByClassInObjectsLayer para buscar las coordenadas de todas las orugas que pusimos en el mapa utilizando Tiled. Cuando las tengamos, se las pasamos al método insertBadGuys para que se inserten en pantalla.

/src/scenes/Game.js

class Game extends Phaser.Scene {

  create(){
    ...
    const caterpillarsFromTiled = this.findObjectsByClassInObjectsLayer('caterpillarEnemy', map);
    const caterpillars = this.insertBadGuys(this, caterpillarsFromTiled, CaterpillarEnemy);
    this.physics.add.overlap(this.player, caterpillars, this.player.checkEnemy, null, this.player);
  }

Insertar Avispa

Vamos a crear un nuevo enemigo: la avispa. Este enemigo no tiene el mismo comportamiento que la hormiga y la oruga, y por tanto, no heredará de la clase Enemy.

De momento, crearemos la clase de este enemigo y activaremos su animación, que debe haber sido cargada en la escena Boot.

class BeeEnemy extends Phaser.Physics.Arcade.Sprite {
  constructor(scene, x, y) {
    super(scene, x, y, 'bee');
    scene.add.existing(this);
    
    this.play('beeFly');
  }
}

export default BeeEnemy;

Hacer que la avispa de vueltas

El comportamiento de la avispa consistirá en dar vueltas en círculos hasta que el jugador se acerque lo suficiente. En ese momento, la avispa atacará al jugador y finalmente volverá a su posición original.

Para hacer que la avispa vuele en círculos, definiremos la trayectoria flyPath que será un círculo con centro en la posición x,y y radio 100. la variable pathIndex contiene el grado de completitud de la trayectoria, y currentPosition será una variable que será alimentada automáticamente mediante la función getPoint.

El método update irá llamando al método getPoint que en función del grado de completitud de la trayectoria (pathIndex) irá alimentando a la variable currentPosition. Por su lado, como puedes ver en el código, la variable pathIndex esta en función de la velocidad (pathSpeed).

class BeeEnemy extends Phaser.Physics.Arcade.Sprite {
  constructor(x, y, scene) {
    ...
    this.flyPath = new Phaser.Curves.Ellipse(x, y, 100, 100);
    // this.pathIndex es el grado de completitud de dicha trayetoria. 0 será el punto inicial de la trayectoria circular y 1 el punto final.
    this.pathIndex = 0;
    this.pathSpeed = 0.001;
    this.currentPosition = new Phaser.Math.Vector2();
		
    // La función getPoint aplicada sobre la trayectoria de vuelo (flyPath), modificara el valor de currentPosition para darle unas coordenadas en funcion del grado de completitud de la trayectoria (primer parametro) recibe dos parámetros
    this.flyPath.getPoint(this.pathIndex, this.currentPosition);
    this.setPosition(this.currentPosition.x, this.currentPosition.y);
  }

  update() {
    // Incrementamos pathIndex, que es el coeficiente que indica el grado de completitud de la trayectoria.
    this.pathIndex = Phaser.Math.Wrap(this.pathIndex + this.pathSpeed, 0, 1);
    // Alimentamos la variable currentPosition, que estará en función del grado de completitud de la trayectoria.
    this.flyPath.getPoint(this.pathIndex, this.currentPosition);
    // Modificamos la posición de la avispa en función de las coordenadas x e y del vector.
    this.setPosition(this.currentPosition.x, this.currentPosition.y);
  }
}

Refactorizar el código para utilizar el estado FLYING y la función checkPlayer

Añadiremos los estados después de la clase Avispa. La avispa ejecutará uno u otro comportamiento en función de unas constantes llamadas estados. Inicialmente, tendremos un estado llamado FLYING. Cuando este sea el estado activo (el valor de la variable this.state) el comportamiento de la avispa será el que definimos en el paso anterior, es decir, volará en círculos.

class BeeEnemy extends Phaser.Physics.Arcade.Sprite {
// La avispa sólo volará en círculos en el estado volando, así que cambiamos un poco el código del update de la Avispa. De momento, el método checkPlayer se encargará de ejecutar este vuelo circular de la avispa:
  update() {
    if (this.state === BeeEnemy.FLYING) {
      this.checkPlayer();
    }
  }

  checkPlayer(){
    this.pathIndex = Phaser.Math.Wrap(this.pathIndex + this.pathSpeed, 0, 1);
    this.flyPath.getPoint(this.pathIndex, this.currentPosition);
    this.setPosition(this.currentPosition.x, this.currentPosition.y);
  }
}
BeeEnemy.FLYING = 0;

Avispa detecta a Player

Cuando el jugador esté lo suficientemente cerca de la avispa, esta le atacará. Para ello, definiremos un area alredodor de la avispa, de tal forma que cuando el jugador penetre en dicho area, la avispa le ataque. Este area será la variable patrolCircle, que como puedes ver es un círculo con origen en la propia avispa y radio 256.

El método checkUpdate irá actualizando constantemente el centro de este círculo a partir de la posición de la propia avispa. También evaluará si el jugador entra dentro del patrolCircle, en cuyo caso mostrará un mensaje de alerta.

class BeeEnemy extends Phaser.Physics.Arcade.Sprite {
  create(){
    ...
    this.patrolCircle = new Phaser.Geom.Circle(0, 0, 256);
  }

  checkPlayer(){
    ...
    //  Actualizamos la posición del círculo que patrulla la avispa para ver si el player se mete dentro
    this.patrolCircle.x = this.x;
    this.patrolCircle.y = this.y;

    // El jugador ha entrado dentro del area de patrulla de la avispa?
    const player = this.scene.player;
    if (this.patrolCircle.contains(player.x, player.y)) {
      alert('perseguir')
    }
  }
}

Avispa persigue a Player

En el caso de que el jugador entre dentro del patrolCircle, en lugar de mostrar un mensaje de alerta, vamos a cambiar el estado de la avispa a CHASING.

Al tener el estado CHASING activado, en el método update estaremos llamando constantemente a la función chasePlayer, que moverá la avispa en la dirección del player, llamando a la función moveTo.

class BeeEnemy extends Phaser.Physics.Arcade.Sprite {

  update(time) {
    if (this.state === BeeEnemy.FLYING) {
      this.checkPlayer();
    } else if (this.state === BeeEnemy.CHASING) {
      this.chasePlayer();
    }
  }

  checkPlayer(){
    ...
    const player = this.scene.player;
    if (this.patrolCircle.contains(player.x, player.y)) {
      this.pathIndex = 0;
      this.state = BeeEnemy.CHASING;
    }
  }

  chasePlayer() {
    const player = this.scene.player;
    this.scene.physics.moveTo(this, player.x, player.y, this.speed);
  }
}

BeeEnemy.FLYING = 0;
BeeEnemy.CHASING = 1;

Avispa ataca a Player

Finalmente, la avispa colisionará con el player y le atacará. En ese momento, activaremos la animación beeAtack, que habremos precargado en la escena Boot.

Cuando la animación de ataque concluya, de momento, mostraremos un mensaje de alerta.

class BeeEnemy extends Phaser.Physics.Arcade.Sprite {
  constructor(scene, x, y) {
    ...
    this.on('animationcomplete', this.animationComplete, this);
  }

  chasePlayer() {
    ...
    if (this.scene.physics.overlap(this, player)) {
      this.play('beeAttack', true);
    }
  }

  animationComplete(animation, frame, sprite) {
    if (animation.key === 'beeAttack') {
      alert("termina ataque");
    }
  } 
}

Avispa vuelve a casa

En lugar de mostrar un mensaje de alerta cuando la animación de ataque concluya, lo que haremos será hacer que la avispa vuelva a su posición inicial.

Para ello, activaremos un nuevo estado llamado RETURNING. Al evaluar los estados en el método update y comprobar que el estado activo es RETURNING, llamaremos a la función returnHome.

La función returnHome irá moviendo la avispa a su posición original y cuando llegue a esta posición, activaremos de nuevo el estado FLYING, que pondrá la avispa a dar vueltas.

class BeeEnemy extends Phaser.Physics.Arcade.Sprite {
  constructor(x, y, scene) {
    ...
    this.startPlace = new Phaser.Math.Vector2(this.currentPosition.x, this.currentPosition.y);
  }

  update(time) {
    ...
    }else if (this.state === BeeEnemy.RETURNING) {
      returnHome();
    }
  }

  animationComplete(animation, frame, sprite) {
    if (animation.key === 'beeAttack') {
      this.state = BeeEnemy.RETURNING;
    }
  }

  chasePlayer(time){
    ...
    if (!this.patrolCircle.contains(player.x, player.y) && time - this.timeFromStartingToChase > 4000) {
      this.returnHome();
    }
  }

  returnHome() {
    this.play('beeFly', true);
    this.scene.physics.moveTo(
      this,
      this.startPlace.x,
      this.startPlace.y,
      this.speed
    );
    
    if (
      Math.round(this.x) === this.startPlace.x &&
      Math(this.y) === this.startPlace.y
    ) {
      this.flyPath.getPoint(this.pathIndex, this.currentPosition);
      this.pathIndex = 0;
      this.state = BeeEnemy.FLYING;
    }
  }
}

BeeEnemy.FLYING= 0;
BeeEnemy.CHASING= 1;
BeeEnemy.RETURNING = 2;

La avispa siempre mira al protagonista

Durante todo el movimiento de la avispa, esta siempre mira en la misma dirección. Queremos que la avispa siempre mire en la dirección del jugador, y para ello, la voltearemos (haremos flip) en función de la posición del player respecto de la avispa. Si el player está a la izquierda de la avispa, la avispa mirará hacia la derecha y si no, mirará hacia la izquierda

if(this.x < this.scene.player.x){
  this.flipX = true;
}else{
  this.flipX = false;
}

El heroe recibe daño

Durante el ataque de la avispa, cuando esta colisione contra el player, de momento no esta ocurriendo nada. Vamos a añadir un código que haga que el jugador realmente reciba el impacto de la avispa.

De momento, cuando se produca esta colisión entre ambos sprites llamaremos a la función hittedByBee, que mostrará un mensaje de alerta.

chasePlayer() {
  ...
  if (this.scene.physics.overlap(this, player)){
    ...
    player.playerHittedByBee();
  }
}
playerHittedByBee(){
  alert('golpeado');
}

El Player sale despedido por los aires

En lugar de un mensaje de alerta, vamos a aplicar al player una velocidad para que salga despedido por los aires, a activar su animación de caer y a activar una variable (isDazing) que bloqueará temporalmente los controles del jugador.

Debemos bloquear los controles del jugador porque si no la velocidad que le aplicamos en el momento de la colisión se verá contrarrestada por las velocidades que se le aplican al jugador cuando estamos controlándolo con las flechas del teclado y no se verá ninguna velocidad aplicada.

playerHittedByBee(){
  this.body.setVelocity(-150,-150);
  this.play('fall', true);
  this.isDazing = true;
}

update() {
  if(this.isAlive){
    if(!this.isDazing){
    //aquí dentro vendría todo el código que ya teníamos de la función update
    }
  }	
}

Reactivar los controles del jugador

Al cabo de un tiempo debemos poder volver a controlar al jugador. Para ello, al cabo de un tiempo, volvemos a setear la variable isDazing con valor false.

playerHittedByBee() {
  ...
  this.scene.time.addEvent({delay: 1000, callback: this.dazeFinished, callbackScope: this});
}

dazeFinished() {
  this.isDazing= false;
}

Añadir meta

El nivel que hemos hecho concluirá cuando el jugador llegue a cierto punto de la escena. Este punto lo habremos colocado previamente en la escena utilizando Tiled, y lo colocaremos de la misma forma que hemos coocado los enemigos, obteniendo sus coordenadas con el método findObjectsByClassInObjectsLayer.

preload(){
  ...
  this.load.image('goal', goal);
}
create(){
  ...
  const goalFromTiled = this.findObjectsByClassInObjectsLayer('goal', map)[0];
  this.putCheckPoint(goalFromTiled.x, goalFromTiled.y, 'goal');
}

putCheckPoint(x, y, sprite) {
  const goal = this.physics.add.sprite(x, y, sprite);
  goal.body.immovable = true;
  goal.body.moves = false;
  goal.setSize(160, 160);
}

Fin del juego

Cuando el jugador colisione con la meta, cargaremos una nueva escena de partida ganada.

putCheckPoint(x, y, sprite) {
  ...
  this.physics.add.overlap(this.player, goal, this.playerReachGoal, null, this);
}
playerReachGoal(){
  this.scene.start('Win');
}

Limitando la cámara

No queremos que la cámara rebase ciertos límites. Podemos limitar el rango de movimiento de la cámara utilizando la siguiente línea.

this.cameras.main.setBounds(0, 0, 3520, 640);

Escena de perder tras explotar

Cuando el jugador pierde, a parte

animationComplete(animation, frame, sprite) {
  if (animation.key === 'crashAnim') {
    this.scene.scene.start('Game Over');
  }
}

Escena de perder tras caer por precicipio

Además de perder la partida cuando el enemigo nos colisiona, también podemos perder al caer por alguno de los agujeros del decorado.

Para ello, el método setBoundCollision activará la colisión con la parte inferior de la pantalla para que cuando esta ocurre se cargue la escena de GameOver.

./src/scenes/Game.js
this.physics.world.setBoundsCollision(false, false, false, true);
this.physics.world.on('worldbounds', () => {
    this.scene.start('GameOver');
});

Además, el jugador también tiene que tener activas algunas propiedades para poder detectar la colisión con el fondo de la pantalla.

./src/characters/Player.js
this.setCollideWorldBounds(true);
this.body.onWorldBounds = true;

Por 9.99€ al mes tendrás acceso completo a todos los cursos. Sin matrícula ni permanencia.