DefBall (codename). Четыре месяца разработки
Всем привет! Это третий выпуск девлога, приуроченный к завершению четвертого месяца разработки. После выхода предыдущей части прошло полтора месяца, и за это время я успел:
- погрузиться в глубокий кризис, сопровождавшийся полной остановкой разработки;
- выйти из кризиса (а может, просто обойти его), просто отказавшись от выполнения задачи, которая передо мной стояла, и занявшись добавлением новых фич;
- сделать первые шаги в области (селф-)маркетинга и понять, насколько это сложно.
На этот раз постараюсь быть краток (хотя кого я обманываю).
Традиционное психологичное вступление
Оглядываясь назад, я понимаю: первые трудности наступили в тот момент, когда я стал задумываться о сюжете и мире игры; это было давно, еще до выхода предыдущего выпуска девлога. Вторая волна проблем и последовавший полный ступор были связаны с разработкой системы прокачки: я не понимал, куда двигаться, какую систему выбрать, как приделать ее к игре; передо мной было слишком много возможностей и слишком много путей, и я не мог принять хоть какое-то решение.
Казалось, разработка зашла в тупик. Я сидел над своими думами думными день за днем и не получал никакого выхлопа. Я стал всем телом ощущать свою давнюю проблему забрасывания игр (и даже чего-то большего). Я начал читать книжки по КПТ и пытался что-то по ним делать, но, как всегда, безуспешно.
Все это продолжалось до тех пор, пока моя любимая девочка
«Ты так сильно переживаешь из-за того, что у тебя не получается сделать эту игру. Сначала ты работал над ней с энтузиазмом, а теперь твое занятие тебя только угнетает, да и отказаться от него насовсем ты не можешь. Раз так, может, стоит передохнуть и заняться тем, что тебе нравится — поиграть, например? Вот зарубишься по полной, наберешься сил и вернешься к разработке. Сделаешь все как проще, как изначально и хотел. Все у тебя получится».
Я так и сделал: поставил себе лучший Diablo-клон на свете и ни разу не пожалел. Last Epoch оказался великолепен, и я пропал в нем, наверное, на неделю.
Когда меня отпустило, я вернулся к проблеме ступора и, как следствие, самокопанию и чтению психокниг. Не знаю, сработала ли психология неким волшебным образом или мне просто повезло, но в какой-то момент мое отношение к проблеме действительно изменилось. Если раньше мне «не хотелось приступать к разработке игры», то теперь я переключился в режим «как же решить проблему со скиллами?» Новое состояние ощущалось по-другому; я больше не чувствовал тяжесть безысходности и даже чем-то походил на того оптимистичного чувака, который был на моем месте три месяца назад.
К решению проблемы со скиллами я подошел следующим образом. Мне нужно было:
- выбрать несколько популярных RPG;
- рассмотреть их системы прокачки и разобрать их на составляющие;
- объединить полученные составляющие в собственную систему прокачки; сделать так несколько раз;
- сравнить полученные системы прокачки и выбрать из них ту, которая больше нравится.
На процесс я убил три дня. Результатом стало мое осознание того, что прокачку вводить рано: для этого игре требуется больше фич. А раз фич не хватает, значит, пора приступать именно к ним!
На скриншоте видно, что моя реабилитация заняла ни много ни мало целый месяц. Как к этому относиться? Пожалуй, как к пути, который мне требовалось пройти, только и всего.
Логово и немного старческого ворчания
Первая фича была выбрана от балды, и ей оказалась
К выбору названия для класса норы (как, впрочем, и для любого другого класса) следовало подойти серьезно и основательно, ведь GDScript весь такой классный, блестящий и «очень динамичный», поэтому возможностей для рефакторинга в нем примерно ноль. Вообще, всю сексуальность GDScript'а можно продемонстрировать двумя скриншотами с официальной документации:
Валяйте, называйте меня брюзгой, но зачем мне утиная типизация, если для каждого доступа к динамическому свойству компу приходится проходиться по красно-черным деревьям и копаться в хэш-таблицах (наверное?), а быстрая и эффективная разработка во время рефакторинга сводится к правке всех символов вручную? Ну да ладно, что это я… Речь шла о названии для класса, и я далеко ходить не стал — вспомнил о существовании гуглопереводчика и для краткости переименовал нору в логово Den
.
Сначала было непонятно, каким образом будет сделано появление блобов, но потом мне на глаза попалось свойство CanvasItem.clip_children
, позволяющее спрайту действовать как маска. Я разделил логово на три части:
- внешняя часть логова рисовалась как обычно;
- «отверстие» являлось маской, его
clip_children
был установлен вCanvasItem.CLIP_CHILDREN_AND_DRAW
; - был еще спрайт появляющегося блоба, который добавлялся к «отверстию» как потомок. На нем рисовалась та же картинка, что и на настоящем блобе, хотя самого блоба еще не существовало. Когда настоящий блоб спаунился в сцену, спрайт пустышки прятался и переставлялся в исходную позицию.
Пока что логово создавало врагов одного и того же типа с одинаковой периодичностью. Чтобы исправить это, я выделил логику создания врагов в отдельный класс StandardSpawner
и ввел правила спауна. Каждое правило обладает весом weight
; чем выше вес, тем выше вероятность его применения. Например, правила с единичными весами будут срабатывать с одинаковой вероятностью, а правило с двойным весом — в два раза чаще. Кроме того, правило определяет, из какого прототипа и с какой периодичностью будут создаваться враги. Теперь одно и то же логово могло создавать разные типы врагов.
Но в таком виде логово вело себя не очень хорошо: враги двигались в одном и том же направлении и из-за этого сбивались в кучи. Нужно было научить их вылетать из логова не прямо в сторону игрока, а немного бочком, с небольшим разбросом по направлениям. Я добавил небольшой класс DeviationParams
, описывающий характеристики разброса, и теперь каждое правило спауна хранило экземпляр этого класса.
Потом переписал _integrate_forces()
врагов, чтобы они рассчитывали вектор ускорения исходя из нормальной и тангенциальной составляющих скорости. И вроде бы все работало как надо, только некоторые враги включали боковое ускорение рывками, а не постепенно. Я добавил дебаг-отрисовку, чтобы понять суть проблемы:
Розовой линией показан вектор целевого направления (в какую сторону блобу нужно лететь), а голубой — вектор ускорения. На гифке видно, что ускорение меняется рывками. Как выяснилось позже, проблема была в логике _integrate_forces()
: ускорение переставало применяться глобально, как только скорость врага достигала максимума. Я поправил код, и поворот врагов стал плавным:
Но теперь вектор ускорения прыгал туда-сюда, резко меняя направление. Причина тому — постоянный модуль этого вектора: враги всегда ускорялись по максимуму. Если в жизни бегун, достигая условно максимальной скорости, перестает ускоряться из-за сил трения, сопротивления среды и прочих факторов (хотя и применяет одни и те же усилия), то в моем случае этого не происходило.
Мне следовало постепенно уменьшать нормальную составляющую ускорения до нуля при приближении скорости врага к максимуму. После очередного багофикса все заработало лучше, хотя иногда фликер все равно происходил. Но это было не критично, потому что с точки зрения игрока поворот блобов смотрелся уже сносно, а враги более-менее равномерно распределялись по уровню.
Ограничение скорости и «тяжелые» снаряды
О том, чтобы ввести жесткое ограничение скорости для всех блобов, я задумывался уже давно. Похожая штука в игре уже была, но она работала только на врагах и вела себя совсем не так; называлась она максимальной скоростью:
Максимальная скорость врагов являлась инструментом гейм-дизайна и позволяла контролировать сложность уровня. Но ее было недостаточно: часто в игре возникали ситуации, когда легкие блобы оказывались под действием сразу нескольких сравнительно больших сил и из-за этого улетали за пределы экрана с большими скоростями.
Ни изменением кривой затухания взрывной волны, ни правкой других параметров проблема не решалась. Так что я добавил блобам свойство _speed_cap
, которое описывалось следующим образом:
Жесткое ограничение по _speed_cap
накладывалось поверх всех остальных физических эффектов (кроме сопротивления среды), поэтому было способно переопределять ускорение, которое враг сам к себе уже применил. Оставалось проверить, как работает это ограничение скорости, и я перешел к фиче, которая позволила бы это сделать — «тяжелым» снарядам.
В оригинальной Аиде у снаряда было свойство velocityAffectCoef
, которое определяло, насколько сильно тот влияет на скорость врага, с которым столкнулся. При единичном значении velocityAffectCoef
весь импульс снаряда передавался врагу, замедляя его; при нулевом значении враг просто летел дальше с той же скоростью.
В DefBall'е у блобов уже было два типа плотности: логическая и физическая. Логическая плотность, по умолчанию равная единице, определяет уровень «здоровья» блоба. Физическая плотность влияет на «тяжесть» блоба в физическом мире Godot'а и по умолчанию установлена в очень маленькое значение (потому что движок не позволяет устанавливать массу в ноль). Таким образом, у меня было все необходимое для того, чтобы создавать снаряды, отталкивающие врагов. Нужно было просто установить физическую плотность в большее значение.
Казалось (а может, и не казалось), что компенсирующее ускорение действует с задержкой, как будто бы в первые мгновения после удара блоб быстро отскакивает назад, но потом так же резко гасит скорость. То ли ускорение применялось спустя кадр, то ли я неверно рассчитывал его модуль — неважно: такое поведение мне понравилось, и я оставил все как есть.
Эффекты вязкости и заморозки
Так как в игре теперь было ограничение скорости, я мог добавить в игру эффекты на его основе. До этого я уже думал о заморозке, но у меня не было уверенности в том, как она должна себя вести. Если снижать скорость замороженного врага с помощью _speed_cap
, то отбросить его назад с помощью взрыва уже не получится: ограничение работает во всех направлениях. Нужно ли заморозке такое поведение?
Я посмотрел на ситуацию с другой стороны и спросил себя: вот есть эффект, который снижает скорость блоба; на что он похож? На липкую паутину, мешающую персонажу двигаться, или холодную ауру, замедляющую его. В моей игре это было похоже на увеличение вязкости среды вокруг блоба, которое тоже частично лишает его контроля. Эффект вязкости, подумал я. Сделаю его, а там посмотрим, где его можно применить.
Как я и предполагал, эффект вязкости не позволял расталкивать врагов и в каких-то ситуациях мог вредить. Но была у него и положительная черта: с обездвиженными врагами, оказавшимися поблизости, разбираться было куда проще, чем с отброшенными, ведь снаряд добирался до них почти в целости и сохранности и наносил больше урона. Вязкость можно использовать для дополнительного контроля, подумал я. Хорошо. Но что делать, если игроку потребуется обратное — разбросать всех врагов?
Этот вопрос я отложил на потом и занялся визуальным отображением вязкости (вернее, заморозки, потому что создавая вязкость, я думал о заморозке). Мне хотелось изобразить на поверхности блоба множество кристалликов, но я пока не был уверен, как буду это делать. Наверное, сначала надо попрактиковаться в отображении нормалей к поверхности блоба, подумал я. Что ж, это можно.
Нормаль каждой из воображаемых сосулек умножалась на ее текущий вес и скармливалась методу draw_line()
. Чтобы не хранить все вектора нормалей для каждого из блобов, я воспользовался процедурным подходом: запоминал только начальный seed
блоба, а все нормали при отрисовке генерировал по новой с помощью простого класса Randomizer
, портированного из старого проекта. С классом FastNoiseLite
я так и не подружился, поэтому пока решил сделать так.
Потом я перевел отрисовку на треугольники и draw_colored_polygon()
:
Из-за того, что все сосульки были одноцветными, на выпуклом блобе они смотрелись не совсем естественно. Я стал менять цвет каждой из сосулек при отрисовке с помощью уже имевшегося класса градиента HslaGradient
(он кэширует цвета, и их можно использовать глобально). Так как теперь сосульки отличались по цвету, нельзя было рендерить их в случайном порядке: если нарисовать дальнюю сосульку после передней, то она перекроет последнюю, и получится ерунда. Проблему можно было решить как минимум двумя способами:
- оставить все как есть и генерировать начальные точки сосулек на случайном расстоянии от центра блоба, а затем сортировать полученный массив точек и рисовать уже из него;
- генерировать начальные точки, двигаясь от дальних краев блоба к центру, рандомизируя только угол.
Я использовал второй подход. Он оказался не без недостатков, но на тот момент меня все устраивало. Я заменил треугольные сосульки шестиугольными кристаллами, и так стало смотреться еще лучше.
Устав от разработки визуальной части, я вернулся к механике эффекта вязкости и несдвигаемости врагов. Было понятно, что вязкость в текущем состоянии может оказаться полезной, и чтобы ее не ломать, нужно было добавить другой, альтернативный эффект, в котором неподвижность врагов была бы вылечена. (Или подпорчена. Смотря как посмотреть.) Естественно, имя для этого эффекта нашлось сразу.
Чтобы заморозка не позволяла врагам приближаться к игроку, но при этом позволяла игроку отбрасывать врагов, я поделил вектор скорости на две составляющие: нормальную и тангенциальную. Нормальная составляющая — это проекция вектора скорости на целевое направление (направление к игроку); другими словами, она позволяет врагу сближаться с игроком или отдаляться от него. Тангенциальная составляющая перпендикулярна нормальной составляющей; она отвечает за отклонение врага от курса — его стрейф. Нормальная составляющая является алгебраической величиной; при сближении врага с игроком она больше ноля, при отдалении — меньше ноля.
Если враг приближается к игроку, эффект заморозки будет ограничивать только нормальную составляющую скорости, то есть скорость сближения. Если враг отдаляется от игрока, скорость ограничиваться не будет. Можно убегать, но нельзя подходить — на этом и основано действие заморозки.
Один из гештальтов я закрыл, и оставалось докрутить визуальную часть. Сделать это на пять с плюсом у меня не получилось, да и не хотелось уже, если честно. Зато кристаллики льда получили двуцветный шейдинг, а дебаггер перестал сыпать ошибками о невозможности триангуляции полигона. Заморозка работала: враги тормозились от стрельбы и разлетались от взрывной волны.
Текстурный кэш
Оказалось (внезапно!), что уже при сравнительно небольшом количестве врагов отрисовка заморозки начинает притормаживать. Этого следовало ожидать, ведь на одного врага приходилось до 64 кристалликов льда, и каждый из них требовал своего. Пора было хватать жертвенный нож и обменивать процедурное разнообразие на миллисекунды процессорного времени. Я стал готовить почву для рантайм-кэширования анимации в текстурный атлас.
Описывать тут особо нечего. Главной проблемой, с которой я столкнулся, было нежелание Годота стабильно рендерить спрайт в текстуру из-под цикла внутри _process()
, даже с использованием RenderingServer.frame_post_draw
. Иногда кадры анимации пропускались, и в атласе зияла пустота. Изначально я планировал использовать Time.get_ticks_usec()
для регулирования нагрузки; сейчас же приходится рендерить анимацию на практически простаивающем процессоре, исходя из принципа «один вызов _process()
— один кадр». Если кто-нибудь знает, в чем дело, пожалуйста, отпишитесь в комментариях.
В результате у меня получилась большая текстура, хранящая все кадры анимации обледенения, и ее можно было накладывать на блобы одной картинкой:
Потом я какое-то время искал, где включается Premultiplied Alpha, и в итоге нашел. Для этого надо было создать для спрайта новый материал и установить blend_mode
в BLEND_MODE_PREMULT_ALPHA
.
Лазер
При малом радиусе действия снарядов у игрока отсутствует возможность «дотянуться» до дальних врагов и что-то с ними сделать. Я подумал, что было бы неплохо добавить дальнодействующее оружие, которое за ману позволяло бы наносить урон издалека.
Первая версия лазера была сделана просто. Его луч обладал определенной толщиной и пронизывал врагов насквозь, поражая все на своем пути. Для определения того, какие блобы подверглись его действию, я создавал вытянутый прямоугольный полигон, повторяющий форму луча, и выполнял шейпкастинг с помощью intersect_shape()
.
Но что если заставить луч пробивать только несколько врагов на своем пути, а потом останавливаться? Я ввел свойство _pierce_level
— задел для будущего развития игрока, как я надеялся. Если _pierce_level = 0
, луч натыкается на первого встреченного врага и дальше не идет. При _pierce_level = 1
луч проткнет первого врага, вынырнет с другой его стороны и пойдет искать следующую цель, а на ней остановится. И так далее.
С таким подходом шейпкастинг не работал, и нужно было использовать intersect_ray()
; это означало, что луч терял свою толщину и бил точечно. Рейкастинг осуществлялся следующим образом: сначала находилась точка пересечения с первым встреченным врагом, затем рассчитывалась точка выхода из него, и уже из этой точки выпускался новый луч для intersect_ray()
. Лазер как бы заныривал во врага, на время терял из виду все остальные блобы и выныривал с другой стороны.
Смотрелось, вроде бы, интересно, но, во-первых, с текущим способом расположения врагов друг над другом такой подход сочетался не очень хорошо, а во-вторых, часто луч начинал моргать, и это мне совсем не нравилось. Тогда я стал проводить рейкастинг по-другому: луч бил несколько раз от центра игрока, и когда попадал во врага, тот исключался из дальнейших поисков. Поэтому пересекающиеся враги больше не игнорировались лучом.
Лазер все равно моргал. Более того, я понял, что даже при больших значениях _pierce_level
он плохо справляется с толпой врагов и, натыкаясь на них, вызывает скорее фрустрацию, чем удовлетворение. Так что в итоге я вернулся к первоначальному варианту, вдобавок еще и более простому в реализации.
Пришло время подумать над визуализацией лазера. В планах было добавить небольшое дрожание толщины его луча, а потом наложить поверх несколько модифицированных синусоид, собранных из массивов точек. Но для начала надо было облегчить себе жизнь.
К тому моменту я дозрел до того, чтобы ввести объекты-автоматизаторы, которые выполняют простые анимационные задачи и избавляют код от мусора, а пользователя — от микроменеджмента. Пожалуй, ближайшей аналогией является класс Tweener
, но им я не пользуюсь по религиозным убеждениям. Так что копилку скриптов пополнили классы AutoSine
, AutoFader
и AutoWiggle
. Все они имеют метод update()
, обновляющий выходное значение по дельте времени, которую можно передать напрямую из _process()
или _physics_process()
.
Я сделал дрожание толщины луча с помощью AutoWiggle
, а сверху наложил кривую, полученную из синусоиды, измененной разными методами.
Затем кривую я выделил в отдельный класс XrayLaserWave
, чтобы было проще играться с несколькими ее экземплярами. После этого перешел с draw_polyline()
на draw_colored_polygon()
, чтобы можно было рисовать линию переменной толщины. В итоге я остановился на варианте с двумя прямоугольниками и тремя синусоидами.
Казалось бы, вот и все: козырные фичи у меня закончились, и выпуск девлога стоит подводить к концу. Но осталось еще кое-что, о чем я не написал, и это…
Маркетинг хренов!
До этого весь мой маркетинг сводился к написанию постов на Гамине, и я, признаться, до сих пор удивляюсь, как оказался на этой дорожке. Если бы не местная движуха, я бы до сих пор делал недоделанные игры в стол, а так у меня хотя бы появилась возможность получать обратную связь, пищу для размышлений и периодические заряды мотивации.
Позже меня стала одолевать мысль, что одного поста в месяц недостаточно. Да, я вполне серьезно вкладывался в его подготовку, иногда минут по двадцать подбирая слова для одного абзаца текста, нарезая кучу гифок и делая вот это вот все; я уже тратил на пост время, сопоставимое с затратами на разработку игры — наверное, процентов двадцать от общего количества человеко-часов. Были ли эти усилия оправданны? Были ли они достаточны?
На самом деле, подобный ход мыслей спровоцировала еще и рассылка от одного известного инди-разработчика и по совместительству инфоцыгана Томаса Браша. Я сотню раз слышал про жизненную необходимость маркетинга, но почему-то никогда не задумывался о нем настолько серьезно. Теперь же я решил, что пора слегка расширить горизонты.
В коем-то веке! Сначала я подготовил себе место на паре забугорных ресурсов, а потом создал группу ВКонтакте. Сделал минималистичное оформление, подумал над описанием. Позже я даже оплатил домен и хостинг для своего сайта (пожалуй, рановато — он до сих пор не функционирует). План был простой: работать над игрой как обычно, а ближе к пятнице начинать собирать визуальные материалы по прогрессу за неделю. С пятницы по понедельник эти материалы должны были раскидываться по разным ресурсам, привлекая внимание людей, будоража алгоритмы, добавляя плюс в мою карму и принося прочий неосязаемый профит.
Четыре недели в таком режиме пролетели незаметно. Мой недомаркетинг отнимал все больше времени. Я узнал, что такое скриншотный субботник, увидел, как разработчики в соцсетях, словно на рынке, рекламируют свои еще не вышедшие игры, и понял, что мне предстоит та же участь. Я с усердием готовил видеоматериалы и обращался к несуществующей аудитории только для того, чтобы увидеть цифру «2» под счетчиком просмотров. Я наблюдал за Белкой с пистолетом и CRUEL, мьютил каналы, забивавшие ленту спамом, и потихоньку осознавал, в каком информационном вакууме я привык жить.
Главное осознание пришло чуть позже: обертка настолько же важна, как и содержание; при этом обертка должна следовать из содержания, отображать его, а не пытаться пустить пыль в глаза. Люди ставят лайки под теми играми, которые выглядят круто; никому не интересны проходные проекты, а мой был именно таким.
С самого начала я работал над игрой, в которой не было ничего выдающегося — это был клон Боевой Кнопки, простой браузерной игры. Она была сырой, ей нечем было похвастаться ни с геймплейной, ни с визуальной точки зрения; не было ничего удивительного в том, что люди обходили ее стороной. Чтобы привлечь чье-то внимание, она должна была выглядеть круто.
Или не должна. Я уже хотел было пустить усилия на создание другой, «визуально насыщенной» части игры — той, которая была бы связана с сюжетом, окружением и некоторыми активностями персонажа. Но через неделю откопал на телефоне одну из первых заметок по игре и вспомнил, что изначально просто хотел
поставить маленькую цель, достигнуть ее и понять, что у меня получается.
Я решил не городить долгострой: это не игра мечты и даже не проба пера, а просто попытка встать на ноги. Идею о выходе в Steam я тоже пока отмел, потому что наконец-то вышел Godot 4.3, в котором веб-экспорт обещали подправить. Пожалуй, я потрачу на DefBall еще от силы полгода и выложу на CrazyGames без лишнего шума. Но до того момента надо еще дожить.
Вот теперь, пожалуй, все. Осталось со всеми попрощаться, сказать спасибо за то, что одолели такой длинный текст, и пожелать от души: доделывайте свои игры.
- 24 августа 2024, 18:20
- 013
11 комментариев