Cómo construir un juego simple en el navegador con Phaser 3 y TypeScript

Foto de Phil Botha en Unsplash

Soy un defensor de los desarrolladores y un desarrollador de back-end, y mi experiencia en desarrollo frontend es relativamente débil. Hace un tiempo quería divertirme y hacer un juego en un navegador; Elegí Phaser 3 como marco (parece bastante popular en estos días) y TypeScript como lenguaje (porque prefiero la escritura estática a la dinámica). Resultó que necesitas hacer algunas cosas aburridas para que todo funcione, así que escribí este tutorial para ayudar a otras personas como yo a comenzar más rápido.

Preparando el medio ambiente

IDE

Elige tu entorno de desarrollo. Siempre puede usar el Bloc de notas antiguo si lo desea, pero sugeriría usar algo más útil. En cuanto a mí, prefiero desarrollar proyectos para mascotas en Emacs, por lo tanto, instalé Tide y seguí las instrucciones para configurarlo.

Nodo

Si estuviéramos desarrollando en JavaScript, estaríamos perfectamente bien para comenzar a codificar sin todos estos pasos de preparación. Sin embargo, como queremos usar TypeScript, tenemos que configurar la infraestructura para que el desarrollo futuro sea lo más rápido posible. Por lo tanto, necesitamos instalar node y npm.

Mientras escribo este tutorial, uso el nodo 10.13.0 y npm 6.4.1. Tenga en cuenta que las versiones en el mundo frontend se actualizan extremadamente rápido, por lo que simplemente tiene las últimas versiones estables. Recomiendo usar nvm en lugar de instalar node y npm manualmente; te ahorrará mucho tiempo y nervios.

Configurando el proyecto

Estructura del proyecto

Usaremos npm para construir el proyecto, así que para comenzar el proyecto vaya a una carpeta vacía y ejecute npm init. npm le hará varias preguntas sobre las propiedades de su proyecto y luego creará un archivo package.json. Se verá algo como esto:

{
  "name": "Starfall",
  "versión": "0.1.0",
  "descripción": "Juego Starfall (Phaser 3 + TypeScript)",
  "main": "index.js",
  "guiones": {
    "prueba": "echo \" Error: no se especificó ninguna prueba \ "&& salida 1"
  },
  "autor": "Mariya Davydova",
  "licencia": "MIT"
}

Paquetes

Instale los paquetes que necesitamos con el siguiente comando:

npm install -D typecript webpack webpack-cli ts-loader phaser live-server

-D opción (a.k.a. --save-dev) hace que npm agregue estos paquetes a la lista de dependencias en package.json automáticamente:

"devDependencies": {
   "live-server": "^ 1.2.1",
   "phaser": "^ 3.15.1",
   "ts-loader": "^ 5.3.0",
   "typecript": "^ 3.1.6",
   "webpack": "^ 4.26.0",
   "webpack-cli": "^ 3.1.2"
 }

Paquete web

Webpack ejecutará el compilador TypeScript y recopilará el conjunto de archivos JS resultantes, así como las bibliotecas, en un JS minimizado para que podamos incluirlo en nuestra página.

Agregue webpack.config.js cerca de su project.json:

const path = require ('ruta');
module.exports = {
  entrada: './src/app.ts',
  módulo: {
    reglas: [
      {
        prueba: /\.tsx?$/,
        uso: 'ts-loader',
        excluir: / node_modules /
      }
    ]
  },
  resolver: {
    extensiones: ['.ts', '.tsx', '.js']
  },
  salida: {
    nombre de archivo: 'app.js',
    ruta: ruta.resolve (__ dirname, 'dist')
  },
  modo: 'desarrollo'
};

Aquí vemos que webpack tiene que obtener las fuentes a partir de src / app.ts (que agregaremos muy pronto) y recopilar todo en el archivo dist / app.js.

Mecanografiado

También necesitamos un pequeño archivo de configuración para el compilador TypeScript (tsconfig.json) donde explicamos en qué versión de JS queremos que se compilen las fuentes y dónde encontrar esas fuentes:

{
  "compilerOptions": {
    "target": "es5"
  },
  "incluir": [
    "src / *"
  ]
}

Definiciones de TypeScript

TypeScript es un lenguaje de tipo estático. Por lo tanto, requiere definiciones de tipo para la compilación. En el momento de escribir este tutorial, las definiciones de Phaser 3 aún no estaban disponibles como el paquete npm, por lo que es posible que deba descargarlas del repositorio oficial y colocar el archivo en el subdirectorio src de su proyecto.

Guiones

Casi hemos terminado la configuración del proyecto. En este momento, debería haber creado package.json, webpack.config.js y tsconfig.json, y agregado src / phaser.d.ts. Lo último que debemos hacer antes de comenzar a escribir código es explicar qué tiene que ver exactamente npm con el proyecto. Actualizamos la sección de scripts de package.json de la siguiente manera:

"guiones": {
  "build": "paquete web",
  "start": "webpack --watch & live-server --port = 8085"
}

Cuando ejecutas la compilación npm, el archivo app.js se compilará de acuerdo con la configuración del paquete web. Y cuando ejecute npm start, no tendrá que preocuparse por el proceso de compilación: tan pronto como guarde cualquier fuente, webpack reconstruirá la aplicación y el servidor en vivo la volverá a cargar en su navegador predeterminado. La aplicación estará alojada en http://127.0.0.1:8085/.

Empezando

Ahora que hemos configurado la infraestructura (la parte que personalmente odio al comenzar un proyecto), finalmente podemos comenzar a codificar. En este paso haremos algo sencillo: dibujar un rectángulo azul oscuro en la ventana de nuestro navegador. Usar un gran marco de desarrollo de juegos para esto es un poco de ... hmmm ... exagerado. Aún así, lo necesitaremos en los próximos pasos.

Permítanme explicar brevemente los conceptos principales de Phaser 3. El juego es una instancia de la clase Phaser.Game (o su descendiente). Cada juego contiene una o más instancias de Phaser.Scene descendientes. Cada escena contiene varios objetos, estáticos o dinámicos, y representa una parte lógica del juego. Por ejemplo, nuestro juego trivial tendrá tres escenas: la pantalla de bienvenida, el juego en sí y la pantalla de puntuación.

Comencemos a codificar.

Primero, crea un contenedor HTML minimalista para el juego. Cree un archivo index.html que contenga el siguiente código:



  
     Starfall 
    
  
  
    
  

Aquí solo hay dos partes esenciales: la primera es una entrada de script que dice que vamos a usar nuestro archivo integrado aquí, y la segunda es una entrada div que será el contenedor del juego.

Ahora cree un archivo src / app.ts con el siguiente código:

importar "phaser";
const config: GameConfig = {
  título: "Starfall",
  ancho: 800,
  altura: 600,
  padre: "juego"
  backgroundColor: "# 18216D"
};
clase de exportación StarfallGame extiende Phaser.Game {
  constructor (config: GameConfig) {
    super (config);
  }
}
window.onload = () => {
  juego var = nuevo StarfallGame (config);
};

Este código se explica por sí mismo. GameConfig tiene muchas propiedades diferentes, puedes verlas aquí.

Y ahora finalmente puede ejecutar npm start. Si todo se hizo correctamente en este y en los pasos anteriores, debería ver algo tan simple como esto en su navegador:

Sí, esta es una pantalla azul.

Haciendo caer las estrellas

Hemos creado una aplicación primaria. Ahora es el momento de agregar una escena donde sucederá algo. Nuestro juego será simple: las estrellas caerán al suelo y el objetivo será atrapar la mayor cantidad posible.

Para lograr este objetivo, cree un nuevo archivo, gameScene.ts, y agregue el siguiente código:

importar "phaser";
export class GameScene extiende Phaser.Scene {
constructor () {
    súper({
      clave: "GameScene"
    });
  }
init (params): void {
    // QUE HACER
  }
preload (): void {
    // QUE HACER
  }
  
  create (): void {
    // QUE HACER
  }
actualización (hora): nulo {
    // QUE HACER
  }
};

Constructor aquí contiene una clave bajo la cual otras escenas pueden llamar a esta escena.

Ves aquí trozos para cuatro métodos. Permítanme explicar brevemente la diferencia entre entonces:

  • Se llama a init ([params]) cuando comienza la escena; esta función puede aceptar parámetros que se pasan de otras escenas o juegos llamando a scene.start (key, [params])
  • preload () se llama antes de que se creen los objetos de escena, y contiene elementos de carga; estos activos se almacenan en caché, por lo que cuando se reinicia la escena, no se vuelven a cargar
  • Se llama a create () cuando se cargan los activos y generalmente contiene la creación de los objetos principales del juego (fondo, jugador, obstáculos, enemigos, etc.)
  • update ([time]) se llama cada tic y contiene la parte dinámica de la escena: todo lo que se mueve, parpadea, etc.

Para asegurarnos de que no lo olvidemos más tarde, agreguemos rápidamente las siguientes líneas en el juego.ts:

importar "phaser";
importar {GameScene} desde "./gameScene";
const config: GameConfig = {
  título: "Starfall",
  ancho: 800,
  altura: 600,
  padre: "juego",
  escena: [GameScene],
  física: {
    predeterminado: "arcade",
    arcade: {
      depuración: falso
    }
  },
  backgroundColor: "# 000033"
};
...

Nuestro juego ahora sabe sobre la escena del juego. Si la configuración del juego contiene una lista de escenas, la primera se inicia cuando se inicia el juego, y todas las demás se crean pero no se inician hasta que se llama explícitamente.

También hemos agregado la física arcade aquí. Se requiere hacer caer nuestras estrellas.

Ahora podemos poner carne en los huesos de nuestra escena del juego.

Primero, declaramos algunas propiedades y objetos que necesitaremos:

export class GameScene extiende Phaser.Scene {
  delta: número;
  lastStarTime: número;
  starsCaught: número;
  starsFallen: número;
  arena: Phaser.Physics.Arcade.StaticGroup;
  info: Phaser.GameObjects.Text;
...

Luego, inicializamos los números:

init (/ * params: any * /): void {
    this.delta = 1000;
    this.lastStarTime = 0;
    this.starsCaught = 0;
    this.starsFallen = 0;
  }

Ahora, cargamos un par de imágenes:

preload (): void {
    this.load.setBaseURL (
      "https://raw.githubusercontent.com/mariyadavydova/" +
      "starfall-phaser3-typescript / master /");
    this.load.image ("star", "assets / star.png");
    this.load.image ("sand", "assets / sand.jpg");
  }

Después de eso, podemos preparar nuestros componentes estáticos. Crearemos el terreno, donde caerán las estrellas, y el texto que nos informa sobre la puntuación actual:

create (): void {
    this.sand = this.physics.add.staticGroup ({
      clave: 'arena',
      frameQuantity: 20
    });
    Phaser.Actions.PlaceOnLine (this.sand.getChildren (),
      nueva Phaser.Geom.Line (20, 580, 820, 580));
    this.sand.refresh ();
this.info = this.add.text (10, 10, '',
      {font: '24px Arial Bold', complete: '#FBFBAC'});
  }

Un grupo en Phaser 3 es una forma de crear un grupo de objetos que desea controlar juntos. Hay dos tipos de objetos: estáticos y dinámicos. Como puede suponer, los objetos estáticos no se mueven (suelo, paredes, varios obstáculos), mientras que los dinámicos hacen el trabajo (Mario, barcos, misiles).

Creamos un grupo estático de las piezas molidas. Esas piezas se colocan a lo largo de la línea. Tenga en cuenta que la línea se divide en 20 secciones iguales (no 19 como podría haber esperado), y las fichas de tierra se colocan en cada sección en el extremo izquierdo con el centro de la ficha ubicado en ese punto (espero que esto explique números). También tenemos que llamar a refresh () para actualizar el cuadro delimitador del grupo (de lo contrario, las colisiones se verificarán en la ubicación predeterminada, que es la esquina superior izquierda de la escena).

Si revisa su aplicación en el navegador ahora, debería ver algo como esto:

Evolución de la pantalla azul

Finalmente hemos llegado a la parte más dinámica de esta escena: la función update (), donde caen las estrellas. Esta función se llama en algún lugar alrededor de una vez en 60 ms. Queremos emitir una nueva estrella fugaz cada segundo. No usaremos un grupo dinámico para esto, ya que el ciclo de vida de cada estrella será corto: será destruido por el clic del usuario o al chocar con el suelo. Por lo tanto, dentro de la función emitStar () creamos una nueva estrella y agregamos el procesamiento de dos eventos: onClick () y onCollision ().

actualización (hora: número): nulo {
    var diff: número = tiempo - this.lastStarTime;
    if (diff> this.delta) {
      this.lastStarTime = time;
      if (this.delta> 500) {
        this.delta - = 20;
      }
      this.emitStar ();
    }
    this.info.text =
      this.starsCaught + "atrapado -" +
      this.starsFallen + "caído (máximo 3)";
  }
onClick privado (estrella: Phaser.Physics.Arcade.Image): () => void {
    función de retorno () {
      star.setTint (0x00ff00);
      star.setVelocity (0, 0);
      this.starsCaught + = 1;
      this.time.delayedCall (100, función (estrella) {
        star.destroy ();
      }, [estrella], esto);
    }
  }
Private onFall (estrella: Phaser.Physics.Arcade.Image): () => void {
    función de retorno () {
      star.setTint (0xff0000);
      this.starsFallen + = 1;
      this.time.delayedCall (100, función (estrella) {
        star.destroy ();
      }, [estrella], esto);
    }
  }
private emitStar (): void {
    var star: Phaser.Physics.Arcade.Image;
    var x = Phaser.Math.Between (25, 775);
    var y = 26;
    estrella = this.physics.add.image (x, y, "estrella");
star.setDisplaySize (50, 50);
    star.setVelocity (0, 200);
    star.setInteractive ();
star.on ('puntero abajo', this.onClick (star), this);
    this.physics.add.collider (estrella, this.sand,
      this.onFall (estrella), nulo, esto);
  }

¡Por fin tenemos un juego! Todavía no tiene una condición de victoria. Lo agregaremos en la última parte de nuestro tutorial.

Soy malo atrapando estrellas ...

Envolviendo todo

Por lo general, un juego consta de varias escenas. Incluso si la jugabilidad es simple, necesitas una escena de apertura (que contenga al menos el botón "Jugar") y una de cierre (que muestre el resultado de tu sesión de juego, como el puntaje o el nivel máximo alcanzado). Agreguemos estas escenas a nuestra aplicación.

En nuestro caso, serán bastante similares, ya que no quiero prestar demasiada atención al diseño gráfico del juego. Después de todo, este es un tutorial de programación.

La escena de bienvenida tendrá el siguiente código en welcomeScene.ts. Tenga en cuenta que cuando un usuario hace clic en algún lugar de esta escena, aparecerá una escena del juego.

importar "phaser";
clase de exportación WelcomeScene extiende Phaser.Scene {
  título: Phaser.GameObjects.Text;
  pista: Phaser.GameObjects.Text;
constructor () {
    súper({
      clave: "WelcomeScene"
    });
  }
create (): void {
    var titleText: string = "Starfall";
    this.title = this.add.text (150, 200, titleText,
      {font: '128px Arial Bold', complete: '#FBFBAC'});
var hintText: string = "Haga clic para comenzar";
    this.hint = this.add.text (300, 350, hintText,
      {font: '24px Arial Bold', complete: '#FBFBAC'});
this.input.on ('puntero abajo', función (/ * puntero * /) {
      this.scene.start ("GameScene");
    }, esta);
  }
};

La escena de puntuación se verá casi igual, lo que lleva a la escena de bienvenida al hacer clic (scoreScene.ts).

importar "phaser";
la clase de exportación ScoreScene extiende Phaser.Scene {
  puntuación: número;
  resultado: Phaser.GameObjects.Text;
  pista: Phaser.GameObjects.Text;
constructor () {
    súper({
      clave: "ScoreScene"
    });
  }
init (params: any): void {
    this.score = params.starsCaught;
  }
create (): void {
    var resultText: string = 'Su puntaje es' + this.score + '!';
    this.result = this.add.text (200, 250, resultText,
      {font: '48px Arial Bold', complete: '#FBFBAC'});
var hintText: string = "Haga clic para reiniciar";
    this.hint = this.add.text (300, 350, hintText,
      {font: '24px Arial Bold', complete: '#FBFBAC'});
this.input.on ('puntero abajo', función (/ * puntero * /) {
      this.scene.start ("WelcomeScene");
    }, esta);
  }
};

Necesitamos actualizar nuestro archivo de aplicación principal ahora: agregue estas escenas y haga que WelcomeScene sea el primero en la lista:

importar "phaser";
importar {WelcomeScene} desde "./welcomeScene";
importar {GameScene} desde "./gameScene";
importar {ScoreScene} desde "./scoreScene";
const config: GameConfig = {
  ...
  escena: [WelcomeScene, GameScene, ScoreScene],
  ...

¿Has notado lo que falta? Correcto, ¡todavía no llamamos a ScoreScene desde ningún lugar! Llamémoslo cuando el jugador se haya perdido la tercera estrella:

Private onFall (estrella: Phaser.Physics.Arcade.Image): () => void {
    función de retorno () {
      star.setTint (0xff0000);
      this.starsFallen + = 1;
      this.time.delayedCall (100, función (estrella) {
        star.destroy ();
        if (this.starsFallen> 2) {
          this.scene.start ("ScoreScene",
            {starsCaught: this.starsCaught});
        }
      }, [estrella], esto);
    }
  }

Finalmente, nuestro juego Starfall parece un juego real: comienza, termina e incluso tiene un objetivo para archivar (¿cuántas estrellas puedes atrapar?).

Espero que este tutorial sea tan útil para usted como lo fue para mí cuando lo escribí :) ¡Cualquier comentario es muy apreciado!

El código fuente de este tutorial se puede encontrar aquí.