Как я написал браузерный 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.moduleObjectsStore.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 — единственное в состоянии модуля — с геттером — не получает 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 удобно записывать аудио в переменную чтобы можно было просто работать [в обход шины] с ними в специфической логике:

    // В @/components/Three/Scene/Hero.js:
    import * as Three from "three";
    
    import {
      DESIGN,
      // ...
    } from '@/utils/constants';
    
    import {
      loaderDispatchHelper,
      // ...
    } from '@/utils/utilities';
    
    function Hero() {
      const audioLoader = new Three.AudioLoader();
      let steps;
      let speed;
      // ...
    
      this.init = (
        scope,
        // ...
      ) => {
        audioLoader.load('./audio/steps.mp3', (buffer) => {
          steps = scope.audio.addAudioToHero(scope, buffer, 'steps', DESIGN.VOLUME.hero.step, false);
          loaderDispatchHelper(scope.$store, 'isStepsLoaded');
        });
      };
    
      this.setHidden = (scope, isHidden) => {
        if (isHidden) {
          // ...
          steps.setPlaybackRate(0.5);
        } else {
          // ...
          steps.setPlaybackRate(1);
        }
      };
    
      this.setRun = (scope, isRun) => {
        if (isRun && scope.keyStates['KeyW']) {
          steps.setVolume(DESIGN.VOLUME.hero.run);
          steps.setPlaybackRate(2);
        } else {
          steps.setVolume(DESIGN.VOLUME.hero.step);
          steps.setPlaybackRate(1);
        }
      };
    
      // ...
    
      this.animate = (scope) => {
        if (scope.playerOnFloor) {
          if (!scope.isPause) {
            // ...
    
            // Steps sound
            if (steps) {
              if (scope.keyStates['KeyW']
                || scope.keyStates['KeyS']
                || scope.keyStates['KeyA']
                || scope.keyStates['KeyD']) {
                if (!steps.isPlaying) {
                  speed = scope.isHidden ? 0.5 : scope.isRun ? 2 : 1;
                  steps.setPlaybackRate(speed);
                  steps.play();
                }
              }
            }
          } else {
            if (steps && steps.isPlaying) steps.pause();
    
            // ...
          }
        }
      };
    }
    
    export default Module;
    

    Казалось бы шаги нужно делать лупом? Ан — нет, не получится. И, например, вешать еще один «последний шаг» на «окончание движения» — не позволит само устройство инерционного контрола собирающего много быстрых событий. С ним даже не особо ясно когда это вообще происходит — когда позиция героя перестаёт изменяться — нам «уже не надо». Найденное мною решение — простое и хорошо работает, дает нужный эффект. Сэмпл шага это ровно два шага. Если появляется событие клавиатуры — запускаем аудио. И оно всегда отыгрывает до конца. Если персонаж переходит на бег или начинает ползти — просто меняем скорость.

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

    if (!isLoop) audio.onEnded = () => audio.stop();

    Имейте ввиду!

    import * as Three from "three";
    
    import { DESIGN, OBJECTS } from '@/utils/constants';
    
    import { loaderDispatchHelper } from '@/utils/utilities';
    
    function Module() {
      const audioLoader = new Three.AudioLoader();
      // ...
    
      let material = null;
      const geometry = new Three.SphereBufferGeometry(0.5, 8, 8);
      let explosion;
      let explosionClone;
    
      let boom;
    
      this.init = (
        scope,
        fireMaterial,
        // ...
      ) => {
        // Звук наземных врагов - загружаем в инициализации на объекты через шину
        audioLoader.load('./audio/mechanism.mp3', (buffer) => {
          loaderDispatchHelper(scope.$store, 'isMechanismLoaded');
    
          scope.array = scope.enemies.filter(enemy => enemy.name !== OBJECTS.DRONES.name);
    
          scope.audio.addAudioToObjects(scope, scope.array, buffer, 'mesh', 'mechanism', DESIGN.VOLUME.mechanism, true); 
        });
    
        // Звук взрыва - то есть - "добавляемой и уничтожаемой" сущности - загружаем и записываем в переменную
        material = fireMaterial;
    
        explosion = new Three.Mesh(geometry, material);
    
        audioLoader.load('./audio/explosion.mp3', (buffer) => {
          loaderDispatchHelper(scope.$store, 'isExplosionLoaded');
          boom = buffer;
        });
      };
    
      // ...
    
      // ... где-то в логике врагов:
      this.moduleFunction = (scope, enemy) => {
        scope.audio.startObjectSound(enemy.id, 'mechanism');
        // ...
        scope.audio.stopObjectSound(enemy.id, 'mechanism');
        // ...
      };
    
      // При добавлении взрыва на шину взрывов:
      this.addExplosionToBus = (
        scope,
        // ...
      ) => {
        explosionClone = explosion.clone();
        // ..
        scope.audio.playAudioOnObject(scope, explosionClone, boom, 'boom', DESIGN.VOLUME.explosion);
        // ..
      };
    }
    
    export default Module;
    

    Попробуйте подобрать и принять зеленый цветок запускающий машину времени в игре, классно? ))

    Шина событий и сообщения

    Очевидно что кроме аудиомикшера игре необходима еще одна одна шина: делей для событий — модуль который будет задерживать отправку изменений в контексте игрового времени. Например, если было показано сообщение, а потом система поставлена на паузу — мы не можем использовать нигде обычные таймауты — только обновляемые в анимационном цикле часы Clock Three. Модуль хранит актуальные записи о связанных событиях и в нужный момент вызывает переданный коллбэк.

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

    Мир

    Модель первой локации
    Модель первой локации

    В инициализации модуля мира по порядку:

    1. Загружаются все переиспользуемые в остальных модулях текстуры и создаются все такие материалы и геометрии.

    2. Загружается и разбирается модель уровня. На ее основе формируются массивы данных обо всех игровых объектах в OBJECTS и «рабочие» массивы, списки псевдообъектов для сущностей с одинаковой функциональностью в основном контексте.

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

    4. Двери переносятся в отдельную группу из основной модели. Из этих двух групп — очищенной сцены и все дверей создаются стартовые «октодеревья».

    5. Инициализируются все остальные модули.

    Я разбираю один файл glb и как совершенно необходимый такой игре редактор уровней и как готовую модель для построения стартовых октодеревьев мира, и, отдельно — дверей в нем, и как почти готовую не текстурированную основу самого примитивного визуального мира. Различать примитивы можно с помощью специфических маркеров в их наименовании. Это не самое надежное соглашение, оно чревато ошибками, но они легко обнаруживаются визуально при ручном тестировании. Изменения можно вносить очень быстро. Тут уже все зависит от вашей фантазии и выдуманного с помощью нее дизайна и геймплея, ну и количества времени которые вы можете на это потратить. Например, я использую маркер Mandatory если хочу чтобы цветок или бутылка были обязательными, если его нет — постройка зависит от рандома. Или для механики включения-выключения информационных панелей — собирается отдельный массив с их «комнатами» — параллелепипедами определяющими объем в котором панель реагирует на персонажа. Для геометрии такого объекта следует сделать при инициализации:

    room.geometry.computeBoundingBox();

    room.visible = false;

    И теперь у нас может быть вот такой публичный метод в модуле панелей и его — забегая вперед — можно будет использовать в модуле Атмосферы при проверках «отношения» панелей к герою:

    // В @/components/Three/Scene/World/Screens.js:
    this.isHeroInRoomWithScreen = (scope, screen) => {
     scope.box.copy(screen.room.geometry.boundingBox).applyMatrix4(screen.room.matrixWorld); 
     if (scope.box.containsPoint(scope.camera.position)) return true;
     return false;
    };

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

    Псевдообъект-помощник для двери
    Псевдообъект-помощник для двери
    Дверь не закрывается
    Дверь не закрывается

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

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

    Кастинг

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

    Псевдообъекты-помощники для предметов
    Псевдообъекты-помощники для предметов

    Геометрия и материал готовиться в мире перед инициализацией всех вещей и надежнее сделать материал «двусторонними» — так кастинг будет работать даже если герой оказался внутри псевдообъекта:

    // В @/components/Three/Scene/World.js:
    
    const pseudoGeometry = new Three.SphereBufferGeometry(DESIGN.HERO.HEIGHT / 2,  4, 4); 
    const pseudoMaterial = new Three.MeshStandardMaterial({
     color: DESIGN.COLORS.white,
     side: Three.DoubleSide,
    });
    
    new Bottles().init(scope, pseudoGeometry, pseudoMaterial);
    

    В модуле конкретной вещи:

    // В @/components/Three/Scene/World/Thing.js:
    import * as Three from 'three';
    
    import { GLTFLoader } from '@/components/Three/Modules/Utils/GLTFLoader';
    
    import { OBJECTS } from '@/utils/constants';
    
    import { loaderDispatchHelper } from '@/utils/utilities';
    
    function Thing() {
      let thingClone;
      let thingGroup;
      let thingPseudo;
      let thingPseudoClone;
    
      this.init = (
        scope,
        pseudoGeometry,
        pseudoMaterial,
      ) => {
        thingPseudo = new Three.Mesh(pseudoGeometry, pseudoMaterial);
    
        new GLTFLoader().load(
          './images/models/Objects/Thing.glb',
          (thing) => {
            loaderDispatchHelper(scope.$store, 'isThingLoaded'); // загружена модель
    
            for (let i = 0; i < OBJECTS.THINGS[scope.l].data.length; i++) {
              // eslint-disable-next-line no-loop-func
              thing.scene.traverse((child) => {
                // ... - тут "покраска" материалами частей вещи
              });
    
              // Клонируем объект и псевдо
              thingClone = thing.scene.clone();
              thingPseudoClone = thingPseudo.clone();
    
              // Псевдо нужно дать правильное имя чтобы мы могли различать его при кастинге
              thingPseudoClone.name = OBJECTS.THINGS.name;
              thingPseudoClone.position.y += 1.5; // корректируем немного позицию по высоте
              thingPseudoClone.visible = false; // выключаем рендер
    
              thingPseudoClone.updateMatrix(); // обновляем
              thingPseudoClone.matrixAutoUpdate = false; // запрещаем автообновление
    
              // Делаем из обхекта и псевдо удобную группу
              thingGroup = new Three.Group();
              thingGroup.add(thingClone);
              thingGroup.add(thingPseudoClone);
    
              // Выставляем координаты из собранных из модели уровня данных
              thingGroup.position.set(
                OBJECTS.THINGS[scope.l].data[i].x,
                OBJECTS.THINGS[scope.l].data[i].y,
                OBJECTS.THINGS[scope.l].data[i].z,
              );
    
              // Записываем в "рабочие объеты" - по ним будем кастить и прочее
              scope.things.push({
                id: thingPseudoClone.id,
                group: thingGroup,
              });
              scope.objects.push(thingPseudoClone);
    
              scope.scene.add(thingGroup); // добавляем на сцену
            }
            loaderDispatchHelper(scope.$store, 'isThingsBuilt'); // построено
          },
        );
      };
    }
    
    export default Thing;

    Теперь мы можем «тыкать» направленным вперед лучом из героя в анимационном цикле Hero.js:

    // В @/components/Three/Scene/Hero.js:
    import { DESIGN, OBJECTS } from '@/utils/constants';
    
    function Hero() {
      // ...
    
      this.animate = (scope) => {
        // ...
    
        // Raycasting
    
        // Forward ray
        scope.direction = scope.camera.getWorldDirection(scope.direction);
        scope.raycaster.set(scope.camera.getWorldPosition(scope.position), scope.direction);
        scope.intersections = scope.raycaster.intersectObjects(scope.objects);
        scope.onForward = scope.intersections.length > 0 ? scope.intersections[0].distance < DESIGN.HERO.CAST : false;
    
        if (scope.onForward) {
          scope.object = scope.intersections[0].object;
    
          // Кастим предмет THINGS
          if (scope.object.name.includes(OBJECTS.THINGS.name)) {
            // ...
          }
        }
    
        // ...
      };
    }
    
    export default Hero;

    Кастинг очень полезен и для усовершенствования ИИ врагов. С помощью него возможно проверять имеет ли смысл, есть ли возможность двигаться-прыгать вперед, лететь вниз, делать выстрел. В утилитах:

    // В @/utils/utilities.js:
    
    // let arrowHelper;
    
    const fixNot = (value) => {
     if (!value) return Number.MAX_SAFE_INTEGER;
     return value;
    };
    
    export const isEnemyCanMoveForward = (scope, enemy) => {
     scope.ray = new Three.Ray(enemy.collider.center, enemy.mesh.getWorldDirection(scope.direction).normalize());
    
     scope.result = scope.octree.rayIntersect(scope.ray);
     scope.resultDoors = scope.octreeDoors.rayIntersect(scope.ray);
     scope.resultEnemies = scope.octreeEnemies.rayIntersect(scope.ray);
    
     // arrowHelper = new Three.ArrowHelper(scope.direction, enemy.collider.center, 6, 0xffffff);
     // scope.scene.add(arrowHelper);
    
     if (scope.result || scope.resultDoors || scope.resultEnemies) {
       scope.number = Math.min(fixNot(scope.result.distance), fixNot(scope.resultDoors.distance), fixNot(scope.resultEnemies.distance));
       return scope.number > 6;
     }
     return true;
    };
    

    Для наглядной визуальной отладки подобных механик очень полезен объект Three ArrowHelper. Если мы включим его добавление на сцену в функции выше:

    Отладка с включенными стрелочными помощниками
    Отладка с включенными стрелочными помощниками

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

    // В @/utils/utilities.js:
    export const isToHeroRayIntersectWorld = (scope, collider) => {
     scope.direction.subVectors(collider.center, scope.camera.position).negate().normalize();
     scope.ray = new Three.Ray(collider.center, scope.direction);
    
     scope.result = scope.octree.rayIntersect(scope.ray);
     scope.resultDoors = scope.octreeDoors.rayIntersect(scope.ray);
     if (scope.result || scope.resultDoors) {
       scope.number = Math.min(fixNot(scope.result.distance), fixNot(scope.resultDoors.distance));
       scope.dictance = scope.camera.position.distanceTo(collider.center);
       return scope.number < scope.dictance;
     }
     return false;
    };
    

    Враги

    Кроме инициализации самих низовых модулей врагов, модуль Enemies.js содержит и всю общую логику обслуживающую жизненный цикл врагов. Сейчас у меня четыре этапа-режима жизни у врагов:

    // В @/utils/constatnts.js:
    export const DESIGN = {
      DIFFICULTY: {
        civil: 'civil',
        anarchist: 'anarchist',
        communist: 'communist',
      },
      ENEMIES: {
        mode: {
          idle: 'idle',
          active: 'active',
          dies: 'dies',
          dead: 'dead',
        },
        spider: {
          // ...
          decision: {
            enjoy: 60,
            rotate: 25,
            shot: {
              civil: 40,
              anarchist: 30,
              communist: 25,
            },
            jump: 50,
            speed: 20,
            bend: 30,
          },
        },
        drone: {
          // ...
          decision: {
            enjoy: 50,
            rotate: 25,
            shot: {
              civil: 50,
              anarchist: 40,
              communist: 30,
            },
            fly: 40,
            speed: 20,
            bend: 25,
          },
        },
      },
      // ...
    };
    // В @/components/Three/Scene/Enemies.js:
    import { DESIGN } from '@/utils/constants';
    
    import {
      randomInteger,
      isEnemyCanShot,
      // ...
    } from "@/utils/utilities";
    
    function Enemies() {
      // ...
    
    
      const idle = (scope, enemy) => {
        // ...
      };
    
      const active = (scope, enemy) => {
        // ...
    
        // Где-то в логике агрессивного режима: решение на выстрел (если отдыхает)
        scope.decision = randomInteger(1, DESIGN.ENEMIES[enemy.name].decision.shot[scope.difficulty]) === 1;
        if (scope.decision) {
          if (isEnemyCanShot(scope, enemy)) {
            scope.boolean = enemy.name === OBJECTS.DRONES.name;
            scope.world.shots.addShotToBus(scope, enemy.mesh.position, scope.direction, scope.boolean);
            scope.audio.replayObjectSound(enemy.id, 'shot');
          }
        }
      };
    
      const gravity = (scope, enemy) => {
        // ...
      };
    
      this.animate = (scope) => {
        scope.enemies.filter(enemy => enemy.mode !== DESIGN.ENEMIES.mode.dead).forEach((enemy) => {
          switch (enemy.mode) {
            case DESIGN.ENEMIES.mode.idle:
              idle(scope, enemy);
              break;
    
            case DESIGN.ENEMIES.mode.active:
              active(scope, enemy);
              break;
    
            case DESIGN.ENEMIES.mode.dies:
              gravity(scope, enemy);
              break;
          }
        });
      };
    }
    
    export default Enemies;
    

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

    Но! Самое важное на что нужно обратить внимание: в idle — спокойном режиме — полноценно двигается некоторое случайное время только один выбранный случайным образом враг. Остальные — поворачиваются на месте + может и должна быть запущена анимация. Такая оптимизация позволяет действительно полноценно разгрузить систему.

    Столкновения

    «Октодеревом» в данном тексте обозначается максимально упрощенная модель 3D-пространства которое занимает некоторая группа объектов — с минимально необходимым и достаточным для обсчета количеством граней, рёбер и вершин.

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

    В текущей реализации используются три октодерева: мир: 1) пол, бетонные блоки, трубы, стекла, а также 2) двери и 3) враги. Каждый из врагов обсчитывает свои столкновения с «персональным» октодеревом врагов собранным «без него». 

    Мы можем и должны обновлять октодеревья. Если дверь была открыта-закрыта или враг передвинулся — нам нужно пересобрать соответствующее октодерево. При открытии закрытии двери лучше всего сделать это два раза: когда дверь открылась или закрылась настолько что герой уже может или наоборот больше не может пройти через нее и когда процесс открытия/закрытия завершен.

    В моей реализации на объекты врагов инициализируется по две псевдо-коробки. Это не оптимально, но сделано в целях балансировки визуальной составляющих двух механик: 1) столкновение героя с врагами 2) столкновения врагов с миром и между собой. Визуальное тестирование показало что для реализации первой механики нужна «коробка» намного более меньшего размера чем для второй.

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

    Мы не можем обновлять октодерево врагов героя или персональные октодеревья врагов каждый раз когда вызывается обсчет столкновений — наша система просто сразу «ляжет» в таком случае. Но достаточно делать это, например, каждые полсекунды. Обновили октодерево всех врагов для героя — запускаем таймер на полсекунды. Точно также и с персональными октодеревьями врагов.

    // В @/utils/constatnts.js:
    export const DESIGN = {
      OCTREE_UPDATE_TIMEOUT: 0.5,
      // ...
    };
    // В @/utils/utilities.js:
    // Обновить персональное октодерево врагов для одного врага
    import * as Three from "three";
    import { Octree } from "../components/Three/Modules/Math/Octree";
    
    export const updateEnemiesPersonalOctree = (scope, id) => {
      scope.group = new Three.Group();
      scope.enemies.filter(obj => obj.id !== id).forEach((enemy) => {
        scope.group.add(enemy.pseudoLarge);
      });
      scope.octreeEnemies = new Octree();
      scope.octreeEnemies.fromGraphNode(scope.group);
      scope.scene.add(scope.group);
    };
    
    // Столкновения врагов
    const enemyCollitions = (scope, enemy) => {
      // Столкновения c миром - полом, стенами, стеклами и трубами
      scope.result = scope.octree.sphereIntersect(enemy.collider);
      enemy.isOnFloor = false;
    
      if (scope.result) {
        enemy.isOnFloor = scope.result.normal.y > 0;
        // На полу?
        if (!enemy.isOnFloor) {
          enemy.velocity.addScaledVector(scope.result.normal, -scope.result.normal.dot(enemy.velocity));
        } else {
          // Подбитый враг становится совсем мертвым после падения на пол и тд
          // ...
        }
    
        enemy.collider.translate(scope.result.normal.multiplyScalar(scope.result.depth));
      }
    
      // Столкновения c дверями
      scope.resultDoors = scope.octreeDoors.sphereIntersect(enemy.collider);
      if (scope.resultDoors) {
        enemy.collider.translate(scope.resultDoors.normal.multiplyScalar(scope.resultDoors.depth));
      }
    
      // Делаем октодерево из всех врагов без этого, если давно не делали
      if (scope.enemies.length > 1
        && !enemy.updateClock.running) {
        if (!enemy.updateClock.running) enemy.updateClock.start();
    
        updateEnemiesPersonalOctree(scope, enemy.id);
    
        scope.resultEnemies = scope.octreeEnemies.sphereIntersect(enemy.collider);
        if (scope.resultEnemies) {
          result = scope.resultEnemies.normal.multiplyScalar(scope.resultEnemies.depth);
          result.y = 0;
          enemy.collider.translate(result);
        }
      }
    
      if (enemy.updateClock.running) {
        enemy.updateTime += enemy.updateClock.getDelta();
    
        if (enemy.updateTime > DESIGN.OCTREE_UPDATE_TIMEOUT && enemy.updateClock.running) {
          enemy.updateClock.stop();
          enemy.updateTime = 0;
        }
      }
    };
    

    Своя атмосфера

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

    Если вывалится за стену и забежать за край неба
    Если вывалится за стену и забежать за край неба

    Я долго придумывал и придумал как сделать прикольное небо, облака: это огромная вывернутая по одной из осей и вращающаяся текстурированная сфера.

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

    Пуленепробиваемые стекла
    Пуленепробиваемые стекла

    Да, это вам не React c TS и тестами в финтех и банки!

    Выводы которые я могу сделать на основе практики создания браузерной FPS на Three:

    • Мы не можем использовать тени и множество источников света

    • Мы должны экономить память в анимационном цикле и использовать в нем только готовые переменные 

    • Мы должны и во всех остальных возможных аспектах максимально тщательно оптимизировать анимационный цикл, кастинг и обсчет столкновений в нем в контексте геймплея, так, чтобы сохранить драйв, но избежать падения производительности

    • Статическая типизация и юнит-тесты ничем не могут помочь в данном эксперименте

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

    Комментарии 37

      +1
      Очень интересно. Но на core i5-3470/8Gb памяти/ Radeon 5670адски тормозит (да, это старое железо, но не совсем ведь древнее). Через примерно 40 секунд повисла вкладка с игрой и выключилась музыка в соседней в хроме.
        +2

        В Хром? А вижу, пичаль, да, надо будет еще лучше все оптимизировать, видимо… Хотя, в принципе, кажется, изначально было очевидно что все это достаточно бесполезная и странная затея)))… Но на обоих моих компах — свежем ноуте и "среднем" стационарном с видеокартой — в Хром и Яндекс — можно играть… Спасибо за фидбек!

          +1
          Да, в нём. В Яндекс.Браузере так же.
          +2
          Вспомнилась .kkrieger
            –1

            Только нужно бы оптимизировать размер загружаемых скриптов — убрать лишние места и текстуры, оптимизировать вычисления, из самого three.js убрать неиспользуемые фичи...

              0

              Спасибо за каммент! Может быть я не вкурсе чего-то. В тексте я показываю что все лишние места — удаляются из модели при каждой постройке мира-локации и "лишних текстур по сути нет" — все всегда используются. А небольшое "дублирование" возникает от того что текстуры наносятся на "бинарные" glb — без UV-разверток, поэтому им иногда нужно разное маштабирование. Про "оптимизацию вычислений" если можно поподробнее? Я тут почитал про GPU.js — видимо, его можно прикрутить? И очень интересно самое последнее — как "убрать из three неиспользуемые фичи"?

              0

              А, понял, каммент от эксперта, ясно. Ждал ответа, думал-думал: вероятно вы имеете в ввиду полезные модули для препроцессинга и прочее — которые остались от моих многочисленных экспериментов и первоначальной песочницы. Их наличие никак не влияет на производительность.

          +1
          В Firefox не получается играть. При нажатии WASD начинается поиск по странице, и игра становится на паузу. В Chrome всё нормально.
            +1

            Спасибо за фидбек! Я вообще, если честно, в какой-то момент был приятно удивлен когда попробовал в Лисе и запустилось...))

              0

              Как-то мы не могла понять, что крашится в браузерах с нашим движком. А потом вдруг осенило. Для быстрого перемещения по сцене используется Ctrl+WASD. Вот Ctrl+W и сыграл свою роль. WASD в браузерах лучше нет)

            +2
            Да и нынешняя молодежь осознала что в сегодняшней ситуации «честно и нормально» зарабатывать можно только в айти
            Не помню случая, чтобы разработчик возвращал деньги, если сорвал им сроки сдачи проекта. Немало ситуаций, когда проект не сданный в срок становится не нужен (например, лэндос под рекламную кампанию). Честность и нормальность получения денег в подобной ситуации под вопросом.
              0

              Ваш ответ показался странным и не адекватным цитате из поста которая в нем используется — о том что молодежь в общем и целом окончательно осознала что "надо идти в разработку выгодно"… Но если играть в эту игру — кажется — отдавать начинающему разработчику — и в штате и тем более на фриланс проект который будучи не сданным в срок станет не нужным и в минус — это очевидный косяк не работника, а "головы" — организатора?

                –1
                Не придумывайте то, чего нет. Я привёл полную логически завершённую цитату, не теряющую смысл вне контекста. Для наглядности истории поставлю обе Ваши цитаты рядом. Совершенно очевидно будет, что они несут разный смысл:
                Да и нынешняя молодежь осознала что в сегодняшней ситуации «честно и нормально» зарабатывать можно только в айти
                идти в разработку выгодно
                И на этом для себя закрою эту тему в данной дискуссии.

                это очевидный косяк не работника, а «головы» — организатора?
                Нет, мы специалисты в профе, а не заказчик. И я ничего не писал про «начинающего» (к чему Вы ввели сущность, которой не было — не важно, оставлю на совести Вашего образа мышления). Мы взялись, мы и должны отвечать. Всё остальное — болтовня.
                  0

                  Про "джунов" как раз следует из контекста цитаты — "молодежь которая стоит на пороге HR-отдела" и тд… Новички могут сорвать сроки и при этом очень сильно стараться — просто не хватает опыта. Прямая ответственность старших наставников и самого бизнеса — не ставить джунов на ответственные участки. А если "сроки срывает" "не джун", то он не профессионал, а мошенник… Ну и о том кто его нанял тоже стоит задуматься...


                  Пока я вижу только мастера по написанию неадекватных камментов, извините, но для меня это только так.

                    –1
                    Давно я такой околесицы не встречал на «сантиметр квадратный», да ещё и от первой буквы, до последней. Вы смешали в кучу не только людей и коней, но и много «нового и дивного».

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

                    Вот Вам мат. часть:
                    Молодёжь — лица от 14 до 35-ти лет. Ни в контексте цитаты, ни в контексте Вашего дополнения к ней не следует должно явным образом, что стоящие у HR-отдела являются джунами или начинающими, итп. Вы это сейчас, на ходу придумали
                    Новичок — субъект, только пришедший в сферу, не обладает ни знаниями, ни навыками
                    Джун — обладает базовыми навыками и знаниями, достаточными для начала работы (новичку вообще нечего делать у HR-отдела, никто его ни куда не возьмёт)
                    Бизнес — сущность, цель и задача (прямая ответственность) которой только зарабатывание денег — коммерческая выгода (не путать с Участниками бизнеса)
                    Старший наставник — более опытный наставник из других наставников. Его компетенция — наставники и/или сложившиеся разработчики. Для джунов есть просто наставник, а обычно, «ведущий пары», а не отдельная должность.
                    Срыв сроков — явление, которое может настичь даже профессионала. И вопрос лишь в том, как спец/профи себя поведёт в данной ситуации.
                    Наниматель — лицо, совершенно не обязанное быть хоть сколько-то в теме. Оценка сроков и/или их принятие (равно как и отказ), это компетенция исключительно разработчика (как и ответственность за ход работ в соответствие с ТЗ и Договором).

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

                    Я писал только про разработчика, согласившегося взять заказ с указанными сроками, сорвавшего сроки и обязанного отвечать за свои поступки, чего не наблюдается в реальности.

                    Внятных контраргументов от Вас нет, и ни у кого их быть не может (включая меня, если бы я попытался занять Вашу позицию) и быть не может — разраб оценил проект, оценил сроки, сопоставил, согласился. Только он и отвечает за ход работ и сдачу проекта. Точка.
                      +1

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


                      Я понимаю если бы "матчасть", нотации — внимание — сугубо по теме поста — стали бы мне читать — для того они и камменты. А тут — неадеват полный. Ну уже то что после фразы "И на этом для себя закрою эту тему в данной дискуссии." вы потом еще выдаете целую проповедь с болдом и подчеркиванием — уже о многом говорит.


                      Напишите, пожалуйста, свою статью об ролях и участниках, рисках рабочих процессов в разработке, вот это все — можете сюда ссылку потом повесить? А пока — будьте последовательны и "закройте тему" пожалуйста?


                      upd: и да: ""Срыв сроков" — явление, которое может настичь даже профессионала." Это холивар, но если специалист не дает адекватных оценок и плюс еще не корректирует их трезво в процессе — стоит тренироваться больше с наставниками — любыми — старшими, младшими )))… Сорвать срок можно только в двух ситуациях: 1) внезапная потеря трудоспособности и некому подхватить 2) сроки в принципе неадекватны и при их выставлении не учитывается реальная оценка самого профессионала.

                        0
                        Это было не для Вас, это было для остальных читателей, чтобы они не попались на удочку Вашего потока сознания облечённого в слабосвязанные словоформы. А для меня да, эта тема тут и с Вами закрыта, ибо было очевидно уже тогда, что ничего внятного Вы не предложите сколь аргументированной ни была бы позиция Вашего оппонента.
                          0

                          А я с вами даже не начинал и не хочу спорить — по многим причинам. Начиная с того, что просто не интересно и точно ни к чему не приведет. "Остальным читателям" которые до комментариев доберутся, тоже думаю не особенно интересен ваш пассивно-агрессивный поток сознания "не про Three и тд" и то как вы его проецируете в неподходящем для этого месте. Утомили.

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

                        Просто это звучит немного сюрреалистично, так как разработка — не игра одного актёра, и там присутствует много факторов. Нельзя же говорить, что обычный работяга на стройке виноват в том, что не положил кирпич за 3 дня, как договаривались, ведь оказывается «сверху» люди не могли договориться где купить этот кирпчик и только 2 дня потратили на «закупку».

                        Определенно, если человек «фрилансит», то риски должны брать на себя как и наниматель (именно для его бизнеса), так и сам работник (по поводу проекта), тут уже как у них договоры «настроены» :)
                          –1

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

                            0
                            Безусловно, Вы правы, отвечать должен тот, кто взял на себя смелость заключить договор. Поэтому, конечно же, речь о прямой паре заказчик-исполнитель.

                            Конечно же да, работяга не виноват в указанных Вами условиях. Но мы говорим о другой ситуации. О ситуации, в которой, по факту, разработчик не рассчитал силы/сложность/личную занятось итд итп.

                            Лично я считаю, что заказчик не должен брать на себя эти риски. Просто потому, что он понятия не имеет «куда и как копать». Вряд ли Вам будут интересны причины установки нового окна со щелями. Вряд ли Вы согласитесь брать на себя риски наличия оных.

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

                            Первый:
                            Ко мне обратился постоянный заказчик и запросил доработку модуля, описав хотелки. Мы эти хотелки перевели в язык ТЗ, я обдумал и сказал, что это может занять как 3-5 дней, так и пару недель, т.к. причины проблем могут крыться не в модуле, а в программе, а покопавшись в ней можно выяснить, что проблема вообще на стороне сервера. А копание в сервере может привести к необходимости доработать ещё что-то. В итоге мы отказались от реализации т.к. она была сильно ему невыгодна. Я описал этот свежий случай довольно абстрактно, да, но, полагаю, Вы без проблем поймёте основной вектор.

                            Второй:
                            Другой клиент обратился за доработкой функционала программы, хотелки описал. Мы эти хотелки перевели в язык ТЗ, пофутболили и я назвал сроки. По ЛИЧНЫМ причинам сроки были сорваны (но я изначально знал, что заказ не имеет критичности в сроках). Я дал заказчику доп. работу бесплатно и описал пользу от этой плюхи. Разговоров о срыве сроков не было вовсе, сотрудничаем и дальше, с обоюдным удовольствием.

                            На включение автора статьи в наш разговор предлагаю не обращать внимание, его эмоциональные экзальтации лишены логического смысла и аргументации.
                              0

                              Вы можете ответить как все ваши сверхценные для моих читателей словоизлияния хотя бы минимально отвечают теме поста, упражнениям и экспериментам на открытых технологиях для саморазвития и драйва? Как написание кастомной браузерной игры "для себя" вообще связано с вашим виденьем рисков в капиталистических отношениях в разработке?

                                0
                                Разумеется, могу. Темой статьи, любой, является всё, что в неё входит. Абсолютно всё, что в неё входит. По этому причине, конечно же, лучше избегать отклонений от основного вектора статьи.

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

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


                                  Да, знаю, у меня крайне витиеватые, возможно — несколько излищние, не сухие и очень личные стилистика и слог. Мне кажется это преимущество вполне, кому-то может не нравится — других статей полно. Но это не повод, как говорят в народе — "цепляться к словам" и разводить холивары. Тем более что вот у меня нет времени на такое — нереальный дедлайн и помогаю "не сорвать сроки", прямо сейчас. )

                                    0
                                    Вот, например, только этот очередной мастерский пассаж про «абсолютно все», сам по себе — отличная затравка для бессмысленного рубилова еще на пару дней камментов не по теме...
                                    Внезапно, и волшебным образом, я этого не делаю. И в первом своём комменте поднял лишь один вопрос. На который с Вами возник спор, а с другим опонентом дискуссия. Да, наверное, это я «такой-сякой», ага)

                                    Искренне желаю успешной сдачи!
                                0
                                Опять же, возможно я и ошибаюсь, но могу судить со своего опыта.
                                На всех фирмах, в которых мне повезло работать, между этапами «хотелок клиента» и «разработчик хотелок» существует серьезная прослойка в 2-3 этапа бизнес-отношений, к которым рядовой разработчик вообще не имеет никаких отношений.

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

                                Если клиент напрямую общается с разработчиком о производстве продукта (в т.ч. и интеллектуального), то тут, конечно же, компания вообще никаких дел не имеет к ним и, как я сказал выше, все ложится на плечи «договоренностей» между сторонами, как официальных, так и нет.
                                  0
                                  Полностью согласен с Вами, если смотреть в указанном Вами разрезе. Но предложенный мной разрез другой. Вот мой исходный коммент
                                  Не помню случая, чтобы разработчик возвращал деньги, если сорвал им сроки сдачи проекта. Немало ситуаций, когда проект не сданный в срок становится не нужен (например, лэндос под рекламную кампанию). Честность и нормальность получения денег в подобной ситуации под вопросом.
                                  Из него никак не следует, что разработчик внутри компании. Хотя, соглашусь, что и то, что он фрилансер итп, тоже не следует должно явным образом (хотя и намного более очевидно по семантике фразы). Сознаю свою вину, меру, степень, глубину)
                    +1

                    подключил блютус-клаву к смартфону, а сайт по прежнему пишет "you need a PC keyboard to play"

                      0

                      Нужно мне наверное как-то изменить формулировку на этой заглушке. Игра именно "для десктопных компьютеров", ноутов, но "не гаджетов".

                      0
                      Побегал чуток. У меня никаких тормозов нет. 8гб озу, ай5 10-ген, 1650 карточка. Всё летает. Забавная получилась штука :) Молодцом. Тоже всё хочу игрульки поделать, но как представлю эти юнити да анрилы, в тоску вгоняет. Не подумал я про Three. Надо будет поизучать. Спасибо.
                      ЗЫ: А блендер классный выбор! Я прям как-то после 9 лет 3дмакса попробовал, влюбился!
                        0

                        Ой, как приятно! Спасибо за поддержку! Да юнити да анрилы похоже — окончательно не о том уже, как я "бегло поглядел" — прежде всего для тех кто хочет "рисовать и кликать", а не "писать". )) Да — мне тоже скучно в эту сторону смотреть. Доросли ли браузеры до "крутых игр" (хотя ведь дело не в графоне, на самом деле) — вопрос открытый (не кажется еще не совсем, тем более — во множественном числе), но Three — возможно — самое интересное что можно сейчас смотреть на фронте в плане интерактива и выразительных средств, доставляет, да.

                          0
                          Надо пробовать. А модельки как экспортил? Я в Godot как-то пытался из блендера модель поместить. Как-то это у меня вышло, но как именно уже не помню. А в браузер как её положить? Это как статитка где-то хранится и по урлу подгружается? Типа как картинка или шрифт. А в каком формате? А развёртка, если в блендере её делать, тоже сохраняется? Короче, куча вопросов. Прям напомнил за игры, спасибо! :)
                        0
                        Это конечно интересно, игра простенькая но видно что человек старался.
                          0

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


                          Единственный вопрос по вашему камменту, что вы имеете ввиду под "простенькая"? Можно увидеть пример "сложной" игры на Three? Или вы сравниваете мое кастомное js-соло с профессиональными оркестрами из энтерпрайза?

                            0
                            Да понятно дело, всё равно красава. Вроде того))))

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое