Разработка браузерных игр с использованием Phaser3, React, Typescript

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

Разработка браузерных игр с использованием Phaser3, React, Typescript

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

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

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

Разработка браузерных игр с использованием Phaser3, React, Typescript

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

  • Typescript
  • React
  • Webpack
  • HTML/CSS
  • Phaser3

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

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

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

Кадр из игры warcraft 2<br />
Кадр из игры warcraft 2

Почему Phaser3?

Разработка браузерных игр с использованием Phaser3, React, Typescript

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

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

Разработка браузерных игр с использованием Phaser3, React, Typescript

На официальных ресурсах Phaser можно найти бесчисленное количество примеров кода, игр и best-практик. Также среди достоинств: регулярные обновления и новые фичи, огромное комьюнити разработчиков, открытая и полная документация, доступны книги от создателя фреймворка Richard Davey @photonstorm.

Практика

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

Требования: NodeJS >= v20, NPM >= v10

Для начала, выгружаем проект. Устанавливаем зависимости и запускаем:

npm install npm start

Демо содержит 2 связанные, но изначально не особо ладящие друг с другом, технологии - React и Phaser. Для того, чтобы они работали вместе без проблем, в Index.html объявлено 2 разных контейнера, каждый из них привязывает свой фреймворк соответственно:

<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 });
Входная предзагрузочная сцена. На заднем плане - шейдер<br />
Входная предзагрузочная сцена. На заднем плане - шейдер

Главная сцена, на которой будет строиться весь геймплей - 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); } }
Спрайт-лист мага<br />
Спрайт-лист мага

Для создания анимаций движения персонажа во всех направлениях и умений по спрайтам:

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) } }
Спрайт-лист бафа<br />
Спрайт-лист бафа

Также стоит упомянуть механику обработки столкновений в игре. Для этого используется функционал аркадного физического движка.

Создадим предмет - лицо, как группу предметов, для его последующего респауна по истечению 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 компоненты.

Отладочная информация<br />
Отладочная информация

Чтобы отобразить отладочную информацию в левом верхнем углу экрана необходимо:

Объявить компонент с отладочной информацией

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;
Форма входа в игру<br />
Форма входа в игру

Для отключения событий клика по блоку 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 мултьтиплееров

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

Ссылки

Начать дискуссию