Привет, Хабр.
Я frontend-разработчик и большую часть времени работаю с интерфейсами. В какой-то момент мне стало интересно, можно ли на привычном для меня веб-стеке сделать не демо с вращающимся кубом, а законченную 3D-игру — с загрузкой сцены, управлением, звуком, меню, мобильной версией и публикацией на платформе.
Так появилась Taproom 8 — браузерная игра, в которой нужно искать аномалии в мрачном баре.
Играть: Taproom 8 в Яндекс Играх
Исходный код: GitHub
Ниже расскажу не столько о самой игре, сколько о том, как она устроена внутри.
С чего всё началось
С Three.js я познакомился во время разработки 2D/3D-редактора. Там библиотека была инструментом для работы с объектами на сцене: камерой, геометрией, материалами и взаимодействиями.
В какой-то момент я поймал себя на мысли: если я уже умею загружать модели, двигать камеру и обрабатывать события, почему бы не попробовать собрать поверх этого небольшую игру?
Идея для Taproom 8 получилась довольно простой. Игрок попадает в комнату и сначала видит её нормальное состояние. Это нулевой цикл, в нём нет аномалий. Нужно запомнить помещение, после чего начинаются следующие циклы. В каждом из них может измениться один объект: исчезнуть стул, поменяться картина, появиться странный силуэт или измениться поведение двери.
Игрок должен выбрать одну из двух дверей:
«Есть аномалия»;
«Аномалий нет».
Правильный ответ переводит на следующий уровень, ошибка возвращает прохождение к началу.
На первый взгляд механика маленькая. Но вокруг неё быстро появились задачи, которых не было в первоначальном прототипе:
как хранить состояние раунда;
как добавлять аномалии без переписывания игрового кода;
как передавать события между Vue и Three.js;
как загружать большую GLB-сцену;
как сделать стилизацию под PS1;
как переживать сворачивание вкладки и рекламные паузы;
как не заставить телефон превращаться в маленький обогреватель.
Архитектура проекта
Стек получился таким:
Технология | Зона ответственности |
|---|---|
Vue 3 | меню, настройки, HUD, экраны загрузки и мобильные контролы |
TypeScript | игровая логика и контракты между подсистемами |
Three.js | сцена, камера, модели, материалы и WebGL-рендеринг |
Vite | сборка и dev-сервер |
Pinia | состояние интерфейса и настроек |
vue-i18n | русский и английский языки |
Vitest | тестирование игровой логики и адаптеров |
Главное архитектурное решение — не смешивать Vue-компоненты с объектами 3D-сцены.
Условно приложение разделено на несколько слоёв:
Vue UI ↓ typed EventBus GameCoordinator / LifecycleCoordinator ↓ Game → SceneManager → GameScene ↓ Three.js: camera, player, level, input, interaction, renderer Platform adapters → Yandex Games SDK
Vue отвечает на вопрос «какой экран сейчас показать». Игровой слой отвечает на вопрос «что должно произойти в мире».
Например, кнопка в UI не открывает дверь напрямую и не вызывает методы Three.js. Она отправляет событие выбора двери. GameCoordinator принимает его, проверяет состояние текущего раунда и запускает вычисление ответа.
Three.js — это не игровой движок
Это важное уточнение, которое я сам окончательно осознал уже во время разработки.
Three.js — библиотека для работы с 3D-графикой в браузере. Она даёт сцену, камеры, геометрию, материалы, текстуры, загрузчики и рендерер. Но она не предоставляет готовую игровую архитектуру уровня Unity или Unreal Engine.
В Three.js нет готовых:
игровых сцен и переходов между ними;
игрового цикла с нужными правилами;
контроллера персонажа;
полноценной физики и коллайдеров;
системы раундов и сохранений;
UI, меню и игровых состояний;
интеграции с конкретной игровой платформой.
Поэтому в проекте появился собственный небольшой игровой слой. Это не попытка написать новый Unity. Просто несколько узких классов, каждый из которых решает одну задачу:
Gameуправляет жизненным циклом игры;LoopзапускаетrequestAnimationFrame;SceneManagerхранит активную сцену;GameSceneсвязывает уровень, игрока, ввод, звук и взаимодействия;Rendererнастраивает WebGL и PS1-пайплайн;GameCoordinatorуправляет сессиями и раундами.
У Game есть обычный игровой цикл:
export class Game { private readonly loop: Loop public constructor(/* ... */) { this.loop = new Loop((deltaSeconds, elapsedSeconds) => { this.update(deltaSeconds, elapsedSeconds) }) } private update(deltaSeconds: number, elapsedSeconds: number): void { this.scenes.update(deltaSeconds, elapsedSeconds) this.renderActiveScene() } private renderActiveScene(): void { const scene = this.scenes.active if (scene === null) { return } this.renderer.render(scene.scene, scene.camera) } }
Это выглядит просто, но именно такой слой позволяет не разносить вызовы renderer.render, обработку паузы и переключение сцен по Vue-компонентам.
Игровой цикл и проблема большой паузы
Сначала я написал самый обычный цикл. Потом обнаружил классическую проблему: пользователь сворачивает вкладку, браузер перестаёт вызывать кадры, а после возвращения приходит огромный deltaTime.
Если напрямую передать его в движение игрока, персонаж после возвращения может телепортироваться через стену.
Поэтому в Loop есть ограничение максимального шага:
export class Loop { private readonly maxDeltaSeconds: number private previousTimeMilliseconds = 0 private elapsedSeconds = 0 private animationFrameId: number | null = null public constructor( private readonly update: (delta: number, elapsed: number) => void, options: { maxDeltaSeconds?: number } = {}, ) { this.maxDeltaSeconds = options.maxDeltaSeconds ?? 0.1 } private readonly tick = (timeMilliseconds: number): void => { if (this.animationFrameId === null) { return } const rawDelta = Math.max( 0, (timeMilliseconds - this.previousTimeMilliseconds) / 1000, ) const delta = Math.min(rawDelta, this.maxDeltaSeconds) this.previousTimeMilliseconds = timeMilliseconds this.elapsedSeconds += delta this.update(delta, this.elapsedSeconds) this.animationFrameId = requestAnimationFrame(this.tick) } }
В реальной версии дополнительно накапливается elapsedSeconds, а при паузе цикл останавливается полностью. Ограничение 0.1 секунды — это страховка, а не замена нормальной обработке жизненного цикла.
Типизированная шина событий
Мне не хотелось, чтобы UI импортировал GameScene, а игровой код начинал знать о конкретных Vue-компонентах. Для связи я сделал маленький типизированный EventBus.
События описаны одной картой:
export interface GameEventMap { 'ui:graphics-changed': { readonly quality: 'normal' | 'potato' } 'ui:mobile-move': { readonly x: number readonly y: number } 'interaction:door-selected': { readonly answer: boolean readonly doorId: 'anomaly' | 'no-anomaly' readonly objectName: 'CorrectDoor' | 'WrongDoor' } 'round:started': { readonly level: number readonly anomalyId: string | null readonly hasAnomaly: boolean } }
После этого TypeScript не даст отправить событие с неправильным payload:
gameEventBus.emit('interaction:door-selected', { answer: true, doorId: 'anomaly', objectName: 'CorrectDoor', }) gameEventBus.on('round:started', (round) => { console.log(round.level, round.anomalyId) })
Игровой координатор подписывается на событие выбора двери:
this.eventBus.on( 'interaction:door-selected', ({ answer }) => this.evaluateDoor(answer), )
Для меня здесь важна не сама шина, а направление зависимости. UI знает названия событий. Игровой слой знает, что эти события означают. Но UI не знает, в каком объекте Three.js лежит дверь и как именно проигрывается её анимация.
Сцена из Blender: договор важнее магии
Уровень собирался в Blender и экспортировался в level.glb. Я не хотел зашивать координаты каждого объекта в TypeScript. Это быстро превращает уровень в набор чисел, который неудобно редактировать.
Вместо этого у сцены есть договорённость по структуре и именам:
Scene ├── StaticGeometry ├── Gameplay │ ├── PlayerSpawn │ ├── CorrectDoor │ ├── WrongDoor │ └── AnomalyObjects ├── InteractiveDoors ├── Colliders └── Lights
Пример группы аномалий:
AnomalyObjects ├── FlipFlopObj │ ├── CHAIR_easy │ ├── LAMP_medium │ └── BOTTLE_hard ├── FlipTextureObj │ ├── Painting01_easy │ └── Sign01_hard └── SpriteAnomalyPoints ├── Hallway_easy └── Doorway_hard
Суффикс в имени объекта — это не часть игровой логики в случайном смысле. Это маленький DSL уровня:
_easy— простая аномалия;_medium— средняя;_hard— сложная.
Преимущество подхода в том, что добавление аномалии выглядит так:
создать или скопировать объект в Blender;
положить его в нужную группу;
назвать объект с правильным суффиксом;
экспортировать GLB.
В коде не появляется новый if для каждого стула или картины.
Как движок находит аномалии
При загрузке GLB движок сканирует прямых детей специальных групп и проверяет их имена:
const DIFFICULTY_BY_SUFFIX = { easy: 'Easy', medium: 'Medium', hard: 'Hard', } as const function parseDefinition( object: Object3D, kind: LevelAnomalyKind, ): LevelAnomalyDefinition | string { const match = /_(easy|medium|hard)$/i.exec(object.name) if (match === null) { return `Object "${object.name}" has no difficulty suffix.` } const suffix = match[1].toLowerCase() const difficulty = DIFFICULTY_BY_SUFFIX[suffix] const assetBaseName = object.name.slice(0, match.index).trim() return { kind, difficulty, targetObjectId: object.name, assetBaseName, } }
Если объект назван неправильно, загрузка не обязательно падает целиком. Движок отправляет recoverable error, а конкретная некорректная аномалия исключается из пулов. Это оказалось полезнее, чем белый экран из-за одной опечатки в Blender.
Освещение: в production-сцене нет источников света
В основной сцене я пошёл по пути запечённого освещения. В level.glb нет рабочих PointLight, SpotLight или DirectionalLight. Группа Lights остаётся частью контракта сцены, но сама production-сцена содержит только геометрию и уже подготовленные текстуры.
На стороне Three.js это позволяет не считать динамические тени:
this.instance = new WebGLRenderer({ antialias: false, powerPreference: 'high-performance', }) this.instance.shadowMap.enabled = false
Для основной геометрии используются текстуры с уже запечёнными светом и тенями. Поэтому применение материала выглядит проще, чем в сцене с realtime lighting:
texture.flipY = false applyPS1TextureStyle(texture) mesh.material = new MeshBasicMaterial({ map: texture, })
MeshBasicMaterial не участвует в расчёте освещения. В данном случае это не недостаток, а ожидаемое поведение: свет уже находится в цвете текстуры.
Что было сложным при запекании
Запечь свет один раз — не значит нажать одну кнопку и забыть о проблеме. Основные сложности были такими.
Во-первых, нужно было следить за UV-развёрткой и отступами между островами. На маленькой текстуре слишком маленький padding быстро превращается в светлые или тёмные швы после сжатия и масштабирования.
Во-вторых, low-poly-геометрия и PS1-стилизация плохо скрывают ошибки. В реалистичной сцене небольшой артефакт можно замаскировать мягким светом, туманом или постобработкой. Здесь любая грязная полоса на стене становится частью заметного пиксельного узора.
В-третьих, аномалии меняют состояние комнаты только на один цикл. Если у картины есть обычная и аномальная версия, обе текстуры должны выглядеть так, будто они находятся в одной и той же сцене. Иначе игрок замечает не саму аномалию, а то, что у неё внезапно другой уровень освещения.
Наконец, в сцене есть коллайдеры и интерактивные объекты, которые не должны попадать в итоговую картинку. Они экспортируются в отдельные группы и отключаются после загрузки:
private prepareCollisionMesh(mesh: Mesh): void { mesh.visible = false mesh.material = new MeshBasicMaterial({ side: DoubleSide }) mesh.userData['collisionOnly'] = true }
Так визуальная геометрия и геометрия столкновений живут в одном GLB, но выполняют разные задачи.
Загрузка GLB, внешние текстуры и Draco
Продакшен-уровень экспортирован в GLB с Draco-сжатием. Для его загрузки используется GLTFLoader и DRACOLoader:
this.loader = new ThreeGLTFLoader(manager) this.dracoLoader = new DRACOLoader(manager) this.dracoLoader.setDecoderPath(DRACO_GLTF_CONFIG) this.loader.setDRACOLoader(this.dracoLoader)
Сам загрузчик кеширует не результат после загрузки, а Promise:
public load(url: string): Promise<GLTF> { const cached = this.cache.get(url) if (cached !== undefined) { return cached } const request = this.loader.loadAsync(url) this.cache.set(url, request) return request.catch((error) => { this.cache.delete(url) throw error }) }
Это защищает от параллельной загрузки одного и того же ресурса, когда несколько подсистем почти одновременно запросили модель.
Часть текстур лежит отдельно от GLB. Это было удобно для аномалий: не нужно пересобирать всю модель ради замены одной картины.
Например, для объекта Painting01_medium используются:
textures/Painting01_v1.png — обычное состояние textures/Painting01_v2.png — состояние с аномалией
Загрузчик группирует меши по URL текстуры и загружает каждый URL один раз. После этого одна текстура может быть назначена нескольким объектам.
В production-сборке оставляется только нужная часть Draco-декодера. Это небольшая оптимизация размера бандла: проекту нужен декодер GLB, но не все варианты ресурсов из директории Three.js.
Аномалия как обратимое изменение состояния
Я не стал делать аномалии набором функций, которые меняют сцену и надеются, что следующий раунд всё исправит. У аномалии есть явный жизненный цикл:
export interface Anomaly { readonly id: string readonly difficulty: AnomalyDifficulty readonly targetObjectId: string readonly isApplied: boolean apply(): void reset(): void }
Менеджер не разрешает активировать две аномалии одновременно:
export class AnomalyManager { private current: Anomaly | null = null public activate(anomaly: Anomaly): void { if (this.current === anomaly) { return } if (this.current !== null && this.current !== anomaly) { throw new Error('Another anomaly is already active.') } anomaly.apply() this.current = anomaly } public reset(): void { this.current?.reset() this.current = null } }
Для удаления объекта презентер сохраняет исходную видимость:
const snapshot = { object, visible: object.visible, } object.visible = false // После ответа игрока snapshot.object.visible = snapshot.visible
Для замены текстуры сохраняется исходный материал. Новый материал клонируется, чтобы не поменять материал всех объектов, которые случайно используют тот же экземпляр:
const original = mesh.material const anomaly = createAnomalyMaterial(original, anomalyTexture) mesh.material = anomaly // reset mesh.material = original anomaly.dispose()
Это немного больше кода, чем просто присвоить visible = false, но состояние раунда становится обратимым. После ответа игрока можно гарантированно вернуть сцену к исходному виду.
Как распределяются уровни сложности
В игре девять циклов — от 0 до 8. Нулевой цикл всегда чистый. Для остальных используется таблица весов:
Уровень | Нет аномалии | Easy | Medium | Hard |
|---|---|---|---|---|
0 | 100 | 0 | 0 | 0 |
1 | 30 | 56 | 14 | 0 |
2 | 30 | 49 | 17 | 4 |
3 | 30 | 35 | 28 | 7 |
4 | 30 | 21 | 35 | 14 |
5 | 30 | 17 | 35 | 18 |
6 | 30 | 14 | 35 | 21 |
7 | 30 | 11 | 31 | 28 |
8 | 30 | 7 | 28 | 35 |
Это не проценты в строгом смысле, а веса. Например, на первом уровне сумма равна 100, а на остальных — 100 тоже, поэтому читать таблицу как проценты удобно.
Выбор сложности сделан отдельно от выбора конкретной аномалии:
const selectedDifficulty = selectWeightedDifficulty( config.weights, this.random, ) const difficulty = consecutiveClearRounds >= 2 ? RoundDifficulty.Hard : selectedDifficulty
Последнее условие нужно, чтобы игра не превращалась в серию пустых комнат. Если два раза подряд выпал цикл без аномалии, следующий раунд принудительно получает сложную аномалию.
Почему обычного random недостаточно
Если каждый раз делать простой случайный выбор из массива, одна и та же аномалия может выпасть несколько раз подряд, а другая долго не появляться.
Для каждого уровня сложности используется ShuffleBag. Он перемешивает пул, выдаёт элементы по одному и наполняется заново только после того, как пул закончился. При перезаполнении дополнительно проверяется, чтобы последняя выданная аномалия не стала первой в новом цикле.
Так рандом остаётся рандомом, но перестаёт быть раздражающим.
PS1-рендеринг: не один фильтр поверх картинки
Мне хотелось получить ощущение старых игр для PlayStation, но одного цветового фильтра было недостаточно. Поэтому эффект состоит из нескольких частей.
1. Сцена сначала рисуется в маленькое разрешение
Вместо рендера сразу в размер canvas сцена рисуется в WebGLRenderTarget:
public render( renderer: WebGLRenderer, scene: Scene, camera: Camera, ): void { renderer.setRenderTarget(this.target) renderer.clear() renderer.render(scene, camera) renderer.setRenderTarget(null) renderer.clear() renderer.render(this.outputScene, this.outputCamera) }
Текстура target увеличивается на экран с NearestFilter, поэтому пиксели не сглаживаются.
2. Текстуры не используют билинейную фильтрацию
export function applyPS1TextureStyle(texture: Texture): void { texture.magFilter = NearestFilter texture.minFilter = NearestFilter texture.generateMipmaps = false texture.anisotropy = 1 texture.needsUpdate = true }
Отключение mipmap и anisotropy здесь сделано специально. В обычной игре это могло бы ухудшить качество вдали от камеры, но для нужного визуального стиля такой результат подходит лучше.
3. Вершины привязываются к виртуальной пиксельной сетке
Вершинный шейдер переводит позицию в NDC, округляет её до виртуальной сетки и возвращает обратно:
vec2 ndc = projected.xy / projected.w; vec2 pixelPosition = (ndc * 0.5 + 0.5) * ps1SnapResolution; pixelPosition = floor(pixelPosition + 0.5); ndc = (pixelPosition / ps1SnapResolution) * 2.0 - 1.0; projected.xy = ndc * projected.w; gl_Position = projected;
Без этого камера и объекты выглядят слишком «современно»: текстуры пиксельные, а геометрия продолжает двигаться плавно.
4. Добавляется ordered dithering
В постпроцессинге используется матрица Bayer 4×4. Она добавляет контролируемое распределение оттенков и помогает получить более характерную ступенчатую картинку в тёмных областях.
В итоге PS1-эффект одновременно стал частью визуального стиля и способом уменьшить стоимость рендера.
Оптимизация под слабые устройства
Браузерная игра запускается не только на современном компьютере. В Яндекс Играх нужно учитывать старые ноутбуки, бюджетные телефоны и браузеры с ограниченным GPU.
Снижение внутреннего разрешения
В игре есть два пресета качества:
Устройство и качество | Максимальная высота target | Масштаб от CSS-размера |
|---|---|---|
Desktop / normal | 480 | 0.6 |
Desktop / potato | 360 | 0.5 |
Mobile / normal | 540 | 0.85 |
Mobile / potato | 480 | 0.7 |
Реальное разрешение вычисляется при изменении размера viewport:
const scaledHeight = Math.round(displayHeight * this.resolutionScale) const height = Math.min(scaledHeight, this.resolutionHeight) const width = Math.round(height * aspect) this.target.setSize(width, height)
Отдельный мобильный пресет нужен потому, что мобильный viewport и плотность пикселей ведут себя иначе. Я не стал использовать window.devicePixelRatio напрямую — на Retina-экране это могло бы умножить стоимость каждого кадра.
this.instance.setPixelRatio(1) this.instance.setSize(width, height, false)
Что ещё уменьшает нагрузку
В рендерере отключены аппаратное сглаживание и realtime-тени:
new WebGLRenderer({ antialias: false, powerPreference: quality === 'normal' ? 'high-performance' : 'low-power', }) renderer.shadowMap.enabled = false
Кроме этого:
освещение основной сцены запечено в текстурах;
GLB сжат Draco;
текстуры кешируются по URL;
одинаковые ресурсы не загружаются параллельно несколько раз;
mipmap и anisotropy отключены в PS1-режиме;
коллайдеры скрываются и не участвуют в обычном рендере;
при уничтожении сцены освобождаются геометрии, материалы, текстуры и WebGL-контекст.
Качество переключается из настроек UI и передаётся в Renderer событием:
gameEventBus.emit('ui:graphics-changed', { quality: 'potato', })
Автоматически менять качество по FPS я не стал. Для небольшой игры предсказуемый пресет оказался понятнее: игрок сам может выбрать «нормально» или «для слабых устройств», а картинка не начинает неожиданно прыгать во время прохождения.
Пауза, вкладка браузера и Яндекс Игры
Публикация добавила ещё один класс проблем. Пауза может произойти по разным причинам:
игрок свернул вкладку;
окно потеряло фокус;
браузер отправил
pagehide;Яндекс Игры прислали SDK-событие паузы;
началась реклама;
пользователь открыл внутриигровое меню.
Если обрабатывать каждую причину отдельным game.pause(), можно легко получить ошибку: реклама уже закончилась, но вкладка всё ещё скрыта — а игра преждевременно продолжилась.
Поэтому причины паузы хранятся в Set:
type PauseReason = | 'visibility' | 'focus' | 'page' | 'sdk' | 'advertising' private readonly pauseReasons = new Set<PauseReason>() private addPauseReason(reason: PauseReason): void { const wasPaused = this.pauseReasons.size > 0 this.pauseReasons.add(reason) if (wasPaused) { return } this.eventBus.emit('platform:pause-requested', undefined) void this.audio.suspend() } private removePauseReason(reason: PauseReason): void { this.pauseReasons.delete(reason) if (this.pauseReasons.size > 0) { return } this.eventBus.emit('platform:resume-requested', undefined) }
Игра продолжается только тогда, когда исчезла последняя причина паузы.
Для платформы сделан отдельный адаптер. Игровой код не вызывает YaGames.init() и не знает детали callback API рекламы. Он получает абстрактные события вроде advertising:break-started и advertising:break-finished.
SDK-адаптер отвечает за:
загрузку и инициализацию SDK;
LoadingAPI.ready();GameplayAPI.start()иGameplayAPI.stop();fullscreen-рекламу;
rewarded-рекламу;
платежи, если они доступны;
события
game_api_pauseиgame_api_resume.
Это пригодилось и для локальной разработки: на localhost приложение может запускаться без настоящего SDK, а основной игровой код при этом остаётся тем же.
Тестирование не только «чистой» математики
Three.js-сцену неудобно тестировать так же, как обычную функцию. Но большая часть правил игры вообще не требует браузера и WebGL.
В Vitest тестируются:
переходы между уровнями;
возврат на нулевой цикл после ошибки;
защита от ошибки с ограниченным числом попыток;
выбор сложности по весам;
отсутствие повторов в
ShuffleBag;применение и сброс аномалий;
обработка пауз и рекламы;
поведение адаптеров Яндекс Игр;
сохранение и чтение настроек.
Генератор сессии принимает источник случайности через dependency injection:
export interface GameSessionGeneratorOptions { readonly anomalyPools: AnomalyPools readonly random?: RandomSource }
В тесте можно передать детерминированный генератор и проверить конкретный сценарий, не надеясь на удачу Math.random().
Это особенно важно для игры с вероятностями. Тест «обычно выпадает Easy» почти бесполезен. Гораздо лучше проверить, что для заданного источника случайности выбран конкретный результат и что правила перехода не нарушаются.
Что оказалось сложнее всего
Самой неожиданной частью была не 3D-графика.
Сцену можно загрузить за вечер. Дверь можно открыть. Камеру можно заставить двигаться. Но потом выясняется, что игру нужно остановить при потере фокуса, вернуть звук после рекламы, не сломать управление на телефоне, освободить ресурсы при перезагрузке сцены, пережить отсутствие SDK на localhost и не отправить игрока в стену после возвращения из фоновой вкладки.
То есть основная сложность браузерной игры — не только нарисовать мир, но и корректно встроить его в среду, где этот мир постоянно прерывается событиями браузера.
Вторая сложность — граница между контентом и кодом. Если каждая новая аномалия требует менять TypeScript, значит, система контента ещё недостаточно отделена от движка. Контракт имён и групп в Blender стал простым, но полезным способом провести эту границу.
Третья — отсутствие полноценного игрового движка. Это одновременно минус и плюс. Приходится самому принимать решения по циклу, состояниям, ресурсам и взаимодействиям. Зато каждый слой остаётся небольшим и понятным, а проект не зависит от большой runtime-экосистемы.
Что получилось в итоге
В результате получился не просто Three.js-прототип, а небольшая браузерная игра с:
отдельным игровым слоем поверх Three.js;
Vue-интерфейсом, не управляющим объектами 3D-сцены;
типизированной шиной событий;
загрузкой GLB и Draco-декодером;
контрактом сцены из Blender;
системой аномалий с уровнями сложности;
обратимым применением и сбросом изменений;
запечённым освещением;
PS1-рендерингом через низкое разрешение, nearest-фильтрацию, vertex snapping и dithering;
мобильным управлением;
настройками качества для слабых устройств;
звуком и пространственным позиционированием;
обработкой пауз браузера, SDK и рекламы;
тестами игровой логики и платформенных адаптеров.
Код проекта открыт на GitHub. Игру можно запустить прямо в браузере:
Вместо заключения
Я начинал с желания проверить, можно ли на веб-технологиях сделать полноценную маленькую игру. В итоге больше всего узнал не о создании мешей и материалов, а о границах ответственности между системами.
Three.js отвечает за то, чтобы что-то появилось на экране. Но всё остальное — зачем это появилось, когда остановиться, что считать аномалией, как восстановить сцену, что делать после рекламы и как не перегрузить слабый телефон — уже приходится проектировать самому.
И, пожалуй, именно это оказалось самым интересным.
Теги: gamedev, разработка игр, Three.js, WebGL, Vue.js, TypeScript, Blender, браузерная игра, Яндекс Игры, PS1, оптимизация

