Hoe je een eenvoudig spel in de browser kunt bouwen met Phaser 3 en TypeScript

Ik ben een voorstander van ontwikkelaars en een backend-ontwikkelaar, en mijn expertise op het gebied van frontend-ontwikkeling is relatief zwak. Een tijdje geleden wilde ik wat lol maken en een spel maken in een browser; Ik koos Phaser 3 als framework (het ziet er tegenwoordig behoorlijk populair uit) en TypeScript als taal (omdat ik de voorkeur geef aan statisch typen boven dynamisch). Het bleek dat je wat saaie dingen moet doen om alles te laten werken, dus ik heb deze tutorial geschreven om de andere mensen zoals ik te helpen sneller aan de slag te gaan.

De omgeving voorbereiden

IDE

Kies uw ontwikkelomgeving. U kunt altijd een gewoon oud Kladblok gebruiken als u dat wilt, maar ik raad u aan iets nuttigers te gebruiken. Wat mij betreft, ik geef er de voorkeur aan om huisdierenprojecten te ontwikkelen in Emacs, daarom heb ik tij geïnstalleerd en de instructies gevolgd om het op te zetten.

Knooppunt

Als we ontwikkelden op JavaScript, zouden we prima kunnen beginnen met coderen zonder al deze voorbereidingsstappen. Omdat we TypeScript echter willen gebruiken, moeten we de infrastructuur opzetten om de toekomstige ontwikkeling zo snel mogelijk te laten verlopen. We moeten dus node en npm installeren.

Terwijl ik deze tutorial schrijf, gebruik ik knooppunt 10.13.0 en npm 6.4.1. Houd er rekening mee dat de versies in de frontend-wereld extreem snel updaten, dus je neemt gewoon de laatste stabiele versies. Ik raad ten zeerste aan om nvm te gebruiken in plaats van node en npm handmatig te installeren; het bespaart u veel tijd en zenuwen.

Het opzetten van het project

Project structuur

We zullen npm gebruiken voor het bouwen van het project, dus om het project te starten, ga naar een lege map en voer het uit npm init. npm zal u verschillende vragen stellen over uw projecteigenschappen en vervolgens een package.jsonbestand maken. Het ziet er ongeveer zo uit:

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

Pakketjes

Installeer de pakketten die we nodig hebben met het volgende commando:

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

-Doption (aka --save-dev) zorgt ervoor dat npm deze pakketten package.jsonautomatisch aan de lijst met afhankelijkheden toevoegt :

"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 zal de TypeScript-compiler uitvoeren en de reeks resulterende JS-bestanden en bibliotheken verzamelen in één verkleinde JS, zodat we deze op onze pagina kunnen opnemen.

Voeg toe webpack.config.jsaan uw 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' };

Hier zien we dat webpack de bronnen moet beginnen vanaf src/app.ts(die we binnenkort zullen toevoegen) en alles in het dist/app.jsbestand moet verzamelen .

TypeScript

We hebben ook een klein configuratiebestand nodig voor de TypeScript-compiler ( tsconfig.json) waarin we uitleggen naar welke JS-versie we de bronnen willen compileren en waar we die bronnen kunnen vinden:

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

TypeScript-definities

TypeScript is een statisch getypte taal. Daarom zijn typedefinities vereist voor de compilatie. Op het moment van schrijven van deze tutorial waren de definities voor Phaser 3 nog niet beschikbaar als het npm-pakket, dus het kan zijn dat je ze moet downloaden van de officiële repository en het bestand in de srcsubmap van je project moet plaatsen.

Scripts

We zijn bijna klaar met het opzetten van het project. Op dit moment moet je hebt gemaakt package.json, webpack.config.jsen tsconfig.json, en voegde eraan toe src/phaser.d.ts. Het laatste dat we moeten doen voordat we beginnen met het schrijven van code, is uitleggen wat npm precies met het project te maken heeft. We werken het scriptsgedeelte van de package.jsonals volgt bij:

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

Wanneer je het uitvoert npm build, wordt het app.jsbestand gebouwd volgens de webpack-configuratie. En als je draait npm start, hoef je je geen zorgen te maken over het bouwproces: zodra je een bron opslaat, zal webpack de app opnieuw bouwen en zal de live-server deze opnieuw laden in je standaardbrowser. De app wordt gehost op //127.0.0.1:8085/.

Ermee beginnen

Nu we de infrastructuur hebben opgezet (het deel dat ik persoonlijk haat bij het starten van een project), kunnen we eindelijk beginnen met coderen. In deze stap doen we iets eenvoudigs: teken een donkerblauwe rechthoek in ons browservenster. Het gebruik van een groot game-ontwikkelingsraamwerk hiervoor is een beetje ... hmmm ... overkill. Toch hebben we het nodig bij de volgende stappen.

Laat me kort de belangrijkste concepten van Phaser 3 uitleggen. Het spel is een instantie van de Phaser.Gameklasse (of zijn afstammeling). Elk spel bevat een of meer exemplaren van Phaser.Scenenakomelingen. Elke scène bevat verschillende objecten, statisch of dynamisch, en vertegenwoordigt een logisch onderdeel van het spel. Ons triviale spel heeft bijvoorbeeld drie scènes: het welkomstscherm, het spel zelf en het scorescherm.

Laten we beginnen met coderen.

Maak eerst een minimalistische HTML-container voor de game. Maak een index.html bestand met de volgende code:

   Starfall 

Er zijn hier slechts twee essentiële onderdelen: het eerste is een scriptitem dat zegt dat we ons ingebouwde bestand hier gaan gebruiken, en het tweede is een div item dat de game-container zal zijn.

Maak nu een bestand src/app.tsmet de volgende code:

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

Deze code spreekt voor zich. GameConfig heeft veel verschillende eigenschappen, je kunt ze hier bekijken.

En nu kun je eindelijk rennen npm start. Als alles correct is gedaan in deze en eerdere stappen, zou u zoiets eenvoudigs als dit in uw browser moeten zien:

De sterren laten vallen

We hebben een elementaire applicatie gemaakt. Nu is het tijd om een ​​scène toe te voegen waarin iets zal gebeuren. Ons spel zal simpel zijn: de sterren zullen op de grond vallen, en het doel is om er zoveel mogelijk te vangen.

Om dit doel te bereiken, maakt u een nieuw bestand gameScene.ts, en voegt u de volgende code toe:

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 bevat hier een sleutel waaronder andere scènes deze scène mogen noemen.

U ziet hier stompjes voor vier methoden. Laat me kort het verschil uitleggen tussen toen:

  • init([params])wordt aangeroepen wanneer de scène start; deze functie accepteert mogelijk parameters die worden doorgegeven vanuit andere scènes of games door te bellenscene.start(key, [params])
  • preload()wordt aangeroepen voordat de scèneobjecten worden gemaakt en het bevat laadactiva; deze middelen worden in de cache opgeslagen, dus wanneer de scène opnieuw wordt opgestart, worden ze niet opnieuw geladen
  • 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.