Hvordan man bygger et simpelt spil i browseren med Phaser 3 og TypeScript

Jeg er en talsmand for udviklere og en backend-udvikler, og min ekspertise inden for frontend-udvikling er relativt svag. For et stykke tid siden ville jeg have det sjovt og lave et spil i en browser; Jeg valgte Phaser 3 som en ramme (det ser meget populært ud i disse dage) og TypeScript som sprog (fordi jeg foretrækker statisk skrivning frem for dynamisk). Det viste sig, at du skal gøre nogle kedelige ting for at få det hele til at fungere, så jeg skrev denne vejledning for at hjælpe de andre mennesker som mig med at komme hurtigere i gang.

Forberedelse af miljøet

IDE

Vælg dit udviklingsmiljø. Du kan altid bruge almindeligt gammelt notesblok, hvis du ønsker det, men jeg vil foreslå at bruge noget mere nyttigt. For mig foretrækker jeg at udvikle kæledyrsprojekter i Emacs, derfor har jeg installeret tidevand og fulgt instruktionerne for at konfigurere det.

Node

Hvis vi udviklede JavaScript, ville det være helt fint at starte kodning uden alle disse forberedelsestrin. Da vi imidlertid vil bruge TypeScript, er vi nødt til at indstille infrastrukturen for at gøre den fremtidige udvikling så hurtig som muligt. Derfor er vi nødt til at installere node og npm.

Når jeg skriver denne tutorial, bruger jeg node 10.13.0 og npm 6.4.1. Bemærk, at versionerne i frontend-verdenen opdateres ekstremt hurtigt, så du tager simpelthen de nyeste stabile versioner. Jeg anbefaler kraftigt at bruge nvm i stedet for at installere node og npm manuelt; det sparer dig meget tid og nerver.

Opsætning af projektet

Projektstruktur

Vi bruger npm til at opbygge projektet, så for at starte projektet skal du gå til en tom mappe og køre npm init. npm vil stille dig flere spørgsmål om dine projektegenskaber og derefter oprette en package.jsonfil. Det vil se sådan ud:

{ "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" }

Pakker

Installer de pakker, vi har brug for, med følgende kommando:

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

-Doption (aka --save-dev) gør npm automatisk at tilføje disse pakker til listen over afhængigheder package.json:

"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 kører TypeScript-kompilatoren og samler en masse resulterende JS-filer såvel som biblioteker i en minificeret JS, så vi kan inkludere den på vores side.

Tilføj webpack.config.jsnær din 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' };

Her ser vi, at webpack skal hente kilderne fra src/app.ts(som vi snart tilføjer) og samle alt i dist/app.jsfilen.

TypeScript

Vi har også brug for en lille konfigurationsfil til TypeScript-kompilatoren ( tsconfig.json), hvor vi forklarer, hvilken JS-version vi ønsker, at kilderne skal kompileres til, og hvor disse kilder skal findes:

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

TypeScript-definitioner

TypeScript er et statisk skrevet sprog. Derfor kræver det typedefinitioner til kompilering. På tidspunktet for skrivningen af ​​denne vejledning var definitionerne for Phaser 3 endnu ikke tilgængelige som npm-pakken, så det kan være nødvendigt at du downloader dem fra det officielle lager og placerer filen i srcunderkataloget til dit projekt.

Scripts

Vi er næsten færdig med projektets oprettelse. I dette øjeblik skulle du har oprettet package.json, webpack.config.jsog tsconfig.json, og tilføjede src/phaser.d.ts. Den sidste ting, vi skal gøre, før vi begynder at skrive kode, er at forklare, hvad npm præcist har at gøre med projektet. Vi opdaterer scriptssektionen af package.jsonfølgende:

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

Når du udfører npm build, app.jsbygges filen i henhold til webpack-konfigurationen. Og når du kører npm start, behøver du ikke bekymre dig om byggeprocessen: så snart du gemmer en kilde, genopbygger webpack appen og live-serveren genindlæser den i din standardbrowser. Appen hostes på //127.0.0.1:8085/.

Kom godt i gang

Nu hvor vi har oprettet infrastrukturen (den del, jeg personligt hader, når jeg starter et projekt), kan vi endelig begynde at kode. I dette trin gør vi en ligetil ting: tegne et mørkeblåt rektangel i vores browservindue. Brug af en stor spiludviklingsramme til dette er en smule ... hmmm ... overkill. Alligevel har vi brug for det i de næste trin.

Lad mig kort forklare hovedbegreberne i Phaser 3. Spillet er en forekomst af Phaser.Gameklassen (eller dens efterkommer). Hvert spil indeholder en eller flere forekomster af Phaser.Sceneefterkommere. Hver scene indeholder flere objekter, enten statiske eller dynamiske, og repræsenterer en logisk del af spillet. For eksempel vil vores trivielle spil have tre scener: velkomstskærmen, selve spillet og score-skærmen.

Lad os begynde at kode.

Opret først en minimalistisk HTML-container til spillet. Opret en index.html fil, der indeholder følgende kode:

   Starfall 

Der er kun to vigtige dele her: den første er en scriptpost, der siger, at vi skal bruge vores indbyggede fil her, og den anden er en div post, der vil være spilcontaineren.

Opret nu en fil src/app.tsmed følgende kode:

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); };

Denne kode er selvforklarende. GameConfig har mange forskellige egenskaber, du kan tjekke dem her.

Og nu kan du endelig løbe npm start. Hvis alt blev gjort korrekt på dette og tidligere trin, skal du se noget så simpelt som dette i din browser:

At få stjernerne til at falde

We have created an elementary application. Now it’s time to add a scene where something will happen. Our game will be simple: the stars will fall to the ground, and the goal will be to catch as many as possible.

To achieve this goal create a new file, gameScene.ts, and add the following code:

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 here contains a key under which other scenes may call this scene.

You see here stubs for four methods. Let me briefly explain the difference between then:

  • init([params]) is called when the scene starts; this function may accept parameters, which are passed from other scenes or game by calling scene.start(key, [params])
  • preload() is called before the scene objects are created, and it contains loading assets; these assets are cached, so when the scene is restarted, they are not reloaded
  • 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], ...

Har du bemærket, hvad der mangler? Højre, vi ringer ikke til hvor som ScoreScenehelst endnu! Lad os kalde det, når spilleren har savnet den tredje stjerne:

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); } }

Endelig ser vores Starfall-spil ud som et rigtigt spil - det starter, slutter og har endda et mål at arkivere (hvor mange stjerner kan du fange?).

Jeg håber, at denne tutorial er lige så nyttig for dig, som den var for mig, da jeg skrev den :) Enhver feedback er meget værdsat!

Kildekoden til denne vejledning kan findes her.