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

Soy un defensor de los desarrolladores y un desarrollador de backend, y mi experiencia en el desarrollo de 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 sobre 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

Elija su entorno de desarrollo. Siempre puede usar el viejo Bloc de notas si lo desea, pero le sugiero que use algo más útil. En cuanto a mí, prefiero desarrollar proyectos favoritos 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 utilizar 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, utilizo 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 toma las últimas versiones estables. Recomiendo encarecidamente usar nvm en lugar de instalar node y npm manualmente; te ahorrará mucho tiempo y nervios.

Configurar el proyecto

Estructura del proyecto

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

{ "name": "Starfall", "version": "0.1.0", "description": "Starfall game (Phaser 3 + TypeScript)", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "author": "Mariya Davydova", "license": "MIT" }

Paquetes

Instale los paquetes que necesitamos con el siguiente comando:

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

-DLa opción (también conocida como --save-dev) hace que npm agregue estos paquetes a la lista de dependencias package.jsonautomáticamente:

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

Webpack

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

Agregue webpack.config.jscerca de su project.json:

const path = require('path'); module.exports = { entry: './src/app.ts', module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ } ] }, resolve: { extensions: [ '.ts', '.tsx', '.js' ] }, output: { filename: 'app.js', path: path.resolve(__dirname, 'dist') }, mode: 'development' };

Aquí vemos que el paquete web tiene que obtener las fuentes a partir src/app.ts(que agregaremos muy pronto) y recopilar todo en el dist/app.jsarchivo.

Mecanografiado

También necesitamos un pequeño archivo de configuración para el compilador de 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" }, "include": [ "src/*" ] }

Definiciones de TypeScript

TypeScript es un lenguaje escrito estáticamente. 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 paquete npm, por lo que es posible que deba descargarlas del repositorio oficial y colocar el archivo en el srcsubdirectorio de su proyecto.

Guiones

Casi hemos terminado la configuración del proyecto. En este momento usted debe haber creado package.json, webpack.config.jsy tsconfig.json, y ha añadido 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 scriptssección de la package.jsonsiguiente manera:

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

Cuando ejecute npm build, el app.jsarchivo se creará de acuerdo con la configuración del paquete web. Y cuando lo ejecutes npm start, no tendrás que preocuparte por el proceso de compilación: tan pronto como guardes cualquier fuente, webpack reconstruirá la aplicación y el servidor en vivo la recargará en tu navegador predeterminado. La aplicación se alojará en //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 ... 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 Phaser.Gameclase (o su descendiente). Cada juego contiene una o más instancias de Phaser.Scenedescendientes. 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.

Empecemos a codificar.

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

   Starfall 

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

Ahora crea un archivo src/app.tscon el siguiente código:

import "phaser"; const config: GameConfig = { title: "Starfall", width: 800, height: 600, parent: "game" backgroundColor: "#18216D" }; export class StarfallGame extends Phaser.Game { constructor(config: GameConfig) { super(config); } } window.onload = () => { var game = new StarfallGame(config); };

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

Y ahora finalmente puedes correr 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:

Haciendo caer las estrellas

Hemos creado una aplicación elemental. 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:

import "phaser"; export class GameScene extends Phaser.Scene { constructor() { super({ key: "GameScene" }); } init(params): void { // TODO } preload(): void { // TODO } create(): void { // TODO } update(time): void { // TODO } };

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

Aquí verá códigos auxiliares de cuatro métodos. Permítanme explicar brevemente la diferencia entre entonces:

  • init([params])se llama cuando comienza la escena; esta función puede aceptar parámetros, que se pasan de otras escenas o juegos llamandoscene.start(key, [params])
  • preload()se llama antes de que se creen los objetos de la escena y contiene activos de carga; estos activos se almacenan en caché, por lo que cuando se reinicia la escena, no se vuelven a cargar
  • create() is called when the assets are loaded and usually contains creation of the main game objects (background, player, obstacles, enemies, etc.)
  • update([time]) is called every tick and contains the dynamic part of the scene — everything that moves, flashes, etc.

To be sure that we don’t forget it later, let’s quickly add the following lines in the game.ts:

import "phaser"; import { GameScene } from "./gameScene"; const config: GameConfig = { title: "Starfall", width: 800, height: 600, parent: "game", scene: [GameScene], physics: { default: "arcade", arcade: { debug: false } }, backgroundColor: "#000033" }; ...

Our game now knows about the game scene. If the game config contains a list of scenes then the first one is started when the game is begun, and all others are created but not started until explicitly called.

We have also added arcade physics here. It is required to make our stars fall.

Now we can put flesh on the bones of our game scene.

First, we declare some properties and objects we’re gonna need:

export class GameScene extends Phaser.Scene { delta: number; lastStarTime: number; starsCaught: number; starsFallen: number; sand: Phaser.Physics.Arcade.StaticGroup; info: Phaser.GameObjects.Text; ...

Then, we initialize numbers:

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

Now, we load a couple of images:

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

After that, we can prepare our static components. We will create the ground, where the stars will fall, and the text informing us about the current score:

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

A group in Phaser 3 is a way to create a bunch of the objects you want to control together. There two types of objects: static and dynamic. As you may guess, static objects don’t move (ground, walls, various obstacles), while dynamic ones do the job (Mario, ships, missiles).

We create a static group of the ground pieces. Those pieces are placed along the line. Please note that the line is divided into 20 equal sections (not 19 as you’ve may have expected), and the ground tiles are placed on each section at the left end with the tile center located at that point (I hope this explains those numbers). We also have to call refresh() to update the group bounding box (otherwise, the collisions will be checked against the default location, which is the top left corner of the scene).

If you check out your application in the browser now, you should see something like this:

We have finally reached the most dynamic part of this scene — update() function, where the stars fall. This function is called somewhere around once in 60 ms. We want to emit a new falling star every second. We won’t use a dynamic group for this, as the lifecycle of each star will be short: it will be destroyed either by user click or by colliding with the ground. Therefore inside the emitStar() function we create a new star and add the processing of two events: onClick() and onCollision().

update(time: number): void { var diff: number = time - this.lastStarTime; if (diff > this.delta) { this.lastStarTime = time; if (this.delta > 500) { this.delta -= 20; } this.emitStar(); } this.info.text = this.starsCaught + " caught - " + this.starsFallen + " fallen (max 3)"; } private onClick(star: Phaser.Physics.Arcade.Image): () => void { return function () { star.setTint(0x00ff00); star.setVelocity(0, 0); this.starsCaught += 1; this.time.delayedCall(100, function (star) { star.destroy(); }, [star], this); } } private onFall(star: Phaser.Physics.Arcade.Image): () => void { return function () { star.setTint(0xff0000); this.starsFallen += 1; this.time.delayedCall(100, function (star) { star.destroy(); }, [star], this); } } private emitStar(): void { var star: Phaser.Physics.Arcade.Image; var x = Phaser.Math.Between(25, 775); var y = 26; star = this.physics.add.image(x, y, "star"); star.setDisplaySize(50, 50); star.setVelocity(0, 200); star.setInteractive(); star.on('pointerdown', this.onClick(star), this); this.physics.add.collider(star, this.sand, this.onFall(star), null, this); } 

Finally, we have a game! It doesn’t have a win condition yet. We’ll add it in the last part of our tutorial.

Wrapping it all up

Usually, a game consists of several scenes. Even if the gameplay is simple, you need an opening scene (containing at the very least the ‘Play!’ button) and a closing one (showing the result of your game session, like the score or the maximum level reached). Let’s add these scenes to our application.

In our case, they will be pretty similar, as I don’t want to pay too much attention to the graphic design of the game. After all, this a programming tutorial.

The welcome scene will have the following code in welcomeScene.ts. Note that when a user clicks somewhere on this scene, a game scene will appear.

import "phaser"; export class WelcomeScene extends Phaser.Scene { title: Phaser.GameObjects.Text; hint: Phaser.GameObjects.Text; constructor() { super({ key: "WelcomeScene" }); } create(): void { var titleText: string = "Starfall"; this.title = this.add.text(150, 200, titleText, { font: '128px Arial Bold', fill: '#FBFBAC' }); var hintText: string = "Click to start"; this.hint = this.add.text(300, 350, hintText, { font: '24px Arial Bold', fill: '#FBFBAC' }); this.input.on('pointerdown', function (/*pointer*/) { this.scene.start("GameScene"); }, this); } };

The score scene will look almost the same, leading to the welcome scene on click (scoreScene.ts).

import "phaser"; export class ScoreScene extends Phaser.Scene { score: number; result: Phaser.GameObjects.Text; hint: Phaser.GameObjects.Text; constructor() { super({ key: "ScoreScene" }); } init(params: any): void { this.score = params.starsCaught; } create(): void { var resultText: string = 'Your score is ' + this.score + '!'; this.result = this.add.text(200, 250, resultText, { font: '48px Arial Bold', fill: '#FBFBAC' }); var hintText: string = "Click to restart"; this.hint = this.add.text(300, 350, hintText, { font: '24px Arial Bold', fill: '#FBFBAC' }); this.input.on('pointerdown', function (/*pointer*/) { this.scene.start("WelcomeScene"); }, this); } };

We need to update our main application file now: add these scenes and make the WelcomeScene to be the first in the list:

import "phaser"; import { WelcomeScene } from "./welcomeScene"; import { GameScene } from "./gameScene"; import { ScoreScene } from "./scoreScene"; const config: GameConfig = { ... scene: [WelcomeScene, GameScene, ScoreScene], ...

Have you noticed what is missing? Right, we do not call the ScoreScene from anywhere yet! Let’s call it when the player has missed the third star:

private onFall(star: Phaser.Physics.Arcade.Image): () => void { return function () { star.setTint(0xff0000); this.starsFallen += 1; this.time.delayedCall(100, function (star) { star.destroy(); if (this.starsFallen > 2) { this.scene.start("ScoreScene", { starsCaught: this.starsCaught }); } }, [star], this); } }

Finally, our Starfall game looks like a real game — it starts, ends, and even has a goal to archive (how many stars can you catch?).

I hope this tutorial is as useful for you as it was for me when I wrote it :) Any feedback is highly appreciated!

The source code for this tutorial may be found here.