«День в стиле Ghibli»
Ручка в виде кредитки

Разработка браузерных игр с использованием 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 мултьтиплееров

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

Ссылки

реклама
разместить
Начать дискуссию
«Реклама и SEO-мусор испортили поисковики, теперь ChatGPT, Perplexity, Claude и даже собственный ИИ Google ищут информацию лучше»

Но «классический поиск» всё ещё может быть хорош — например, если нужно найти конкретную страницу, считает старший обозреватель The Wall Street Journal Джоанна Стёрн.

1919
33
11
11
Эта проблема заключается в том, что приоритеты другие, они не развивают поиск. У того же Google вообще куча практически заброшенных сервисов, экосистема говно, много чего не заброшенного, но не развивается должным образом. У меня иногда возникает ощущение, что эти товарищи сами не пользуются тем, что делают. Многое можно улучшать и улучшать ещё, но им похер, Google может легко превратится в подобие рамблера, дело времени. Кто бы что не говорил, но поиск нужен, интернет должен быть децентрализован, а они всё пытаются централизовать, то соц.сети, то ИИ чат-боты.
Я видел штрафы на маркетплейсах 5 000 000 рублей и больше. Поэтому, сделал бесплатный бот, чтобы селлеры могли защитить свои права

Ходят легенды, что работники склада ВБ используют рулетки, которые начинаются с 5 см. Как думаете, правда или вымысел? Но сегодня не про легенды. Я придумал бота, который поможет сразу узнать за что селлеры получают штраф и какие действия предпринять, чтобы урегулировать конфликт с маркетплесом.

Я видел штрафы на маркетплейсах 5 000 000 рублей и больше. Поэтому, сделал бесплатный бот, чтобы селлеры могли защитить свои права
99
88
11
Компания Figure обучила своих роботов естественной человеческой походке

Почему это заслуживает внимания? Потому что это была нетривиальная техническая задача! С помощью сложной модели гуманоидного робота Figure 02 обучили ходить, как человека.

66
44
44
Естественная походка? Какие-то синяки в «Красное и белое» ломятся – осторожненько так идут, чтобы не спалили и чтобы ветром не сдуло.
Телеграм - большой капкан: как инфоцыгане крадут сотни тысяч? Научись видеть таких за милю!

Делай так и будешь по уши в проблемах, как я когда-то!

Правда или миф?
1515
22
11
Феномен Гребенюка: Как от учителя по истории дойти до самого популярного предпринимателя в России

Я человек разносторонний, отдал 13 лет бальным танцам, где работал со своей будущей женой, поработал учителем по обществознанию и истории 2 года, учителем много не заработаешь, поэтому я..

Феномен Гребенюка: Как от учителя по истории дойти до самого популярного предпринимателя в России
3838
1818
11
11
Опять инфоцыгане, все из бизнес молодости…
От воспитательницы в детском саду до работы в Яндексе, Тинькофф и Авито.

Наша сегодняшняя героиня — молодая феминистка, только что закончившая ВУЗ по специальности филолог, решает устроиться воспитательницей в детский сад за 20 000 рублей в месяц. Как ей удалось попасть на работу в крупнейшие IT-компании и построить там карьеру, а потом всё бросить и уехать в другую страну — читай прямо сейчас!

От воспитательницы в детском саду до работы в Яндексе, Тинькофф и Авито.
11
11
Маркетинг мёртв: 3 самых бесполезных вещи, на которые вы сливаете бюджет

Мы живём в то время, когда каждый второй маркетолог называет себя "диджитал-стратегом", "трансформационным лидером" или ещё каким-нибудь придурком, не несущим в себе никакого смысла. За этими громкими титулами скрывается одно — тотальная профнепригодность

Маркетинг мёртв: 3 самых бесполезных вещи, на которые вы сливаете бюджет
1515
77
О редизайне и ребрендинге простыми словами. Гайд на рост прибыли бизнеса с помощью обновления дизайна бренда.
О редизайне и ребрендинге простыми словами. Гайд на рост прибыли бизнеса с помощью обновления дизайна бренда.

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

44
33
11
Ещё один неверный шаг, и я бы потерял 1,5 млн ₽ на ровном месте? Не знал что реальность такая…

С такими слова обратился ко мне некий Никита.

Никита, не в обиду)) Самое главное, что мы справились. 💪
1515
22
реклама
разместить
Мультиканальность для бизнеса: временный тренд или новая реальность

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

Мультиканальность для бизнеса: временный тренд или новая реальность
1111
22
22