DefBall (codename). Месяц разработки
Всем привет!
Никогда не писал девлогов, но пост на Гамине к этому подтолкнул. Не знаю, насколько удачна эта затея, но попробовать давно стоило. Ну так вот.
Психолочное нематематичное вступление
Начну с того, что у меня есть вредная привычка. Время от времени я захожу на какой-нибудь CrazyGames и тешу свой мозг дофаминовыми играми с тупым геймплеем. Бывает, дело затягивается до четырёх утра, а на следующий день я «ни о чём не жалею». Чуть больше месяца назад шальной нейрон в моей голове решил повторить затею.
Игра называлась (и до сих пор называется) Battle Button Clicker. И так как я любитель несколько извращенных развлечений, спать в ту ночь я лёг с уставшим указательным пальцем правой руки. Но, что важнее, не только с ним, но и с парой мыслей:
- закликивание — зло;
- я ведь мог бы сделать так же, только лучше.
Разработчики бывают разные, и каждый случай уникальный. Я не исключение. 10-го мая 2024 года в моей голове роились
Есть чувство неудовлетворённости. Тяжесть в груди, как обычно бывает. Занятия, которые я себе придумываю, как будто не имеют шанса принести мне в конце концов желаемый профит.
Я снова начал копать в сторону Purple Dreams (вернее, Омнидельты). Неделю назад пришла в голову идея, и всё тут. Я вспоминаю, как начинал писать движок для реинкарнации Аиды на Си. Это долго. Это не то чтобы сложно. Но это долго. Это очень долго. Когда я начинаю подсчитывать, сколько времени у меня ушло просто на то, чтобы освоить азы кастомной аллокации и сколько времени прошло с начала работы над проектом, мне становится тяжело. Появляется мысль: я не успею. Непонятно, до чего не успею — до своей смерти или до скачка технологий, или ещё до чего-то.
С Дельтой — ну… Она подарила вдохновение на какое-то время. А теперь я опять не знаю, за что зацепиться, чтобы начать развитие проекта. Как будто я ищу вдохновения, радости, интриги; я как будто бы пытаюсь сам себя заинтересовать. Придумать сцены, которые заставят меня интересоваться.
А сейчас я испытываю тяжесть. Она мешает мне хотя бы просто начать… нет, она как будто бы лишает меня самой возможности чувствовать что-то другое, ведь она лежит сверху, а интерес лежит снизу. Как я почувствую интерес, если сверху — тяжесть?
Я знаю, что это было 10 мая, потому что сейчас передо мной лежит телефон, а в нём открытый SimpleMind с заметкой. Именно в тот день я возобновил привычку ежедневно выгружать свои мысли на физический носитель, по возможности устанавливать себе глобальные цели и постоянно себе о них напоминать.
Если кому-то покажется, что мысли были унылыми и депрессивными, то не переживайте. Уже на следующий день
Похоже, для меня решение в деньгах.
Сейчас это кажется таким простым. Как будто бы. Наверное, на деле не является. Но может быть, мне на самом деле надо просто взять готовый говно-движок и забабахать стрёмный вырвиглазный неотёсанный проект длиной в месяц (условно). Чтобы он просто начал приносить деньги. Ведь так проще. Не париться ни о чём. Просто что-нибудь сделать. Минуя всякие принципы, вопросы «а почему», «как лучше» и всё такое. Как было с финалкой.
Конечно, все мы, находящиеся здесь, понимаем, что решение для нас не только и не столько в деньгах. Если бы нам были нужны только деньги, мы бы точно не выбрали геймдев. Геймдев — это низкомаржинально. Геймдев — это высокорисково. Но мы раз за разом возвращаемся к играм, потому что у нас не остаётся другого выбора: нам нравится делать игры, и мы ничего не можем с этим поделать. Извращённые развлечения — действительно мой профиль, стоит признать это ещё раз.
Выбор движка
Тут всё оказалось совсем несложно. Я бы даже сказал, что не было никакого процесса выбора; вместо него был процесс попроще.
- Скачать и установить Unity.
- Создать пробный проект.
- Подождать 15 минут, пока пробный проект откроется.
- Удивиться количеству прошедшего времени, закрыть проект, и открыть его повторно.
- Подождать 10 минут, пока готовый проект откроется.
- Удивиться ещё раз, закрыть проект и открыть его повторно.
- Подождать 10 минут, пока готовый проект откроется, и сделать вывод, что 10 минут — это действительно 10 минут.
- Закрыть проект на Unity.
- Вспомнить про то, что когда-то пробовал Godot.
- Скачать и установить Godot.
- Создать пробный проект.
- Подождать несколько секунд, пока пробный проект откроется.
- Закрыть пробный проект.
- Открыть пробный проект.
- Обрадоваться.
- Снести Unity к херам.
Вот честно, ни разу не позавидовал Unity-разработчикам. Unity уже не тот. Конечно, по поводу Godot у меня тоже есть сомнения, но он в сравнении с Unity — как Blender четырёхлетней давности в сравнении с 3DS Max.
Может быть, вы ещё не слышали, но я пришел сюда для развязывания священных войн, так что скажувброшу прямо:
мне сразу же не понравился GDScript:
- обязательные точки с запятой, обозначающие конец строки и принятые во многих других нормальных языках, отменены; приходится переносить строки с помощью обратного слеша, и выглядит это настолько же уродски, как в макросах C++;
- фигурные скобки, обозначающие область видимости и границы
if...else
, заменены индентацией; - Visual Studio? Нет. Приходится пользоваться тем автокомплитом, который имеется в самом Godot (хотя я задумывался о том, чтобы попробовать VS Code плюс godot-tools);
- По той же причине забудьте об удобном рефакторинге — он даже в рамках одного скрипта не работает (есть
Ctrl-D
иCtrl-Shift-F
, но это совсем другое); - нет интерфейсов;
- класс из одного скрипта не всегда узнаёт о только что появившихся свойствах и методах класса в другом скрипте; спасает перезагрузка проекта (благо, длится она несколько секунд);
- присваивание внутри более сложного выражения не поддерживается, так что нельзя сделать
some_method(a = some_other_method())
(но этой фичи не было и в C#, так что можно и потерпеть); - автокомплит ломается при использовании
$
, хотя я таким всё равно не пользуюсь; - GDScript является интерпретируемым; я узнал об этом спустя месяц разработки;
- GDScript слизан с Python.
for i in 1e18: Python.burn_in_hell_with_exclamation_mark()
С другой стороны:
- к встроенному автокомплиту привыкаешь за неделю — ничего страшного, когда-то давно я и без него обходился;
- используя GDScript, никаких ограничений, связанных с .NET, не испытываешь и вообще о них не думаешь;
- по словам разрабов, из всех поддерживаемых языков GDScript имеет наиболее тесную интеграцию с движком, но я не проверял;
- так как большую часть времени я занимаюсь написанием кода, Godot стал для меня настоящим IDE с большой буквы… нет, со всех трёх заглавных букв. Ощущения от него примерно такие же, как от пользования Flash IDE в далеких нулевых: мне не приходится покидать редактор, чтобы начать писать код.
По большей части, ощущения от движка ровные и приятные. Можно сказать даже, что я испытываю удовольствие от его использования. Как минимум, мне не надо ждать десяти минут, чтобы открыть пустой проект.
Игры-референсы
В качестве референса был выбран DefBall образца 2016 года, который выглядел так:
Позже я развил идею в другую игру с кодовым названием Aida:
Моей задачей было сделать игру, похожую на Battle Button Clicker и использующую подходы из ДефБолла и Аиды. Про общую концепцию я напишу в одном из следующих постов, если они, конечно, будут. А пока что в этом тексте и так слишком много букв.
Начало разработки
Первым делом я вспомнил, что у меня 4K-монитор, а это, как оказалось, не всегда приятно. Я задумался, а как вообще делаются 2D-игры под разные разрешения, и вышел на статью, лежащую в доках Godot, как раз про это. Решил пока что рендерить в одно разрешение и растягивать отрендеренное по размеру окна.
Что дальше? Надо сделать главного героя — зонд игрока. Я засел в After Effects и сделал тестовый ассет. Импортировал его в движок, научил поворачиваться в сторону мышки. Но было видно, что вращение всего спрайта приводит к нехорошим искажениям, так что в дальнейшем надо было сделать по-другому.
Оказалось, Godot умеет рисовать круги программно и неплохо с этим справляется. Правда, без антиналожения. Метод рендеринга поддерживает нецелые радиусы, в результате круг уменьшается не рывками, а плавно:
Чтобы избежать использования ассетов на этапе разработки, я решил пока что рисовать блобы с помощью четырех кругов, как это было в Аиде. Тут же реализовал shrinking — растворение блоба в агрессивной окружающей среде и, как следствие, уменьшение его радиуса.
Затем я приделал к блобу физическое представление. Я унаследовал блоб от RidigBody2D
(чтобы в дальнейшем об этом пожалеть). А потом столкнулся с проблемой: при изменении радиуса соседние блобы перестают друг с другом взаимодействовать. Это происходило из-за того, что Shape2D
является ресурсом, а ресурсы не уникальны. Для того, чтобы можно было устанавливать радиус каждому блобу индивидуально, нужно было создавать дубликат формы, назначенной в сцене.
Дальше я попробовал создавать экземпляры CircleShape2D
в рантайме и навешивать их на блобы. Всё получилось. Но до полного создания блоба из-под кода, минуя сцены, я так и не добрался.
На тот момент меня ещё беспокоила зависимость геймплея от разрешения. Физика в Godot пиксельная, радиусы блобов тоже измеряются в пикселях, и при изменении разрешения (например, при игре на другом мониторе) какие-то вещи перестанут работать так, как надо. Я решил ввести аналог метрической системы. Отныне радиусы блобов задавались в условных внутриигровых юнитах, а при ините переводились в пиксели. То есть при создании блоба я устанавливал радиус radius
в нужное значение, а блоб уже затем рассчитывал значение _radius_px
. Ох и натерпелся я потом с этой системой. И ещё натерплюсь. Но, пожалуй, оно того всё же стоит.
Снаряды, враги и взрывы
После этого я реализовал снаряды, пока без пушек. Долго думал, как же быть с децентрализованной обработкой столкновений. Не придумал ничего лучше, чем просто смириться. В итоге не пожалел (но это только пока). Реализовал функционал появления блобов: их непрозрачность увеличивается от нуля до единицы после добавления на сцену.
И наконец-то добавил врагов. При столкновении с зондом враги должны были отнимать часть его здоровья (вернее, массы) и самоуничтожаться. При этом изначально я хотел сделать зонд неподвижным, как в оригинале; но после коллизии враг передавал зонду свой импульс, и тот начинал двигаться. В оригинальной Аиде у меня была самописная физика, и неподвижные объекты делались просто: я устанавливал массу блоба в +Infinity
, и он получался статическим; то ли я специально обрабатывал такие ситуации в движке, то ли просто все расчёты ускорения автоматически возвращали ноль из-за бесконечной массы — я уже не помню, да и лень смотреть. Так как в Godot у меня не было своей кастомной физики, я не мог просто установить массу зонда в бесконечность: физика руинилась, блобы пропадали с экрана. В итоге придумал применять к зонду силу, похожую на силу упругости: чем дальше от изначального положения, тем она выше. Выглядело интересно, и я эту штуку оставил.
Ввел понятия «логическая масса» и «физическая масса». Если вкратце, то они нужны были для того, чтобы отделить здоровье блоба от его поведения в физической подсистеме. Если подробнее, то вот
Возможно, придется вводить две массы: логическую и физическую. (Нужно заменить термин «логическая».) Логическая масса — это аналог здоровья блоба; она является произведением площади блоба на логическую плотность его материала. Физическая масса — это масса физического тела (внутри Godot’а); это произведение площади блоба и его физической плотности. Физическую массу я решил отделить от логической для того, чтобы было проще управлять поведением блобов при столкновении. Например, я хочу, чтобы у снаряда было много здоровья (логической массы), но при этом чтобы он не отталкивал врагов при столкновении; в этом случае логическую плотность я устанавливаю в обычное (или большое) значение, а физическую — в ноль (или в малое значение).
К слову, термин «логическая» я так и не заменил.
И всё-таки физика, сделанная за тебя — одновременно благословение и проклятие. Ты получаешь множество вещей из коробки, но лишаешься контроля. Если бы я захотел добавить в игру статические блобы, мне пришлось бы наследовать их не от RigidBody2D
, а от AnimatableBody2D
. Вроде бы, один класс блобов, просто одни неподвижны, а другие двигаются. Но в GDScript нет множественного наследования (может быть, оно к лучшему). Единственное решение, которое я придумал, — вводить классы поведений Behavior
и цеплять их к объектам, как компоненты в Unity. Но до этого я не дошёл, потому что пришлось бы полностью переписывать код.
Была ещё одна неприятная штука: снаряды после столкновения отлетали назад или начинали двигаться с меньшей скоростью; в оригинале такого не было.
Сделал отрисовку взрывной волны с помощью GradientTexture2D
. Меня беспокоило то, что при больших радиусах взрыва придется создавать слишком большую текстуру, и рендеринг будет тормозить. Поэтому я ограничил размер текстуры максимальным значением, а для больших взрывов просто растягивал спрайт, на который эта текстура была натянута. Но теперь на ней были видны пиксели; позже я решил эту проблему, просто установив фильтрацию в нужное значение: оказалось, и на низких разрешениях градиенты смотрятся отлично. Это ж градиенты.
После того, как с отрисовкой взрыва было покончено (но не насовсем), я добавил раздельные слои Node2D
для разных типов объектов: враги добавлялись на один слой, взрыв — на другой и так далее.
Реализовал расталкивающее поведение взрыва. Мне не нравилось, как взрыв себя ведёт, но на пока этого было достаточно.
К этому моменту я посмотрел на результаты своих трудов и подумал: пожалуй, для первой версии фич хватает. Стало быть, пора официально завершить работу над версией v0.0.0 и переходить к v0.0.1.
(На дату тега в TortoiseGit можно не смотреть, потому что я не пушил теги на гитхаб, а так как проект гулял с домашнего компа на рабочий и обратно, в какой-то момент локальный проект остался без тегов. Тег для версии v0.0.0 я создавал вручную гораздо позже.)
В описании к версии я указал:
Probe, Enemies, Bullets and Explosions are implemented.
Такая маленькая строчка, а сколько работы под ней спрятано!
Молния, пушки и приятные ништяки
Пришло время приступать к другим вещам. Я реализовал молнию, пока без визуальной части
а потом с ней. Отрисовку производил программно с помощью draw_line()
.
Наконец-то я решил проблему со снарядами, которая не давала мне покоя. Чтобы они не отскакивали и не теряли скорость при столкновениях, в _process()
я кэшировал текущую скорость в _prev_linear_velocity
, а в методе _on_body_entered()
восстанавливал ее из закэшированного _prev_linear_velocity
.
Да, это лечится настолько просто.
Сделал гансеты и пушки к ним. Наконец-то всё стало выглядеть, как в оригинале.
Гансет — это набор пушек. Пока что расстановка пушек контролируется относительным расстоянием до центра блоба-владельца и дельтой угла. Но такой подход не всегда красиво выглядит, и в будущем нужно будет ввести дополнительное условие для расстановки пушек.
К тому времени меня стала беспокоить ещё одна вещь: fluent interface. Несмотря на «неоптимизированность» самого подхода, он мне нравится: с его помощью можно удобно инициализировать сложные иерархии объектов, в большинстве случаев не прибегая к их именованию. Вот, например, как были сделаны мозги босса в Аиде:
В GDScript разделителем строк служит не точка с запятой, а, собственно, переход на новую строку; нельзя просто взять и разделить строку на несколько частей, нажав на Enter
, дописав нужную часть строки и поставив ;
(как это делается в C / C++ / C# / JavaScript и много где ещё). Перенести строку можно, используя обратный слеш, но это выглядит так себе. Решение, как всегда, оказалось простым: мы делаем простой метод fluent()
:
подготавливаем методы-инициализаторы, как обычно:
а потом оборачиваем всю инициализацию в вызов fluent()
, не забыв прикастовать результат к нужному типу:
Всё, что находится между открывающей и закрывающей скобкой при вызове любого метода, не будет делиться на части при переносе строк; GDScript понимает, что выражение ещё не завершено, поэтому нам не надо ставить обратные слеши в конце каждой незавершенной строки.
Вроде бы, мелочь, а приятно.
Мана и особые снаряды
Затем я реализовал методы _draw_circle()
и _draw_truncated_circle()
для отрисовки усеченного круга и круга с заданным количеством точек.
Это позволило мне добавить в игру ману. Казалось бы, отрисовка маны — простая задача, но, как оказалось, всё сложнее, и даже в оригинале я рисовал ее четырьмя частями.
Взрывные снаряды — то, чего не было в оригинале. Из-за того, что новые блобы появляются близко к центру взрыва, импульс, действующий на них, получается слишком большим, и их уносит далеко-далеко.
Искрящиеся снаряды — снаряды с молнией. Из-за отсутствия интерфейсов пришлось приделать способность обладания молнией вообще всем блобам на свете.
Тут я посмотрел, что у меня получилось, и снова сказал: молодец, могёшь!
- Lightning, GunSets with Guns, Probe’s mana, explosive Bullets and sparkling Bullets were implemented.
- Bullets don’t bounce off the Enemies after collision.
- Explosion now deals direct damage.
- Attenuation object was implemented; now it’s used inside Explosion.
- Enemies don’t stop accelerating towards the player’s Probe after being hit by Explosion.
Еще больше мелких ништяков
С новыми силами я сел за рисование задника. Вернее, не рисование, потому что рисовать я, видимо, не очень люблю. Я использовал After Effects и процедурный шум. На первое время пойдет.
На тот момент не было способа извне кастомизировать взрывы и молнии, приделанные к снарядам. Я ввел объекты-определения, наследующиеся от базового класса Def
; это легковесные объекты, подобные определениям в старом добром Box2D, которые содержат только данные, необходимые для инициализации объекта, и больше ничего.
Потом враги научились наносить урон зонду игрока. Вместе с тем появилась необходимость отображать минимальный и максимальный радиус зонда. Вообще мне хотелось придерживаться подхода, при котором GUI минимален или совсем отсутствует, а всю инфу о состоянии персонажа можно узнать, просто взглянув на него.
Затем я стал думать о том, как сделать врагов-пустышек, не наносящих урона зонду игрока. Такие враги могли бы пригодится на обучающих уровнях игры. Рассуждения привели меня к мысли о том, что придется вводить коэффициент прямого урона, как это было в оригинале. И хотя обсуждение камикадзе-взаимодействий должно было остаться за кадром, я всё же приведу цитату из лога:
Ввожу коэффициент прямого урона. Он необходим, так как если я захочу сделать пустышки, не наносящие урона, придется устанавливать логическую плотность в ноль. Это приведет к неопределенности радиуса. (…) Здесь логическую плотность можно рассматривать как защиту блоба, а коэффициент прямого урона — как атаку. (…) На самом деле, не всё так просто. Я не зря вводил понятие камиказде-взаимодействия: в случае попарного расчета урона могут возникать ситуации, когда оба блоба остаются живы. Камикадзе-взаимодействие решает эту проблему. Сначала блоб-камикадзе стремится истратить всю свою массу на прямой урон по противнику; если противник не выживает, камикадзе летит дальше. В этом случае расчет урона ведется в определенном порядке, и порядок его применения имеет большое значение.
Получается, я считал камикадзе-взаимодействия пережитком прошлого и хотел отказаться от него, а получилось так, что ранее ввел их неспроста. Даже собственные проекты со временем сильно забываются. Тем более проекты восьмилетней давности.
Последная фича, которую я реализовал, — эффекты. К ним относятся эффекты горения, оглушения, заморозки и прочие штуки, которые можно «прицепить» на объект и обновлять их вместе с ним. Я реализовал их следующим образом:
- каждый блоб обладает собственным экземпляром эффектора;
- эффектор, в свою очередь, может содержать ссылки на вложенные интеграторы;
- интеграторы могут быть разных типов: интегратор горения, интегратор оглушения и так далее;
- интегратор содержит эффекты своего типа: например, интегратор горения содержит эффекты горения;
- интегратор сам решает, каким образом ему обращаться со своими эффектами. Он может использовать самый мощный или самый продолжительный эффект, или объединять несколько маленьких эффектов в один большой;
- при добавлении эффекта к блобу мы просим эффектор: эй, эффектор, добавь эффект горения к блобу. Эффектор создает интегратор горения, если он ещё не создан, и добавляет к нему эффект.
- при обновлении блоб обновляет и эффектор, но блоб сам ответственен за применение эффектов к себе. В данный момент реализация именно такова.
Итоги
Конечно, было много другой работы, о которой я здесь не написал. Если навскидку, то:
- я портировал из старых проектов методы перевода цвета из HSL в RGB, потому что имеющиеся в Godot мне не подошли (или скорее, я торопился);
- дописал дополнительный класс для затухания взрывной волны; вместо степенной функции он использовал кривую Безье;
- более-менее поднастроил затухание взрывной волны (мда, на настройку механик иногда уходит слишком много времени);
- написал кастомный HSLA-градиент — нативный градиент Godot почему-то не справлялся с альфа-каналом;
- написал простую систему частиц для эффекта горения.
В итоге получилась играбельная технодемка, которой я зачем-то ни с кем не поделюсь, потому что устал и не буду билдить бинарники за просто так:
На этом всё. Я чуть-чуть замучился и хочу спать. Пишите, пожалуйста, в комментариях, любые мысли по поводу игры и девлога, куда мне следовало бы двигаться дальше, к какому издателю обращаться (хотя подождите. какой издатель. мы же инди), и вообще все мысли по поводу. Насколько перспективна задумка. Может, кому-то хочется поиграть в демку. Если будет надо, конечно же, соберу.
Ну, в общем, всё. Хорошо, что этот текст заканчивается. Подумали мы все.
Наверное, это моветон писать постскриптумы, так что.
P.
Вот теперь точно всё. Всем добра и удачи!
Обновление поста от 2024−05−23
Сделал билд под Винду. Скачать можно здесь.
Управление: стрельба — ЛКМ, взрыв — пробел.
- Зонд
- Максимальный радиус: 32
- Радиус смерти: 8.
- Логическая плотность: 1.
- Физическая плотность: 0.1.
- Гансет
- Характеристики всех снарядов.
- Скорость растворения всех снарядов, юнитов в секунду: 2.
- Логическая плотность: 1.
- Сопротивление среды (Godot-native, работает плохо): 0.6
- Главная пушка.
- Начальный радиус снарядов: 6.
- Начальная скорость снарядов: 200.
- Разрывные снаряды.
- Вероятность выпуска разрывных снарядов: 0.25.
- Дельта радиуса: 45.
- Импульс воздействия у эпицентра: 40.
- Прямой урон у эпицентра: 5.
- Время жизни взрыва: 0.4 секунды.
- Искрящиеся снаряды.
- Вероятность выпуска искрящихся снарядов: 0.25.
- Количество искр: 3.
- Собственный радиус молнии: 20.
- Урон на искру: 10.
- Две дополнительные пушки.
- Начальный радиус снарядов: 4.
- Начальная скорость снарядов: 200.
- Задняя пушка.
- Начальный радиус снарядов: 5.
- Начальная скорость снарядов: 200.
- Воспламеняющие снаряды.
- Все снаряды имеют 15-процентный шанс стать воспламеняющими.
- Урон в секунду: 50 единиц массы.
- Длительность горения: 2 секунды.
- Характеристики всех снарядов.
- Взрывной модуль.
- Дельта радиуса: 100.
- Кулдаун: 0.5 секунды.
- Стоимость: 30 единиц маны.
- Искровой модуль.
- Радиус: 40.
- Количество искр: 5.
- Урон на искру в секунду: 50.
- 21 мая 2024, 19:12
- 019
19 комментариев