Организация системы эвентов в Unity — глазами геймдизайнера

    Всем привет!

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

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

    В итоге моя система позволяет не делать таких ссылок вовсе. Она решает главную задачу: мне с ней удобно работать, она не захламляет код лишним мусором и, вроде бы, не так ужасна по производительности как постоянные вызовы GetComponent().

    Буду рад любой критике на тему, почему так делать не надо, и как делать всё-таки надо.

    Для начала я переопределил стандартный функционал эвентов Unity для передачи двух GameObject в качестве параметров: субъекта и объекта эвента:

    [System.Serializable]
    public class Event : UnityEvent<GameObject, GameObject> {}

    Типы эвентов я храню в статическом классе со всякого рода константами:

    public enum EventTypes
        {
            TargetLock,
            TargetLost,
            TargetInRange,
            TargetOutOfRange,
            Attack,
        }

    Класс-обработчик этих эвентов тривиален
    public class EventManager : MonoBehaviour
    {
        Dictionary<EventTypes, Event> events;
    
        static EventManager eventManager;
    
        public static EventManager Instance
        {
            get
            {
                if (!eventManager)
                {
                    eventManager = FindObjectOfType(typeof(EventManager)) as EventManager;
    
                    if (!eventManager)
                    {
                        print("no event manager");
                    }
                    else
                    {
                        eventManager.Init();
                    }
                }
                return eventManager;
            }
        }
    
        void Init()
        {
            if (events == null)
            {
                events = new Dictionary<EventTypes, Event>();
            }
        }
    
        public static void StartListening(EventTypes eventType, UnityAction<GameObject, GameObject> listener)
        {
            if (Instance.events.TryGetValue(eventType, out Event thisEvent))
            {
                thisEvent.AddListener(listener);
            }
            else
            {
                thisEvent = new Event();
                thisEvent.AddListener(listener);
                Instance.events.Add(eventType, thisEvent);
            }
        }
    
        public static void StopListening(EventTypes eventType, UnityAction<GameObject, GameObject> listener)
        {
            if (eventManager == null) return;
            if (Instance.events.TryGetValue(eventType, out Event thisEvent))
            {
                thisEvent.RemoveListener(listener);
            }
        }
    
        public static void TriggerEvent(EventTypes eventType, GameObject obj1, GameObject obj2)
        {
            if (Instance.events.TryGetValue(eventType, out Event thisEvent))
            {
                thisEvent.Invoke(obj1, obj2);
            }
        }
    }


    Затем я создал компонент Events, который прикрепляется к каждому объекту в игре.
    В нём я создаю пары «Эвент — хэндлер» для всех типов эвентов в игре.

    
    public class Events : MonoBehaviour
    {
        Dictionary<EventTypes, UnityAction<GameObject, GameObject>> eventHandlers;
    
        void HandlersInit()
        {
            eventHandlers = new Dictionary<EventTypes, UnityAction<GameObject, GameObject>>
            {
                { EventTypes.TargetLock, TargetLock },
                { EventTypes.TargetLost, TargetLost },
                { EventTypes.TargetInRange, TargetInRange },
                { EventTypes.TargetOutOfRange, TargetOutOfRange },
                { EventTypes.Attack, Attack },
            };
        }
    }
    

    В итоге файл получается громоздким, но для меня удобно, что он один — для всех объектов разом.

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

        void OnEnable()
        {
            foreach (KeyValuePair<EventTypes, UnityAction<GameObject, GameObject>> pair in eventHandlers)
                StartListening(pair.Key, pair.Value);
        }
    
        void OnDisable()
        {
            foreach (KeyValuePair<EventTypes, UnityAction<GameObject, GameObject>> pair in eventHandlers)
                StopListening(pair.Key, pair.Value);
        }

    Теперь мне нужно понять, к какому объекту прикреплён этот инстанс Events.

    Для этого я ищу у gameObject ссылки на компоненты: например, если наш объект — Character, соответствующее поле станет != null:

        Monster _mob;
        Character _char;
    
        void ComponentsInit()
        {
            _mob = GetComponent<Monster>();
            _char = GetComponent<Character>();
        }

    Это дорогая операция, но я делаю её только один раз в Awake().

    Теперь осталось описать хэндлеры для всех типов эвентов:

        void TargetLock(GameObject g1, GameObject g2)
        {
                if (_char) _char.TargetLock(g1, g2);
                if (_mob) _mob.TargetLock(g1, g2);
        }

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

    Соответственно, внутри компонентов Character или Monster я уже пишу что-то в этом роде:

        public virtual void TargetLock(GameObject g1, GameObject g2)
        {
            if (g1 == gameObject)
                target = g2;
            if (g2 == gameObject)
                TargetedBy(g1);
        }

    При этом мне не нужно поддерживать никаких перекрёстных ссылок между объектами, все новые эвенты и их «первичные» хэндлеры я держу в одном месте, а конечные объекты получают всю необходимую им информацию вместе с эвентом.

    Пока что я не столкнулся с заметными проблемами с производительностью: система «незаметно» работает с 100+ типами эвентов и десятками объектов на экране, обрабатывая даже чувствительные ко времени события вроде получения урона персонажем от коллизии со стрелой.
    Поделиться публикацией

    Комментарии 14

      0
      Как предполагается работать с динамически создаваемыми объектами? Например, со спауном врагов, препятствий и тп.
        0
        Если я правильно понял ваш вопрос, то лично у меня объекты спавнятся из префабов.
        У каждого префаба уже есть прикреплённый компонент Events, который на старте сразу понимает, «кто он» и начинает реагировать на соответствующие эвенты.
          0
          Но по сути это та же максимальная жесткая связность, о которой было написано в начале поста. Мы не можем что-то поменять посередине, не можем изменить апи, не поломав все префабы.
            0
            А приведите, пожалуйста, пример такого ломающего изменения?
              0
              Когда, например, захочется передавать 3 GameObject-а в качестве параметров в обработку или еще какие-то другие данные. Как только меняется сигнатура метода / типа — все линки на префабах ломаются и приходится их перенавешивать заново.
                0
                В теории объекта и субъекта эвента должно быть достаточно для любой ситуации при условии, что остальные параметры хранятся в них самих и доступны извне.

                Например, моб может обрабатывать ситуацию, в которой игрок атакует другого моба, и, проверив «дружественность» этого моба (который хранится в таргете у игрока и доступен извне), агриться на игрока.

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

                Однако, я с удовольствием выслушал бы альтернативные пути решения подобных задач!
                Мне очень импонирует ECS (во всяком случае то, как она звучит в концепции), но её текущая реализация в Unity показалась мне очень ограниченной и сложной.
                  0
                  Мне очень импонирует ECS (во всяком случае то, как она звучит в концепции), но её текущая реализация в Unity показалась мне очень ограниченной и сложной.

                  Есть альтернативные реализации на чистом шарпе без завязки на unity, например, тот же Entitas.
                    0
                    Да, я слышал, но как-то руки не доходили поковырять — хотелось нативного решения.
                    Но Вы меня вдохновили, и вчера ночью я написал собственную ECS на базе Юнити :)
                    При всей своей примитивности(в моей дилетантской реализации) она очень радует меня своей структурой данных и удобством работы.
                    Мне сложно оценить эффективность работы, но вот на моём макбуке(1,2 GHz Intel Core m3) система, меняющая координаты компонентам в методе Update(), начинает захлёбываться ближе к 100.000 объектов с таким компонентом, до того выдавая вполне сносные 30 fps.
                    Несколько сотен тысяч обращений к компонентам в секунду мне для моих целей вполне должно хватить.
      0

      почему эвент —

        0
        Я тоже сейчас ищу слабо связную архитектур и кое что нашел. Это в разы проще и удобнее и независимо от контекста. www.youtube.com/watch?v=raQ3iHhE_Kk&t=1748s

        Я сейчас активно занимаюсь доработкой и пишу свой фреймворк на её основе. Если заинтересовало, то пиши мне в вк. vk.com/pro_unit

        Ну и все желающие тоже, буду рад рассказать =)
          0
          Как я понял в любом случае у нас идет обращение к инстансу, то есть реализуется сингтон, то есть смысл ивентов теряется напрочь. Что мешает отправлять обычные функции в этот инстанс (антипаттерн мега-класс), зато соблюдается инкапсулирование.
            0
            Смысл эвентов в том, что на них подписаны все, кому оно надо.
            В моем случае — вообще все, но это элементарно разбивается на отдельный компонент для каждого вида объектов: плодим классы CharacterEvents, MobEvents, etc, различаюшиеся по сути только набором эвентов, которые они слушают, и хэндлеров к ним, и аттачим их к соответствующим префабам.
            0
            А у Unity разве нету нативной реализации какого нибудь аналога сигналов и групп как в Godot например, мне казалось в UE4 и Unity давно уже изобретен велосепед, со слабо связаностью сигналов или observe паттерна?

            В Godot ты можешь просто вызвать connect между сигналом источника и методом таргета обьекта.
            Если же надо обратиться к группе обьектов, или выполнит бродкаст, то один вызов get_group_nodes(«Group») выдаст тебе список всех обьектов, а значит можно либо напрямую обратиться, либо создать какой нибудь event brodcast, который ловит сигналы, и по ним рассылает бродкасты определенной группе.

            События кстати какие то примитивные, с привязкой к enum. Почему бы не сделать какой нибудь handler, принимающий правила и пусть он сам по себе и является событием.
            Функция и будет обработчиком, которая определит правило поведения.

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

            Почему бы внутри обьектов не создать компонент, который накапливает полученные события.
            А уже компонент, в свой цикл вызова Update/Tick/etc… обрабатывает все накопленные ивенты.
            Если идти дальше. Еще и подсчитывать прошедшее время, что бы остановит обработку, если она затянется.

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

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

            Самое читаемое