Как создать простую браузерную игру с помощью Phaser 3 и TypeScript

TypeScript

Большую часть своего времени я занимаюсь backend-разработкой, поэтому я не могу похвастаться большим опытом во frontend. Какое-то время назад у меня возникло желание создать браузерную игру. Недолго думая, я выбрала Phaser 3 в качестве фреймворка (так как в настоящее время он достаточно популярен) и TypeScript в качестве языка программирования (потому что я предпочитаю статическую типизацию, а не динамическую). Оказалось, что нужно разобраться с огромным множеством скучных вещей, дабы все работало. Именно поэтому я написала эту статью, чтобы помочь таким же людям, как и я, быстрее приступить к самому интересному этапу.

Подготовка

IDE

Выберите среду разработки, в которой будете работать. Вы всегда можете использовать старый добрый Notepad++ если хотите, но я бы рекомендовала что-то более полезное. Лично я предпочитаю разрабатывать свои проекты в Emacs.

Node

Если бы мы разрабатывали наш проект на JavaScript, мы бы прекрасно обошлись без всех этих подготовительных этапов. Однако, поскольку мы собираемся использовать TypeScript, нам нужно настроить инфраструктуру, чтобы в будущем разработка продвигалась как можно быстрее. Для этого необходимо установить Node.js и npm.

Во время написания этой статьи, я использую node 10.13.0 и npm 6.4.1. Обратите внимание на то, что в мире frontend-разработки все очень быстро меняется, поэтому старайтесь использовать самые последние стабильные версии. Я настоятельно рекомендую использовать nvm, вместо ручной установки node и npm, так как это позволит уберечь ваши нервы и сэкономить время.

Настройка проекта

Структура проекта

Для сборки проекта мы будем использовать npm, поэтому перейдите в пустую папку и запустите npm init. npm задаст вам несколько вопросов о свойствах вашего проекта, а затем создаст файл package.json. Это будет выглядеть примерно так:

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

Пакеты

Установите нужные пакеты с помощью следующей команды:

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

-D (также известная как --save-dev) заставляет npm автоматически добавлять эти пакеты в список зависимостей в 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 запустит компилятор TypeScript и соберет кучу результирующих JS файлов, а также библиотек, в один уменьшенный JS файл, который мы сможем включить в нашу страницу.

Добавьте webpack.config.js рядом с вашим 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'
};

Здесь мы видим, что webpack должен получить исходники (sources), начиная от src/app.ts(которые мы вскоре добавим), и собрать все в dist/app.js файл.

TypeScript

Также нам нужен небольшой конфигурационный файл для компилятора TypeScript (tsconfig.json), в котором мы объясняем, к какой версии JS должны быть скомпилированы исходники и где их найти:

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

TypeScript типы

TypeScript — это статически типизированный язык, следовательно, конечные типы переменных и функций устанавливаются на этапе компиляции. На момент написания этой статьи, типы для Phaser 3 еще не были доступны в виде nmp пакета, поэтому вам может потребоваться загрузить их из официального репозитория и поместить файл в подкаталог src вашего проекта.

Скрипты

Мы почти закончили настройку проекта. На данный момент вы должны были создать package.json, webpack.config.js и tsconfig.json, а также добавить src/phaser.d.ts. Последнее, что нужно сделать перед тем, как начать писать код — это объяснить, что конкретно npm должен сделать с проектом. Необходимо изменить раздел scripts файла package.json следующим образом:

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

При выполнении npm build, файл app.js будет собран в соответствии с конфигурацией webpack. И когда вы запустите npm start, вам не придется беспокоиться о процессе сборки: как только вы сохраните какой-либо исходник, webpack перестроит приложение, а live-server перезагрузит его в вашем браузере, стоящем по умолчанию. Приложение будет размещено по адресу http://127.0.0.1:8085/.

Начало работы

Итак, мы наконец можем приступить к написанию кода. На этом этапе мы сделаем одну простую вещь: нарисуем темно-синий прямоугольник в окне нашего браузера.

Позвольте мне кратко объяснить основные концепции Phaser 3. Игра является экземпляром класса Phaser.Game (или его потомком). Каждая игра содержит один или несколько экземпляров классаPhaser.Scene. Каждая сцена содержит несколько статических или динамических объектов, и представляет собой логическую часть игры. Например, наша тривиальная игра будет состоять из трех сцен: экрана приветствия, самой игры и экрана с очками, полученными за игру.

Давайте же приступим к написанию кода.

Перво-наперво, создайте простой HTML-контейнер для игры. Создайте файл index.html, который будет содержать следующий код:

<!DOCTYPE html>
<html>
  <head>
    <title>Starfall</title>
    <script src="dist/app.js"></script>
  </head>
  <body>
    <div id="game"></div>
  </body>
</html>

Здесь есть два важных момента: первый— это тег script, который позволяет подключить какой-либо внешний js-файл и тег div, который представляет из себя контейнер для нашей игры.

Теперь создайте файл src/app.ts, в котором будет такой код:

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

Данный код не требует никаких пояснений. GameConfig содержит в себе большое количествосвойств, на этой странице вы можете ознакомиться с ними.

Наконец вы можете запустить npm start. Если все было сделано правильно, вы увидите следующее в своем браузере: 

Да, это голубой экран.

Заставляем звезды падать с неба

Мы создали элементарное приложение. Теперь пришло время добавить что-нибудь интерактивное. Идея у игры простая: звезды будут падать на землю, а мы должны будем их ловить и чем больше, тем лучше.

Чтобы добиться этого, создайте новый файл gameScene.ts и добавьте в него такой код:

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 содержит в себе key, согласно которомудругие сцены могут вызвать эту сцену.

Далее мы видим стабы для четырех методов. Позвольте мне кратко объяснить разницу между ними:

  • init([params]) вызывается в тот момент, когда запускается сцена; эта функция может принимать параметры, которые передаются из других сцен или игры, путем вызова scene.start(key, [params])
  • preload()вызывается до создания объектов сцены и содержит в себе loading assets (загрузочные ресурсы); эти ресурсы кэшируются, поэтому при перезапуске сцены они не перезагружаются
  • create() вызывается, когда загружаются ресурсы (assets) и, как правило, включает создание основных игровых объектов (фона, игрока, препятствий, врагов и т.д.)
  • update([time]) вызывается every tick (каждый кадр) и содержит динамическую часть сцены — все, что движется, мигает и т.д.

Пока не забыла, давайте быстренько добавим следующие строки в 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"
};
...

Теперь наша игра знает о GameScene. Если конфигурационный файл игры содержит в себе список сцен, тогда первая из них запускается при запуске игры, а все остальные создаются, но не запускаются до тех пор, пока их явно не вызовут. 

Также мы добавили физический движок arcade physics. Это нужно для того, чтобы наши звезды падали.

Сейчас мы, так сказать, добавим мясо к костям нашей GameScene.

Во-первых, мы объявим некоторые свойства и объекты, которые нам понадобятся:

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

Затем мы инициализируем числа:

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

И загрузим пару изображений:

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

После этого мы можем подготовить наши статические компоненты. Мы создадим землю, куда будут падать звезды, и текст, информирующий нас о текущем счете:

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

Группы в Phaser 3 — это отличный способ создать группу объектов, которыми вы хотите управлять одновременно. Есть два типа объектов: статические и динамические. Как вы уже догадались, статические объекты не двигаются (земля, стены, различные препятствия), в то время как динамические наоборот (машины, ракеты, знаменитый итальянский водопроводчик).

Мы создадим статическую группу из кусочков земли. Эти кусочки расположены вдоль линии. Обратите внимание на то, что линия разделена на 20 одинаковых частей и плитки с землей расположены в каждой из этих частей. Также мы должны вызвать refresh(), чтобы обновить group bounding box (ограничивающий параллелепипед группы). В противном случае collisions (столкновения) будут проверены относительно местоположения по умолчанию, которое находится в левом верхнем углу сцены.

Если вы проверите, как выглядит ваше приложение, вы увидите нечто вроде этого:

Эволюция голубого экрана

Вот мы и добрались до самой динамичной части этой сцены — функции update(), в которой будут падать звезды. Мы хотим видеть падающую звезду каждую секунду, поэтому данная функция вызывается примерно один раз в 60 мс. Мы не будем использовать динамическую группу, поскольку жизненный цикл каждой звезды будет очень коротким: звезда будет уничтожена либо щелчком по ней пользователя, либо из-за столкновения с землей. Поэтому внутри функции emitStar() мы создаем новую звезду и добавляем обработку двух событий: onClick() и 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);
  }

Ура, у нас есть игра! Но все еще нет условий, которые нужны для победы. Мы добавим их в последней части нашей статьи.

Я плохо ловлю звезды.…

Заканчиваем

Обычно любая игра состоит из нескольких сцен. Даже если ваш игровой процесс максимально прост, вам нужна вступительная сцена (например, включающая в себя кнопку “Играть!”) и заключительная сцена (например, показывающая ваш игровой результат). Давайте добавим эти сцены в наше приложение.

В нашем случае они будут очень похожи между собой, так как я не хочу уделять много внимания графическому оформлению игры. В конце концов, это просто туториал по программированию.

Вступительная сцена будет включать следующий код в welcomeScene.ts. Обратите внимание, что когда пользователь кликает в любом месте вступительной сцены — появляется игровая сцена.

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

Заключительная сцена будет выглядеть практически также. Нажатие в любом месте этой сцены приведет к появлению вступительной сцены (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);
  }
};

Теперь нам нужно обновить наш основной файл приложения: добавьте в него новые сцены и сделайте WelcomeScene первой в списке.

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

Вы заметили, чего не хватает? Правильно, мы все еще не вызываем ScoreScene из ниоткуда. Давайте сделаем так: сцена со счетом за игру будет вызываться после третьей пропущенной звезды:

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

Ну что же, наша игра “Starfall” выглядит как настоящая игра — она начинается, заканчивается и в ней есть цель (сколько звезд вы сможете поймать?).


Весь исходный код вы можете найти здесь.

Специально для сайта ITWORLD.UZ. Новость взята с сайта NOP::Nuances of programming