DefBall (codename). Четыре месяца разработки

Title (a).png

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

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

На этот раз постараюсь быть краток (хотя кого я обманываю).

Традиционное психологичное вступление

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

Казалось, разработка зашла в тупик. Я сидел над своими думами думными день за днем и не получал никакого выхлопа. Я стал всем телом ощущать свою давнюю проблему забрасывания игр (и даже чего-то большего). Я начал читать книжки по КПТ и пытался что-то по ним делать, но, как всегда, безуспешно.

Phycho (small).png

Все это продолжалось до тех пор, пока моя любимая девочка

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

Я так и сделал: поставил себе лучший Diablo-клон на свете и ни разу не пожалел. Last Epoch оказался великолепен, и я пропал в нем, наверное, на неделю.

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

К решению проблемы со скиллами я подошел следующим образом. Мне нужно было:

  • выбрать несколько популярных RPG;
  • рассмотреть их системы прокачки и разобрать их на составляющие;
  • объединить полученные составляющие в собственную систему прокачки; сделать так несколько раз;
  • сравнить полученные системы прокачки и выбрать из них ту, которая больше нравится.

На процесс я убил три дня. Результатом стало мое осознание того, что прокачку вводить рано: для этого игре требуется больше фич. А раз фич не хватает, значит, пора приступать именно к ним!

Break in development (small).png

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

Логово и немного старческого ворчания

Первая фича была выбрана от балды, и ей оказалась

Den Task.png

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

GDScript is very dynamic.png

GDScript is non-refactorable.png

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

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

  • внешняя часть логова рисовалась как обычно;
  • «отверстие» являлось маской, его clip_children был установлен в CanvasItem.CLIP_CHILDREN_AND_DRAW;
  • был еще спрайт появляющегося блоба, который добавлялся к «отверстию» как потомок. На нем рисовалась та же картинка, что и на настоящем блобе, хотя самого блоба еще не существовало. Когда настоящий блоб спаунился в сцену, спрайт пустышки прятался и переставлялся в исходную позицию.

ep-002-devlog-den-start-v0.gif

Пока что логово создавало врагов одного и того же типа с одинаковой периодичностью. Чтобы исправить это, я выделил логику создания врагов в отдельный класс StandardSpawner и ввел правила спауна. Каждое правило обладает весом weight; чем выше вес, тем выше вероятность его применения. Например, правила с единичными весами будут срабатывать с одинаковой вероятностью, а правило с двойным весом — в два раза чаще. Кроме того, правило определяет, из какого прототипа и с какой периодичностью будут создаваться враги. Теперь одно и то же логово могло создавать разные типы врагов.

ep-002-devlog-den-regularity-v0.gif

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

Deviation Params.png

Потом переписал _integrate_forces() врагов, чтобы они рассчитывали вектор ускорения исходя из нормальной и тангенциальной составляющих скорости. И вроде бы все работало как надо, только некоторые враги включали боковое ускорение рывками, а не постепенно. Я добавил дебаг-отрисовку, чтобы понять суть проблемы:

ep-002-gameplay-p2-500px-v0.gif

Розовой линией показан вектор целевого направления (в какую сторону блобу нужно лететь), а голубой — вектор ускорения. На гифке видно, что ускорение меняется рывками. Как выяснилось позже, проблема была в логике _integrate_forces(): ускорение переставало применяться глобально, как только скорость врага достигала максимума. Я поправил код, и поворот врагов стал плавным:

ep-002-devlog-accel-flicker-v0.gif

Но теперь вектор ускорения прыгал туда-сюда, резко меняя направление. Причина тому — постоянный модуль этого вектора: враги всегда ускорялись по максимуму. Если в жизни бегун, достигая условно максимальной скорости, перестает ускоряться из-за сил трения, сопротивления среды и прочих факторов (хотя и применяет одни и те же усилия), то в моем случае этого не происходило.

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

ep-002-devlog-week-results-v0.gif

Ограничение скорости и «тяжелые» снаряды

О том, чтобы ввести жесткое ограничение скорости для всех блобов, я задумывался уже давно. Похожая штука в игре уже была, но она работала только на врагах и вела себя совсем не так; называлась она максимальной скоростью:

Max Speed.png

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

ep-003-no-hard-speed-limit-v0.gif

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

Speed Cap.png

Жесткое ограничение по _speed_cap накладывалось поверх всех остальных физических эффектов (кроме сопротивления среды), поэтому было способно переопределять ускорение, которое враг сам к себе уже применил. Оставалось проверить, как работает это ограничение скорости, и я перешел к фиче, которая позволила бы это сделать — «тяжелым» снарядам.

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

В DefBall'е у блобов уже было два типа плотности: логическая и физическая. Логическая плотность, по умолчанию равная единице, определяет уровень «здоровья» блоба. Физическая плотность влияет на «тяжесть» блоба в физическом мире Godot'а и по умолчанию установлена в очень маленькое значение (потому что движок не позволяет устанавливать массу в ноль). Таким образом, у меня было все необходимое для того, чтобы создавать снаряды, отталкивающие врагов. Нужно было просто установить физическую плотность в большее значение.

ep-003-hard-speed-limit-v0.gif

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

Эффекты вязкости и заморозки

Так как в игре теперь было ограничение скорости, я мог добавить в игру эффекты на его основе. До этого я уже думал о заморозке, но у меня не было уверенности в том, как она должна себя вести. Если снижать скорость замороженного врага с помощью _speed_cap, то отбросить его назад с помощью взрыва уже не получится: ограничение работает во всех направлениях. Нужно ли заморозке такое поведение?

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

003-viscosity.gif

Как я и предполагал, эффект вязкости не позволял расталкивать врагов и в каких-то ситуациях мог вредить. Но была у него и положительная черта: с обездвиженными врагами, оказавшимися поблизости, разбираться было куда проще, чем с отброшенными, ведь снаряд добирался до них почти в целости и сохранности и наносил больше урона. Вязкость можно использовать для дополнительного контроля, подумал я. Хорошо. Но что делать, если игроку потребуется обратное — разбросать всех врагов?

Этот вопрос я отложил на потом и занялся визуальным отображением вязкости (вернее, заморозки, потому что создавая вязкость, я думал о заморозке). Мне хотелось изобразить на поверхности блоба множество кристалликов, но я пока не был уверен, как буду это делать. Наверное, сначала надо попрактиковаться в отображении нормалей к поверхности блоба, подумал я. Что ж, это можно.

ep-003-shards-normals-v0.gif

Нормаль каждой из воображаемых сосулек умножалась на ее текущий вес и скармливалась методу draw_line(). Чтобы не хранить все вектора нормалей для каждого из блобов, я воспользовался процедурным подходом: запоминал только начальный seed блоба, а все нормали при отрисовке генерировал по новой с помощью простого класса Randomizer, портированного из старого проекта. С классом FastNoiseLite я так и не подружился, поэтому пока решил сделать так.

Потом я перевел отрисовку на треугольники и draw_colored_polygon():

ep-003-shards-triangles-v0.gif

Из-за того, что все сосульки были одноцветными, на выпуклом блобе они смотрелись не совсем естественно. Я стал менять цвет каждой из сосулек при отрисовке с помощью уже имевшегося класса градиента HslaGradient (он кэширует цвета, и их можно использовать глобально). Так как теперь сосульки отличались по цвету, нельзя было рендерить их в случайном порядке: если нарисовать дальнюю сосульку после передней, то она перекроет последнюю, и получится ерунда. Проблему можно было решить как минимум двумя способами:

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

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

ep-003-shards-hex-crystals-v0.gif

Устав от разработки визуальной части, я вернулся к механике эффекта вязкости и несдвигаемости врагов. Было понятно, что вязкость в текущем состоянии может оказаться полезной, и чтобы ее не ломать, нужно было добавить другой, альтернативный эффект, в котором неподвижность врагов была бы вылечена. (Или подпорчена. Смотря как посмотреть.) Естественно, имя для этого эффекта нашлось сразу.

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

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

ep-003-freeze-effect-v0.gif

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

ep-003-shards-shaded-freeze-v0.gif

Текстурный кэш

Оказалось (внезапно!), что уже при сравнительно небольшом количестве врагов отрисовка заморозки начинает притормаживать. Этого следовало ожидать, ведь на одного врага приходилось до 64 кристалликов льда, и каждый из них требовал своего. Пора было хватать жертвенный нож и обменивать процедурное разнообразие на миллисекунды процессорного времени. Я стал готовить почву для рантайм-кэширования анимации в текстурный атлас.

Описывать тут особо нечего. Главной проблемой, с которой я столкнулся, было нежелание Годота стабильно рендерить спрайт в текстуру из-под цикла внутри _process(), даже с использованием RenderingServer.frame_post_draw. Иногда кадры анимации пропускались, и в атласе зияла пустота. Изначально я планировал использовать Time.get_ticks_usec() для регулирования нагрузки; сейчас же приходится рендерить анимацию на практически простаивающем процессоре, исходя из принципа «один вызов _process() — один кадр». Если кто-нибудь знает, в чем дело, пожалуйста, отпишитесь в комментариях.

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

004-shards-500x500-fast.gif

Потом я какое-то время искал, где включается Premultiplied Alpha, и в итоге нашел. Для этого надо было создать для спрайта новый материал и установить blend_mode в BLEND_MODE_PREMULT_ALPHA.

004 34-1 Shards Texture Before.png004 34-2 Shards Texture After.png

Лазер

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

Первая версия лазера была сделана просто. Его луч обладал определенной толщиной и пронизывал врагов насквозь, поражая все на своем пути. Для определения того, какие блобы подверглись его действию, я создавал вытянутый прямоугольный полигон, повторяющий форму луча, и выполнял шейпкастинг с помощью intersect_shape().

ep-004-laser-simple-v0.gif

Но что если заставить луч пробивать только несколько врагов на своем пути, а потом останавливаться? Я ввел свойство _pierce_level — задел для будущего развития игрока, как я надеялся. Если _pierce_level = 0, луч натыкается на первого встреченного врага и дальше не идет. При _pierce_level = 1 луч проткнет первого врага, вынырнет с другой его стороны и пойдет искать следующую цель, а на ней остановится. И так далее.

С таким подходом шейпкастинг не работал, и нужно было использовать intersect_ray(); это означало, что луч терял свою толщину и бил точечно. Рейкастинг осуществлялся следующим образом: сначала находилась точка пересечения с первым встреченным врагом, затем рассчитывалась точка выхода из него, и уже из этой точки выпускался новый луч для intersect_ray(). Лазер как бы заныривал во врага, на время терял из виду все остальные блобы и выныривал с другой стороны.

ep-004-laser-diving-v0.gif

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

ep-004-laser-piercing-v0.gif

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

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

К тому моменту я дозрел до того, чтобы ввести объекты-автоматизаторы, которые выполняют простые анимационные задачи и избавляют код от мусора, а пользователя — от микроменеджмента. Пожалуй, ближайшей аналогией является класс Tweener, но им я не пользуюсь по религиозным убеждениям. Так что копилку скриптов пополнили классы AutoSine, AutoFader и AutoWiggle. Все они имеют метод update(), обновляющий выходное значение по дельте времени, которую можно передать напрямую из _process() или _physics_process().

Я сделал дрожание толщины луча с помощью AutoWiggle, а сверху наложил кривую, полученную из синусоиды, измененной разными методами.

ep-004-xlaser-one-wave-v0.gif

Затем кривую я выделил в отдельный класс XrayLaserWave, чтобы было проще играться с несколькими ее экземплярами. После этого перешел с draw_polyline() на draw_colored_polygon(), чтобы можно было рисовать линию переменной толщины. В итоге я остановился на варианте с двумя прямоугольниками и тремя синусоидами.

ep-004-xlaser-three-waves-v0.gif

Казалось бы, вот и все: козырные фичи у меня закончились, и выпуск девлога стоит подводить к концу. Но осталось еще кое-что, о чем я не написал, и это…

Маркетинг хренов!

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

Позже меня стала одолевать мысль, что одного поста в месяц недостаточно. Да, я вполне серьезно вкладывался в его подготовку, иногда минут по двадцать подбирая слова для одного абзаца текста, нарезая кучу гифок и делая вот это вот все; я уже тратил на пост время, сопоставимое с затратами на разработку игры — наверное, процентов двадцать от общего количества человеко-часов. Были ли эти усилия оправданны? Были ли они достаточны?

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

Twitter Header (d) (1).png

В коем-то веке! Сначала я подготовил себе место на паре забугорных ресурсов, а потом создал группу ВКонтакте. Сделал минималистичное оформление, подумал над описанием. Позже я даже оплатил домен и хостинг для своего сайта (пожалуй, рановато — он до сих пор не функционирует). План был простой: работать над игрой как обычно, а ближе к пятнице начинать собирать визуальные материалы по прогрессу за неделю. С пятницы по понедельник эти материалы должны были раскидываться по разным ресурсам, привлекая внимание людей, будоража алгоритмы, добавляя плюс в мою карму и принося прочий неосязаемый профит.

Четыре недели в таком режиме пролетели незаметно. Мой недомаркетинг отнимал все больше времени. Я узнал, что такое скриншотный субботник, увидел, как разработчики в соцсетях, словно на рынке, рекламируют свои еще не вышедшие игры, и понял, что мне предстоит та же участь. Я с усердием готовил видеоматериалы и обращался к несуществующей аудитории только для того, чтобы увидеть цифру «2» под счетчиком просмотров. Я наблюдал за Белкой с пистолетом и CRUEL, мьютил каналы, забивавшие ленту спамом, и потихоньку осознавал, в каком информационном вакууме я привык жить.

Главное осознание пришло чуть позже: обертка настолько же важна, как и содержание; при этом обертка должна следовать из содержания, отображать его, а не пытаться пустить пыль в глаза. Люди ставят лайки под теми играми, которые выглядят круто; никому не интересны проходные проекты, а мой был именно таким.

С самого начала я работал над игрой, в которой не было ничего выдающегося — это был клон Боевой Кнопки, простой браузерной игры. Она была сырой, ей нечем было похвастаться ни с геймплейной, ни с визуальной точки зрения; не было ничего удивительного в том, что люди обходили ее стороной. Чтобы привлечь чье-то внимание, она должна была выглядеть круто.

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

поставить маленькую цель, достигнуть ее и понять, что у меня получается.

Я решил не городить долгострой: это не игра мечты и даже не проба пера, а просто попытка встать на ноги. Идею о выходе в Steam я тоже пока отмел, потому что наконец-то вышел Godot 4.3, в котором веб-экспорт обещали подправить. Пожалуй, я потрачу на DefBall еще от силы полгода и выложу на CrazyGames без лишнего шума. Но до того момента надо еще дожить.

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

P. S.: так как я понапридумывал себе дополнительной работы и теперь весь такой занятой (нет), следующий выпуск будет готов только через два месяца, не раньше 14 октября. Думаю, то же самое можно сказать и о последующих выпусках: они будут выходить не чаще, чем два раза в месяц. Не знаю, зачем я об этом предупреждаю. Наверное, это просто попытка договориться с самим собой.

  • dump
  • 24 августа 2024, 18:20