Как я написал браузерный 3D FPS шутер на Three.js, Vue и Blender

Стартовый экран игрыСтартовый экран игры

Мотивация

На пути каждого коммерческого разработчика (не только кодеров, но, знаю, у дизайнеров, например, также) рано или поздно встречаются топкие-болотистые участки, унылые мрачные места, блуждая по которым можно вообще забрести в мертвую пустыню профессионального выгорания и/или даже к психотерапевту на прием за таблетками. Работодатели-бизнес очевидно задействует ваши наиболее развитые скилы, выжимая по максимуму, стек большинства вакансий оккупирован одними и теми же энтерпрайз-инструментами, кажется, не для всех случаев самыми удачными, удобными и интересными, и вы понимаете что вам придется именно усугублять разгребать тонну такого легаси… Часто отношения в команде складываются для вас не лучшим образом, и вы не получаете настоящего понимания и отдачи, драйва от коллег… Умение тащить себя «по-мюнхаузеновски за волосы», снова влюбляться в технологии, увлекаться чем-то новым [вообще и/или для себя, может быть — смежной областью], имхо, не просто является важным качеством профессионала, но, на самом деле, помогает разработчику выжить в капитализме, оставаясь не только внешне востребованным, конкурентоспособным с наступающей на пятки молодежи, но, прежде всего, давая энергию и движение изнутри. Иногда приходится слышать что-нибудь вроде: «а вот мой бывший говорил, что если бы можно было не кодить, он бы не кодил!». Да и нынешняя молодежь осознала что в сегодняшней ситуации «честно и нормально» зарабатывать можно только в айти, и уже стоят толпою на пороге HR-отдела… Не знаю, мне нравилось кодить с детства, а кодить хочется что-нибудь если не полезное, то хотя бы интересное. Короче, я далеко не геймер, но в моей жизни было несколько коротких периодов когда я позорно «загамывал». Да само увлечение компьютерами в детстве началось, конечно же, с игр. Я помню как в девяностые в город завезли «Спектрумы». Есть тогда было часто практически нечего, но отец все-таки взял последние деньги из заначки, пошел, отстоял невиданно огромную очередь и приобрел нам с братом нашу первую чудо-машину. Мы подключали его через шнур с разъемами СГ-5 к черно-белому телевизору «Рекорд», картинка тряслась и моргала, игры нужно было терпеливо загружать в оперативную память со старенького кассетного магнитофона [до сих пор слышу ядовитые звуки загрузки], часто переживая неудачи… Несмотря на то что ранние программисты и дизайнеры умудрялись помещать с помощью своего кода в 48 килобайт оперативной памяти целые миры с потрясающим геймплеем, мне быстро надоело играть и я увлекся программированием на Бейсике)), рисовал спрайтовую графику (и векторная «трехмерная» тогда тоже уже была, мы даже купили сложную книжку), писал простую музыку в редакторе… Так вот, некоторое время назад мне опять все надоело, была пандемийная зима и на велике не покататься, рок-группа не репетировала… Я почитал форумы и установил себе несколько более-менее свежих популярных игр, сделанных на Unity или Unreal Engine, очевидно. Мне нравятся РПГ-открытые миры-выживалки, вот это все… После работы я стал каждый вечер погружаться в виртуальные миры и рубиться-качаться, но хватило меня ненадолго. Игры все похожи по механикам, однообразный геймплей размазан по небольшому сюжету на кучу похожих заданий с бесконечными боями… Но, самое смешное, это реально безбожно лагает в важных механиках. Лагают коммерческие продукты которые продают за деньги… А любой «баг», имхо, это сильное разочарование — он мгновенно выносит из виртуальной среды, цифровой сказки в реальный мир… Конечно, отличная графика, очень круто нарисовано. Но, утрируя, я понял что все эти поделки на энтерпрайзных движках, по сути — даже не кодят. Их собирают менеджеры и дизайнеры, просто «играясь с цветом кубиков», но сами кубики, при этом практически «не меняются»… Вообщем, когда стало совсем скучно, я подумал что «а я ведь тоже так могу», да прямо в браузере на богомерзком непредназначенным для экономии памяти серьезного программирования джаваскрипте. Решил наконец полностью соответствовать тому что все время с умным видом повторяю сыну: «уметь делать игры, намного интереснее чем в них играть». Одним словом, я задался целью написать свой кастомный браузерный FPS-шутер на открытых технологиях.

Итак, на данный момент, первый результат по этой долгоиграющей «таски на самого себя» — можно тестить: http://robot-game.ru/

Стек и архитектура

Вполне может быть, что я не вкурсе чего-то (ммм… на ум приходит что-нибудь вроде quakejs и WebAssembly), но, с основной технологией было, походу, особо без вариантов. Библиотека Three.js давно привлекала мое внимание. Кроме того, в реальной коммерческой практике, несколько раз, но уже приходилось сталкиваться с заказами на разработку с ее использованием. На ней я сделал собственно саму игру.

Очевидно, что нужно что-то «вокруг» — для простого интерфейса пользователя: шкал, текстовых сообщений, инструкций, контролов настроек, вот этого всего. Я решил поленился, не усложнять себе жизнь и использовать любимый фреймворк Vue 2, хотя, надо было, конечно, писать на свежем, похожем по дизайну и еще более прогрессивном по сути молниеносном Svelte. Но так как хорошенько разобраться предстояло, прежде всего, с Three, думаю, это было правильное решение. Хорошо знакомый и предсказуемый, лаконичный, изящный, удобный и эффективный Vue, позволил практически не тратить время на «внешний» пользовательский интерфейс.

Когда-то давно я работал дизайнером на винде и достаточно бойко рисовал 2D в Иллюстраторе, но навыков 3D у меня никаких не было. А вот в процессе создания шутера пришлось пойти, скачать и установить одним кликом на свой нынешний Linux Blender. Я быстро научился рисовать с помощью примитивов мир, отдельные объекты, и даже научился делать UV-развертки на них. Но! В целях простоты, скорости работы и оптимизации объема ассетов в моей нынешней реализации не используются текстурные развертки. Я просто подгружаю «чистые» легковесные «бинарные glTF»: .glb-файлы и натягиваю на них всего несколько вариантов нескольких текстур «уже в джаваскрипте». Это приводит к тому что текстуры на объектах искажаются в разных плоскостях, но на основном бетоне для стен, смотрится даже прикольно, такой «разный, рваный ритм». Кроме того, сейчас персонажи не анимируются — пока не было времени изучить скелетную анимацию. Одной из основных целей написания этой статьи является желание найти (по знакомым не получилось) специалиста который поможет довести проект до красоты (очень хочется) и согласится добавить совсем немного анимаций на мои .glb (об условиях — договоримся). Тогда враги, будут погружаться в виде «glTF со встраиванием»: .gltf-файлов — со встроенными текстурами и анимациями. Сейчас уже есть два вида врагов: ползающие-прыгающие наземные дроны-пауки и их летающая версия. Первых нужно научить шевелить лапками при движении и подбирать их в прыжке, а вторым добавить вращение лопастей.

Модель дрона-паука в BlenderМодель дрона-паука в Blender

Для того чтобы игру нельзя было тупо-легко прочитить через браузерное хранилище я добавил простенький бэкенд на Express с облачной MongoDB. Он хранит в базе данные о прогрессе пользователя по токену, который на фронте записывается в хранилище. Хотелось сделать не просто FPS-шутер, а привнести в геймплей элементы РПГ. Например, в нынешней реализации мир делиться на пять больших уровней-локаций между которыми можно перемещаться через перезагрузку. При желании локации можно быстро дорисовывать из уже имеющихся и добавлять в игру, указывая только двери входа и выхода, стартовую и конечную координату, хорошее направление камеры для них (при переходе живого персонажа через дверь текущее направление сохраняется-переносится). На каждом уровне есть только одна формальная цель — найти и подобрать пропуск к двери на следующий уровень. Пропуски не теряются при проигрыше на локации (только при выборе перехода на стартовый уровень после выигрыша на последнем пятом). А вот враги и полезные предметы — цветы и бутылки — при переходе между локациями, проигрыше или перезагрузке страницы — пока выставляются заново согласно основной glb-модели — одновременно и схеме, и визуальной «клетке» локации — об этом дальше. И тут вот первое важное про архитектуру: мой фронтенд это «совсем примитивное SPA». Vue, например, ни для чего не нужен роутер. Вероятно, я получу негативную реакцию некоторых продвинутых читателей, после того, как сообщу что потратил кучу времени для того чтобы попробовать организовать перезагрузку-очистку сцены «внутри» системы — и пока с самым провальным результатом. Вот к такой спорной мысли я пришел в процессе своих экспериментов: самый эффективный, простой, даже, в этой ситуации, правильный и при этом, конечно же, топорный подход, это нативный форс-релоад после того как мы сохраняем или обнуляем данные пользователя на бэкенде:

window.location.reload(true);

А потом просто — дадада — считываем их обратно)) и строим всю сцену заново, с чистого листа, так сказать. Тут, конечно, можно было бы улучшить — «прокидывать» пользователя через хранилище вместо того чтобы ожидать разрешения запроса, но это не критично, в данном случае. Небольшое количество оптимизированных текстур (меньше полтора мегабайта сейчас), сильно компрессированного аудио (MP3, понятно: 44100Гц 16 бит, но с сильным сжатием 128 кбит/с — меньше полтора мегабайта все вместе сейчас), основная модель-локация весящая около 100Кб и модели отдельных объектов — каждая еще меньше… Я добился того что переход между локациями — «полная перезагрузка мира» — занимает вполне приемлемое время, судя по записи перфомансов — примерно две с чем-то, три секунды. И это, кажется, меньше чем во всех «шовных» открытых мирах от энтерпрайза которые я видел. Продвинуто «бесшовный» я тоже один нашел и поиграл, но он лагал хуже всех, и когда сюжет наконец двинулся с мертвой точки — вдруг перестали работать сейвы; тут я уже забил…

Все использующиеся в игре текстуры Все использующиеся в игре текстурыПерфомансПерфоманс

Хочется сразу сказать что техлиды и сеньоры с менторским тоном и заоблачной экспертизой в микробенчмаркинге в комментариях только приветствуются. Это же вообще самое забавное и интересное на Хабре — когда лиды с сеньорами начинают рубиться в комментариях за стоимость операций в джаваскрипте и то, чей микробенчмаркинг заоблачнее! Остается только надеятся на то, что когда вы будете размазывать мой «форсрелоад как дешёвое и сердитое средство изменения сцены» вы обязательно продемонстрируете ваши работающие примеры в которых сцена Three с большим количеством разнообразных объектов на ней очищается и заново инициализируется через свои внутренние методы (например, без перезагрузки текстур и прочих ассетов, аудио). Я же не говорю что это невозможно, это очевидно дорого. Намного дороже чем просто сделать форсрелоад. Понятно что хороший проект это прежде всего кодовая база которая может и должна легко развиваться. Но невозможно прикрутить все фичи сразу, а использование дешевого релоада сейчас никак не блокирует добавление более сложного функционала в будущем. Да и кроме дешевизны более простой подход и «идеологически» привлекателен. Я убежден что хороший код это простой и понятный код, хороший подход — простой подход, точно так же как и интерфейс который они предоставляют. Простое решение лучше сложного, особенно если мы только начинаем строить что-то.

Для того чтобы избежать лишних сложностей в моей реализации сцена практически «неизменна». Она разворачивается, запускается и дальше функционирует в некотором постоянном виде [порождая и уничтожая только выстрелы и взрывы] — пока не происходит переход в другую локацию (или проигрыш на этой). Конкретнее: cейчас я нигде кроме удаления «не подлежащих внешнему учету» выстрелов и взрывов не использую scene.remove(object.mesh)— например — при сборе героем полезных предметов, делая вместо этого:

// встроенное свойство на Object3D в Three
object.mesh.visible = false;
// кастомный флаг кастомного массива объектов
object.isPicked = true;

Поэтому мы, например, можем даже использовать свойство id: number mesh`ей вместо uuid: string для учета и идентификации объектов. Так как все подлежащие учету объекты всегда остаются на сцене — мы можем быть уверены что Three не поменяет айдишники, сдвинув нумерацию «под коробкой» при удалении элемента (но если вы хотите все-таки удалять что-то такое — просто опирайтесь на uuid при работе с этим).

Я нигде и не на чем не использую .dispose(), так как мне просто «нечего удалять». В документации библиотеки сказано что «лучший момент и повод для этого — переход между уровнями, когда можно и нужно, например — удалить ненужные текстуры». Как вы видите выше, текстур у нас совсем немного и они «все всегда нужны».

Посмотрим на структуру проекта:

.
└─ /public // статические ресурсы
│  ├─ /audio // аудио
│  │  └─ ...
│  ├─ /images // изображения
│  │  ├─ /favicons // дополнительные фавиконки для браузеров
│  │  │  └─ ...
│  │  ├─ /modals // картинки для информационных панелей
│  │  │  ├─ /level1 // для уровня 1
│  │  │  │  └─ ...
│  │  │  └─ ...
│  │  ├─ /models
│  │  │  ├─ /Levels
│  │  │  │  ├─ /level0 // модель-схема Песочницы (скрытый уровень 0 - тестовая арена)
│  │  │  │  │  └─ Scene.glb
│  │  │  │  └─ ...
│  │  │  └─ /Objects
│  │  │     ├─ Element.glb
│  │  │     └─ ...
│  │  └─ /textures
│  │     ├─ texture1.jpg
│  │     └─ ...
│  ├─ favicon.ico // основная фавиконка 16 на 16
│  ├─ index.html // статичный индекс
│  ├─ manifest.json // файл манифеста
│  └─ start.jpg // картинка для репозитория )
├─ /src
│  ├─ /assets // ассеты сорцов
│  │  └─ optical.png // у меня один такой )))
│  ├─ /components // компоненты, миксины и модули
│  │  ├─ /Layout // компоненты и миксины UI-обертки над игрой
│  │  │  ├─ Component1.vue // копонент 1
│  │  │  ├─ mixin1.js // миксин 1
│  │  │  └─ ...
│  │  └─ /Three // сама игра
│  │     ├─ /Modules // готовые полезные модули из библиотеки
│  │     │  └─ ...
│  │     └─ /Scene
│  │        ├─ /Enemies // модули врагов
│  │        │  ├─ Enemy1.js
│  │        │  └─ ...
│  │        ├─ /Weapon // модули оружия
│  │        │  ├─ Explosions.js // взрывы
│  │        │  ├─ HeroWeapon.js // оружие персонажа
│  │        │  └─ Shots.js // выстрелы врагов
│  │        ├─ /World // модули различных элементов мира
│  │        │  ├─ Element1.js
│  │        │  └─ ...
│  │        ├─ Atmosphere.js // модуль с общими для всех уровней объектами (общий свет, небо, звук ветра) и проверками взаимодействия между другими модулями
│  │        ├─ AudioBus.js // аудио-шина
│  │        ├─ Enemies.js // модуль всех врагов
│  │        ├─ EventsBus.js // шина событий
│  │        ├─ Hero.js // модуль персонажа
│  │        ├─ Scene.vue // основной компонент игры
│  │        └─ World.js // мир
│  ├─ /store // хранилище Vuex
│  │  └─ ...
│  ├─ /styles // стилевая база препроцессора SCSS
│  │  └─ ...
│  ├─ /utils // набор утилитарных js-модулей для различных функциональностей
│  │  ├─ api.js // интерфейс для связи с бэкендом
│  │  ├─ constants.js // вся конфигурация игры и тексты-переводы
│  │  ├─ i18n.js // конфигурация переводчика
│  │  ├─ screen-helper.js // модуль "экранный помощник"
│  │  ├─ storage.js // модуль для взаимодействия с браузерным хранилищем
│  │  └─ utilities.js // набор полезных функций-атомов
│  ├─ App.vue // "главный" компонент
│  └─ main.js // эндпоинт сорцов Vue
└─ ... // все остальное на верхнем уровне проекта, как обычно: конфиги, gitignore, README.md и прочее

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

Сейчас игра «в спокойном состоянии» — когда потревоженных врагов нет или совсем мало, на компьютере с поддержкой GPU выдает практически коммерческие 60FPS в Google Chrome (ну или Yandex Bro). В Firefox игра запускается, но показатель производительности не менее чем в 2–3 раза ниже. А когда начинается мясо, появляется много потревоженных врагов, выстрелов и взрывов — в «Лисе» процесс начинает лагать и может вообще повиснуть. Моя экспертиза в микробенчмаркинге сейчас пока не позволяет с умным видом рассуждать о причинах этой разницы. Будем считать что дело «в более слабой поддержке WebGL и вычислительных способностях», что-то такое))…

Легенда

Так как выбранный мною «тип игры» совсем обычный — классический FPS, «пиф-паф, ойойой», мне была нужна актуальная захватывающая легенда. Прообраз главного героя — Робот-собутыльник появился из нашего с друзьями музыкального рок-творчества: существует пластинка его имени и забавный клип про него… В игре речь, видимо, идет о потомках «пьющего робота»…

Земля, далекое будущее. Люди давным-давно перебили друг-друга в ядерных войнах, выясняя кто правый, кто левый, кто белый, а кто красный и прочее. На не затронутых бомбардировками атоллах в Тихом Океане размножилось несколько рас человекоподобных роботов. Например, более человекоподобные, имитирующие органику, двуполость и личные отношения Собутыльники, которые перерабатывают животных и растительность в жизненную силу и спецэффекты. Внутри них, по тонким крепким трубкам, течет специальный сброженный органический микс, схожий с человеческим вином, приводя их в движение. Или более машиноподбные однополые Кибер-Танцоры, проповедующие медитативный Дзинь-Нойз. На почве гендерных и религиозных разногласий между культурами, конечно же, понеслась жестокая война.

Пара молодых Собутыльников отправились в свадебное путешествие по океану. Но катер внезапно дал течь и они с трудом дотянули до ближайшего атолла. Прекрасный на первый взгляд остров оказался опасной ловушкой, так как был давно облюбован для медитаций Танцорами.

Робот-Собутыльник приходит в себя на полу пыточной камеры тюрьмы Однополых… Баки пусты… Его мучители, видимо, решили что он уже не жилец, и оставили подыхать… На стене висит портрет легендарного Последнего Президента идеологического предтечи и кумира Танцоров человека, когда-то развязавшего последнюю в истории человечества войну…

Аллегории прозрачные, конечно. Но сама рамочная идея про конфликт однополых и двуполых машин в будущем, имхо, замечательная. Так и будет, точно, все предпосылки уже налицо.))

В игре пока нет самих главных врагов, только их дроны (нужен специалист по скелетной анимации — отзовись!), но уже присутствуют специфические объекты и отдельная механика позволяющая глубже раскрыть генезис Собутыльников, историю их борьбы против «демократической» диктатуры Танцоров. Это информационные панели которые включаются когда герой находится поблизости и — важно — в одном помещении с ними. Ниже об этом будет подробнее, когда будем разбирать постройку мира и взаимодействие модулей.

ДашбордДашборд

Если подойти к панели и нажать E открывается модаль с исторической справкой:

Рассказ о будущем внутриРассказ о будущем внутри

Это задел для дальнейшего развития в сторону РПГ. Например на таких дашбордах несложно организовать «торговый ларек», в котором можно было бы менять поверженный и подобранный металлолом на полезные цветы и бутылки.

Геймплей

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

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

Цветы и бутылкиЦветы и бутылки

Так как у нас «почти РПГ» и возможно собирать полезный стафф перемещаясь назад по локациям — цветы и бутылки имеют определенный вес и герой не может носит с собой больше 25 его единиц. Вес вместе с количеством возвращаемого приемом цветка здоровья помогает сбалансировать всю эту механику: «тупые» цветы — дающий неуязвимость красный и прокачивающий виномет фиолетовый — весят больше, а здоровье дают меньше чем более «интересные» желтый и зеленый.

Сейчас уже есть три уровня сложности игры — они влияют на срок действия цветов, дистанцию на которой героя обнаруживают враги (если персонаж ползет — может подобраться в два раза ближе) и, конечно же — силу урона от выстрелова и взрывов.

Уровни сложностиУровни сложности

Если я найду скелетного аниматора и смогу довести до ума то что уже есть и потом продолжить развивать проект дальше, можно будет подумать о следующих фичах:

  • Разное оружие. Виномет потеряет прицел, но станет работать как пулемет-автомат — не нужно будет все время нажимать на левую кнопку мыши. С прицелом будет снайперская винтовка стреляющая «стальными» снарядами (которые, например, можно делать из собранных разрушенных врагов — тут бы пригодился уже упомянутый «торговый ларек» на дашбордах).

  • Еще один вид бутылок — с крепышом — часовые мины: установил — быстро отбегаешь. Будут полезны для разрушения Танков с огромным здоровьем или крупных скоплений любых врагов.

  • Новые типы врагов. Танки — медленные, но очень живучие и с убойным выстрелом. Стационарные дроны-пушки умеющие стрелять не в горизонтальной плоскости навесом — как делают дроны сейчас, а под разными углами и двойными зарядами. Рядовые бойцы Танцоры — Роботы-Курицы — мой барабанщик почему-то их именно так видит. В идеале они высаживаются как спезназ, приземляясь на челноке в центр третьей локации когда герой на нее заходит. В пятой локации может появиться босс: Робот-Блогер Финальный с ракетницей…

  • Трубочных и двуполых Собутыльников нарисовать сложно, но в идеале было бы рассадить их по камерам Централа — четвертой локации.

  • Можно добавить 2D-карту с врагами (внизу и по центру экрана)

Планов полно, но без скелетной анимации они бессмысленны, конечно…

Но хватит лирики, перейдем к техническим решениям и собственно коду.

Конфигурация

Особенный кайф от написания кастомной игры в том, что после того как вы доставили новые фичи или любые изменения в код вам просто необходимо расслабиться и их честно искренне протестировать. Ручками. Сделать несколько «каток», по любому. Тесты тут никак и ничем не помогут, даже, убежден, наоборот — будут мешать прогрессу, особенно если вы не работаете по заранее известному плану, а постоянно экспериментируете. Браузерная игра на джаваскрипт это в принципе превосходный пример того, когда статическая типизация или разработка через тестирование будут только мешать добиться действительно качественного результата. (А на чем тут необходимо проверять типы, господа сеньоры? Я до сих пор в замешательстве от React c CSS Modules и просто — Flow, а не TS даже — в котором авторы маниакально проверяли что каждый, еще и передаваемый по цепочке компонент, класс модулей для оформления !!! это string… А тут что будем маниакально типизировать, вектора?). И даже сам Роберт Мартин в «Идеальном программисте» делает несколько пассажей на тему бессмысленности TDD, когда говорит о «рисках при разработке GUI». В моей игре — можно сказать что и нет практически ничего кроме тонны двумерного и трехмерного GUI, ну и логики для него. Любая ошибка — либо вызовет исключение, либо неправильное поведение во «вьюхе» и геймплее, которое может быть очень быстро обнаружено с помощью визуальной проверки, но очень сомнительно что вообще способно быть покрыто тестом.

На самом деле, создавая любую программу или даже просто разметку для программы вы всегда непреложно должны следовать некоторым общеизвестным простым принципам (но не всегда это TDD). Чтобы не происходило — укороченный нереальный дедлайн, термоядерная перестрелка за окном — вы должны писать свой код таким образом, чтобы его можно было легко отлаживать и развивать. Любое важное или повторяющиеся больше одного раза значение должно быть отражено в конфигурации. Влияющие на геймплей параметры должны быть локализованы в одном файле — балансируя геймплей работать вы должны именно и практически только с ним одним.

Все настройки настройки и значения влияющие на геймплей и дизайн (константа DESIGN), а также весь текстовый контент-переводы у меня сосредоточены в constants.js.

Контрол

На сайте библиотеки Three представлено большое количество полезных примеров с демо-стендами, самых разных реализаций, функциональностей которые стоит изучить и по возможности к месту использовать. Я отталкивался в своих исследованиях, прежде всего, вот от этого примера. Это правильный, мягкий — инерционный — контрол от первого лица — который математически обсчитывает столкновения с «клеткой»-миром — gld-моделью с помощью октодерева. Проверять столкновения можно для капсулы (для героя или врагов) или «обычных» сферы Sphere и луча Ray от Three. Этого в принципе достаточно для чтобы сделать FPS-игру: сделать так чтобы герой и враги не сталкивались с миром и между собой, выстрелы взрывались при попадании в другие объекты и тд.

Для того чтобы понимать что происходит когда вы нажимаете кнопку Играть, игра запускается и «курсор мыши пропадает» вы должны знать о браузерной фиче Pointer_Lock_API. Мы добавляем такой контрол вместе со всей стандартной кухней Three в инициализации основного компонента-сцены, ищите:

// Controls

// In First Person

...

Но! Тут нюанс — браузеры обязательно «оставляют путь для панического отступления пользователю» и резервируют клавишу Esc для того чтобы пользователь всегда мог разлочить указатель. Это касается нашего UI/UX — в игре необходима клавиша P — ставящая мир на паузу. Когда указатель залочен — то бишь — запущен игровой процесс — нажатие на Esc, как уже сказано — вызовет паузу. Но если мы попытаемся добавить обработку отпускания по 27ому коду даже только для режима паузы, все равно очень быстро увидим в консоли:

ОшибкаОшибка

Поэтому: забудьте про Esc. Пауза — по клавише P. Есть еще одно ограничение и проблема связанная с созданием хорошего FPS-контрола: оружие. Я так понял что в энтерпрайзных реализациях руки-оружие это отдельный независимый план наложенный поверх мира. С Three, насколько я понимаю, сделать так не получится. Поэтому мой пока единственный в арсенале грозный виномет с оптическим прицелом — это объект сцены который «приделан к контролу». Я копирую вектор направления камеры на него. Но около зенита и надира в результате его начинает «штормить» — он не может однозначно определить позицию. При взгляде «совсем под ноги» я его просто скрываю, а вот стрелять наверх нужно. Что делать с этим небольшим и не особо заметным багом я пока не придумал.

Оптический прицел винометаОптический прицел винометаВыстрел вверхВыстрел вверх

Пытаясь сделать скоростной задорный шутер на Three мы можем сразу забыть о тенях или дополнительных источниках освещения, особенно движущихся. Да, я пытался запилить качающиеся на ветру лампы для особенного мрачняка и криповости, движущиеся тени от них. Нет — никак нельзя — даже статичные точечные источники света сильно просаживают производительность (а нам еще врагов гонять). По поводу света я пришел к простому компромиссу: чтобы картинка не выглядела совсем «сухо» и «скучно» — приделать мощный фонарик к контролу, герою. Фонарик можно выключать — клавиша T.

Далее я просто пройдусь по основным модулям давая небольшие комментарии в интересных моментах.

Сцена

Основной компонент Scene.vue предоставляет:

  • всю стандартную кухню Three: Renderer, Scene и ее туман, Camera и Audio listener в ней, Controls

  • набор утилитарных переменных для использования в анимационных циклах низовых модулей

  • переменные для хранения коллекций примитивных дополнительных объектов — превдоmesh`ей — по которым работает кастинг

  • в том числе и через используемые миксины — все необходимые ему самому или его низовым модулям геттеры и экшены стора Vuex

  • обрабатывает большинство (кроме тех, что удобно ловить в логике героя) событий клавиатуры, мыши и так далее

  • инициализирует Аудиошину, Шину Событий и Мир

  • анимирует Шину Событий, Героя и Мир

  • в наблюдателях значений важных геттеров добавляет игровой логики

Весь код тут простой, прямо очевидный, практически не требующий дополнительных пояснений. Что-то мы будем рассматривать дальше, например что за такие превдоmesh`и для кастинга. Но стоит только остановить внимание на такой простой базовой сущности как переменные. Дело в том что существует еще один действительно важный аспект который позволит нам «вытянуть эту задачу с шутером» — это — тут можно начинать смеяться — «экономия памяти» в джаваскрипте (господа техлиды-сеньоры?). Да — не надо ни при каких обстоятельствах создавать переменные в анимационных циклах и проверках низовых модулей, нужно использовать только те что уже есть в основном компоненте (ну или модуле), созданы заранее. Контекст основного компонента можно передавать в публичные методы низовых модулей-функций.

Стандартный модуль — героя, врагов, предмета или специфического объекта вроде двери или информационной панели — в общем виде выглядит так:

import * as Three from 'three';

import { DESIGN } from '@/utils/constants';

function Module() {
  let variable; // локальная переменная - когда очень удобна или необходима при инициализации или во всей логике  
  // ...

  // Инициализация
  this.init = (
    scope,
    texture1,
    material1,
    // ...
  ) => {
    // variable = ...
    // ...
  };

  // Функция анимационного цикла для этого модуля - опционально (предметы, например, не нужно анимировать)
  this.animate = (scope) => {
    // А вот тут и в остальной логике стараемся использовать уже только переменные Scene.vue:
    scope.moduleObjectsSore.filter(object => object.mode === DESIGN.ENEMIES.mode.active).forEach((object) => {
      // scope.number = ...
      // scope.direction = new Three.Vector3(...);
      // variable = ... - так, конечно, тоже можно, главное не let variableNew;
      // ...
    });
  };
}

export default Module;

Стор

Хранилище Vuex поделено на 3 простых модуля. layout.js отвечает за основные параметры игрового процесса: паузы-геймоверы и тд, взаимодействует с API-бекенда. В hero.js — большое количество полей и их геттеров, но всего два экшена/мутации. Этот модуль позволяет в максимально унифицированной форме распространять изменения значений отдельных параметров, шкал, флагов на герое с помощью setScale или может пакетно установить эти значения через setUser.

Третий модуль совсем примитивный preloader.js и целиком состоит из однотипных boolean-полей с false по дефолту. Пока его поле isGameLoaded — единственное в состоянии модуля — с геттером — с false не получает true при запуске или перезагрузке приложения — пользователь будет видеть лоадер. Каждое из остальных полей — обозначает подгрузку определенного ассета: текстуры, модели, аудио или постройку определенного типа объектов.

Если нам нужно подгрузить, например, текстуру песка:

import * as Three from 'three';

import { loaderDispatchHelper } from '@/utils/utilities';

function Module() {
  this.init = (
    scope,
    // ...
  ) => {
    const sandTexture = new Three.TextureLoader().load(
      './images/textures/sand.jpg',
      () => {
        scope.render(); // нужно вызвать рендер если объекты использующию эту текстуру заметны "на первом экране"  
        loaderDispatchHelper(scope.$store, 'isSandLoaded');
      },
    );

  };
}

export default Module;
// В @/utils/utilities.js:

export const loaderDispatchHelper = (store, field) => {
  store.dispatch('preloader/preloadOrBuilt', field).then(() => {
    store.dispatch('preloader/isAllLoadedAndBuilt');
  }).catch((error) => { console.log(error); });
};

Когда отправка сообщения о том что элемент подгружен разрешается — функция-помощник из набора атомов-утилит отправляет экшен проверяющий «все ли готово?».

Согласен что решение по прелоадера не идеальное с точки зрения UI в том смысле что мы не демонстрируем общий прогресс по загрузке. Но на данном этапе это не кажется критически важным, особенно в свете озвученной выше концепции и даже факта «быстрой перезагрузки локаций».

Аудиошина

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

Аудио бывают:

1) Звучащие на контроле-герое и PositionalAudio на объектах

2) Луп или сэмпл

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

В Hero удобно записывать аудио в пе

© Habrahabr.ru