Забытое секретное оружие Unity — UnityEvents

в 6:39, , рубрики: unity, unity3d, архитектура, выстрелить в ногу, геймдев, комары, ликбез, Проектирование и рефакторинг, разработка игр, туториалы

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

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

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

Постойте, но я-то знаю, что такое система событий!

Как показал опрос, проведённый недавно в русскоязычном сегменте Unity-сообщества, пугающе большой процент отечественных разработчиков вообще не подозревает о том, что такая вещь, как события, была когда-то измыслена человеческим разумом. Отчасти, этому общему пробелу в знаниях поспособствовала официальная документация, хитрым противолодочным зигзагом обходящая вопросы практического применения событий в Unity, отчасти — общая запутанность архитектуры этой части простого и удобного, в сущности, движка.

Кроме того, Вы-то наверняка пользуетесь одним из наиболее известных механизмов, предоставляемых средствами C#. Это хороший выбор — но, быть может, Вам будет интересно узнать и о возможностях встроенной в Unity системы и её собственных «плюшках»?

Ликбез, или «Будильник против таймера»

(если Вы знакомы с тем, что такое игровые события — спокойно пропускайте этот раздел)

imageБольшинство современных игровых движков чем-то схожи с обычными механическими часами. Объекты движка — шестерёнки большого часового механизма игрового мира. И, как часовой механизм функционирует за счёт того, что шестерёнки хитро соединены между собой, так жизнь и движение игрового мира осуществляются за счёт взаимодействии объектов друг с другом.

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

  • Активные — инициатива в получении новых данных исходит от самого объекта, т.е. объект сам посылает запрос.
  • Пассивные — информация поступает в результате внешнего (по отношению к объекту) события — объект для её получения ничего толком и не делал.

Рассмотрим пример из мира реального. Представим себе, что мы голодны, и ставим по этому поводу на огонь вариться большую кастрюлю пельменей. Алгоритм сего действа прост — закинуть пачку пельменей в горячую подсолёную воду, включить огонь, отойти на десять минут (посидеть в интернете пол-часика), снять угольки пельмени с огня.

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

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

Можно использовать пассивный способ — завести будильник, поставив его на то время, когда пельмени должны по нашим прикидкам свариться. Когда будильник звонит, мы реагируем на это событие и идём принимать меры в отношении еды.

Однозначно, второй способ удобнее — не нужно постоянно отрываться от текущих дел, искать вокруг себя объект «часы», а потом выяснять у него интересные нам данные. Кроме того, небезразличные к судьбе близкого обеда домашние могут также подписаться на событие «будильник прозвонил» и идти заниматься своими делами, освобождённые от необходимости регулярно подходить и поднимать к глазам наши любимые часы, или же спрашивать об обеде у нас лично.

В свою очередь, первый способ — способ регулярного активного обращения для получения каких-то сведений — называется в литературе polling (от англ. «poll» — опрос). Вызывающий событие объект называется отправителем, воспринимающий его объект — получателем, процесс слежения за событием называется прослушиванием (listen), а функция, вызывающаяся у получателя по получению события зовётся обработчиком.

Часы? Пельмени? Ась?!

image

Хорошо-хорошо, другой пример. Смотрели второго «Шрека»?

Сценка на YouTube

Вспомните момент, когда трое героев едут в карете в Далёкое-Далёкое Королевство. Замечательная юморитическая сценка, где непоседливый Осёл достаёт героев, регулярно интересуясь:

-Мы уже приехали?
-Нет.
-… мы уже приехали?
-Нет, не приехали!
-… мы уже приехали?
-Неееееет!

То, чем занимается Осёл — это хороший пример polling-а в чистом виде. Как видите, это здорово утомляет :)
Остальные герои, в свою очередь — условно — подписаны на событие «приезд», и потому попусту не тратят системного времени, ресурсов, а также не требуют дополнительно усилий программиста на реализацию добавочной жёсткой связи между объектами.

Секрет, который у всех на виду

Итак, как же реализовать события на Unity? Давайте рассмотрим на простом примере. Создадим в новой сцене два объекта — мы хотим, чтобы один генерировал сообщение по щелчку мыши на нём (или мимо него, или не мыши вообще), а второй — реагировал на событие и, скажем, перекрашиваться. Обзовём объекты соответствующе и создадим нужные скрипты — Sender (генерирующий событие) и Receiver (принимающий событие):

image 

Код скрипта Sender.js:

public var testEvent : Events.UnityEvent = new Events.UnityEvent ();

function Start ()  { }

function Update () 
{
    // По нажатию любой клавиши 
    // вызываем событие (если оно существует)
    if (Input.anyKeyDown && testEvent != null)
        testEvent.Invoke ();    
}

Особое внимание обратите на строку:

public var testEvent: Events.UnityEvent = new Events.UnityEvent ();

И код скрипта Receiver.js:

function Start () { }

function Update () { }

// Функция перекрашивания объекта в произвольный цвет
function Recolor()
{
    renderer.material.color = Color(Random.value, Random.value, Random.value);
}

Теперь посмотрим на объект Sender, и увидим:

image

Ага! Наше событие, объявленное как публичная переменная — объект типа UnityEvent — не замедлило показаться в редакторе свойств. Правда, пока оно выглядит одиноким и потерянным, так как ни один из объектов на него не подписан. Исправим этот недочёт, указав Receiver как слушающий событие объект, а в качестве вызываемого по получению события обработчика — нашу функцию Recolor():

image

Запустим, нажмём пару раз любую кнопку… есть, результат достигнут — объект послушно перекрашивается в самые неожиданные цвета:

image

«Эй! Стоп-стоп! Но мы же этим уже пользовались, когда работали с Unity UI!» — скажете Вы… и окажетесь неожиданно правы.

UI, поставляющийся в Unity с версии 4.6, использует тот же самый механизм событий. Вот только набор событий UI ограничен вызываемыми пользовательским вводом, в то время как движок позволяет программисту насоздавать их на любой повод и вызывать их по любому удобному поводу.

Но давайте двигаться дальше!

«Добавить второго получателя? Нельзя, всё вы врёте!»

Отлично, работает. Работает! Но из зала уже доносятся голоса людей, запустивших среду, потестировавших, и начинающих уличать автора в… неточностях. И правда — после недолгого изучения, пытливый читатель выясняет, что редактор Unity разрешает связать событие только с одним экземпляром Receiver, и начинает ругаться. Второй экземпляр просто некуда оказывается вписать — поле принимающего сигнал объекта не предусматривает множественности записей.

И это провал, казалось бы.

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

Синтаксис команды для этого прост:

экземплярСобытия.AddListener(функцияОбработчик);

Если же, забегая вперёд, есть желание по ходу исполнения кода перестать слушать событие, то поможет функция:

экземплярСобытия.RemoveListener(функцияОбработчик);

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

Но есть нюанс — экземпляр события хранится в Sender, а Receiver о нём ничего не знает. Что же делать? Не тащить же указатель на Sender внутрь Receiver-а?

Умные книжки на этом месте зачастую ограничиваются формулировками вроде: «А решение этого вопроса мы оставляем на самостоятельную проработку читателю». Однако, я покажу здесь одно хитрое, не совсем очевидное (и не самое изящное) решение. Подходит оно не всегда и не всюду, но если Вы сумеете добраться до ситуации, когда Вам нужно что-то большее — то к этому моменту Вы наверняка уже обладаете достаточным багажом знаний, чтобы справиться с подобной проблемой самостоятельно.

Итак, решение: событие, как любую уважающую себя переменную, можно без особых проблем сделать… статическим!

Изменим код Sender следующим образом:

public static var testEvent: Events.UnityEvent = new Events.UnityEvent ();

Переменная закономерно пропала из редактора, поскольку теперь она не принадлежит ни одному экземпляру класса. Зато теперь она доступна глобально. Чтобы её использовать, изменим код Receiver:

Receiver.js

function Start () {
    OnEnabled();
}

function Update () { }

function Recolor()
{
    renderer.material.color = Color(Random.value, Random.value, Random.value);
}

function OnEnabled()
{
    Sender.testEvent.AddListener(Recolor);
}

function OnDisable()
{
    Sender.testEvent.RemoveListener(Recolor);
}

Размножим объект Receiver и запустим игру:

image

Да, теперь ансамбль Receiver-ов дружно перекрашивается в разные цвета на любое нажатие клавиши. Но что же мы сделали внутри скрипта?

Всё очень просто — раз переменная статическая, то обращаемся теперь не к конкретному экземпляру объекта, а по имени класса. Более интересен другой момент — функции OnEnabled и OnDisable. Объекты, по ходу игры, имеют тенденцию выключаться или вообще удаляться с уровня. Unity же достаточно нервно реагирует, когда на событие раньше был кто-то подписан, но ни один объект теперь больше не слушает. Мол, эй, где все?

Да и когда объект неактивен, ему обычно нет реальной необходимости продолжать дальше ловить события — это, буде имплементировано, могло бы привести к интересным последствиям. Соответственно, как минимум разумно привязывать/отвязывать функции в тот момент, когда объект включается/отключается. А когда объект удаляется, то у него предварительно автоматически вызывается функция OnDisable — так что можно на этот счёт и не беспокоиться.

Бонусный уровень — удаление объекта в обработчике

Казалось бы, всё элементарно — в том же Recolor теперь вызываем Destroy(this.gameObject) — и дело сделано? Попробуем, и...

И не получается. Удаляется только самый первый (иногда — два) из принимающих событие объектов, а до остальных почему-то после этого событие уже не доходит. Странно? Странно и необъяснимо. Может быть, кто-то из гуру Unity подскажет мне идеологически правильный подход, но поскольку я люблю придумывать велосипеды пока на обнаружил более элегантное решение, то поделюсь, опять же, своим.

Если удаление обрабатывающего событие объекта в обработчике мешает дальнейшей обработке — то давайте и не будем объект удалять в обработчике. Удалим его в сопрограмме, выполняющейся после обработки события — для чего движок, кстати, имеет стандартный метод. Будем удалять объект при помощи команды Destroy(this.gameObject, 0.0001). Удаление произойдёт, но не сразу, а будет отложено на 0.0001 секунды. Невооружённым глазом этой паузы не заметить, а вот процесс обработки события не запнётся на объекте и спокойно продолжится дальше.

Передача параметров

Иногда бывает нужно передать событие, сопроводив его какой-то характеристикой произошедшего — радиусом, уроном, количеством, подтипом, матерным комментарием игрока, и так далее. Для этих целей придуманы разновидности UnityEvent с числом аргументов от одного до четырёх. Их практическое применение не сложнее рассмотренного выше.

Sender.js

class MyIntEvent extends Events.UnityEvent.<int> {}
public static var testEvent : MyIntEvent = new MyIntEvent ();

function Start ()  { }

function Update () 
{
    // По нажатию любой клавиши 
    // вызываем событие (если оно существует)
    if (Input.anyKeyDown && testEvent != null)
        testEvent.Invoke (15);  
}

Receiver.js

public var health : int = 100;

function Start () {
    OnEnabled();
}

function Update () { }

function Recolor(damage: int)
{
    health = health - damage;
    if (health <=0)
        Destroy(this.gameObject, 0.0001);
    else
        renderer.material.color = Color(1.0f, health/100.0, health/100.0);
}

function OnEnabled()
{
    Sender.testEvent.AddListener(Recolor);
}

function OnDisable()
{
    Sender.testEvent.RemoveListener(Recolor);
}

Работает — вот несколько склеенных в один кадров:

image

Как видите, ничего особо хитрого делать не надо.

Практическое применение системы событий

Часто бывает так, что объекту нужно постоянно иметь информацию о том, случилось ли определённое действие, или выполнено ли определённое условие. Сидящий в засаде монстр проверяет — пересёк ли кто-нибудь из персонажей черту, за которой его разрешено видеть и атаковать? Венерианские комары проверяют — не включился ли фумигатор, во время работы которого им положено с визгами разлететься во все стороны? Бесплотный и неосязаемый триггер на карте проверяет — уничтожил ли уже игрок десяток порученных ему по квесту сусликов?

Если начать реализовывать в игре всё подобные условия активным полингом — то есть через регулярные проверки величин самим объектом — то здесь разработчика поджидает набор самых интересных трудностей. Сидящий в засаде монстр должен регулярно «спрашивать» у каждого предмета по эту сторону линии — а не является ли он, мол, персонажем? Комары должны «знать в лицо» игрока с фумигатором (получать при создании указатель на него?) и периодически уточнять, не включил ли он сие страшное оружие. Триггеру живётся ещё сложнее — он должен хранить /полный набор всех сусликов/ и регулярно проверять, сколько ещё живы.

Конечно, предложенные примеры предлагают весьма примитивную реализацию. Комаров, например, можно загнать при рождении в массив специального /менедежера комаров/, а у игрока хранить указатель на менеджер комаров. При включении фумигатора будет вызываться метод менеджера, который пройдёт по массиву и вызовет у каждого комара метод «пугайся-и-улетай»… но лишние менеджеры — оно нам так уж и надо?

Чем UnityEvent лучше альтернативных вариантов? И, кстати, а что с производительностью?

imageЕсли Вы не первый год работаете в Unity, то наверняка уже как-то реализовывали в своих проектах события. Теперь Вы знаете про ещё один механизм событий в Unity. Чем же лучше UnityEvent аналогичных средств, например, из числа стандартного функционала C#?

Есть разработчики, которые пишут на JavaScript. Да-да, именно для них, в первую очередь, в статье код и приводится на этом легковесном языке. По понятным причинам, средства, предоставляемые C#, им недоступны. Для них UnityEvent — достойный и удобный механизм, доступный «из коробки» — бери и пользуйся.

Удобно также то, что UnityEvent — один и тот же для кода на JS и на C#. Событие может создаваться в коде C# и слушаться кодом на JS — что, опять же, невозможно напрямую со стандартными делегатами C#. Следовательно, этими событиями можно без зазрения совести связать фрагменты кода на разных языках, буде такое непотребство завелось у Вас в проекте (например, после закупки в Unity Store особо важного и сложного ассета).

Анализ быстродействия событий разных типов в Unity показал, что UnityEvent может быть от 2 до 40 раз медленнее (в части затрат времени на вызов обработчика), чем та же система делегатов C#. Что же, разница не столь уж велика, поскольку это всё равно ОЧЕНЬ БЫСТРО, если не злоупотреблять.

Сравните со встроенным SendMessage и ужаснитесь :)

Заключение

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

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

События — мощный механизм в ваших руках. Никогда не забывайте о его существовании. Применяйте его с умом — и разработка Ваших проектов станет быстрее, приятнее и успешнее.

Ссылки

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

Вот наиболее интересные из них, а также просто полезные ссылки по теме:

Автор: Wolf4D

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js