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

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

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

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

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

  • Typescript

  • React

  • Webpack

  • HTML/CSS

  • Phaser3

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

  • Математики

  • Физики

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

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

Кадр из игры 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

Выше представлена ссылка на демо проекта. Теперь по порядку.

Требования: 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

Комментарии (7)


  1. MAXH0
    03.11.2023 03:51
    +6

    Быть разработчиком игр - весело и круто.

    :|||:


    1. MAXH0
      03.11.2023 03:51
      +2

      Просто не мог не привести этот мем. Статья хорошая!, но даже на уровне обучения приходится доказывать ученикам, что делать игры - это ТРУД, а на FUN. Это как не надо путать - испечь тортик себе или работать в пекарне.


      1. tfkfan Автор
        03.11.2023 03:51

        Если творчество вам нравится и доставляет удовольствие то это не труд, а вызов


  1. 13luck
    03.11.2023 03:51
    +1

    Пытаюсь представить себе как можно разрабатывать игру на голом фазере, это же страдания, не? Вы приводите ссылки на материалы, но они туго объясняют как структурировать игровой проект. Phaser действительно может много как движок, но его отличие от других движков, это то, что у него нет официальной IDE. И это затрудняет ваши продвижения в разработке. Для начинающих и не только могу посоветовать неофициальную, но очень мощную IDE https://phasereditor2d.com/

    Любопытно посмотреть ещё какие-нибудь материалы на тему, так как сами разрабатываем многопользовательскую игру, точнее переносим в веб настолку.


    1. tfkfan Автор
      03.11.2023 03:51

      Суть фейзера это как раз таки разработка через код, мне как разработчику это легче, судя по всему и создатель фреймворка делает также. Про структуру, это сильно зависит от конечной задумки: если игра достаточно проста, а в качестве gui допускается использовать обычные react/html компоненты то темплейты выше самое то. Все сводится к обычной верстке, React компоненты в одном пакете, сцены в другом, персонажи и модели в третьем, сеть в четвертом, грубо говоря. Если же ui предполагается быть нарисованным, то это уже другая история, core и плагины phaser думаю берут на себя эту задачу, кстати тут как раз ide была бы хорошим подспорьем, но только тут. Что касается геймплея, так или иначе, вся логика находится на сценах и в большинстве случаев они разрастаются до существенных обьемов и требуют декомпозиции, это норма для фейзера. Работа с сетью на ваше усмотрение, может растекаться на все приложение, видел также вариант перевода вебсокет сообщений во внутренние события phaser’а, что тоже выглядит удобно. IDE это конечно хорошо, но ее отсутствие не мешает мне писать свой мультиплеер. А уж если художественные материалы специально рисуются под вашу игру то совсем проблем не вижу, не придется подгонять анимации под конкретную текстуру и тд. Мне лично не подошел только Tiled как редактор уровней, ибо была проблема в отрисовке полигонов, и пришлось написать свой - заняло пару дней.


      1. 13luck
        03.11.2023 03:51

        > а в качестве gui допускается использовать обычные react/html
        Можно, но в таком случае вы теряете кучу возможностей анимировать свой gui через tweens, использовать частицы и др.

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

        Разработка через код усложнена тем, что приходится каждый раз в голове восстанавливать геймлей, это терпимо для маленьких проектов. Для больших это становиться бедствием. Правда, попробуйте использовать предложенный редактор, он поможет структурировать проект, вы действительно не теряете ничего как разработчик, а наоборот приобретаете ещё возможности. Также редактор добавляет к Phaser концепт, не существующий в фазере, но уже давно привычный в других движках — это prefab (по сути это как компонент в React).

        Ну и про DebugPanel закину, тоже оверхед, можно подключить тот же dat.gui и не колхозить ничего :D
        В любом случае желаю вам успехов ;)


    1. tfkfan Автор
      03.11.2023 03:51

      Есть немножко кода на моем гитхаб профиле, в данный момент как раз занимаюсь его наполнением и закину скоро что нибудь еще