Мы делали ремастер
целый год
Gamedev
Fair Pixel

Простая архитектура интерактивных объектов и пример их использования в Erra: Exordium

Здравствуйте жители DTF.

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

Данная статья имеет скорее обучающий контекст, поэтому мы решили разместить её в разделе gamedev.

Мы расскажем вам одну небольшую историю про один маленький объект и опишем подход, который мы используем в архитектуре нашей игры.

Интерактивные объекты или объекты окружения, с которыми могут взаимодействовать NPC и главный герой, являются одной из основных составляющих любой игры.

Работая над черновыми механиками главного героя и поведением NPC, параллельно мы добавляли различные объекты окружения, с которыми должно происходить взаимодействие. Очевидно, это начало влиять на код героя и NPC. Код разрастался новыми правилами связанными с объектами окружения. Поэтому пришлось остановится и подумать! Что из этого получилось, мы хотим поделиться с вами.

История про ящик

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

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

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

А что если, ящик поставить на ящик и на него еще ящик, и с каждой стороны ящик? Сказано - сделано. Зачем, пока неясно, но выглядело интересно. Смена слоя ящика (перемещение) начиналась с верхнего, т.е. подсвечивался тот ящик, который был выше всех и на уровне рук героя.

К тому времени в игре развивалась сущность интерактивных объектов под кодовым названием “активатор”. Самый простой активатор - это проходимый объект заднего плана, который должен делать вкл/выкл чего-либо.

С появлением разнообразного оружия появились активаторы, которые должны были реагировать на это оружие. И так как ящики заднего плана были на том же слое физики, то оружию приходилось обрабатывать “коронованный” тег с учетом разных комбинаций видов, возможности разрушения и прочее и прочее…

То, что появилось как идея на ранних этапах - внедрилось как вирус во многие правила поведения игровых сущностей.

Но в один прекрасный день, мы проснулись и сказали: “как же это z@ #b @LOW”. За несколько дней из проекта был искоренен код ящика. И сегодня мы отдаем ему честь. Мы помним, но не скорбим. R.I.P.

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

В нашем проекте Erra: Exordium мы используем правило - чем меньше объектов MonoBehaviour с методами Start / Update на сцене, тем лучше.

Да, на сцене полно разных “заскриптованых” объектов, но MonoBehaviour-методы Start и/или Update (и прочая ересь) есть только у одного короля! Поэтому интерактивные объекты, которые содержат в себе обновляемую логику, находятся в спячке.

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

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

Похожая ситуация происходит с противниками. Все “противные” относятся к одной группе объектов и управляются отдельным блоком. На старте враги спят. Пробуждаются только те, которые были активированы другим интерактивным объектом. Например, главный герой зашел в зону активации (тумблер) или открыл дверь, за которой стоял враг.

Откройте! С чего всё началось?

Первым объектом был тумблер. Простой объект, с которым можно было связать множество других объектов сцены. Когда герой пересекается с тумблером, тот проходится по связанным объектам, в которых есть реализация интерфейса IInteraction и вызывает у них метод Interact.

Вслед за ним появился объект — дверь. Именно она открыла путь более сложной архитектуры взаимодействия героя или любого другого объекта с объектами окружения.

На данный момент, наша дверь унаследована от активатора. Реализует интерфейсы повреждения, открытия и обновления. Может быть разрушаемой или нет. Закрыта с разных сторон на засов. Требующая ключ или введение кода. С замком режима самоуничтожения. Быть люком в полу. И по открытию, давать сигналы другим объектам.

Но опустим хронологию и расскажем, что есть на данный момент.

Архитектура интерактивных объектов

Верхушка интерактивных объектов - класс Interaction, унаследованный от MonoBehaviour. Он дает всем интерактивным объектам: хранение уникального номера, инициализацию, восстановление и вызов действия.

Вызов метода Interact просто меняет значение флага включен или выключен.

Класс ActivateObject, унаследованный от Interaction, в своем вызове Interact передает этот флаг вкл/выкл методу gameObject.SetActive, который в свою очередь включает или выключает объект на сцене.

Класс Tumbler, разовая зона реагирующая на персонажей. Пересекаясь с Tumbler персонаж вызывает метод Interact, который дает команду связанным объектам с интерфейсом IInteraction. Например, можно связать Tumbler с множеством объектов сцены, у которых присутствует компонент ActivateObject. При входе в объект Tumbler включаться и/или выключаться объекты ActivateObject.

ChainTumbler имеет дополнительный список объектов, которые должны обратиться к нему, прежде чем дать команду связанным объектам с интерфейсом IInteraction. Например, тот же Tumbler или множество таких объектов, могут быть использованы как ключи. ActivateObject получит команду, когда все Tumbler обратятся к ChainTumbler.

Интерактивный объект может иметь обновляемую логику. Для этого он реализует интерфейс IUpdateable. По умолчанию такой объект спит. При взаимодействии с ним, объект добавляет себя в блок управления окружением.

Mover перемещает связанные объекты на дистанцию по направлению за время. Когда кто-то вызывает его метод Interact, Mover добавляет себя, как объект IUpdateable, в контроллер окружения и начинается вызов метода UpdateMe (так как метод Update зарезервирован в MonoBehaviour). В UpdateMe Mover двигает объекты и по завершению движения, пометит себя на удаление из списка обновляемых объектов окружения.

Например, Tumbler связан с объектом ActivateObject и Mover. Mover связан с этим же ActivateObject. Когда персонаж входит в Tumbler, то включается ActivateObject и появляется на сцене, а Mover начинает его двигать.

IntervalTumbler похож по своему назначению на Tumbler, но у него есть обновляемая логика, которая через интервал дает сигнал связанным объектам с интерфейсом IInteraction. Например, если связать IntervalTumbler с ActivateObject, выставить время 1 секунду, то связанные объекты ActivateObject будут через момент включаться и выключаться.

Усложняем!

Активатор - это интерактивный объект, который нужно подтвердить действием и он унаследован от Interaction. Активатор реализует интерфейс IAction и минимум должен:

  • иметь подсветку (окантовку) во время пересечения с героем;
  • назначать вид действия героя (например, поднять, повернуться, взять рукой, покрутить и прочее)
  • что-то сделать

Класс Activator один из примеров объектов на подтверждение действием и большая часть кнопок использует его. Главный герой и интерактивные объекты общаются друг с другом через интерфейсы IInteraction и IAction. Чтобы определить какой из интерфейсов искать в качестве компонента объекта во время коллизии, объекту присваивается тег: Interaction или Action.

Когда персонаж пересекается с объектом тега Action, то при нахождении компонента IAction, включается подсветка объекта через вызов Contour.Show(). Сам IAction добавляется в перечень принятия решений на стороне героя. Если нажать на кнопку действия, то персонаж переходит в состояние ActionState. В ActionState реализована логика принятия решений: включение соответствующей анимации на основе IAction.ActionType и вызов IAction.Action() в определенном кадре.

Например, состояние ActionState получает значение ActionType = “нажатие кнопки”, поэтому включается анимация, где персонаж протягивает руку. А в кадре вытянутой руки происходит вызов Action() класса Activator.

К активатору также можно применить обновляемую логику. Для этого реализуем интерфейс IUpdateable.

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

Когда требуется передать сигналы связанным объектам в зависимости от событий своего коллайдера, мы используем интерфейс IColliding.

Дело в том, что все наши персонажи используют кинематическую физику и обработку только тех коллизий, которые нам нужны. Так как мы придерживаемся правила “меньше объектов на сцене с методами MonoBehaviour Start/Update и прочими”, то сюда же относятся методы связанные с событиями коллизий: OnCollisionEnter2D, OnCollisionStay2D, OnCollisionExit2D, OnTriggerEnter2D, OnTriggerStay2D, OnTriggerExit2D. Если вам это интересно, мы можем рассказать подробнее в следующих статьях.

TumblerZone может передавать сигналы, если в него вошли и вышли или что-то одно. Например, TumblerZone связан с объектом Mover, который умеет возвращаться на исходную позицию. А Mover связан с любым объектом препятствия (стена). Когда персонаж входит в зону, то Mover получает сигнал и двигает связанный объект вверх. Если персонаж выходит из зоны, то Mover возвращает препятствие на исходную позицию.

Другой пример. TumblerZone запоминает объекты, которые вошли в его коллайдер. На вход первого объекта (персонажа), TumblerZone запускает связанный объект дверь — она открывается. Если из зоны выйти, то дверь закроется. Персонаж должен принести в зону еще один объект, который TumblerZone запомнит. После этого, если персонаж выйдет из коллайдера TumblerZone, то дверь по прежнему будет открыта.

Библиотека интерактивных объектов и варианты комбинаций

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

Тумблер - одноразовая зона, войдя в которую, всем связанным объектам будет вызван метод Interact.

Фраза - разновидность тумблера, который вызывает демонстрацию надписи фразы главного героя над его головой.

Тумблер и цепь - получив сигналы от объектов цепи, будет вызван метод Interact у всех связанных объектах.

Тумблер интервала - вызывает метод Interact другим объектам через интервал. Может быть включен и выключен другим объектом через метод Interact.

Тумблер зона - может реагировать на вход и выход объектов из своего коллайдера, запоминать кто вошел и вышел. Вызывает метод Interact у всех связанных объектах. Может быть включен и выключен другим объектом.

Зона подсветки - как тумблер зона, но умеет лишь подсвечивать другие интерактивные объекты.

Вращатель - включается/выключается другим объектом. Вращает связанный объект в заданных пределах.

Прожектор - похож на тумблер зону, который унаследован от вращателя. При нахождении персонажа в своей зоне, останавливает вращение и вызывает метод Interact у всех связанных объектов.

Движение - основная задача двигать другие объект.

Штора - визуальный объект, который закрывает часть локации. Создается в момент запуска сцены на основе полигонального коллайдера. Может быть скрыта другим объектом, например при открытии двери.

Активатор - самый распространенный объект, который требует подтверждения от игрока. Многоразовый тумблер. Может блокироваться другими объектами. Для активации может требовать артефакт (ключ).

Дверь - разновидность активатора, имеет много разных параметров, вариантов взаимодействия и реагирования. При разрушении или открытии вызывает метод Interact у всех связанных объектах. Может быть выключена другим объектом через метод Interact.

Контейнер - разновидность активатора, который при открытии дает герою различные объекты. Для открытия может требовать артефакт, ввести код замка, завершить пазл...

Поднимаемый - упрощенный контейнер, который исчезает со сцены в момент поднятия.

Информативный - записка, газета, плакат.

Фоновая анимация - управляется другими объектами, запускает или останавливает анимацию.

Падающий - включается только другими объектами. Падает на землю и выключается.

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

Платформа - двигается по направлению на дистанцию со скоростью, может быть запущена или остановлена другими объектами.

Объект заднего плана - для атмосферы заднего плана, может реагировать на персонажей. Включается только живыми объектами.

Переносимый - управляется только персонажем, но может давать команду другим интерактивным объектам при пересечении с ними.

Зона повреждения - много всяких настроек для повреждения любого объекта, который реализует соответствующий интерфейс разрушения. Зона может управляться другими объектами.

Точка спавна - создание объекта по ссылке на префаб стандартными средствами. Может управляться другими объектами. Используется редко (мы за пул объектов).

Эффект - управляется другими объектами для того, чтобы включить/отключить спрайт или проиграть/остановить систему частиц.

Обозначения кружков в блоках:

  • I - наличие интерфейса IInteraction
  • А - наличие интерфейса IAction
  • С - наличие интерфейса IColliding
  • Серый кружок справа - наличие связи с другими объектами с интерфейсом IInteraction

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

Подойдя к двери, подтверждаем действие. Дверь открывается и растворяет штору.

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

Персонаж должен включить три активатора. По включению, активатор передает команду тумблеру цепи. Собрав команды из трех активаторов, тумблер открывает дверь.

Войдя в тумблер зону, та передает сигнал объекту, который поднимает стену вверх. Выйдя из зоны, тумблер снова подает сигнал и стена опускается. Чтобы стена не опускалась, нужно поставить переносимый объект в тумблер зону.

Активатор подает сигнал тумблеру интервала. Через указанное время, тумблер включает падающий объект.

Один из активаторов отключен. Чтобы его включить, нужно использовать другой активатор.

Подытожим

Один вход и множество выходов основаны на интерфейсе IInteraction.

Все интерактивные объекты на момент старта уровня - спят. У них нет обработки стандартных вызовов коллизий, Start, Update и прочее. Коллизия обрабатывается на стороне “живых” объектов, которые также пытаются послать сигнал через интерфейс IInteraction.

Если объект содержит IUpdateable, то во время получения сигнала, объект добавляет себя в список обновляемых объектов. По завершению своих задач, он дает команду на удаления себя из списка обновляемых объектов.

Такая простая архитектура дает общение между объектами и решает вопросы оптимизации.

Заключение

Если вы читаете эти строки, значит вы прочитали (пролистали) статью до конца. С радостью и мы почитаем ваши комментарии.

Следите за новостями, поддерживайте инди, слушайтесь родителей и будьте здоровыми.

Команда Fair Pixel.

Страница игры в Steam
Страница игры в Twitter
Cтраница игры в Instagram

0
20 комментариев
Популярные
По порядку
Написать комментарий...

Спасибо за статью, интересно было почитать.
А откуда такой страх перед Start и Update? В Unity же есть возможность включать/отключать Update для конкретных объектов, если не нужно, чтобы они вхолостую обрабатывались.

3

Спасибо за отзыв, попробую ответить на ваш вопрос.

Когда-то давно, изучая тему оптимизации, я натолкнулся на ряд статей, например вот эту:
https://blog.unity.com/ru/technology/1k-update-calls

Что касается метода Start, так сложилось со временем, мы перестали использовать его совсем из-за архитектуры, которую выстрадали. "Управляющий уровнем" на старте сцены собирает все объекты со сцены. Ищет там ряд интерфейсов. Создает и восстанавливает различные объекты окружения, врагов и прочее. У этого работяги есть свои состояния или этапы: факт загрузки сцены, сбор и идентификация объектов, создание противников, инициализация объектов (вместо Start), восстановление состояний объектов, создание доп. объектов (например, места урона противников). Так мы, в том числе, понимаем на каком этапе загрузка сцены и какое значение прогресса загрузки отображать в UI.

2

Любопытная статья. Хотя, как автор сам признает, тест не очень релевантен, потому что в его случае сам Update не делал ничего и поэтому сложно нормально посчитать соотношение "полезного" времени исполнения к общему.
Опять же, не в курсе реалий вашего проекта, но сомневаюсь, что у вас активно 10'000 обновляемых объектов каждый кадр. К тому же в своем итерировании вы наверняка совершаете дополнительные проверки, которые тоже влияют на время исполнения.
Вообще все мое взаимодействие с оптимизацией в итоге свелось к тому, что можно оптимизировать 99% проекта и получить 10% прироста производительности, а можно найти и оптимизировать тот самый 1%, который ускорит игру в разы.
Это все не попытка докопаться и объяснить "как надо", просто наблюдения и обмен опытом. Интересно, как разные команды решают одинаковые проблемы :)

Вот насчет Start - отличное решение. И для дебага должно быть сильно приятнее, чем разбираться, в каком порядке движок в этот раз решил создать объекты.

1

Отключать Update? Это как, давно пользуюсь движком, но не встречал такого

1

this.enabled = false
Соответственно если нужно, чтобы скрипт был изначально отключен, можно снять чекбокс на нем внутри GameObject'а
Я в целом понимаю и уважаю стремление к оптимизации, но по большей части движок умеет разруливать такие проблемы. Поэтому подобные подходы кажутся интересными с точки зрения попытки придумать/реализовать собственную систему, но вот с точки зрения повышения производительности - вряд ли действительно помогут.

2

А, речь о таком. Подумал, что можно отдельно от всего вырубить Апдейт и оставить другую логику, какие-нибудь корутины.

1

Как бы такой велосипед и у нас. Есть один поток и в нем разные блоки, которые хранят объекты для обновления. А сами объекты уже туда могут добавляться и удаляться. При необходимости можно каждый блок вынести в отдельный поток.

1

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

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

Для меня апдейт это что-то вроде крайней меры, когда задачу иначе не решишь, но такие случаи на самом деле нечасто попадались. Как вариант, можно сделать систему тактов, где объекты будут подписываться и через такое то количество тактов обновлять свою логику. Это больше подходит для стратегий или пошаговых игр, как вариант.

1

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

0

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

А чтобы не было проблем с добавлением фич, то уже надо следить, чтобы объекты были независимые, ну и минимум точек для взаимодействия. Тогда в теории, в отдельно взятой системе может быть сколько угодно запутанный и сложный код, но пока он замкнут внутри системы, все хорошо.

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

1

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

2

Все так. Главное, чтобы хватило опыта и/или интуиции, чтобы на эти вопросы правильно ответить.

1

Это точно

0

Верхушка интерактивных объектов - класс Interaction, унаследованный от MonoBehaviour. Он дает всем интерактивным объектам: хранение уникального номера, инициализацию, восстановление и вызов действия.

Уверены, что так лучше? Кмк, тут лучше бы композиция подошла.

2

Не совсем понимаю, можете привести пример пожалуйста?
Здесь наследование, композиция не подходит, так как нужно размещать объекты в виде компонентов. Чтобы настраивать параметры в Inspector. Например в Inspector настраивать связи с другими объектами, которые реализуют интерфейс IInteraction.

0

Мне так сложно со стороны сказать, не видя код и не понимая специфики. Просто на фоне популяризации того же ECS такие штуки рекомендуют делать композицией. В целом, это не фишка ECS, конечно, такое в принципе рекомендуется по возможности.

1

В данном примере просто наследование основного объекта от MonoBehaviour. Если кто-то хочет использовать ECS - пожалуйста. Я не стал описывать и делать акцент на ECS. Хотя это один из вариантов куда можно двигаться дальше. Цель статьи немного в другом. Усложнять не хотелось.

2

У меня не было никакой претензии, если что. Просто интересно всегда послушать, как люди приходят к тому или иному решению/реализации)

2

Я вас понимаю) ECS у нас не используется. Начиналось все стандартным подходом. Потом начали городить свои велосипеды. Есть даже в архитектуре часть, где объектом управляет скрипт, который не крепится к объекту как компонент. Например, враги собираются на сцене в объекты используя данные из scriptableobject. И ими может управлять уже более глобальный блок. Но наверно я расскажу об этом в другой раз.

2

Постараюсь ответить на все вопросы. Техническая часть статьи была на мне ред.

1
Читать все 20 комментариев
null