Все заметки

Про работу с событиями

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

Системы в игровом цикле запускаются по очереди и если для обмена событиями использовать обычную pub/sub модель с синхронным уведомлением подписчиков, то никакого порядка не будет. Системы ниже могут генерировать события на которые будет выполняться код из коллбэков выше и все это может страшно запутаться.

В самом начале работы над движком я решил, что весь код должен исполняться строго по порядку и самый простой вариант – это скидывать события в некий объект из которого все заинтересованные лица будут эти события брать и в ответ на них что-то делать. Это приведет к постоянным проверкам на наличие событий, но зато в игровом цикле полный порядок. Так я написал MessageBus, который практически не трогал до текущего момента.

Схема MessageBus
Хоть это и называется MessageBus, на деле это обычное хранилище с get()/send() апишкой и очисткой в конце каждого кадра

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

Пример работы с MessageBus
Обычно работа с сообщениями выглядит как-то так

Чтобы не ломать порядок обработки событий, я решил что продолжу использовать MessageBus под капотом движка, но наружу в системы будет передаваться некий адаптер вокруг шины, который будет иметь более понятное апи с подпиской на события и передачей коллбэков. Выглядит это как обычный EventEmitter, но внутри он полагается на все ту же самую шину – вместо уведомления подписчиков о событиях, он складывает их в шину, а для оповещения есть специальный метод update(), который движок сам будет вызывать перед запуском очередной системы. Таким образом, шина одна, а эмиттер у каждой системы свой.

Схема MessageBus + EventEmitter
Схема стала более замысловатой, появилось больше скрытых под капотом шагов, но код писать стало легче
Пример работы с MessageBus + EventEmitter
Никаких больше массивов и проверок, вполне себе привычное апи для фронтенд разработки

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

Код внутри EventEmitter-а
Весь некрасивый код переехал в реализацию EventEmitter-а

Тут я задумался действительно ли важно соблюдать порядок выполнения систем и событий? Даже сейчас в тех небольших играх что я делал это уже доставляло неудобства. Когда систем несколько десятков, то никакой редактор не поможет поддерживать их порядок. В этот момент пришлось признать, что с самого начала я выбрал неудачное решение, а сейчас стараюсь обмазать его костылями и переусложнить реализацию, когда стало ясно, что оно мне не подходит. Волевым решением я выкинул MessageBus из движка и оставил только EventEmitter в его обычном виде без сюрпризов внутри.

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

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

Тут я вспомнил, что при разработке веб-интерфейсов, если ты хочешь подписаться на событие для конкретного DOM элемента, в самой ноде все для этого есть. Каждый элемент сам реализует интерфейс подписки. Почему бы не сделать так же и вместо глобального эмиттера запихать его в каждый объект? Ну и в сцены заодно если вдруг потребуются глобальные события не привязанные к объектам. Таким образом я переписал все в третий раз.

Финальная схема EventEmitter
Порядок оповещения выглядит еще более запутанным, но на самом деле все так же как и при одном эмиттере

Стало лучше, на мой взгляд. Как будто так и должно быть с самого начала. Я начал думать: какие еще есть проблемы? Что можно с ними сделать?

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

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

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