[Из песочницы] Забытое секретное оружие Unity — UnityEvents

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


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


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


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

Как показал опрос, проведённый недавно в русскоязычном сегменте 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. {}
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 и ужаснитесь :)


Заключение


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


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


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


Ссылки


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


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


  • Мягкое нутро системы событий (реверс-инжиниринг)
  • Чем плох SendMessage
  • Применение делегатов в Unity
  • Система событий C# в Unity
  • Официальный урок, показывающий работу с делегатами
  • Автор надстраивает встроенную систему сообщений и просто делает интересности

Комментарии (1)

  • 26 августа 2016 в 09:54 (комментарий был изменён)

    0

    Попробуйте передавать в качестве параметра MarshalByValue тип (для этого придется сделать наследника UnityEvent с нужным типом) — будет гарантированный GC allocation. Ну и «EventBusTest» как пример использования простой шины событий. По поводу «невозможности использования C# кода в JS» — нужно положить C# код в папочки первого этапа компилирования (Standard Assets, Plugins, etc) — тогда этот код станет доступным в JS.

© Habrahabr.ru