Разработка браузерных игр с использованием Phaser3, React, Typescript
Думаю, ни для кого не секрет, что каждый уважающий себя разработчик программного обеспечения должен иметь в своем портфолио хотя бы один пет-проект, а лучше полноценный продукт, дающий дополнительный постоянный заработок. Предметных областей и тематик приложений великое множество, но среди них есть одна, которая заслуживает отдельного внимания — разработка своей собственной игры.
Преимущества разработки игр
Прежде всего, следует понимать, что разработка игр достаточно трудоемкий процесс. Как правило, он включает в себя как знание особенностей фронтенда, так и бекенда и всех сопутствующих серверных технологий, особенно, если речь идет о мультиплеерах. Кроме того, часто необходимо задумываться над производительностью, используемыми ресурсами, архитектурой и алгоритмами. Потребуется подкачать знание математики, геометрии, физики. Навыки художника, пусть и в минимальном объеме, тоже будут очень кстати. А если игра имеет коммерческую цель - то и маркетинг, анализ рынка пригодятся. В итоге, разработка игр позволяет прокачать свои навыки настолько, что любое собеседование или работа в коммерческой компании над различными веб - сервисами покажется легкой прогулкой. Тем более, что с игрой в портфолио вы будете выделяться среди других кандидатов на вакансии. Быть разработчиком игр - весело и круто.
С чего начать?
Браузерная игра - достойная идея, но нужно идти в ногу со временем и использовать последние технологии. В этой статье использую и постараюсь раскрыть связку:
Typescript
React
Webpack
HTML/CSS
Phaser3
Разумеется, помимо технических навыков следует вспомнить базовые понятия:
Математики
Физики
Компьютерной графики
Школьного уровня понимания этих предметов вполне достаточно. В качестве художественных материалов можно взять готовые ресурсы, спрайты, модели из других игр в свободном доступе, например warcraft 2. Все материалы из статьи не используются в коммерческих целях.
Почему Phaser3?
Потому что на данный момент это самый часто используемый и активно развивающийся open-source фреймворк для разработки браузерных игр и интерактивных приложений на JavaScript/TypeScript
Какие будут ваши доказательства?
На официальных ресурсах Phaser можно найти бесчисленное количество примеров кода, игр и best-практик. Также среди достоинств: регулярные обновления и новые фичи, огромное комьюнити разработчиков, открытая и полная документация, доступны книги от создателя фреймворка Richard Davey @photonstorm
Практика
https://github.com/tfkfan/phaser3-game-demo
Выше представлена ссылка на демо проекта.
В основе демо лежит стартер https://github.com/tfkfan/phaser3-react-template
Теперь по порядку.
Требования: NodeJS >= v20, NPM >= v10
Для начала, выгружаем проект. Устанавливаем зависимости и запускаем:
npm install
npm start
Демо содержит 2 связанные, но изначально не особо ладящие друг с другом, технологии - React и Phaser. Для того, чтобы они работали вместе без проблем, в Index.html объявлено два разных контейнера, каждый из них привязывает свой фреймворк соответственно:
<div id="root" class="app-container">
....
<div id="game-root">
Заметьте, что контейнер React с id ="root" находится первым, на нем будет строиться все UI проекта, блок с z-index отличным от нуля(для отрисовки UI поверх игровых сцен), нестатический и позиционированный, что добавляет удобства в верстке. В блоке id="game-root" используется только canvas, поэтому можно пожертвовать его позиционированием, прилепляем его к вернему левому краю абсолютным позиционированием.
Любая Phaser игра начинается с конфигурации фреймворка.
phaser-game.ts :
const config = {
type: Phaser.WEBGL, // Тип приложения - WEBGL/CANVAS
parent: 'game-root',
canvas: document.getElementById('game-canvas') as HTMLCanvasElement,
width: window.innerWidth ,
height: window.innerHeight,
pixelArt: true,
scene: [BootstrapScene, GameScene],
physics: { // подключение физического движка
default: 'arcade',
arcade: {
debug: false
}
}
}
Все параметры, впринципе, должны быть интуитивно понятны, но самый главный из них это набор сцен:
scene: [BootstrapScene, GameScene]
Сцены - основной объект для отрисовки игрового содержимого, через нее проходят все ресурсы, события и процессы в игре. Первая из них используется в качестве предзагрузчика. Все загруженные в первой сцене ресурсы будут доступны в других. Ресурсы могут быть разные, это и спрайт-листы, и атласы анимаций, и звуковые файлы, Tilemap-файлы, шейдеры и пр.
Любая сцена имеет 4 важных функции, изменяя которые, можно управлять игровой логикой:
preload - загружает ресурсы, и это все.
init - запускается следом. Позволяет получить данные при переходе из предыдущей сцены, инициализирует игровую логику.
create - позволяет создать объекты и привязать их к сцене.
Большинство игровых объектов достаточно просто объявить в этом методе.
Под капотом они сами обновляются в игровом цикле.update - игровой цикл. Здесь можно добавить дополнительную логику, когда базового функционала метода create уже не хватает.
В конструкторе передается строковый ключ этой сцены.
export default class BootstrapScene extends Phaser.Scene {
constructor() {
super('bootstrap')
}
init() {
store.dispatch(setLoading(true))
}
preload() {
this.load.on(Phaser.Loader.Events.PROGRESS, (value: number) => {
CONTROLS.setProgress(100 * value);
});
this.load.tilemapTiledJSON('worldmap', './assets/maps/new/map01merged.json');
this.load.image('tiles', './assets/maps/new/tiles.png');
this.load.atlas('mage', './assets/playersheets/mage.png', './assets/playersheets/mage.json');
this.load.image('fireball', './assets/skillsheets/fire_002.png');
this.load.spritesheet('buff', './assets/skillsheets/cast_001.png', {frameWidth: 192, frameHeight: 192});
this.load.image('face', './assets/images/face.png');
this.load.spritesheet('fireballBlast', './assets/skillsheets/s001.png', {frameWidth: 192, frameHeight: 192});
this.load.audio('intro', ['./assets/music/phaser-quest-intro.ogg']);
this.load.glsl('fireball_shader', './assets/shaders/fireball_shader.frag');
}
create() {
CONTROLS.setProgress(100);
store.dispatch(setLoading(false))
this.sound.add('intro').play({
seek: 2.550
});
this.add.shader('fireball_shader', window.innerWidth/2, window.innerHeight/2, window.innerWidth ,window.innerHeight);
}
}
Сцена данного предзагрузчика также имеет функционал, позволяющий показать прогресс загрузки всех прописанных ресурсов, выводя данные с помощью глобального объекта контроля React компонентов CONTROLS, но об этом позднее. Также прописываем инструкцию проигрывания музыки на старте:
this.sound.add('intro').play({
seek: 2.550
});
Главная сцена, на которой будет строиться весь геймплей - GameScene.
Рассмотрим метод create:
create() {
CONTROLS.setVersion(`Phaser v${Phaser.VERSION}`)
this.input.keyboard.on(Phaser.Input.Keyboard.Events.ANY_KEY_DOWN, (evt: { key: string; }) => {
this.player.setSkillIndex(this.skillIndexMap[evt.key])
const direction = this.keymap[evt.key]
if (direction)
this.player.walk(direction, true)
});
this.input.keyboard.on(Phaser.Input.Keyboard.Events.ANY_KEY_UP, (evt: { key: string; }) => {
const direction = this.keymap[evt.key]
if (direction)
this.player.walk(direction, false)
});
this.input.on(Phaser.Input.Events.POINTER_DOWN, (evt: {
worldX: number;
worldY: number;
}) => this.player.attack(new Vector2(evt.worldX, evt.worldY)));
this.createAnimations()
this.displayMap()
this.createPlayer()
this.cameras.main.startFollow(this.player)
// examples
// Animation/Sprite
this.anims.create({
key: 'explosion',
frames: this.anims.generateFrameNumbers('fireballBlast', {start: 0, end: 19, first: 0}),
frameRate: 20,
repeat: -1
})
this.add.sprite(2500, 1100, "").play('explosion')
// Arcade Physics / collision
const items = this.add.group([this.createItem()])
this.physics.add.collider(this.player, items, (object1: Phaser.Tilemaps.Tile | Phaser.GameObjects.GameObject, object2: Phaser.Tilemaps.Tile | Phaser.GameObjects.GameObject) => {
object2.destroy(true)
setTimeout(() => {
items.add(this.createItem(), true)
}, 3000)
})
}
Для отрисовки анимаций, персонажей и многих других игровых объектов, в большинстве случаев используются спрайты.
Спрайт - это миниатюрный игровой "контейнер" текстур и анимаций с различными параметрами: координаты позиции на игровом поле, скорости, ускорения движения и др. Например:
export default class Face extends Phaser.Physics.Arcade.Sprite {
constructor(scene: Scene, x: number, y: number) {
// Сцена, координаты, ключ текстуры
super(scene, x, y, 'face');
// Привязка к физике
this.scene.physics.add.existing(this)
// Привязка к сцене
this.scene.add.existing(this)
}
}
Для создания анимации, необходимо после загрузки ресурсов также указать последовательность кадров и связать ее с уникальным ключом.
Создадим анимацию взрыва из 20 нарезанных сверху-вниз, слева-направо кадров текстуры fireballBlast:
this.anims.create({
key: 'explosion',
frames: this.anims.generateFrameNumbers('fireballBlast', {start: 0, end: 19, first: 0}),
frameRate: 20,
repeat: -1
})
Ширина и высота кадра, а также ключ текстуры берется из загрузки на предыдущей сцене:
this.load.spritesheet('fireballBlast', './assets/skillsheets/s001.png', {frameWidth: 192, frameHeight: 192});
Далее создадим спрайт в точке (2500, 1100) и запустим анимацию "explosion" при помощи функции play
this.add.sprite(2500, 1100, "").play('explosion')
Для создания персонажа используем функцию this.createPlayer()
createPlayer(): Mage {
return this.player = new Mage(this, 2100, 1000, store.getState().application.nickname)
}
Где персонаж является объектом класса Mage
export default class Mage extends Player {
private skillFactory: SkillFactory = new SkillFactory(); // Factory объект создания умений
private skills = ["Fireball", "Buff"] // Всего 2 умения
private currentSkillIndex = 0 // Индекс текущего умения
constructor(scene: Scene, x: number, y: number, name:string) {
super(scene, x, y, "mage", name);
//Сцена, позиция игрока, ключ текстуры, имя
}
// Измений текущее умение
public setSkillIndex(index: number) {
if (index === undefined || index < 0 || index > 1)
return
CONTROLS.setSkill(index)
this.currentSkillIndex = index
}
// Кастовать умение по цели
override attack(target: Vector2) {
this.skillFactory.create(this.scene, this.x, this.y, target, this.skills[this.currentSkillIndex])
super.attack(target)
}
}
В свою очередь он наследуется от класса Player с логикой анимирования
движущегося и атакующего персонажа в 8 направлениях(взависимости от
нажатой клавиши)
//Phaser.Physics.Arcade.Sprite - класс спрайта, используемый в физическом движке и имеющий расширенный функционал
export default abstract class Player extends Phaser.Physics.Arcade.Sprite {
private animationKey: string;
private attackAnimationKey: string;
public isMoving: boolean;
public isAttack: boolean;
public name: string;
public target: Vector2;
private nameHolder: Phaser.GameObjects.Text;
private directionState: Map<Direction, boolean> = new Map([
[Direction.RIGHT, false],
[Direction.UP, false],
[Direction.DOWN, false],
[Direction.LEFT, false]
]);
private directionVerticalVelocity: Map<Direction, number> = new Map([
[Direction.UP, -GameConfig.playerAbsVelocity],
[Direction.DOWN, GameConfig.playerAbsVelocity]
])
private directionHorizontalVelocity: Map<Direction, number> = new Map([
[Direction.RIGHT, GameConfig.playerAbsVelocity],
[Direction.LEFT, -GameConfig.playerAbsVelocity]
])
protected constructor(scene: Scene, x: number, y: number, textureKey: string, name: string) {
super(scene, x, y, textureKey);
this.name = name;
this.init();
}
private init() {
this.isMoving = false;
this.isAttack = false;
this.animationKey = Direction.UP;
this.scene.physics.add.existing(this)
this.scene.add.existing(this);
this.nameHolder = this.scene.add.text(0, 0, this.name, {
font: '14px pixel',
stroke: "#ffffff",
strokeThickness: 2
}).setOrigin(0.5);
}
attack(target: Vector2) {
this.isAttack = true
this.target = target
this.attackAnimationKey = `${this.animationKey}attack`
this.play(this.attackAnimationKey);
this.on(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
this.isAttack = false;
this.handleMovingAnimation()
}, this);
}
walk(direction: Direction, state: boolean) {
if (this.directionState.get(direction) === state)
return;
this.directionState.set(direction, state)
const vec = [0, 0]
const activeState = Array.from(this.directionState.entries())
.filter(value => value[1])
.map(value => {
if (this.directionVerticalVelocity.has(value[0])) {
vec[1] = this.directionVerticalVelocity.get(value[0])
} else if (this.directionHorizontalVelocity.has(value[0]))
vec[0] = this.directionHorizontalVelocity.get(value[0])
return value[0]
})
this.isMoving = activeState.length > 0
if (activeState.length === 1)
this.animationKey = activeState[0]
else if (activeState.length === 2)
this.animationKey = activeState[1] + activeState[0]
this.setVelocity(vec[0], vec[1])
this.handleMovingAnimation()
}
private handleMovingAnimation() {
if (this.isAttack)
return;
if (this.isMoving)
this.play(this.animationKey);
else {
this.play(this.animationKey);
this.stop()
}
}
override preUpdate(time, delta): void {
super.preUpdate(time, delta);
this.nameHolder.setPosition(this.x, this.y - 30);
}
}
Для создания анимаций движения персонажа во всех направлениях и умений по спрайтам:
createAnimations() {
GameConfig.playerAnims.map((key) => ({
key,
frames: this.anims.generateFrameNames("mage", {
prefix: key,
start: 0,
end: 4
}),
frameRate: 8,
repeat: !key.includes("attack") && !key.includes("death") ? -1 : 0
})).concat([
{
key: 'fireballBlast',
frames: this.anims.generateFrameNumbers('fireballBlast', {start: 0, end: 19, first: 0}),
frameRate: 20,
repeat: 0
},
{
key: 'buff',
frames: this.anims.generateFrameNumbers('buff', {start: 0, end: 19, first: 0}),
frameRate: 20,
repeat: 0
}
]).forEach((config) => this.anims.create(config));
}
В том числе, используя атлас анимаций мага mage.json, указывающий координаты и размеры конкретного кадра:
"frames": {
"up0": {
"frame": {
"x": 0,
"y": 0,
"w": 75,
"h": 61
}
},
"up1": {
"frame": {
"x": 0,
"y": 61,
"w": 75,
"h": 61
}
},
"up2": {
"frame": {
"x": 0,
"y": 122,
"w": 75,
"h": 61
}
},
"up3": {
"frame": {
"x": 0,
"y": 183,
"w": 75,
"h": 61
}
},
"up4": {
"frame": {
"x": 0,
"y": 244,
"w": 75,
"h": 61
}
},
....
Phaser имеет богатый функционал, в том числе удобное манипулирование устройствами ввода. Данная строчка позволяет повесить обработчик нажатия любой кнопки клавиатуры:
this.input.keyboard.on(Phaser.Input.Keyboard.Events.ANY_KEY_DOWN, (evt: { key: string; }) => {
this.player.setSkillIndex(this.skillIndexMap[evt.key])
const direction = this.keymap[evt.key]
if (direction)
this.player.walk(direction, true)
});
Аналогично с мышью, добавляем обработку клика:
this.input.on(Phaser.Input.Events.POINTER_DOWN, (evt: {
worldX: number;
worldY: number;
}) => this.player.attack(new Vector2(evt.worldX, evt.worldY)));
Умение, или выстрел - важная составляющая игры. Это абстрактный класс, который сам по себе также является спрайтом и содержит функции отрисовки анимации. Количество текстур не имеет значения. Метод play так или иначе запустит нужную анимацию.
export abstract class Skill extends Phaser.Physics.Arcade.Sprite {
protected target: Vector2;
protected initialPosition: Vector2;
private finallyAnimated = false;
protected constructor(scene: Phaser.Scene, x: number, y: number, image: string, target: Vector2) {
super(scene, x, y, image, 0);
this.scene.add.existing(this);
this.scene.physics.add.existing(this)
this.target = target;
this.initialPosition = new Vector2(x, y)
this.init()
}
protected preUpdate(time: number, delta: number) {
super.preUpdate(time, delta);
if (!this.finallyAnimated && new Vector2(this.x, this.y).distance(this.target) < GameConfig.skillCollisionDistance) {
this.finallyAnimated = true
this.setVelocity(0, 0)
this.animateFinally().then(sprite => this.destroy(true))
.catch(e => this.destroy(true))
}
}
protected abstract playFinalAnimation(): void
animateFinally(): Promise<Skill> {
return new Promise((resolve, reject) => {
try {
this.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (animation: Phaser.Animations.Animation) => {
try {
resolve(this)
} catch (e) {
reject(e)
}
}, this);
this.playFinalAnimation()
} catch (e) {
reject(e)
}
})
}
init(): void {
const vel = new Vector2(this.target.x - this.initialPosition.x, this.target.y - this.initialPosition.y).normalize()
this.setPosition(this.initialPosition.x, this.initialPosition.y)
this.setVelocity(vel.x * GameConfig.skillAbsVelocity, vel.y * GameConfig.skillAbsVelocity)
}
}
Огненный шар
export class Fireball extends Skill {
constructor(scene: Phaser.Scene, x: number, y: number, target: Vector2) {
super(scene, x, y, "fireball", target);
}
override init() {
super.init();
this.setScale(0.02, 0.02);
}
override playFinalAnimation() {
this.play("fireballBlast");
this.setScale(1, 1)
}
}
Баф
export class Buff extends Skill {
constructor(scene: Phaser.Scene, x: number, y: number, target: Vector2) {
super(scene, x, y, "buff", target);
}
override playFinalAnimation() {
this.play("buff");
}
override init(): void {
this.setPosition(this.initialPosition.x, this.initialPosition.y)
}
}
Также стоит упомянуть механику обработки столкновений в игре. Для этого используется функционал аркадного физического движка.
Создадим предмет - лицо, как группу предметов, для его последующего
респауна по истечению 3х секунд после столкновения персонажа с ним.
createItem(): Face {
return new Face(this, 2500, 1100)
}
// Arcade Physics / collision
const items = this.add.group([this.createItem()])
this.physics.add.collider(this.player, items, (object1: Phaser.Tilemaps.Tile | Phaser.GameObjects.GameObject, object2: Phaser.Tilemaps.Tile | Phaser.GameObjects.GameObject) => {
object2.destroy(true) // Уничтожение объекта со сцены при столкновении
setTimeout(() => {
items.add(this.createItem(), true) // пересоздание
}, 3000)
})
Отрисовка игрового поля
Игровая карта представляет собой набор файлов: map01merged.json, tiles.png, tiles.tsx ( не путать с typescript tsx файлом).
В качестве редактора уровней использовался - Tiled, предназначеный для построения любых, в том числе изометрических уровней, карт, на основе тайлов и тайлсетов.
Богатая поддержка Tiled в Phaser позволяет гибко оперировать с самими тайлами карты - клетками. Их можно заменять, удалять, применять эффекты и обработку коллизий игровых объектов с ними.
Рендеринг карты очень простой
displayMap() {
this.map = this.add.tilemap('worldmap');
const tileset = this.map.addTilesetImage('tiles', 'tiles');
for (let i = 0; i < this.map.layers.length; i++)
this.map.createLayer(0, tileset, 0, 0).setVisible(true);
}
Пользовательский интерфейс
Как я уже сказал, взаимодействие игрока происходит через устройства ввода и пользовательский интерфейс, который представляет из себя обычные React компоненты.
Чтобы отобразить отладочную информацию в левом верхнем углу экрана необходимо:
Объявить компонент с отладочной информацией
const DebugPanel = () => {
const [fps, setFps] = useState(0);
const [version, setVersion] = useState('');
const [skill, setSkill] = useState(0);
CONTROLS.registerGameDebugControls({
setVersion,
setFps,
setSkill
})
return (
<>
<div>
<span >
Fps: {fps}
</span>
<br></br>
<span >
Version: {version}
</span>
<br></br>
<span >
Current skill: {skill+1}
</span>
</div>
</>
);
};
export default DebugPanel;
Связать хуки компонента с глобальным объектом CONTROLS, зарегистрировав их
CONTROLS.registerGameDebugControls({
setVersion,
setFps,
setSkill
})
Объявить необходимый регистратор в файле controls.ts
export type ValueSetter<T> = (T) => void;
// Create your own react controls interface
interface GameDebugControls {
setVersion: ValueSetter<string>
setFps: ValueSetter<number>
setSkill: ValueSetter<number>
}
interface GameLoaderControls {
setProgress: ValueSetter<number>
}
// Add your own react controls
interface GameControlsMap {
debug?: GameDebugControls
loader?: GameLoaderControls
}
class GameControls {
private controls: GameControlsMap = {}
// Create your own register controls method
public registerGameDebugControls(controls: GameDebugControls) {
this.controls.debug = controls
}
public registerGameLoaderControls(controls: GameLoaderControls) {
this.controls.loader = controls
}
// Create your own valueSetter method
public setFps(fps: number) {
if (checkExists(this.controls.debug))
this.controls.debug.setFps(fps)
}
public setSkill(skill: number) {
if (checkExists(this.controls.debug))
this.controls.debug.setSkill(skill)
}
public setVersion(version: string) {
if (checkExists(this.controls.debug))
this.controls.debug.setVersion(version)
}
public setProgress(progress: number) {
if (checkExists(this.controls.loader))
this.controls.loader.setProgress(progress)
}
}
export const CONTROLS: GameControls = new GameControls()
И спокойно вызывать из игровой сцены
CONTROLS.setVersion(`Phaser v${Phaser.VERSION}`)
CONTROLS.setFps(Math.trunc(this.sys.game.loop.actualFps));
Точно таким же компонентом является и форма входа в игру:
export const Login = () => {
const dispatch = useAppDispatch()
const onStart = (evt) => {
evt.preventDefault()
const data = new FormData(evt.target)
if(!data.get("name")) {
alert("Name is required")
return;
}
CONTROLS.setProgress(50)
dispatch(setLoading(true))
dispatch(setNickname(data.get("name").toString()))
setTimeout(() => {
dispatch(setLoading(false))
dispatch(setCurrentPage(Page.GAME))
launchGame()
}, 3000)
};
return (
<div className="center-extended">
<div className="fade-in">
<Card className="game-form">
<Form onSubmit={onStart} initialValues={{name: "name"}}>
<Input type="text" placeholder="Input your name" name='name'/>
<Button type="submit" color="success">Start game!</Button>
</Form>
</Card>
</div>
</div>
);
};
export default Login;
Для отключения событий клика по блоку React компонентов достаточно поправить свойство "pointer-events":
document.getElementById("root").style.pointerEvents="none"
Значение этого css-свойства можно изменить в конкретных местах там, где обработка клика необходима (кнопки, формы и т.д.).
Вебсокеты
В данном демо также имеется поддержка работы с вебсокетами. Для работы с ними есть файл network.ts
class Network {
private socket: any;
private events: Map<number, [any, OnMessageHandler]> = new Map<number, [any, OnMessageHandler]>()
constructor() {
if (!window.WebSocket) {
// @ts-ignore
window.WebSocket = window.MozWebSocket;
}
if (window.WebSocket) {
this.socket = new WebSocket("ws://localhost:8085/websocket");
} else {
alert("Your browser does not support Web Socket.");
}
this.socket.addEventListener('open', (event) => {
console.log("Connection established");
});
this.socket.addEventListener('error', (event) => {
console.log(event.message);
});
this.socket.addEventListener('close', (event) => {
console.log("Web Socket closed");
});
this.socket.addEventListener('message', (evt) => {
const eventData = JSON.parse(evt.data);
if (this.events.has(eventData.type)) {
const arr = this.events.get(eventData.type)
arr[1].call(arr[0], eventData.data);
}
});
}
public on(type: number, handler: OnMessageHandler, thisArg:any) {
this.events.set(type, [thisArg, handler]);
}
public send(type: number, data: any = null) {
if (this.socket.readyState !== WebSocket.OPEN) {
console.log("Socket is not ready");
return;
}
this.socket.send(this.createEvent(type, data));
}
private createEvent = (eventType: number, payload: any = null) => {
const obj: any = {
type: eventType,
data: null
};
if (payload) {
obj.data = payload
}
return JSON.stringify(obj);
}
}
export const network = new Network();
Для отправки сообщения на сервер достаточно вызвать метод send из любого места приложения:
network.send(TYPE, JSON_OBJECT)
Для обработки входящего сообщения достаточно объявить где-нибудь обработчик вида:
network.on(TYPE, (data)=> {}, this)
Итог
Демо игры получилось достаточно бодрым. Полученные навыки в ходе разработки пусть даже такого небольшого демо - бесценны. Теперь вы без проблем можете создать свой собственный вариант игры, постепенно расширяя ее функционал.
В скором времени выйдет статья, раскрывающая backend мултьтиплееров
Делитесь материалом с коллегами, пишите комментарии на какую тему хотели бы увидеть материал