Исходные данные: записался я тут на онлайновый осенний Siberian Game Jam, где требовалась за трое суток сделать игру, каким либо образом интерпретирующую заданную тему (в качестве таковой выпал "Закон Мёрфи"). При этом желательно было сделать весь контент именно своими руками (картинки, текстуры, модели, аудио и так далее).
И начну с такой важной вещи, как выбор игрового движка для джема. Как возможный вариант рассматривался PlayCanvas (онлайновая webgl платформа с редактором и своим хостингом для проектов), но из за желательной для конкурса заливки на itch.io и последнего апдейта PlayCanvas, немного изуродовавшего мои префабы с модельками в текущем проекте (в которых теперь намертво зависли пачки уведомлений о legacy-мешах), решил, что использование данного фреймворка сейчас немного под вопросом и оптимальнее будет взять Godot или Unigine.
Последний не взял потому, что этот конкретный джем на 72 часа, а без некоторых встроенных инструментов прототипирование будет медленнее, чем в Godot. Опять же, повышенные требования Unigine к производительности и вес приложения, хотя, по опыту, архив подобного проекта в относительно приемлемые рамки 200-300 Мб вполне уложился бы. Ну и такой момент, что итоговый геймплей по нему (а снять видео - часть условий конкурса) я вряд ли смогу записать, не потеряв значительную часть fps (что не факт, зависит от проекта, но вероятность высокая). Сверх того, специфика джема налегала на предпочтительное использование своего hand made контента, а в стартовом проекте Unigine для такого нужно чистить некоторый начальный контент - текстуры, объекты. И даже после этого какие-то текстуры останутся - для той же стандартной воды и прочего. Можно, конечно, счесть их базовым набором движка, или не использовать где-то кроме, как в составе тех самых стандартных объектов, но... вы поняли. Скорее запасной вариант, чем основной.
Таким образом Godot смотрелся наиболее подходящим под условия, поэтому заранее настроился на работу с ним, обновив версию до 3.3.3, немного подготовив сцену, пособирав заготовки скриптов и определив некий пул базовых идей, которые хотелось в том или ином виде запрототипировать.
Ну а делать я вознамерился что-то про перелёты между летающими островками. Мысль давняя, изначально почерпнутая где-то в одном из старых (девяностые года) игровых журналов - упоминалась там какая-то (так и не вышедшая) мультиплеерная игра, где персонажи всех рас могли летать и исследовали различные локации, парящие в воздухе. Вобщем, задумка пылилась, иногда пытаясь вырасти то в одну, то в другую сторону, и вот недавно я её снова вспомнил, так как появилось понимание, с чем ещё это можно можно интересно скомбинировать.
В списке вероятных тем для конкурса не заметил такой, которая сильно шла бы вразрез с задуманным, поэтому продолжил прорабатывать это направление. Таким образом, к старту конкурса я уже определился с тем, что это будет игра про махолёт с какой-то dizzy-like механикой сбора/применения предметов. Заодно прикинул, что обычного вида островки я, наверное, не успею сделать как надо, скорее всего придётся моделить им специальные коллайдеры. Ходить по островкам всё-равно будет нельзя, а если выпадет тема "Ритмы города", то на них, видимо, придётся дополнительно создавать какую-то видимость построек. Поэтому решил делать модульные шестиугольные колонны, из которых собирать такие кластеры, в качестве островков, а при необходимости придать им вид башенок или что-то в этом роде.
Вот такая игра получилась в итоге:
Но вернёмся пока обратно к началу.
Минутка предложений/пожеланий. Касаемо темы конкурса. Как мне кажется, не стоило пропускать в качестве темы подобный треш, как "Закон Мёрфи". Все прочие смотрелись адекватнее. Что с ней не так? Всё просто: любые темы в такой явной форме декларирующие всевозможные проблемы (читай баги), неприятности, препятствия, ловушки, странности и неочевидности, всегда выливаются в огромное расхождение ожиданий - участники стараются лучше раскрыть это направление, а оценивающее жюри мучается, пытаясь хоть как-то сориентироваться в этом ужасе, чтобы понять - это игра зависла, или это было преотличное раскрытие тематики. Это всё, конечно, лирика, но просто сравните с темой прошлого, весеннего джема: "Подводные города". Направление понятное, образное, без треша и троллинга.
С другой стороны - не суть важно, интерпретировать можно любую тему. Если так просят, чтобы игра наказывала, то, похоже, я заглянул на тот самый конкурс, куда нужно было заглянуть. Потому, что знаю я тут как раз одну игру, которая всё делает по своим правилам, но тотальная невнимательность к происходящему раз за разом губит случайно заглянувшего пользователя-неофита, заставляя его быстрее сбежать в эти свои гипер-однокнопочные кликеры, да выпить чаю, для успокоения нервов. Речь, собственно, идёт об игре "Вангеры". Хотя, будем честны, тот же интерфейс там действительно можно значительно улучшить, практически ничего не потеряв в геймплее и основном процессе.
Таким образом я вышел на то, чтобы использовать очередной привет из "Вангеров" - решил привнести оттуда моменты с внезапно выпадающими грузами. А то привыкли, понимаешь, в современных играх, что в инвентаре всё всегда в целости сохранности, да на своих местах. Почему бы не нарушить эту концепцию... Так так, а персонажами тогда будут говорящие головы, к ним можно будет залетать в гости... А почему бы ещё грузам, собственно, не портиться по пути. Или как-то негативно/позитивно влиять друг на друга в инвентаре. Или... в общем, мысли уже текли в направлении развития механик.
Обдумывая это всё дотекстурил колонны, замоделил головы, стал накидывать в игре функционал активных точек, где можно поговорить с персонажами (на пустышках и примитивах). Отрендерил в Blender самодельную карту освещённости (HDRI). Записал звук для взмаха крыла на телефоне. Аудио получилось в формате .m4a, но, как оказалось, сконвертировать его в понятный движку .ogg умеет установленный Vlc плеер и ничего дополнительного искать не нужно. Переимпортировал файл в Godot, чтобы убрать неотключаемое зацикливание, выставляемое для ogg по умолчанию на этапе импорта. Далее привязал проигрывание звука к анимации временных заглушек для крыльев, чтобы синхронизировать его со взмахами.
В вопросе определения столкновений для быстроты использовал узлы визуальных рейкастов. Правда, можно было вместо них сделать зону с коллайдером, тем более одно тело-коллайдер всё-равно понадобилось для отслеживания махолётом предметов и активных точек.
По скриптам: есть глобальный (содержащий некоторые переменные и ссылки), скрипт махолёта и камеры (также обрабатывающий нажатия клавиатуры), скрипт главной сцены (управляющий интерфейсом и большинством событий), скрипты предметов и активных точек (сигнализирующие о коллизиях).
Сами предметы устроены, словно некая "матрёшка" - внутри находится сцена (читай префаб) с визуальной частью, которая содержит внутри себя все предметы и только один из них выставляет видимым. Эта сцена сидит внутри сцены подбираемого предмета, и та уже обладает зоной столкновения. Далее, я хотел разделить предметы, которые будут лежать на своих местах на старте и ничего не делать в update от тех, которые постепенно полетят вниз, когда будут выброшены махолётом. Поэтому для удобства завёл сцену с пустышкой-спавнером, скрипт которой будет на старте игры прикреплять статический предмет на свои координаты в мире. А махолёт, по задумке, должен был создавать (когда выбрасывает предмет) другую сцену-пустышку, которая подгрузит предмет внутрь себя и далее будет падает вниз вместе с ним. Правда времени на реализацию падения предметов не хватило, поэтому они просто спавнятся в мире на старте, а при выбрасывании висят в воздухе.
Внутри самого махолёта расположены просто три одинаковые сцены с визуалом предметов, которые принимают вид того или иного предмета, когда махолёт вызывает нужную функцию в их скриптах. То есть внутри махолёта нам требуется только префаб с универсальным визуалом, а во внешнем мире такие же префабы находятся внутри префабов с коллайдерами, так как с ними понадобится взаимодействовать, а не просто наблюдать за ними.
Алгоритм сбора предметов следующий: последний предмет, с которым произошла коллизия, заносит свой номер в глобальную переменную. Если коллизия прекратилась - обнуляет её. Скрипт махолёта, в свою очередь, обращается к последнему запомненному идентификатору предмета, если нажата кнопка подбора, и, если тот ненулевой, "забирает" предмет. Также он забирает ссылку на скрипт того предмета, оставляя её пустой у глобального объекта, и по ссылке на предмет вызывает в нём метод самоуничтожения.
У данного решения есть свои нюансы - если рядом много предметов, то подобрав один, нужно немного пролететь, чтобы сработала регистрация следующего и можно было подбирать его. В другом проекте я делал подбор предметов через широкий "собирающий" выстрел, который лучше справляется с последовательным сбором всех близкорасположенных предметов (просто контактируя с первым попавшимся).
В условиях ограниченного времени набрасывал сперва какие-то заглушки для тех основных вещей, что понадобятся - модели, экраны, функции, кнопки. Собственно, многое универсальное базовое можно было реализовать заранее, ещё до конкурса - зачатки интерфейса, какие-то типовые скрипты для объектов, механизм сброса уровня. Я предварительно набросал основу управления летающим игроком, а вот интерфейс, кнопки и прочую базу пришлось уже успевать как-то формировать в процессе конкурса.
В конце игры желательно было сделать рестарт, чтобы игрок мог нормально начать игру по новой, не перезапуская приложение. Для этого потребовалось дополнительно продумать, каким образом это будет происходить. Я остановился на том, что создал специальный слой (сцену), содержащий в себе спавнеры стартовых предметов. А выбрасываемые из махолёта предметы крепил уже именно к этому слою. В конце игры происходит удаление этого слоя (вместе с которым удобно удалятся и все предметы, выброшенные во время игровой сессии) и загружается его новая чистая копия, со стартовой расстановкой. Прочие игровые объекты - махолёт, головы, "островки", менюшки интерфейса - при рестарте остаются нетронутыми, однако им выставляются стартовые настройки внутри скриптов. То есть головы возвращатся к своим первым фразам, махолёт возвращается в стартовую позицию и обнуляет свои предметы, скрываются/показываются нужные менюшки.
Не во всех играх рестарт так уж жизненно необходим, особенно если можно легко выйти или перезагрузить страницу (в случае бразуерной игры), но по всей концепции выходило, что предметы будут теряться и ломаться, следовательно нужно сразу предусмотреть либо какое-то их реинкарнирование, либо вариант отката к началу, либо ещё какие альтернативы. Поэтому есть рестарт, а для текущего "прохождения" предметы и не требуются.
Ещё была заведена отдельная переменная под состояния игры, чтобы, допустим, заблокировать управление движением махолёта, когда он остановился поговорить, или когда игра перезапускается. Когда подбирается предмет, то для порядка включается специальный флаг, временно запрещающий какие-то новые манипуляции, пока предмет не будет нормально установлен куда нужно и снова сбросит флаг.
Для тестов я завёл некоторые вспомогательные кнопки - "создать случайный предмет", "увеличенная скорость" и "конец игры". Главное не забывать убирать подобные штуки из релиза.
Саму механику выпадения предметов я сначала пробросил в абстрактном виде - чтобы тикали таймеры, вызывая с некоторой периодичностью пустующую функцию ревизии инвентаря. Эта функция поначалу просто писала в лог, что произошла ревизия.
Когда предметы уже были заведены и их можно было собирать/доставлять, то осталось время и на дописывание полноценной реализации их периодческого выпадения. Для этого я назначил разным типам предметов, после какого количества ревизий они должны отреагировать выпадением или каким-то другим эффектом (те же яблоки, например, должны были просто испортится со временем, превратившись, по сути, в новый предмет "испорченное яблоко", но, я не успел это написать). После чего предмет, если он не выпал, начинает снова отсчитывать ревизии, до следующего триггера.
Для того, чтобы предотвратить массовый триггер (если вдруг на махолёте окажется 2 или 3 одинаковых предмета сразу), второй предмет срабатывает на "лимит ревизий +1", а третий на "лимит ревизий +2". То есть, например, у нас на борту 2 бочки и 1 ящик. При каждой ревизии счётчик ревизий предмета "бочка" растёт на 2 (бочек две, поэтому каждая заносит 1-ку от себя в этот общий счётчик - это потому, что счётчики закреплены за типом предмета, а не за каждым его уникальным объектом). Ящик, в свою очередь, повышает на 1 счётчик ящика. Наконец, происходит ревизия N, при которой бочка в первом слоте должна свалиться, но вот счётчик обнулится лишь после того как проверены все три слота. Так как вторая бочка находится во втором слоте, который смотрит на лимит N+1, вместо N, то, когда проверка доходит до него - бочка во втором слоте уже не реагирует, проскакивая эту ревизию.
В принципе, данный вопрос решался и другим способом. Просто счётчики на старте ревизии тикают для всех трёх предметов сразу, потом уже начинаются проверки, что сделать с каждым и обнуление счётчиков идёт уже финальным действием, в зависимости от того, какой предмет был брошен. Если делать "тик-проверка" для первого, "тик-проверка" для второго и "тик-проверка" для третьего, то и массового триггера не случится. Но быстрее было сделать тот вышеуказанный способ, чем переписывать на три раздельные пары "тик-проверок".
Могут происходить, конечно, выпадения нескольких предметов сразу просто по случайности - совпали периоды. Но это уже лучше, чем два-три одинаковых каждый раз будут выпадать и визуально сливаться в один (хотя, это было бы замечательное раскрытие нашей темы "дайте всем багам случится", да?). По крайней мере в рамках джема достаточно того, что получилось, а потом можно добавить, допустим, регулирующий флаг, чтобы в каждую ревизию выпадало не более одной вещи - так сказать, "дроп-фильтр".
Тем временем, оставалось сделать ещё кучу всего (не считая формирования игрового билда, времени на его тесты и финальный залив на страницу). Во-первых, замоделить-затекстурить, собственно, сам махолёт, заменив собранный из примитивов самолётик. Отрендерить изображение с ним, хотя бы на стартовый экран. Затем расставить островки, добавить уже готовые головы, сделать возможность говорить с ними и проставить им срабатывание какой-то минимальной анимации при контакте. В целом я в конце каждого дня записывал себе такой to do лист пока всё помню, чтобы утром быстрее въехать в ситуацию и сразу понимать, за что браться.
Предметы, кстати, не успел замоделить и затекстурить, но я на что-то такое и рассчитывал, планируя в подобном случае обозначить их примитивами и выделить на предметы пару отдельных однотонных материалов. Так и произошло.
Хинт - в Godot среди формы мешей нету тора, но есть CSG объект в форме тора, поэтому для кольцеобразных объектов можно использовать его. Ну и в целом из самих CSG можно набросать какие-то произвольные тестовые формы, обойдясь без 3д-пакета, главное их не двигать относительно друг друга в реальном времени во время игры, чтобы не считать булевы операции по новой. Хотя и это можно, если сильно надо - одна из моих игр построена на этом эффекте. Если контактируют немного объектов, они относительно простые и общая площадь соприкосновения не такая большая, то потеря производительности от движущихся CSG не так существенна и можно это как-то использовать.
Успел, кстати, сделать кэш материалов. То есть отдельную мини-сцену, содержащую в себе все используемые материалы, чтобы показать её на камеру в начале игры, для своевременного компилирования шейдеров. Хотя, на стартовом экране в камеру и так попадают почти все, поэтому в кэше буквально пара материалов и частицы.
Незадолго до сдачи проекта внезапно понял, что в сцене тени от источника были всю дорогу отключены. Просто рисованные текстуры и под самозатенением смотрелись нормально, а в условиях перелётов от островка к островку падающую тень в принципе видишь не так часто.
Для некоторой оптимизации также повыключал отбрасывание теней у самого очевидного. Во-первых, у больших прямоугольных монолитов по краям локации, чтобы не создавали слишком много затенённого пространства. Во-вторых, у верхних фрагментов гексогональных колонн, так как тени от нижнего фрагмента в принципе достаточно.
Кажется, я до сих пор не произнёс слово "архитектура". Так вот, статья как раз по большей части про архитектуру приложения, подходы к организации тех или иных механизмов. Архитектура - это важно. Что можно продумать заранее, до написания решений - нужно продумать заранее. И смотреть на вещи комплексно, потому что решения для одной какой-то части отразятся и на прочем проекте - по возможности это тоже надо учитывать. То, что хорошо работало по отдельности, может уже не так хорошо работать вместе. Либо для того результата, который нам нужен, могут внезапно подойти более простые решения, использующие меньше взаимодействий/сущностей.
О чём ещё важно сказать? Фичекат, он же "выпиливание фишек". Важно приоритезировать течение разработки - выделять важные и второстепенные вещи, и, в зависимости от этого, прокладывать дальнейший курс, оставляя одно и вырезая другое. Лучше всего, чтобы "вырезанным" оказалось то, что ещё и не было имплементировано. Но может случаться, что приходится резать уже готовый элемент. Например, потому, что нет времени его нормально оттестировать. Поэтому стараемся предугадывать, полноценное прикручивание каких элементов может стать действительно проблемным и попутно искать возможные пути решения - видоизменить, перевести в разряд второстепенных, убрать вовсе и так далее. Универсальных критериев тут нет - бросившись вырезать вобще всё, оставите от проекта рожки да ножки.
Например, кучу попутно придумавшихся предметов я убрал подальше, выбрав для реализации только 3 квестовых, ну и для разнообразия добавил ещё 3 "мусорных" предмета, которые просто можно потаскать. Квестовые предметы тоже мало что дают, кроме того, что на них отреагируют получатели. Но я собирался добавить некоторым из них эффекты, если останется время, и это получилось сделать. Ещё я выбросил вроде бы одну из основных планируемых механик - возможность применить правый предмет к какой-то активной точке (как в Dizzy). Просто у меня уже была возможность дать предмет голове, через кнопку в интерфейсе общения с ней, и решил, что этого в принципе будет пока достаточно. Также под нож пошёл механизм выдачи предметов махолёту самими головами - они должны были уметь бросать их на какие-нибудь платформочки, неподалёку от себя. Зато лишний балл в раскрытие темы конкурса - почему, действительно, персонажи должны что-то давать за выполнение заданий? В конце концов у них может быть совсем иной взгляд на ваши взаимоотношения или банальный склероз, или ещё что.
Ещё одна вещь, которую хотелось бы посоветовать. Переключаемость, или берите пример с этих ваших корутин. Текущее дело стопорится или требует времени (рендерится картинка, отсылается файл, декодируется видео, банально нет настроения) - возьмите пока другую задачу (помоделить, потекстурить, прикинуть архитектуру, переосмыслить иерархию, сделать набросок, записать аудио, расписать приоритеты и так далее).
В принципе, на этом у меня вроде бы всё. А поиграть в то, что получилось можно скачав архив на страничке itch.io (для Windows, файл весит 24 или 17 Мб, в зависимости от расширения): https://thenonsense.itch.io/maholet