Tantramantra и магия проектирования
Во время разработки различных механик и прочего интерактива для компьютерных игр, складываются различные схемы-рецепты для реализации требуемого функционала. Большая их часть не привязана к конкретному используемому движку/языку. О некоторых из них я расскажу на примере одного из своих проектов с биомашинками.
Тантрамантра
Для начала о самом проекте. Это тестовый полигон из нескольких карт-миров, где можно покататься на машинке-биотрансформере, которая умеет прыгать, кувыркаться, стрейфиться и создавать некоторые объекты. Также подконтрольное транспортное средство умеет переключаться в различные формы, в том числе и те, которые заточены под полёт (не имеют колёс).
На картах присутствуют телепорты (синие звездообразные объекты), переносящие машинку в другие миры. Всего таких миров четыре — стартовый, с водой и скалами, с ровной поверхностью и ещё один водный.
В стартовом мире игрока довольно быстро начинают преследовать два терминатора, одолеть которых можно при помощи специального оружия (после чего кататься уже в более спокойной обстановке). Если же они окажутся ловчее, то происходит game over и игра начинается заново.
Проектирование игровых механизмов
Обычно пользователь, включая любую игру, даже особо не думает, как там всё устроено внутри (даже если играет разработчик, надо ещё настроиться на определённый лад, чтобы воспринимать происходящее более аналитически). Интерактив просто происходит, всё случается само собой. Одним, словом — какая-то магия, хотя вроде и относительно привычная. Сюжеты, герои, нарратив — это уже всё наносное (хотя и немаловажное с другой точки зрения), так как игрок в состоянии гонять даже абстрактные кубики по экрану, просто потому, что «клац, клац, поехали» его уже зацепило и понесло. Игра при этом не обязана быть спиномозговой — перекладывание карт, щёлкание разными включателями/переключателями и прочая размеренная деятельность точно также цепляют пользователя своей обратной реакцией, потому как она просто есть.
Вопросы вида «почему же Марио разбивает головой кирпичи» — это совершенно не то, что волнует играющего в Supermario. Он планирует в какую трубу прыгнуть, как разбить блок и успеть схватить гриб, чем бы убить назойливое облачко. По сути это решение различных задач, представленных в виде символов и внутриигровых взаимосвязей. Символы, конечно, запускают ассоциации, поэтому попутно можно обсудить с окружающими кирпичи, водопроводчиков, трубы с мухоловками и прочий происходящий психодел (которого на самом то деле и не происходит, просто наверное это более отвлечённая и интересная тема, чем обсуждать какие-то скучные бытовые вещи).
Собственно, человек в состоянии придумать себе некое абстрактное развлечение по любому поводу буквально на ровном месте, выражается ли это в счёте ворон, фотографировании, разглядывании проезжающих автомобилей, в каких-то мини-челленджах, вроде успеть куда-то в срок или предпочесть обойтись условным напильником, вместо похода в сервис-центр. То есть нет никакой истории, просто деятельность, пусть даже и мыслительная, а весь интерактив заключается хотя бы даже в фильтрации входящей информации (на что обратить внимание, что игнорировать и так далее).
Так вот, хотя внутриигровое действо воспринимается пользователем на уровне довольно очевидных случающихся событий (нажал то, взял это) — за ширмой происходят совсем другие истории, от которых требуется только произвести нужное впечатление. А быть и казаться — это разные вещи.
В задачи игрового проектирования как раз и входит подбор определённой модели, которая позволит создать нужный интерактив. Пути решения этих задач могут быть довольно разными, но довольно часто можно (и нужно) искать ту модель, которая просто сформирует нужную иллюзию, без лишних переусложнений. То есть — вам не нужно подключать плагин вывода текста, если требуется показать всего пару надписей, и достаточно вывести картинку с ними. Не требуется подключать физическую библиотеку, если вся физика в вашей игре — это упрощённая гравитация через смещение объекта вниз в каждом кадре. И так далее, и тому подобное.
Иногда это работает и в другую сторону — когда уже есть какие-то встроенные инструменты, та же физика, то почему бы какие-то вещи не делать через неё. Тот же таймер может заменить падающий с определённой высоты кубик-триггер. Игрок всё равно увидит только то, что ему решили показать и воспримет те связи между объектами, которые для него сформировал разработчик.
Придумывая устройство различных действующих в игре законов и связей, нужно разбивать их на отдельные элементы, для начала хотя бы выделяя первоочередной и второстепенный функционал. Слона всё-таки стоит съедать по частям, даже если это не слон вовсе, а допустим…
Грибы
Какое-то время «грибы» в прототипе были декоративным элементом, но сразу подразумевалось, что впоследствии их можно будет хотя бы давить. Таким образом им был нужен следующий функционал: отслеживание столкновений с игроком, чтобы повесить на это какую-то реакцию. В качестве второстепенных деталей: более наглядный эффект уничтожения, а также замещение фактического уничтожения на переход в неактивную фазу и восстановление гриба после некоторого времени.
В итоге столкновения c грибами определяются через использование предложенного движком элемента WorldTrigger, который реагирует на пересечения с геометрией.
Все грибы упакованы в местные «префабы». Параметр IntersectionMask, задающий битовые маски для пересечений, в коде гриба не используется, так как был нужен для рейкастов.
В основной части скрипта происходит следующее: в инициализации на триггер вешается функция Enter. В основном цикле Update накручивается счётчик, если гриб в состоянии «притвориться мёртвым». По истечении данного счётчика гриб меняет состояние на «жив» и снова ждёт когда его переедут. В функции Enter, собственно, гриб временно исчезает и меняет состояние на «мёртв».
Чтобы всё выглядело ещё лучше — можно сначала показать недавно умерший гриб в уменьшенном состоянии, не активируя сам триггер, после чего понемногу модельку гриба увеличивать, и только потом уже включать триггер.
Если бы заготовленных инструментов в виде триггеров и лучей в движке не было, то можно было бы написать свой вариант просчёта столкновений. Например, банально считать разницу координат между грибом и машинкой. А раз особая точность не требуется, то этот процесс тоже можно различным образом оптимизировать, например, проверять расстояние только по двум осям, а на предварительном этапе смотреть всего одну, подключая проверку по второй тогда, когда это необходимо. То есть каждый «гриб» в цикле мог бы проверять — находится ли позиция машинки по Х внутри его диапазона, и если да, то проверять диапазон по Y и принимать какое-то решение дальше.
Терминаторы
Для игры требовались какие-то элементарные враги-преследователи. Я решил не делать их колёсными, создав какие-то простые сферы, перемещающиеся упрощённо.
Таким образом нужно было спланировать следующее: реализация прямолинейного движения, движение в сторону игрока. Второстепенными задачами тут были: возможность стрельбы и какая-то дополнительная полировка движения.
Можно было сделать врагов физическими и двигать с помощью сил, но, чтобы не усложнять я написал им нефизическое движение, регулируемое рейкастами.
А вот как эти враги устроены — пара сфер и пустышка-прицел, в которую спавнятся выстрелы
Весь код приводить не буду, ограничимся разбором параметров. Пара счётчиков для отслеживания пауз между выстрелами и рейкастами. Хитпоинты. Флаги состояний, для определения, когда нужно двигаться прямо (потому что рейкаст не касается земли) и когда нужно на некоторое время переключиться в состояние взлёта над землёй. В инициализации рассчитывается случайная прибавка к скорости, чтоб разные враги двигались с немного отличающимися скоростями и реже наползали друг на друга.
Оружие
Машинки уже умели стрелять всевозможными штуковинами, но то были различные спецэффекты — физические кубики, светящиеся сферы и так далее. Для нанесения повреждений хотелось иметь отдельное подбираемое оружие с патронами, как в шутерах. В первую очередь затем, чтобы игрок более чётко понимал, когда можно стрелять чем-то производящим реальный эффект. Ещё можно было пойти по другому варианту — повесить стрельбу на конкретные машины, чтобы игрок мог в любой момент переключаться на деструктивные формы.
Оружие нужно было: а) поднять, б) стрелять. Это если смотреть с позиции игрока. Ну, а с точки зрения разработчика: а) как-то устанавливать коллизию оружия с машинкой, б) решить, как именно отображать то, что оружие получено в) определить правила по которым оружие используется. Как видно, сами выстрелы таким образом получились даже не в приоритете. Во многом потому, что сам по себе спавн машинкой направленно движущихся объектов уже был заготовлен и оставалось его только прицепить к реализации оружия.
Точка подбора оружия тоже использует WorldTrigger для определения столкновения
А это её код. Определяется машинка и кэшируется ссылка на её скрипт. Также берётся ссылка на триггер и на этапе инициализации ему устанавливается обработчик события на вход объекта внутрь зоны триггера. Если входящим объектом является машинка, то вызывается функция newWeapon внутри её скрипта.
Объект, содержащий в себе модельку летающей пушки, помещается на уровень и выключается. При коллизии с точкой подбора оружия та включает этот объект и он прилетает к игроку под воздействием своего управляющего скрипта, после чего следует за игроком дальше. Машинка при этом переходит в боевой режим (меняется один из важных флагов-переключателей в коде) и в этом изменённом состоянии всё, что ранее происходило по левому щелчку мышки заменяется на выстрелы из оружия. Когда патроны заканчиваются, то машинка уже сама выключает объект с моделькой оружия и возвращается в нормальный режим.
NodePet — объект, изображающий пушку
Код NodePet. В качестве точки привязки, за которой он будет следовать, указана пустышка-прицел, висящая на машинке. А Rotator — это другая пустышка, уже в центре самой пушки, которая копирует на себя поворот того прицела, чтобы пушка смотрела в нужную сторону (в качестве бонуса это даёт эффект вращения пушки вокруг своей оси, когда машинка двигается).
Здесь как раз реализован принцип отслеживания координат — пушка начинает смещаться, если машинка удалилась от неё на определённое малое расстояние. Поначалу отслеживалось отклонение только по осям X и Y, а потом я дописал и Z для большей точности.
Кстати, тут ничего нет про выстрелы. Всё потому, что их создаёт скрипт самой машинки, а вовсе не объект с пушкой.
Выстрелы
Собственно, сами снаряды. Для начала я дал их терминаторам в упрощённом виде, без нанесения повреждений, а когда они уже работали как нужно (включая уже и списание здоровья с машинки, отображаемое в интерфейсе) и механика подбираемого оружия тоже была готова, то дошло дело и до выстрелов игрока.
Работают эти выстрелы немного по-разному.
Фрагмент скрипта вражеских выстрелов. Они появляются и начинают лететь в том направлении, куда смотрел терминатор (то есть на игрока). Производить дополнительные действия выстрел начинает не сразу, а после небольшой задержки (reactionTimer) — простой способ избежать каких-то нежелательных столкновений в точке его появления.
После задержки выстрел начинает анализировать пройденные отрезки на предмет пересечений, и если там что-то нашлось, то уничтожается, проигрывая эффект.
Если у этого чего-то имелось RigidBody, то выстрел применит туда импульс.
А если это ещё и машинка, то выстрел находит на ней скрипт и вызывает там функцию потери хитпоинтов.
Так как вражеские выстрелы отслеживают пересечения с большей частью геометрии, то они часто могут попасть в какую-то визуальную часть машинки, не достигнув её «ядра». Таким образом получается, что часть выстрелов словно не пробивают броню.
Фрагмент скрипта выстрелов игрока. Он уже не толкает RigidBody и не проверяет у объекта наличие скрипта. Здесь в случае попадания создаётся дополнительный рейкаст, который реагирует только на конкретную маску вражеской сферы. Если она обнаружена, то выстрел просто отключает её.
Далее в дело включается скрипт самого врага, который следит за состоянием своей главной сферы. Если она оказалась отключённой, то враг понимает, что ему нанесли урон, значит нужно списать с себя единицу здоровья и включить сферу обратно. Заодно получается визуальный эффект попадания, даже без спавна частиц, только если включать сферу обратно слишком быстро, то этот момент можно не заметить.
Таким образом повредить терминатора намного легче — он более открыт для попаданий, но зато игроку сложно прицеливаться из своего прямолинейного оружия, поэтому у врагов тоже есть некоторые шансы.
Электросеть
У некоторых машинок имелась способность полива пространства перед собой физическими кубиками. И в один прекрасный момент придумалась игромеханика, с которой эти кубики могли бы взаимодействовать — это должна была быть маленькая цепь деактивированных «электростанций», которые можно чинить, забрасывая их теми самыми кубиками.
Требовалась заготовка станции, снова отслеживание столкновений, какой-то механизм показа прогресса починки и определить прочие сопутствующие детали. В этот раз использовался уже PhysicTrigger с маской настроенной только на конкретные кубики.
Отображать прогресс постройки я решил кубиками того же цвета, расположив их кругами у основания станции и выключив. Когда кубик попадает по триггеру станции, то его физическое тело отключается (сам визуальный кубик некоторое время продолжает висеть, словно попав в некое поле), а станция показывает один из своих кубиков, пока не заполнятся все. Как только это произошло, то у станции появляется свечение и протягивается соединительная линия к предыдущей станции. Кроме того каждая станция содержит ссылку на следующую, которую нужно сделать активной. А вместо самой последней включается телепорт в водный мир, который изначально не активен.
Рядом с первой подлежащей ремонту станцией раскиданы несколько кубиков, как некоторый намёк на желаемое от игрока действие.
Демоверсия
Ниже видеонарезка игрового процесса из свежей версии прототипа 01_02
Архив с демкой весит 714Мб. Запускается она на 64-битной Windows и доступна для скачивания на страничке itch.io (используется Unigine engine, поэтому системные требования не самые малые):
thenonsense.itch.io/tantramantra