Думаю, ни для кого не секрет, что каждый уважающий себя разработчик программного обеспечения должен иметь в своем портфолио хотя бы один пет-проект, а лучше полноценный продукт, дающий дополнительный постоянный заработок. Предметных областей и тематик приложений великое множество, но среди них есть одна, которая заслуживает отдельного внимания — разработка своей собственной игры.

Преимущества разработки игр

Прежде всего, следует понимать, что разработка игр достаточно трудоемкий процесс. Как правило, он включает в себя как знание особенностей фронтенда, так и бекенда и всех сопутствующих серверных технологий, особенно, если речь идет о мультиплеерах. Кроме того, часто необходимо задумываться над производительностью, используемыми ресурсами, архитектурой и алгоритмами. Потребуется подкачать знание математики, геометрии, физики. Навыки художника, пусть и в минимальном объеме, тоже будут очень кстати. А если игра имеет коммерческую цель - то и маркетинг, анализ рынка пригодятся. В итоге, разработка игр позволяет прокачать свои навыки настолько, что любое собеседование или работа в коммерческой компании над различными веб - сервисами покажется легкой прогулкой. Тем более, что с игрой в портфолио вы будете выделяться среди других кандидатов на вакансии. Быть разработчиком игр - весело и круто.

С чего начать?

Браузерная игра - достойная идея, но нужно идти в ногу со временем и использовать последние технологии. В этой статье использую и постараюсь раскрыть связку:

  • Typescript

  • React

  • Webpack

  • HTML/CSS

  • Phaser3

Разумеется, помимо технических навыков следует вспомнить базовые понятия:

  • Математики

  • Физики

  • Компьютерной графики

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

Кадр из игры warcraft 2

Почему Phaser3?

Потому что на данный момент это самый часто используемый и активно развивающийся open-source фреймворк для разработки браузерных игр и интерактивных приложений на JavaScript/TypeScript

Какие будут ваши доказательства?

https://phaser.io

https://labs.phaser.io

На официальных ресурсах 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, предназначеный для построения любых, в том числе изометрических уровней, карт, на основе тайлов и тайлсетов.

https://www.mapeditor.org

Богатая поддержка 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 мултьтиплееров

Делитесь материалом с коллегами, пишите комментарии на какую тему хотели бы увидеть материал

Ссылки

https://github.com/tfkfan/phaser3-game-demo

https://github.com/tfkfan/phaser3-react-template