Hogyan készítsünk egy egyszerű játékot a böngészőben a Phaser 3 és a TypeScript használatával

Fejlesztői szószóló és háttérfejlesztő vagyok, és a frontend fejlesztési szakértelem viszonylag gyenge. Nemrégiben szerettem volna szórakozni és játékot készíteni egy böngészőben; Keretként a Phaser 3-at választottam (manapság meglehetősen népszerűnek tűnik), nyelvként pedig a TypeScript-t (mert inkább a statikus gépelést részesítem előnyben a dinamikus helyett). Kiderült, hogy unalmas dolgokat kell tennie annak érdekében, hogy mindez sikerüljön, ezért ezt a bemutatót azért írtam, hogy segítsek a hozzám hasonló embereknek gyorsabban elindulni.

A környezet előkészítése

IDE

Válassza ki a fejlesztői környezetet. Bármikor használhatja a sima régi Jegyzettömböt, ha szeretné, de javaslom valami hasznosabbat. Ami engem illet, inkább az állatbarát projekteket fejlesztem Emacs-ben, ezért telepítettem az árapályt, és a beállításához követtem az utasításokat.

Csomópont

Ha JavaScript-en fejlesztenénk, akkor tökéletesen megkezdhetnénk a kódolást ezen előkészítési lépések nélkül. Mivel azonban a TypeScript-t szeretnénk használni, be kell állítanunk az infrastruktúrát, hogy a jövőbeni fejlesztések a lehető leggyorsabban megvalósuljanak. Ezért telepítenünk kell a csomópontot és az npm-et.

Az oktatóanyag írása közben a 10.13.0 és az npm 6.4.1 csomópontokat használom. Felhívjuk figyelmét, hogy a frontend világának verziói rendkívül gyorsan frissülnek, ezért egyszerűen a legfrissebb stabil verziókat kell választania. Erősen ajánlom az nvm használatát a node és az npm manuális telepítése helyett; ez rengeteg időt és ideget fog megtakarítani.

A projekt beállítása

A projekt felépítése

Az npm-et fogjuk használni a projekt felépítéséhez, így a projekt elindításához lépjen egy üres mappába, és futtassa npm init. Az npm több kérdést tesz fel a projekt tulajdonságairól, majd létrehoz egy package.jsonfájlt. Valahogy így fog kinézni:

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

Csomagok

Telepítse a szükséges csomagokat a következő paranccsal:

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

-DAz opció (aka --save-dev) arra készteti az npm-et, hogy ezeket a csomagokat package.jsonautomatikusan hozzáadja a függőségek listájához :

"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

A Webpack futtatja a TypeScript fordítót, és összegyűjti a keletkező JS fájlok csomópontját, valamint a könyvtárakat egy tömörített JS-be, hogy felvehessük az oldalunkba.

Add hozzá webpack.config.jsa következőhöz 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' };

Itt látjuk, hogy a webpack-nak meg kell szereznie a forrásokat onnan indulva src/app.ts(amit hamarosan hozzáadunk), és mindent dist/app.jsfájlba kell gyűjteni .

Gépelt

Szükségünk van egy kis konfigurációs fájlra is a TypeScript fordítóhoz ( tsconfig.json), ahol elmagyarázzuk, melyik JS verzióhoz szeretnénk fordítani a forrásokat, és hol találjuk meg ezeket:

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

TypeScript definíciók

A TypeScript statikusan tipizált nyelv. Ezért az összeállításhoz típusmeghatározásokat igényel. Az oktatóanyag elkészítésekor a Phaser 3 definíciói még nem voltak elérhetők npm csomagként, ezért előfordulhat, hogy le kell töltenie őket a hivatalos adattárból, és a fájlt be kell helyeznie a srcprojekt alkönyvtárába.

Szkriptek

Már majdnem befejeztük a felállított projektet. Ebben a pillanatban el kellett volna készítenie package.json, webpack.config.jsés tsconfig.json, és hozzáadjuk src/phaser.d.ts. Az utolsó dolog, amit meg kell tennünk a kódírás megkezdése előtt, hogy elmagyarázzuk, mi az npm köze pontosan a projekthez. Az alábbiak szerint frissítjük a scriptsszakaszt package.json:

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

Amikor végrehajtja npm build, a app.jsfájl a webcsomag konfigurációjának megfelelően lesz felépítve. Amikor futtat npm start, akkor nem kell aggódnia a készítési folyamat miatt: amint elment egy forrást, a webpack újjáépíti az alkalmazást, és az élő szerver újratölti az alapértelmezett böngészőben. Az alkalmazás a //127.0.0.1:8085/ címen lesz tárolva.

Elkezdeni

Most, hogy létrehoztuk az infrastruktúrát (azt a részt, amelyet személyesen utálok egy projekt indításakor), végre elkezdhetjük a kódolást. Ebben a lépésben egyenes dolgot fogunk tenni: rajzoljunk egy sötétkék téglalapot a böngészőnk ablakába. Ehhez egy nagy játékfejlesztő keretrendszer használata egy kicsit… hmmm ... túlzás. Mégis szükségünk lesz rá a következő lépéseknél.

Engedje meg, hogy röviden elmagyarázzam a Phaser 3 fő fogalmait. A játék az Phaser.Gameosztály (vagy annak leszármazottja) példánya . Minden játék egy vagy több Phaser.Sceneutódot tartalmaz. Minden jelenet több objektumot tartalmaz, akár statikus, akár dinamikus, és a játék logikai részét képviseli. Például triviális játékunknak három jelenete lesz: az üdvözlő képernyő, maga a játék és a pontszám képernyő.

Kezdjük a kódolást.

Először hozzon létre egy minimalista HTML-tárolót a játékhoz. Készítsen egy index.html fájlt, amely a következő kódot tartalmazza:

   Starfall 

Itt csak két lényeges rész van: az első egy scriptbejegyzés, amely azt mondja, hogy itt fogjuk használni a beépített fájlunkat, a második pedig egy div bejegyzés, amely a játék konténere lesz.

Most hozzon létre egy fájlt src/app.tsa következő kóddal:

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

Ez a kód magától értetődő. A GameConfig rengeteg különféle tulajdonsággal rendelkezik, itt ellenőrizheti őket.

És most végre futhatsz npm start. Ha mindent megfelelően végeztek ezen és az előző lépéseken, akkor valami oly egyszerű dolgot kell látnia a böngészőben:

A csillagok lehullása

Létrehoztunk egy elemi alkalmazást. Itt az ideje hozzáadni egy jelenetet, ahol valami történni fog. A játékunk egyszerű lesz: a csillagok a földre hullanak, és a cél az lesz, hogy minél többet elkapjanak.

A cél elérése érdekében hozzon létre egy új fájlt gameScene.ts, és adja hozzá a következő kódot:

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

A konstruktor itt tartalmaz egy kulcsot, amely alatt más jelenetek hívhatják ezt a jelenetet.

Négy módszer szerint látja itt. Engedje meg, hogy röviden elmagyarázzam az akkori különbséget:

  • init([params])hívják, amikor a jelenet elkezdődik; ez a funkció elfogadhat olyan paramétereket, amelyeket más jelenetek vagy játékok hívással továbbítanakscene.start(key, [params])
  • preload()a jelenetobjektumok létrehozása előtt hívják meg, és betöltési elemeket tartalmaz; ezek az eszközök gyorsítótárban vannak, így a helyszín újrakezdésével nem töltődnek be újra
  • 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.