В предыдущей статье мы попробовали создать базовую сцену в A-Frame, чтобы опробовать основные концепции фреймворка на практике. В этой статье я хотел бы поделится своим опытом создания игры на A-Frame — Recycle! VR. Репозиторий проекта доступен по следующей ссылке.
Переработка!?
Идея создания игры пришла почти сразу как только я узнал о Web VR. Хотя в целом, я считаю, что игры в веб в любом случае будут уступать хорошим проектам даже для мобильных устройств, не говоря уже о персональных компьютерах и консолях. Но, как мне кажется, игра самое сложное испытание. Я начал думать о том, что конкретно я могу сделать. Я наблюдал за другими проектами и мне сразу бросилась в глаза возможность брать что-то с помощью контроллера. А так как я уже довольно длительное время связан с организацией которая занимается раздельным сбором мусора, ответ пришел сам собой. Переработка. Мы берем мусор и бросаем его в корзину. Что может быть проще. Но все оказалось не так просто, об этом, собственно, и пойдет речь далее.
Выбор фреймворка
На момент начала работ над игрой я знал всего о 2-х более менее серьезных фреймворках: React 360, A-Frame. Очевидно, что для создания игры более всего подходил A-Frame. Да, теперь я знаю, что существует еще игровой движок PlayCanvas, который также поддерживает VR, но уже поздно. Тем более, оказалось, что A-Frame тоже не плох для создания игр.
С чего начать?
Я начал с того, что стал изучать официальные примеры игр от разработчиков A-Frame. Благо таких не мало. A-Blast, A-Painter, Museum, Super Craft а теперь еще и Gunters of Oasis. Из всех представленных проектов мне больше всего понравился A-Blast — стрелялка, в которой вам предстоит сразится с самыми милейшими существами во вселенной. Поэтому я хотел взять эту игру как шаблон для своей. Но не получилось. И виной тому была структура игры. Мне показалось, что она слишком захламлена и не продумана. Возможно большего и не требуется, но мне хотелось сделать что-то более удобное и более простое для понимания.
Структура
Структура A-Blast представляет собой лишь одну входную точку — файл index.html, который содержит одну сцену со всеми ассетами, основными игровыми сущностями, управлением и вообще всем всем.
Как видно на скриншоте, помимо необходимых components и systems (A-Frame использует Entity Component System паттерн) тут есть еще bullets и enemies — по сути те же системы, но почему-то имеют свою обертку. Вообщем сказать, что в этом коде легко разобраться нельзя. Поэтому я решил подумать на тему того, как этот код можно структурировать. Первая идея — разбить сцену на составные части. Для чего бы пригодился роутер и шаблоны, которые рендерили бы ту или иную часть сцены. Поискав и первое и второе (нет, не пять минут) я не нашел ничего. Хоть я и сторонник правила не пиши велосипедов, но в этот раз мне пришлось написать свое решение. Хотя, где-то через 2-3 недели я наткнулся на шаблоны от Кевина Нго. Но было поздновато.
Роутер и шаблоны
И так, на сцену выходит a-frame-router-templates. Что же он может? Как говорилось выше, основная его задача рендерить необходимые части игры, например титульный экран, игровое поле, экран окончания игры и тд. Как это сделать? В принципе, все необходимое вы можете найти в документации к модулю на github, но в кратце, мы имеем следующее:
<a-scene router>
...
<!-- Routes -->
<a-route id="start-screen" template="start-screen"></a-route>
<a-route id="game-field" template="game-field"></a-route>
<a-route id="game-over" template="game-over"></a-route>
<a-route id="how-to-play" template="how-to-play"></a-route>
<!-- End Routes -->
...
<!-- Templates -->
<a-template name="controls"></a-template>
<!-- End Templates -->
...
</a-scene>
- Мы добавляем компонент router к сцене.
- Добавляем a-route для каждой части приложения (scene’s frames). Один роут для начального экрана, другой для игрового поля и т.д.
- Рендерим шаблоны напрямую через a-templates
- При необходимости мы меняем роуты через
this.el.systems.router.changeRoute('game-field');
Пометка: этот пример относится к коду сцены, поэтому мы можем вызвать систему router напрямую. - Задаем и подключаем шаблоны, примерно так:
AFRAME.registerTemplate('game-field', ` <a-sub-assets> <a-asset-item id="glass" src="/assets/models/glass_bottle.gltf"></a-asset-item> ... <audio id="fail" src="/assets/sounds/fail.wav" preload></audio> </a-sub-assets> <a-template name="button" options="text: EXIT; position: 0 1 4; rotation: 0 180 0; event: stop-game"></a-template> <a-entity id="indicator" indicator visible="false" position="0 1 -2" text="align: center; width: 4; color: #00A105; value: -1" ></a-entity> <a-entity game-field-manager></a-entity> `);
Пометка: a-sub-assets позволяют загружать ассеты также как и a-assets, но только с тем отличием, что там по умолчанию есть проверка и если ассет уже добавлен, он не будет добавлен снова при смене роута.
Пометка 2: Нормально пользоваться шаблонами можно только с ES6 template string. Иначе это может превратиться в “string” + var + “string”, не круто. У Кевина, например, есть поддержка шаблонизаторов. Но зачем усложнять, не правда ли?
Таким образом можно создать удобную структуру приложения, которая будет содержать следующее: components, systems, templates, states, libs. Ничего лишнего и все по полкам.
Манипуляции с объектами
Самая первая задача, которую предстояло решить — манипуляции с объектами. Нужен был функционал типа хватай — кидай. Первоначально я начал обдумывать способ создания такого компонента с нуля. Чисто на обывательском уровне допустимо такое размышление: у нас есть контроллер (в случае десктопа это курсор), у него есть позиция. Также у нас есть некие объекты, например кубики, у них также есть позиция. Изменяя позицию контроллера мы должны изменить позицию объекта. Просто? Так, вообщем то да, но это не сработает. Назову пару моментов из очень длинного списка, чтобы убедить вас в этом:
- Курсор в A-Frame является потомком a-camera и имеет относительные координаты;
- Позиции контроллера недостаточно, нужно еще учитывать ориентацию, расстояние до объекта, положение камеры (игрока);
- Для объектов имеющих физическое тело это вообще не сработает, так координаты геометрии связаны с координатами тела.
Хорошо, что добрый господин Уил Мерфи и его друзья сделали a-frame-super-hands. По сути эта библиотека содержит все необходимые компоненты:
- hoverable. Наведение. Наводим контроллер или курсор на зону коллизии объекта (обычно это объект целиком)
- grabbable: Захват. Захватываем объект с помощью соответствующей кнопки и перетаскиваем его
- stretchable: Хватаем двумя руками и растягиваем\сжимаем
- draggable\dropable: По сути нужен чтобы определить событие “элемент был брошен в определенное место”
Все необходимое по поводу настройки и подключения super-hands вы можете найти в репозитории, упомянутом выше. Я лишь хочу обратить внимание на ряд нюансов:
- Создавайте отдельные миксины для правой и левой руки. Разделите компоненты по типам поддерживаемых устройств. Например правая рука, это помимо oculus-touch, vive-controls, windows-motion-controls еще может быть oculus-go-controls и gear-vr-controls. Левую руку нужно прятать для мобильных шлемов ВР. Каждый контроллер должен содержать как mixin руки, так и компонент super-hands. Пример;
- Если вы указали objects: .clsname для reycaster, не забудьте добавить его к каждому элементу которой может быть взят с помощью контроллера, иначе не одно событие для super-hands не отработает. Конечно, если colliderEvent: raycaster-intersection;
- Перетаскивание мышкой проецирует 2d координаты в 3d мир, поэтому лучше использовать cursor для десктопа.
Добавляем физику
Добавить физику в a-frame на самом деле очень просто. Для этого есть специальная система. Она добавляется к сцене и вуаля, физика уже у вас в кармашке.
<a-scene physics="debug: false">
<a-box dynamic-body position="0 1 -2"></a-box>
<a-box id="floor" static-body></a-box>
</a-scene>
Пометка: debug: true включает возможность просмотра привязанных к геометрии физических тел. Удобно когда нужно “обрисовать” объект.
По факту это обертка для cannon.js, которая делает всю грязную работу по сопоставлению геометрии и физических тел за вас. Опять же, про то как эта система работает, вы сможете найти в описании репозитория. А я хотел бы остановиться лишь на одном моменте, важном для моей игры.
Мне нужно было сделать так, чтобы нажимая на кнопку к мусору была задана определенная сила (чем больше держишь кнопку зажатой, тем больше сила). Как оказалось эта задача не так уж и проста, какой кажется на первый взгляд. Ну и что же тут сложного? — скажете вы, делаем applyImpluse и вуаля. Не совсем… Он задает вращение объекта по вектору приложенному к центру тела. Используя этот метод у нас получится разве что эмулировать юлу. Хотя если задать вектор с правильным углом к плоскости, может получиться нечто похожее на толчок. Но это не то, что мне было нужно.
Как оказалось, мне нужна была скорость (velocity) при задании этого параметра объект начинает свое движение в заданном направлении. Это направление задается вектором. И вот тут начинается самое интересное. Как найти этот вектор? Я нашел два варианта:
- Достать кватернион контроллера (или камеры для десктопа), который описывает его ориентацию в пространстве. Создать вектор V1 = <1,1,1>, умножить его на силу броска и применить ко всему этому ориентацию.
const velocityVector = new THREE.Vector3(1,1,1); velocityVector.multiplyScalar(this.force); velocityVector.applyQuaternion(controllerQuaternion); this.grabbed.body.velocity.set(velocityVector.x, velocityVector.y, velocityVector.z);
- Найти позицию контроллера (курсора) и позицию бросаемого объекта. Посчитать вектор направления по двум точкам. Нормализовать вектор. И умножить его на силу.
const directionX = (trashPosition.x - zeroPosition.x); const directionZ = (trashPosition.z - zeroPosition.z); const vectorsLength = Math.sqrt(Math.pow(directionX, 2) + Math.pow(directionZ, 2)); const x = (directionX / vectorsLength) * this.force; const y = this.force; const z = (directionZ / vectorsLength) * this.force; this.grabbed.body.velocity.set(x , y, z );
Я выбрал второй вариант потому как в нем я могу посчитать только x и z. А y задать самостоятельно, так как мне нужен был бросок по дуге, чтобы бросаемый мусор попадал в корзину, несмотря на то как пользователь держит контроллер.
Пару слов про модели
С самого начала я решил делать игру в стиле low-poly. Хоть WebGL сегодня и способен рендерить относительно сложные сцены, но его производительность все равно уступает продвинутым библиотекам, таким как DirectX, Vulkan, Mantle и др. Также все зависит от производительности девайса пользователя. Так как я хотел бы ориентироваться на более доступные мобильные шлемы ВР (Oculus Go, Gear VR), я думаю, что low-poly это одно из немногих решений для создания ВР приложения или игры. Хотя несомненно все зависит от объемов.
Ладно, low-poly так low-poly, а как это все сделать? Все очень просто, есть хороший опенсорный инструмент — Blender. Поверьте, он способен на многое, но и для простых задач он подходит вполне не плохо. Обучающих материалов касающихся моделирования в Blender очень много и найти их не составит труда. Я хотел лишь заострить ваше внимание на ряде моментов связанных с веб-разработкой:
- Three-js экспортер устарел. Нужно найти и поставить GLTF экспортер. GLTF это специальный формат разработанный для веба. И да, это JSON.
- GLTF не поддерживает Cycles Renderer поэтому придется использовать Blender Renderer. А это означает, что никаких крутых узлов, трансформаций цвета, металлических отбелесков (можно сделать по другому) не будет.
- Экспортировать нужно только выбранный элемент. Вам же не нужны лишние камеры и свет? File > Export > gltf 2.0. В левом меню Export GLTF 2.0 > Export selected only.
- Экспорт начинаем с позиции <0, 0, 0> в Blender. Масштабировать лучше там же, чтобы потом не использовать scale компонент в a-frame.
- Если вы рисуете открытое пространство как в Recycle! VR, нужно добавлять объекты только туда, куда теоретически может посмотреть игрок. Сзади, за домами, в Recycle! есть пару деревьев и только в том месте, в котором пользователь может их увидеть. Перегружать сцену лишним не нужно.
- Если вам нужно поменять материал модели, нужно дождаться пока она загрузится, достать саму модель вытащить из нее все узлы (GLTF содержит информацию не только о мешах)
e.detail.model.traverse((node) => { if (node.isMesh) { node.material.color = new THREE.Color(someColor); } });
В заключение
Всем спасибо за внимание! Еще раз напомню, что репозиторий проекта доступен по следующей ссылке. Всем желающим внести что-то новое в эту игру — милости просим.