Наш отважный герой, случайно забредший в запретную область ядовитых венерианских джунглей, окружён роем из десяти тысяч голодных полуразумных комаров. Вопрос съедобности человека для инопланетных организмов, с научной точки зрения, вообще-то, достаточно спорен — но венерианские комары, как известно, искренне разделяют позицию Маркса, что критерием истинности суждения является эксперимент. Казалось бы, положение безнадёжно — но герой, не растерявшись, извлекает из широких штанин инвентаря походный термоядерный фумигатор, красивым движением включает его, и…
И на этом моменте разработка игры всерьёз забуксовала, стремительно обрастая костылями и подпорками, поскольку разработчик игры не был знаком с одним маленьким, но крайне важным механизмом движка.
Механизм, которого не знал разработчик, спящий на клавиатуре после продолжительного спринта по программированию совершенно не нужного по сути своей менеджера управления комарами — это встроенная система событий.
Как показал опрос, проведённый недавно в русскоязычном сегменте Unity-сообщества, пугающе большой процент отечественных разработчиков вообще не подозревает о том, что такая вещь, как события, была когда-то измыслена человеческим разумом. Отчасти, этому общему пробелу в знаниях поспособствовала официальная документация, хитрым противолодочным зигзагом обходящая вопросы практического применения событий в Unity, отчасти — общая запутанность архитектуры этой части простого и удобного, в сущности, движка.
Кроме того, Вы-то наверняка пользуетесь одним из наиболее известных механизмов, предоставляемых средствами C#. Это хороший выбор — но, быть может, Вам будет интересно узнать и о возможностях встроенной в Unity системы и её собственных «плюшках»?
Ликбез, или «Будильник против таймера»
(если Вы знакомы с тем, что такое игровые события — спокойно пропускайте этот раздел)
Большинство современных игровых движков чем-то схожи с обычными механическими часами. Объекты движка — шестерёнки большого часового механизма игрового мира. И, как часовой механизм функционирует за счёт того, что шестерёнки хитро соединены между собой, так жизнь и движение игрового мира осуществляются за счёт взаимодействии объектов друг с другом.
Метафора не совсем точна — однако, очевидно, что каждый объект должен иметь данные не только о собственном внутреннем состоянии, но и располагать рядом сведений об окружающем его мире. Способы получения этих сведений делятся на две группы:
- Активные — инициатива в получении новых данных исходит от самого объекта, т.е. объект сам посылает запрос.
- Пассивные — информация поступает в результате внешнего (по отношению к объекту) события — объект для её получения ничего толком и не делал.
Рассмотрим пример из мира реального. Представим себе, что мы голодны, и ставим по этому поводу на огонь вариться большую кастрюлю пельменей. Алгоритм сего действа прост — закинуть пачку пельменей в горячую подсолёную воду, включить огонь, отойти на десять минут (посидеть в интернете пол-часика), снять угольки пельмени с огня.
Вообразим, что это игра, а мы в ней — игровой объект. Как же мы определяем, когда наступит момент идти выключать пельмени?
Можно использовать активный способ получения информации о времени — поставить перед носом таймер / часы — и регулярно запрашивать у них время, бросая на циферблат косые взгляды. Как только, посмотрев в очередной раз, мы увидим, что часы наконец-то отсчитали положенные десять минут с момента начала готовки, мы бежим снимать кастрюлю с огня.
Можно использовать пассивный способ — завести будильник, поставив его на то время, когда пельмени должны по нашим прикидкам свариться. Когда будильник звонит, мы реагируем на это событие и идём принимать меры в отношении еды.
Однозначно, второй способ удобнее — не нужно постоянно отрываться от текущих дел, искать вокруг себя объект «часы», а потом выяснять у него интересные нам данные. Кроме того, небезразличные к судьбе близкого обеда домашние могут также подписаться на событие «будильник прозвонил» и идти заниматься своими делами, освобождённые от необходимости регулярно подходить и поднимать к глазам наши любимые часы, или же спрашивать об обеде у нас лично.
В свою очередь, первый способ — способ регулярного активного обращения для получения каких-то сведений — называется в литературе polling (от англ. «poll» — опрос). Вызывающий событие объект называется отправителем, воспринимающий его объект — получателем, процесс слежения за событием называется прослушиванием (listen), а функция, вызывающаяся у получателя по получению события зовётся обработчиком.
Часы? Пельмени? Ась?!
Хорошо-хорошо, другой пример. Смотрели второго «Шрека»?
Вспомните момент, когда трое героев едут в карете в Далёкое-Далёкое Королевство. Замечательная юморитическая сценка, где непоседливый Осёл достаёт героев, регулярно интересуясь:
-Мы уже приехали?
-Нет.
-… мы уже приехали?
-Нет, не приехали!
-… мы уже приехали?
-Неееееет!
То, чем занимается Осёл — это хороший пример polling-а в чистом виде. Как видите, это здорово утомляет :)
Остальные герои, в свою очередь — условно — подписаны на событие «приезд», и потому попусту не тратят системного времени, ресурсов, а также не требуют дополнительно усилий программиста на реализацию добавочной жёсткой связи между объектами.
Секрет, который у всех на виду
Итак, как же реализовать события на Unity? Давайте рассмотрим на простом примере. Создадим в новой сцене два объекта — мы хотим, чтобы один генерировал сообщение по щелчку мыши на нём (или мимо него, или не мыши вообще), а второй — реагировал на событие и, скажем, перекрашиваться. Обзовём объекты соответствующе и создадим нужные скрипты — Sender (генерирующий событие) и Receiver (принимающий событие):
Код скрипта 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, и увидим:
Ага! Наше событие, объявленное как публичная переменная — объект типа UnityEvent — не замедлило показаться в редакторе свойств. Правда, пока оно выглядит одиноким и потерянным, так как ни один из объектов на него не подписан. Исправим этот недочёт, указав Receiver как слушающий событие объект, а в качестве вызываемого по получению события обработчика — нашу функцию Recolor():
Запустим, нажмём пару раз любую кнопку… есть, результат достигнут — объект послушно перекрашивается в самые неожиданные цвета:
«Эй! Стоп-стоп! Но мы же этим уже пользовались, когда работали с Unity UI!» — скажете Вы… и окажетесь неожиданно правы.
UI, поставляющийся в Unity с версии 4.6, использует тот же самый механизм событий. Вот только набор событий UI ограничен вызываемыми пользовательским вводом, в то время как движок позволяет программисту насоздавать их на любой повод и вызывать их по любому удобному поводу.
Но давайте двигаться дальше!
«Добавить второго получателя? Нельзя, всё вы врёте!»
Отлично, работает. Работает! Но из зала уже доносятся голоса людей, запустивших среду, потестировавших, и начинающих уличать автора в… неточностях. И правда — после недолгого изучения, пытливый читатель выясняет, что редактор Unity разрешает связать событие только с одним экземпляром Receiver, и начинает ругаться. Второй экземпляр просто некуда оказывается вписать — поле принимающего сигнал объекта не предусматривает множественности записей.
(upd: как выяснилось позднее, на самом деле предусматривает, см. комментарий)
И это провал, казалось бы.
Однако, всё не так плохо. Отставив в сторону «программирование мышкой», мы углубимся в код. Оказывается, на уровне кода всё легко, и вполне можно сделать слушателями одного события несколько объектов.
Синтаксис команды для этого прост:
экземплярСобытия.AddListener(функцияОбработчик);
Если же, забегая вперёд, есть желание по ходу исполнения кода перестать слушать событие, то поможет функция:
экземплярСобытия.RemoveListener(функцияОбработчик);
Хорошо, теперь мощь событий в наших руках, и, казалось бы, добавление второго получателя — вопрос решённый.
Но есть нюанс — экземпляр события хранится в Sender, а Receiver о нём ничего не знает. Что же делать? Не тащить же указатель на Sender внутрь Receiver-а?
Умные книжки на этом месте зачастую ограничиваются формулировками вроде: «А решение этого вопроса мы оставляем на самостоятельную проработку читателю». Однако, я покажу здесь одно хитрое, не совсем очевидное (и не самое изящное) решение. Подходит оно не всегда и не всюду, но если Вы сумеете добраться до ситуации, когда Вам нужно что-то большее — то к этому моменту Вы наверняка уже обладаете достаточным багажом знаний, чтобы справиться с подобной проблемой самостоятельно.
Итак, решение: событие, как любую уважающую себя переменную, можно без особых проблем сделать… статическим!
Изменим код Sender следующим образом:
public static var testEvent: Events.UnityEvent = new Events.UnityEvent ();
Переменная закономерно пропала из редактора, поскольку теперь она не принадлежит ни одному экземпляру класса. Зато теперь она доступна глобально. Чтобы её использовать, изменим код Receiver:
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 и запустим игру:
Да, теперь ансамбль Receiver-ов дружно перекрашивается в разные цвета на любое нажатие клавиши. Но что же мы сделали внутри скрипта?
Всё очень просто — раз переменная статическая, то обращаемся теперь не к конкретному экземпляру объекта, а по имени класса. Более интересен другой момент — функции OnEnabled и OnDisable. Объекты, по ходу игры, имеют тенденцию выключаться или вообще удаляться с уровня. Unity же достаточно нервно реагирует, когда на событие раньше был кто-то подписан, но ни один объект теперь больше не слушает. Мол, эй, где все?
Да и когда объект неактивен, ему обычно нет реальной необходимости продолжать дальше ловить события — это, буде имплементировано, могло бы привести к интересным последствиям. Соответственно, как минимум разумно привязывать/отвязывать функции в тот момент, когда объект включается/отключается. А когда объект удаляется, то у него предварительно автоматически вызывается функция OnDisable — так что можно на этот счёт и не беспокоиться.
Бонусный уровень — удаление объекта в обработчике
Казалось бы, всё элементарно — в том же Recolor теперь вызываем Destroy(this.gameObject) — и дело сделано? Попробуем, и...
И не получается. Удаляется только самый первый (иногда — два) из принимающих событие объектов, а до остальных почему-то после этого событие уже не доходит. Странно? Странно и необъяснимо. Может быть, кто-то из гуру Unity подскажет мне идеологически правильный подход, но поскольку я люблю придумывать велосипеды пока на обнаружил более элегантное решение, то поделюсь, опять же, своим.
Если удаление обрабатывающего событие объекта в обработчике мешает дальнейшей обработке — то давайте и не будем объект удалять в обработчике. Удалим его в сопрограмме, выполняющейся после обработки события — для чего движок, кстати, имеет стандартный метод. Будем удалять объект при помощи команды Destroy(this.gameObject, 0.0001). Удаление произойдёт, но не сразу, а будет отложено на 0.0001 секунды. Невооружённым глазом этой паузы не заметить, а вот процесс обработки события не запнётся на объекте и спокойно продолжится дальше.
Передача параметров
Иногда бывает нужно передать событие, сопроводив его какой-то характеристикой произошедшего — радиусом, уроном, количеством, подтипом, матерным комментарием игрока, и так далее. Для этих целей придуманы разновидности UnityEvent с числом аргументов от одного до четырёх. Их практическое применение не сложнее рассмотренного выше.
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);
}
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);
}
Работает — вот несколько склеенных в один кадров:
Как видите, ничего особо хитрого делать не надо.
Практическое применение системы событий
Часто бывает так, что объекту нужно постоянно иметь информацию о том, случилось ли определённое действие, или выполнено ли определённое условие. Сидящий в засаде монстр проверяет — пересёк ли кто-нибудь из персонажей черту, за которой его разрешено видеть и атаковать? Венерианские комары проверяют — не включился ли фумигатор, во время работы которого им положено с визгами разлететься во все стороны? Бесплотный и неосязаемый триггер на карте проверяет — уничтожил ли уже игрок десяток порученных ему по квесту сусликов?
Если начать реализовывать в игре всё подобные условия активным полингом — то есть через регулярные проверки величин самим объектом — то здесь разработчика поджидает набор самых интересных трудностей. Сидящий в засаде монстр должен регулярно «спрашивать» у каждого предмета по эту сторону линии — а не является ли он, мол, персонажем? Комары должны «знать в лицо» игрока с фумигатором (получать при создании указатель на него?) и периодически уточнять, не включил ли он сие страшное оружие. Триггеру живётся ещё сложнее — он должен хранить /полный набор всех сусликов/ и регулярно проверять, сколько ещё живы.
Конечно, предложенные примеры предлагают весьма примитивную реализацию. Комаров, например, можно загнать при рождении в массив специального /менедежера комаров/, а у игрока хранить указатель на менеджер комаров. При включении фумигатора будет вызываться метод менеджера, который пройдёт по массиву и вызовет у каждого комара метод «пугайся-и-улетай»… но лишние менеджеры — оно нам так уж и надо?
Чем UnityEvent лучше альтернативных вариантов? И, кстати, а что с производительностью?
Если Вы не первый год работаете в Unity, то наверняка уже как-то реализовывали в своих проектах события. Теперь Вы знаете про ещё один механизм событий в Unity. Чем же лучше UnityEvent аналогичных средств, например, из числа стандартного функционала C#?
Есть разработчики, которые пишут на JavaScript. Да-да, именно для них, в первую очередь, в статье код и приводится на этом легковесном языке. По понятным причинам, средства, предоставляемые C#, им недоступны. Для них UnityEvent — достойный и удобный механизм, доступный «из коробки» — бери и пользуйся.
Удобно также то, что UnityEvent — один и тот же для кода на JS и на C#. Событие может создаваться в коде C# и слушаться кодом на JS — что, опять же, невозможно напрямую со стандартными делегатами C#. Следовательно, этими событиями можно без зазрения совести связать фрагменты кода на разных языках, буде такое непотребство завелось у Вас в проекте (например, после закупки в Unity Store особо важного и сложного ассета).
Анализ быстродействия событий разных типов в Unity показал, что UnityEvent может быть от 2 до 40 раз медленнее (в части затрат времени на вызов обработчика), чем та же система делегатов C#. Что же, разница не столь уж велика, поскольку это всё равно ОЧЕНЬ БЫСТРО, если не злоупотреблять.
Сравните со встроенным SendMessage и ужаснитесь :)
Заключение
Надеюсь, что эта статья смогла добавить ещё один инструмент в чью-то копилку приёмов и подходов, и ещё одна тайна неплохого, в сущности, движка стала чуть менее таинственной для кого-то. События — крайне мощный инструмент в Ваших руках.
Конечно, это — не «серебряная пуля», решающая все проблемы архитектуры, но они — удобное средство, решающую «одну из первейших задач разработки архитектуры приложения — поиск такого дробления на компоненты и такого их взаимодействия, чтобы количество связей было минимальным. Только в таком случае можно будет потом дорабатывать компоненты по отдельности».
События — мощный механизм в ваших руках. Никогда не забывайте о его существовании. Применяйте его с умом — и разработка Ваших проектов станет быстрее, приятнее и успешнее.
Ссылки
При составлении статьи были, помимо указанных непосредственно в тексте, во множестве использованы различные материалы публикаций.
Вот наиболее интересные из них, а также просто полезные ссылки по теме:
Комментарии (18)
mayorovp
26.08.2016 10:28+1И не получается. Удаляется только самый первый (иногда — два) из принимающих событие объектов, а до остальных почему-то после этого событие уже не доходит. Странно? Странно и необъяснимо. Может быть, кто-то из гуру Unity подскажет мне идеологически правильный подход, но поскольку я люблю придумывать велосипеды пока на обнаружил более элегантное решение, то поделюсь, опять же, своим.
Популярная "особенность" подобных систем. Удаление объекта же приводит к отписыванию от события (вы реализовали это прошлом пункте), верно? Таким образом, внутренняя коллекция обработчиков изменяется в процессе ее обхода. А большинство коллекций на такое не рассчитаны.
К примеру, если там внутри связный список — то удаление в процессе обхода текущего элемента обычно приводит к тому, что обход списка останавливается (если нет специальной реализации этого случая).
AndreyMI
26.08.2016 10:59+5Второй экземпляр просто некуда оказывается вписать — поле принимающего сигнал объекта не предусматривает множественности записей.
На вашей же картинке:
Написано "List is Empty", и кнопочки "±" намекают на то, что можно добавить несколько получателей мышкой.Wolf4D
26.08.2016 18:28Ой, мамочки! Невнимательность — моё второе имя!
Спасибо за замечание, внёс исправление в статью.
Tutanhomon
26.08.2016 11:19+2Все эти Юнитевые «штучки» только на первый взгляд кажутся удобными. Не советовал бы использовать добавление слушателей через инспектор — при рефакторинге кода, или при банальном желании найти, кто подписан на события определенного объекта — поиск по коду вам ничего не даст, а в редакторе придется искать на всех сценах все инстансы исследуемого класса. Подписки через код — ок.
Leopotam
26.08.2016 11:59+1К сожалению, это вынужденная мера, если есть относительно продвинутые десигнеры, желающие собирать поведение и не желающие лезть в код.
AShim
26.08.2016 18:15Логическую часть — да. Но вот сделать меню с помощью Animator и UnityEvents невероятно удобно. Практически без единой строчки кода получается навигация с анимациями, что позволяет перенести работу по UI с программиста полностью на ГД, ТехАртиста или UI/UX-артиста.
Error1024
26.08.2016 12:04Если удаление обрабатывающего событие объекта в обработчике мешает дальнейшей обработке — то давайте и не будем объект удалять в обработчике. Удалим его в сопрограмме, выполняющейся после обработки события — для чего движок, кстати, имеет стандартный метод. Будем удалять объект при помощи команды Destroy(this.gameObject, 0.0001). Удаление произойдёт, но не сразу, а будет отложено на 0.0001 секунды. Невооружённым глазом этой паузы не заметить, а вот процесс обработки события не запнётся на объекте и спокойно продолжится дальше.
Я не профи в юнити, но разве этот код не может сломаться если ОС решит переключить задачи в момент его исполнения, и 0.0001 секунда для юнити просто будет проглочена.Leopotam
26.08.2016 12:11+1Нужно просто в момент вызова эвента собирать подписчиков в отдельный лист и крутить в нем — это позволит модифицировать коллекцию подписки без дополнительных костылей.
lakroft
26.08.2016 18:15Благодарю за статью. В свое время натыкался на события, но не стал использовать из-за того, что если хоть 1 объект уничтожится, не отписавшись от события, это может привести к багу, который придется долго и сложно искать. Если дойдут руки, вынесу на суд общественности свой велосипед, решающий эту проблему.
UnTeam_Vasya
26.08.2016 18:16По поводу удаления объекта в обработчике событий — Вызов обработчиков происходит проходом циклом по их списку. Предположу, что проход осуществляется через Enumerator, и если объект из списка удаляется в цикле, то вызов MoveNext свалиться в ошибку. Если проход осуществляется через for, тогда событие будет вызвано только у нечетных (обращаемся к первому, удаляем его, второй становиться первым, обращаемся ко второму в текущем списке, это уже будет 3 элемент из начального списка, удаляем его, и т.д.).
Поэтому нельзя удалять в объекты в обработчике событий. К вашему методу я бы добавил, отключение элемента (gameObject.SetActive(false)) и изменение его родителя (transform.SetParent(...)).
Neuyazvimy1
27.08.2016 00:27-4У меня есть хорошая реализация системы ивентов. подписывайтесь сделаю статью.
Igor_Sib
27.08.2016 15:48На Хабре есть автор системы SMessages и его статья — хорошая альтернатива стандартным юнитевским эвентам.
evgeniypolyakov
27.08.2016 20:38А в чем преимущество перед нативными С# событиями? На первый взгляд функционал весь тот же самый: https://msdn.microsoft.com/en-us/library/awbftdfh.aspx.
Wolf4D
28.08.2016 10:42В качестве преимуществ я вижу удобство интеграции с UI, возможность использования событий в JavaScript — там, насколько мне известно, адекватных альтернатив немного. Плюс возможность использования одних событий и JS, и C# кодом (события C# без костылей едва ли удастся удобно использовать по назначению из JavaScript), а также возможность настроить простейшие логические связи из дизайнера, а не через код. Последняя возможность, впрочем, сильно на любителя. Плюс, кому-то синтаксис UnityEvent-ов покажется удобнее событий C#, но это вопрос вкуса.
alotofQ
28.08.2016 10:26у меня показывает ошибку строчка
if (Input.anyKeyDown && testEvent!=null) { //testEvent.Invoke() ; // работает testEvent.Invoke(15); }
в C#.
кто нибудь подскажет, как в сишарпе передать параметр в эвент
Leopotam
Попробуйте передавать в качестве параметра MarshalByValue тип (для этого придется сделать наследника UnityEvent с нужным типом) — будет гарантированный GC allocation. Ну и «EventBusTest» как пример использования простой шины событий. По поводу «невозможности использования C# кода в JS» — нужно положить C# код в папочки первого этапа компилирования (Standard Assets, Plugins, etc) — тогда этот код станет доступным в JS.