> Manuales > Manual de Phaser 3

Componentes avanzados y su estructura en clases involucradas para la gestión de los poderes especiales del jugador, en el juego del Breakout realizado por Phaser 3. Nuevas técnicas del framework.

Componentes avanzados en el juego con Phaser 3

La verdad es que, para ser un juego de demostración, hasta el momento hemos podido avanzar bastante en el desarrollo de nuestro Breakout, o clon del clásico Arkanoid. En este artículo vamos a continuar hacia lo que sería la "traca final", como dirían los valencianos, realizando unos componentes avanzados para darle todavía un toque más profesional al juego.

En esta ocasión entramos en un tema un poco más complejo, en relación a lo que hemos visto anteriormente a lo largo del Manual de Phaser, que es la gestión de los poderes especiales. Por ello no tengo intención de explicar punto a punto todas las novedades que hemos introducido, sino simplemente la estructura de clases que hemos incorporado y, por supuesto, las cosas nuevas que podemos aprender del framework Phaser 3.

Cada poder se encuentra representado en el juego con un diamante, que aparece cuando se rompe alguno de los ladrillos del juego. Los diamantes tienen su propia animación y comportamiento, que vimos en la clase anterior en la que aprendimos a manejar animaciones con sprites.

Clases involucradas en la gestión de poderes

La gestión de los poderes por parte del usuario involucra un conjunto de clases nuevo.

De este modo, cada vez que queremos agregar un nuevo poder al juego, simplemente tenemos que crear una nueva clase con el poder especial que hemos agregado. Nuestro juego estará abierto a modificaciones de una manera sencilla!

Clase Power

Esta es una clase genérica. No tiene más que las cosas comunes que necesitamos en todos los poderes.

export class Power {
  constructor(scene, diamonds, powerSprite) {
    this.relatedScene = scene;
    this.powerSprite = powerSprite;
    this.diamonds = diamonds;
  }

  create(x, y) {
    this.diamonds.create(x, y, this.powerSprite, this);
  }

  givePower() {
    console.log('Define the power');
  }
}

Su constructor recibe la escena donde estamos trabajando, la clase que almacena todos los diamantes de un nivel particular y el nombre del sprite para este diamante (porque cada poder se representa con un diamante distinto que tiene una animación basada en un sprite distinto).

El método create de un poder hace que se cree el diamante. Sólo se ejecutará cuando se rompa el ladrillo que tiene asignado un el poder. (No todos los ladrillos del juego liberan poderes, como más adelante veremos).

Por último el método givePower() es el que asigna el poder en particular. Este sería un método abstracto en Programación Orientada a Objetos, pero como Javascript no tiene todavía métodos abstractos, le he dado un comportamiento de base (mostrar un mensaje en la consola) que esperamos que se redefina en cada clase hija.

Un poder particular

Ahora veamos las clases de poderes particulares, que extienden la clase Power y sirven para crear ya un poder real del juego.

Este poder asigna una vida extra al jugador. Usará diamantes azules.

import { Power } from './power.js';

export class LivePower extends Power {
  constructor(scene, diamonds) {
    super(scene, diamonds, 'bluediamond');
  }

  givePower() {
    this.relatedScene.increaseLives();
  }
}

Este otro poder hace que la plataforma sea más grande, facilitando el juego con una mayor superficie de impacto. Para este poder usaremos diamantes rojos.

import { Power } from './power.js';

export class LargePlatformPower extends Power {
  constructor(scene, diamonds) {
    super(scene, diamonds, 'reddiamond');
  }

  givePower() {
    this.relatedScene.setPlatformBig();
  }
}

Este último poder hace que el jugador tenga "pegamento" en la plataforma, de modo que, cuando la bola impacta contra la base, se queda adherida y podemos soltarla cuando nosotros queramos, apuntando mejor a los ladrillos que nos falten por romper. Como puedes ver, usará diamantes verdes.

import { Power } from './power.js';

export class GluePower extends Power {
  constructor(scene, diamonds) {
    super(scene, diamonds, 'greendiamond');
  }

  givePower() {
    this.relatedScene.setGluePower();
  }
}

Has visto qué sencillez para cada poder del juego. Realmente sólo define el diamante que quiere liberar y sobrescribe el método givePower(), que tiene que invocarse en la escena para poder ejecutar ese poder en el juego.

Cada vez que queramos crear un nuevo poder en el juego simplemente tendremos que crear una nueva clase derivada y listo!

Cómo asignar poderes a los ladrillos

Ahora veamos cómo se generan los poderes, asociando éstos a ciertos ladrillos. De manera que, cuando se rompan los ladrillos, se lancen los correspondientes diamantes que liberarán los correspondientes poderes.

Esto implica cambiar las clases que implementan las fases, que ya explicamos en el artículo de implementación de los niveles del juego.

La clase base para las fases ahora incorpora nuevos métodos, o modificaciones de los existentes.

Primero necesitamos modificar el constructor, para agregar un array de poderes.

constructor(scene) {
    this.relatedScene = scene;
    this.powers = [];
}

Asignamos las colisiones de cualquier elemento con los ladrillos. Este método lo hemos hecho genérico para asignar colisiones de cualquier cosa, aunque de momento solo lo usaremos para tratar colisiones de los diamantes.

setBrickCollider(element) {
    this.relatedScene.physics.add.collider(this.bricks, element);
    if (this.fixedBricks) {
      this.relatedScene.physics.add.collider(this.fixedBricks, element);
    }
}

Luego creamos un método que nos diga el índice del ladrillo que se ha roto en este momento:

getBrickIndex(brick) {
  let children = this.bricks.getChildren();
  for(let i in children) {
    if (children[i] == brick) {
      return i;
    }
  }
}

Ahora, cuando gestionamos los ladrillos que se rompen, necesitamos saber si éstos tienen un poder asociado. En cuyo caso se tendrá que crear el correspondiente diamante.

brickImpact(ball, brick) {
  let brickIndex = this.getBrickIndex(brick);
  if(this.powers[brickIndex]) {
    this.powers[brickIndex].create(ball.x, ball.y)
  }
  this.relatedScene.brickImpact(ball, brick);
}

Esto lo hemos hecho comprobando si en el array de poderes, en el índice del ladrillo que se rompió, tenemos alguna clase de poder. En el caso que sí tengamos, invocamos al método del poder que crea el diamante del color que le corresponde a ese poder.

Por último en esta clase teníamos un método que se encargaba de borrar los ladrillos indestructibles, cuando se pasaba a la fase siguiente del juego. En este punto necesitamos borrar también los diamantes que no se hayan colectado.

deleteFixedBricks() {
  if(this.fixedBricks) {
    this.fixedBricks.getChildren().forEach(item => {
      item.disableBody(true, true);
    });
  }
  if(this.diamonds) {
    this.diamonds.diamonds.getChildren().forEach(item => {
      item.disableBody(true, true);
    });
  }
}

Asociar componentes de poderes en una escena del juego

Tenemos lo básico para poder trabajar con nuestros nuevos componentes avanzados para crear los poderes especiales. Ahora veamos cómo los asociamos en cualquiera de las clases de un nivel concreto.

Esta fase es un buen ejemplo de código:

import { Phase } from './phase.js'
import { Diamonds } from "../diamonds.js";
import { LivePower } from '../powers/live-power.js';
import { LargePlatformPower } from '../powers/large-platform-power.js';
import { GluePower } from '../powers/glue-power.js';

export class Phase4 extends Phase {

  create() {
    this.bricks = this.relatedScene.physics.add.staticGroup({
      key: ['bluebrick', 'orangebrick', 'greenbrick', 'yellowbrick'],
      frameQuantity: 10,
      gridAlign: {
        width: 10,
        height: 4,
        cellWidth: 67,
        cellHeight: 34,
        x: 95,
        y: 100
      }
    });

    this.configureColisions();

    this.diamonds = new Diamonds(this.relatedScene);
    this.setBrickCollider(this.diamonds.diamonds);

    this.powers[3] = new LivePower(this.relatedScene, this.diamonds);
    this.powers[35] = new LivePower(this.relatedScene, this.diamonds);
    this.powers[1] = new LargePlatformPower(this.relatedScene, this.diamonds);
    this.powers[24] = new LargePlatformPower(this.relatedScene, this.diamonds);
    this.powers[16] = new GluePower(this.relatedScene, this.diamonds);
    this.powers[29] = new GluePower(this.relatedScene, this.diamonds);

  }
}

Cómo realizar las transformaciones debidas a los poderes

Como vimos, cada poder tiene un método que implementa el comportamiento cuando se tiene que dar el beneficio al jugador. Estas transformaciones se deben realizar en la clase Game, que** es la que conoce a todos los sistemas que tienen que sufrir las modificaciones de estado**.

Las transformaciones ya son más relacionadas a la propia lógica de programación que a características del framework.

Por ejemplo, este es el método de la clase Game que se invoca cuando se quiere asignar una vida extra al jugador, que se otorga con los diamantes azules.

increaseLives() {
  this.liveCounter.increase();
}

Como puedes ver, no hay mucha dificultad, simplemente la pasamos la bola al contador de vidas, para que sea él el que se encargue de asignar la vida extra.

Ya dentro del contador de vidas, se realiza el código final, que asigna esa vida extra creando la correspondiente imagen en el display de vidas.

increase() {
    let targetPos = 765;
    this.liveImages.getChildren().forEach( (item, index) => {
      item.x = item.x - this.displacement;
    })
    let newLive = this.liveImages.create(targetPos, 33, 'platform');
    newLive.setScale(0.3);
}

Esa estrategia de crear componentes que se ocupen de su trabajo simplifica las cosas, porque cada uno encapsula la complejidad de la tarea. Es importante porque, a medida que se van creando variantes en el juego, también se va complicando el código de la escena principal y si no aplicamos un mínimo orden habrá un momento en el que nuestro código será simplemente un caos.

Para componetizar y poder implementar los comportamientos del aumento del tamaño de la plataforma y el pegamento de la bola, hemos tomado la decisión de separar la bola y la plataforma de la escena principal.

Decidimos crear una clase para la bola y otra para la plataforma, que es como se ha quedado el juego en su estado final, aunque la verdad es que esta división del código no es siempre del todo práctica, ya que muchas veces los comportamientos de la bola de y la plataforma van ligados el uno al otro (como por ejemplo mover la plataforma y la bola al mismo tiempo cuando la bola está pegada a la plataforma). Por ello, para hacer muchas cosas con la plataforma, tenemos que entregarle la bola, de modo que sea capaz de completar las acciones. Por ello es probable que en el repositorio final de código del juego tengamos el concepto "jugador" que engloba tanto a la bola como a la plataforma y ambas puedan colaborar de manera más sencilla. Lo dejo como idea y quizás lo refactorice más adelante.

Nota: he de admitir que el diseño de clases lo he ido realizando sobre la marcha a medida que iba realizando el juego. Quizás si ahora empezase desde cero, con las ideas más claras de hasta dónde quería llegar, podría diseñarlo de otra manera y llegar a clases más bonitas y bien hechas.

Este es el código de la bola tal como ha quedado en el estado final del juego:

export class Ball {

  constructor(scene) {
    this.relatedScene = scene;
    this.isGlued = true;
  }

  create() {
    this.ball = this.relatedScene.physics.add.image(385, 430, 'ball');
    this.ball.setBounce(1);
    this.ball.setCollideWorldBounds(true);
  }

  isLost() {
    return (this.ball.y > 500 && this.ball.active) ? true : false;
  }

  get() {
    return this.ball;
  }

  throw(velocity) {
    this.ball.setVelocity(velocity, -300);
    this.isGlued = false;
  }

  removeGlue() {
    this.isGlued = false;
  }
}

Como puedes ver, han salido métodos sencillos y que resultan muy claros con respecto a lo que hacen.

No puedo decir que la clase de plataforma haya quedado tan sencilla, pero ahí va el código.

export const INITIAL_PLATFORM_SIZE = 0.6;
export const LARGE_PLATFORM_SIZE = 1;

export class Platform {
  constructor(scene) {
    this.relatedScene = scene;
    this.size = INITIAL_PLATFORM_SIZE;
    this.gluePower = false;
    this.hasBallGlued = false;
  }

  create() {
    this.platform = this.relatedScene.physics.add.image(400, 460, 'platform').setImmovable().setScale(this.size);
    this.platform.setCollideWorldBounds(true);
  }

  hasGluePower() {
    return this.gluePower;
  }
  
  updatePosition(ball, cursors) {
    if (cursors.left.isDown) {
      this.platform.setVelocityX(-500);
      if (ball.isGlued || this.hasBallGlued) {
        ball.get().setVelocityX(-500);
      }
    }
    else if (cursors.right.isDown) {
      this.platform.setVelocityX(500);
      if (ball.isGlued || this.hasBallGlued) {
        ball.get().setVelocityX(500);
      }
    }
    else {
      this.platform.setVelocityX(0);
      if (ball.isGlued || this.hasBallGlued) {
        ball.get().setVelocityX(0);
      }
    }
  }

  setInitialState(ball) {
    this.platform.x = 400;
    this.platform.y = 460;
    ball.get().setVelocity(0, 0);
    ball.get().x = 385;
    if (this.size == LARGE_PLATFORM_SIZE) {
      ball.get().y = 420;
    } else {
      ball.get().y = 430;
    }
    ball.isGlued = true;
  }

  setSize(size) {
    this.size = size;
    this.platform.setScale(size);
  }
  setBigSize() {
    this.setSize(LARGE_PLATFORM_SIZE);
    this.gluePower = false;
  }
  setInitialSize() {
    this.setSize(INITIAL_PLATFORM_SIZE);
  }

  removeGlue() {
    this.gluePower = false;
  }

  setGluePower() {
    this.setInitialSize();
    this.gluePower = true;
  }

  get() {
    return this.platform;
  }

  isGluedBecausePower() {
    return (this.hasGluePower() && this.hasBallGlued)
  }
}

Gracias a esta separación hemos podido reducir el código de la escena principal del juego, la clase Game. Además, hemos conseguido que el código sea más semántico, gracias a que invoca a muchos métodos de la bola y la plataforma que indican claramente lo que hacen.

import { PhaseConstructor } from '../components/phases/phase-constructor.js';
import { LiveCounter } from '../components/live-counter.js';
import { Platform } from '../components/platform.js';
import { Ball } from '../components/ball.js';

const INITIAL_LIVES = 3;
const INITIAL_VELOCITY_X = -60;

export class Game extends Phaser.Scene {
  
  constructor() {
    super({ key: 'game' });
  }
  
  init() {
    this.glueRecordVelocityX = INITIAL_VELOCITY_X; 
    this.phaseConstructor = new PhaseConstructor(this);
    this.platform = new Platform(this);
    this.ball = new Ball(this);
    this.liveCounter = new LiveCounter(this, INITIAL_LIVES);
    this.score = 0;
  }

  create() {
    this.physics.world.setBoundsCollision(true, true, true, false);
    
    this.add.image(410, 250, 'background');

    this.liveCounter.create();
    
    this.platform.create();
    this.ball.create();
    
    this.physics.add.collider(this.ball.get(), this.platform.get(), this.platformImpact, null, this);
    
    this.phaseConstructor.create();

    this.scoreText = this.add.text(16, 16, 'PUNTOS: 0', { fontSize: '20px', fill: '#fff', fontFamily: 'verdana, arial, sans-serif' });

    this.platformImpactSample = this.sound.add('platformimpactsample');
    this.brickImpactSample = this.sound.add('brickimpactsample');
    this.fixedBrickImpactSample = this.sound.add('fixedbrickimpactsample');
    this.gameOverSample = this.sound.add('gameoversample');
    this.winSample = this.sound.add('winsample');
    this.startGameSample = this.sound.add('startgamesample');
    this.liveLostSample = this.sound.add('livelostsample');
    this.phaseChangeSample = this.sound.add('phasechange');

    this.createAnimations();

    this.cursors = this.input.keyboard.createCursorKeys();
  }

  update() {
    this.platform.updatePosition(this.ball, this.cursors);

    if (this.ball.isLost()) {
      let gameNotFinished = this.liveCounter.liveLost();
      if (!gameNotFinished) {
        this.liveLostSample.play();
        this.platform.setInitialState(this.ball);
        this.platform.setInitialSize();
        this.platform.removeGlue();
        this.glueRecordVelocityX = INITIAL_VELOCITY_X;
      }
    }

    if (this.cursors.up.isDown) {
      if (this.ball.isGlued) {
        this.startGameSample.play();
        this.ball.throw(INITIAL_VELOCITY_X);
      } else if(this.platform.isGluedBecausePower()) {
        this.ball.throw(this.glueRecordVelocityX);
        this.platform.hasBallGlued = false;
      }
    }
  }

  platformImpact(ball, platform) {
    this.platformImpactSample.play();
    this.increasePoints(1);
    let relativeImpact = ball.x - platform.x;
    if(this.platform.hasGluePower()) {
      ball.setVelocityY(0);
      ball.setVelocityX(0);
      this.glueRecordVelocityX = this.calculateVelocity(relativeImpact);
      this.platform.hasBallGlued = true;
    } else {
      ball.setVelocityX(this.calculateVelocity(relativeImpact));
    }
  }

  calculateVelocity(relativeImpact) {
    if(relativeImpact > 50) {
      relativeImpact = 50;
    }
    if (relativeImpact > 0) {
      return (8 * relativeImpact);
    } else if (relativeImpact < 0) {
      return (8 * relativeImpact);
    } else {
      return (Phaser.Math.Between(-10, 10))
    }
  }

  brickImpact(ball, brick) {
    this.brickImpactSample.play();
    brick.disableBody(true, true);
    this.increasePoints(10);
    if (this.phaseConstructor.isPhaseFinished()) {
      this.phaseChangeSample.play();
      this.phaseConstructor.nextLevel();
      this.platform.setInitialState(this.ball);
    }
  }

  fixedBrickImpact(ball, brick) {
    this.fixedBrickImpactSample.play();
  }

  increasePoints(points) {
    this.score += points;
    this.scoreText.setText('PUNTOS: ' + this.score);
  }

  endGame(completed = false) {
    if(! completed) {
      this.gameOverSample.play();
      this.scene.start('gameover');
    } else {
      this.winSample.play();
      this.scene.start('congratulations');
    }
  }

  createAnimations() {
    this.anims.create({
      key: 'bluediamondanimation',
      frames: this.anims.generateFrameNumbers('bluediamond', { start: 0, end: 7 }),
      frameRate: 10,
      repeat: -1,
      yoyo: true,
    });
    this.anims.create({
      key: 'reddiamondanimation',
      frames: this.anims.generateFrameNumbers('reddiamond', { start: 0, end: 7 }),
      frameRate: 10,
      repeat: -1,
      yoyo: true,
    });
    this.anims.create({
      key: 'greendiamondanimation',
      frames: this.anims.generateFrameNumbers('greendiamond', { start: 0, end: 7 }),
      frameRate: 10,
      repeat: -1,
      yoyo: true,
    });
  }

  increaseLives() {
    this.liveCounter.increase();
  }
  
  setGluePower() {
    this.platform.setGluePower();
  }
  
  setPlatformBig() {
    this.platform.setBigSize();
  }

  removeGlueFromBall() {
    this.ball.removeGlue();
  }
}

Este cambio de la bola y plataforma en clases aparte también ha impactado en un par de lugares, en la clase Phase y la clase Diamonds, que hacían uso de la bola, pero son cambios mínimos, donde antes usabamos this.relatedScene.ball ahora tenemos que pedirle la bola así this.relatedScene.ball.get(). También en la clase Diamonds, donde hacíamos ball.setData('glue', false); ahora tenemos que invocar un método específico de la bola para decirle que su estado actual es "sin pegamento" con this.relatedScene.removeGlueFromBall().

Como dije, no voy a explicar todo el detalle de los cambios que hemos realizado, porque es al final un tema de programación. Solo quédate con la estructura de clases y los conceptos de Phaser que hemos ido tratando. Para ver una referencia del código y los cambios completos puedes entrar en el repositorio de GitHub y explorar el código completo, ahora en la rama master, o ver los commits para saber qué se fue introduciendo en cada paso.

El enlace del repositorio GitHub lo tienes aquí: https://github.com/deswebcom/ball-game-phaser

Conclusión

Ahora sí, vamos a dar este juego por terminado. Con estos componentes avanzados en Phaser hemos realizado una versión del Arkanoid bastante atractiva, que espero que te haya resultado sobre todo didáctica.

Estoy seguro que a lo largo de este proyecto has aprendido muchas cosas del framework Phaser 3 y que las podrás aplicar a nuevos juegos que puedas crear tú, o quizás extender esta práctica del Breakout para crear nuevos niveles y poderes para el jugador. Si nos mandas un pull-request, será un placer integrar los niveles o poderes que hayas realizado.

Cualquier duda, nos la puedes comunicar como una FAQ.

En futuros artículos de Phaser ya pensaremos en implementar otro tipo de mejoras en el juego, no tanto abordando nueva funcionalidad, sino prestaciones extra, como controles para que puedas usarlo en móviles, convertir el juego en una progressive web app, etc. Así que si profundizas en este manual podrás seguir aprendiendo cosas que aplicar a cualquier proyecto con Phaser.

Miguel Angel Alvarez

Fundador de DesarrolloWeb.com y la plataforma de formación online EscuelaIT. Com...

Manual