Проблемы и их решения при разработке игры на A-Frame

Привет, Хабр!

A-Frame — интересный фреймворк для создания WebVR-приложений, но, статей о нём в русскоязычном сегменте не так много. А ведь это не плохой инструмент, который позволяет разрабатывать VR-сцены, используя простой HTML-подобный синтаксис и JavaScript.

Если вы когда-нибудь задумывались о создании своей VR-игры или интерактивного 3D-опыта в браузере, но не хотели погружаться в сложные движки вроде Unity или Unreal, то A-Frame — отличный вариант для старта.

В этой статье я разберу проблемы, с которыми можно столкнуться при разработке игры на A-Frame, и покажу, как их решить. Готовы к погружению в мир WebVR? Тогда поехали!

O проекте

Боравто Quiz — это небольшая Web VR игра, про автомобили. Она состоит из двух этапов — гонка и ответы на вопросы. В гонке вам необходимо проехать как можно больше кругов за отведенное время, а в разделе квиза — отвечать на вопросы на автомобильную тематику, получая очки нитро, которые помогут быстрее проехать следующий этап.

Боравто Quiz
Боравто Quiz

Этот проект преследует сразу несколько целей:

  1. Погрузится в разработку A-Frame в 2025 году — посмотреть что изменилось/улучшилось. 

  2. Доработать шаблон Recycle VR используя более современный Web стек — Vite, Typescript.

  3. Адаптировать a-frame-router-templates под новую версию A-Frame.

  4. Сделать полноценную десктоп версию игры с удобным web интерфейсом (Recycle например использует один интерфейс для всех платформ).

Прототипирование

Прототипирование является хорошей практикой, когда сталкиваешься с нетривиальной задачей. В моём случае сложность заключалась в нестандартной механике: я сознательно отказался от управления направлением (по аналогии с игрушечными трековыми машинками), сделав акцент на системе газа/тормоза и переключения передач как основном геймплейном ядре будущей гонки. Опыта в разработке гоночных игр у меня не было, поэтому прототип стал полезным инструментом для проверки этой концепции.

Итак, щепотка гуглинга, немного нейросетей, чистого кодинга и исследований подобных игр (с переключением скоростей) — и получился прототип. Основная идея — сделать виртуальный трек, некую фигуру, по которой будет двигаться авто с заданной скоростью в соответствии с текущей передачей. В первый итерации получилась восьмерка.

Прототип
Прототип

Обновляем позицию авто:

tick() {
    t += this.data.speed * 0.0007;
    // Траектория в виде восьмерки
    const x = Math.sin(t) * 1;
    const z = Math.sin(t * 2) * 0.5;
    // 1.03 - магическая высота стола
    this.el.object3D.position.set(x, 1.03, z - 4);
    // Плавный поворот
    const dx = Math.cos(t) * 1;
    const dz = Math.cos(t * 2) * 1;
    this.el.object3D.rotation.y = Math.atan2(dx, dz) + Math.PI / 2;
 },

Теперь нужно добавить просчет физики:

// Настройки текущей передачи
const gear = gearSettings[this.currentGear];
// Коэффициент скорости для ускорения
const speedRatio = this.speed / gear.maxSpeed;
// Расчет ускорения
let acceleration = gear.acceleration * (1 - speedRatio);
acceleration *= this.throttle * (this.rpm > 5000 ? 0.8 : 1);
this.speed += acceleration;

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

// Параметры фигуры
const width = 1.5 * 2; 
const height = 0.76 * 2; 
const radius = 0.65; 
// Определяем форму
const shape = new THREE.Shape();
// Рисуем ее
shape.moveTo(-width / 2 + radius, -height / 2);
shape.lineTo(width / 2 - radius, -height / 2);
...
...
// Создаем точки по которым будет двигаться авто
const shapePoints = shape.getSpacedPoints(500);

Теперь мы имеем следующий подход — создаем 3D трек в моделере, под него рисуем форму с помощью THREE.Shape — профит.

Сцена

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

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

Стол в угоду оптимизации превратился просто в куб, а вот диораму я решил сделать.

Сцена в Blender
Сцена в Blender

Собрать модели со Sketchfab и из своих собственных запасов, а затем расставить их на сцене, не составило труда. Проблемы начались с запеканием теней. Если в игровых движках, например в Unity, это можно сделать прямо на сцене, то в случае с A-Frame (а на самом деле с любыми веб-решениями, включая Play Canvas) запекать их нужно в 3D-редакторе.

Сделать это в Blender не то чтобы сложно: выбираем объект с материалом и текстурой, переходим во вкладку Render, выбираем Render Engine → Cycles, открываем вкладку Bake, нажимаем одноименную кнопку — и вуаля! Однако если тени для новой поверхности запеклись без проблем, то с треком с первого раза не получилось. Всё из-за UV-развертки отдельных его частей.

В итоге пришлось собирать трек отдельной формой, переделывать UV-развёртку, а также перерисовывать текстуры в Substance Painter.

Текстурирование в Substance
Текстурирование в Substance

Роутинг

Для управления сценами и префабами в A-Frame я использую свое собственное решение — a-frame-router-templates. Создавая проект с нуля на новой версии A-Frame (на момент написания статьи — 1.7.0) — оно перестало работать — при смене сцены — приложение попадало в бесконечный цикл. Оказалось, что API A-Frame в A-Node изменил логику для определения ​​readyState, добавив кастомное событие aframeready. В итоге connectedCallbackвызывался в бесконечном цикле, фикс не сложный, но до него еще нужно было дойти:

doConnectedCallback() {
     - super.connectedCallback();
     + super.doConnectedCallback();
     this.attach();
}

Также, стоить сказать, что a-frame-router-templates теперь доступен в NPM — https://www.npmjs.com/package/a-frame-router-templates.

Шрифты

Игра с самого начала задумывалась исключительно на русском языке. Каково же было моё удивление, когда, взяв стандартный компонент и написав первый вопрос на русском, я обнаружил, что текст отсутствует. Как выяснилось, шрифты в A-Frame сделаны на базе формата MSDF. MSDF представляет собой набор из двух файлов: JSON (который определяет положение символов) и PNG-картинка с отображением этих символов. И, как оказалось, стандартные шрифты A-Frame поддерживают только английский язык.

Без русского текста
Без русского текста

Ну что ж, не беда — будем исправлять. Чтобы сделать новый MSDF-шрифт, нужно выполнить следующие шаги:

  1. Найти оригинальный шрифт в формате TTF.

  2. Перейти на сайт https://msdf-bmfont.donmccurdy.com/

  3. Загрузить шрифт и указать символы, которые должны быть добавлены в набор (причём в нижнем и верхнем регистре отдельно). Также не забываем о специальных символов и пробелах.

  4. Скачать JSON и PNG.

  5. Инвертировать PNG по цвету (в Photoshop: Ctrl+I).

  6. Добавить шрифт в компонент .

Если кому-то нужен Roboto-Regular с русским, можно забирать отсюда.

Интерфейсы

Игра «Боравто Quiz» содержит множество интерфейсных элементов. Полноценная поддержка десктопной версии требует классического интерфейса, накладываемого поверх экрана. Однако в VR-режиме такой подход неприемлем — постоянное мелькание элементов перед глазами быстро утомляет пользователя.

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

  1. Традиционный overlay-интерфейс для десктопной версии

  2. Пространственно интегрированный интерфейс для VR-режима

Для того, чтобы сделать интерфейсы в A-Frame в виртуальной реальности — как таковых проблем нет. Есть  — для текста, для картинок, из  + можно собрать приличную кнопку. Для управления состоянием можно использовать aframe-state-component.

Интрефейсы
Интрефейсы

А вот для оверлейного интерфейса на десктоп в A-Frame сделано не так много. Вообще из подходов можно выделить два варианта (их вероятно больше):

  1. HTML/CSS 

  2. CSS2DRenderer

Десктопный интерфейс предполагался именно оверлей — то есть интерфейсные элементы накладываются поверх экрана и не зависят от 3D элементов на сцене, поэтому выбор был сделан в пользу HTML/CSS.

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

this.el?.sceneEl?.addEventListener('enter-vr', () => {
    // Устанавливаем глобальное состояние (a-frame-state-component работает через события)
    this.el?.sceneEl?.emit('setUiMode', { mode: UIMode.vr });
});
this.el?.sceneEl?.addEventListener('exit-vr', () => {
    this.el?.sceneEl?.emit('setUiMode', { mode: UIMode.dom });
});

Затем, конечно же нужно нарисовать две версии интерфейса. Для VR версии создаем контейнер и позиционируем элементы согласно нашей задумке, а также определяем атрибут visible, который будет управляться через uiMode свойство глобального состояния.


    
    
......
......

Изначально для десктоп-версии я планировал использовать чистый JavaScript (VanillaJS) и создавать элементы интерфейса вручную. Однако быстро стало очевидно, что при таком количестве интерфейсов это решение создаёт серьёзные проблемы с поддержкой кода.

После анализа вариантов я остановился на Alpine.js — лёгком и простом фреймворке для управления веб-интерфейсами (React в данном случае действительно выглядел избыточным). Alpine.js идеально подошёл для проекта, но возникла новая сложность: компонент a-frame-state-component не работает с DOM и, в частности, с Alpine.js.

Решение оказалось простым:

  1. Создать общую систему состояний и действий

  2. Адаптировать её для каждого стейт-менеджера

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

AFRAME.registerSystem('dom-state', {
    init() {
        this.stateUpdateHandler = this.stateUpdate.bind(this);
        this.el?.sceneEl?.addEventListener(
            'stateupdate',
            this.stateUpdateHandler,
        );
    },
    ...
    stateUpdate(e) {
        const customEvent = e as CustomEvent;

        window.Alpine.store('state')?.[customEvent.detail.action]?.(
            window.Alpine.store('state'),
            customEvent.detail.payload,
        );
    },
});

VR Input

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

Пришлось искать радикальное решение. К счастью, мне удалось найти рабочий компонент aframe-keyboard. Единственная сложность заключалась в том, что он не был доступен через NPM — пришлось вручную интегрировать его код в проект.

На этом работа не закончилась. Мне потребовалось доработать элемент , чтобы:

  1. Активировать клавиатуру при клике на поле ввода

  2. Обеспечить корректное отображение вводимого текста

  3. Реализовать управление состоянием

  4. Настроить генерацию соответствующих событий

a-input
a-input

Получилось как-то так, при необходимости можно будет переделать клавиатуру (там есть проблемы с загрузкой шрифтов) и выложить в npm. Из интересного тут — наверное только обработка событий aframe-keyboard:

onKeyboardUpdate(e: Event) {
    const customEvent = e as CustomEvent;
    const code = parseInt(customEvent.detail.code);
    
    switch (code) {
        case 8:
            this.value = this.value.slice(0, -1);
            break;
        case 0o6:
            this.onHolderClick();
            return;
        default:
            this.value = this.value + customEvent.detail.value;
            break;
    }
    
    if (this.data.max && this.value.length > this.data.max) {
        this.value = this.value.substr(0, this.data.max);
    }
    
    this.text?.setAttribute('value', this.value + '_');
    this.el?.sceneEl?.emit('change-input-text', { value: this.value });
 },

Typescript

В отличие от моих предыдущих A-Frame проектов на чистом JavaScript, в этот раз я решил использовать TypeScript. К моему приятному удивлению, для A-Frame существует готовый набор типов, причём довольно качественно реализованный.

Однако первые же попытки создать элементы с подключёнными типапами показали, что идеальной совместимости нет. Вот с какими сложностями пришлось столкнуться:

1. aframe-state-component, хоть и называется компонентом, под капотом работает через систему, которая называется state. Чтобы получить текущее значение состояния, нужно сделать следующее:

this.el?.sceneEl?.systems.state.state.speed; // Ошибка, что такое state.speed?

по умолчанию тайпинги A-Frame не знают какие системы подключены. Как казалось красивое решение с переоткрытием интерфейса — не сработало, поэтому пришлось делать assertion.

const stateSystem = AssertType(
    this.el?.sceneEl?.systems.state,
);
stateSystem.state.speed; // Работает

2. Вытащить сущности с определенными компонентами красиво тоже не получится, но AssertType утилита — наше все:

this.countdownSound = AssertType>(
    document.getElementById('countdown-sound-e'),
);

что хорошо — в дженерик Entity — можно передавать доступные компоненты.

3. Ну и конечно самое главное — компонент. AFRAME.registerComponent — таже поддерживает дженерик для определения внутренности компонента, но все придется описывать досконально:

AFRAME.registerComponent('countdown', {
   i: null,
   countdownSound: null,
   ...
   ...
});

Заключение

Несмотря на все сложности, мне удалось реализовать все задуманные функции. A-Frame, хоть и развивается не так быстро, как хотелось бы, продолжает совершенствоваться.

Сейчас WebVR-сообщество переживает период затишья. Это объяснимо — вокруг VR в целом и его веб-версии в частности уже нет того ажиотажа, что был раньше. Однако я уверен, что ситуация изменится с приходом полноценной поддержки WebGPU. Эта технология сможет дать новый импульс развитию 3D в браузере, что положительно скажется и на WebVR.

Всем хорошего настроения!

Попробовать игру можно здесь: https://bquiz.surge.sh
Исходный код доступен на GitHub: https://github.com/kysonic/borauto-quiz

© Habrahabr.ru