В последнее время среди игровых разработчиков возрос интерес к паттерну "Шина Событий". Этот паттерн часто ругают за его тенденцию к "размыванию логики" и "скрытию зависимостей". Однако, несмотря на критику, полный отказ от этого паттерна также глуп как и написание кода в блокноте вместо специализированной IDE. В этой статье рассмотрим создание игры, целиком основанной на этом паттерне, и поработаем с такими библиотеками, как Zenject, UniRx, и DoTween.

Часть 1: Основы и Подготовка

https://github.com/redHurt96/EventBus_PreparedProject

Начать можно с моего подготовленного проекта с уже расставленным по сцене ассетами и импортированными плагинами или сделать свой. Ссылки на все плагины и ассеты из проекта находятся в его описании.

Структура проекта
Структура проекта

Сам проект разделен на две основные секции: Content и Logic. В Content лежат все материалы, связанные с визуальным оформлением, такие как спрайты и анимации. Logic содержит код и архитектуру проекта.

Почему такое разделение папок? Потому что папки должны рассказывать о структуре приложения. И ни в одном приложении не должно быть структуры по типу: “вот это картинка для кнопочки, положу ее вот здесь недалеко с кодом ИИ ботов”.

Теория

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

В качестве шины событий мы будем использовать MessageBroker, предоставляемый библиотекой UniRx. Он реализует интерфейсы IMessagePublisher и IMessageReceiver, которые мы установим в качестве зависимостей и будем использовать по отдельности, для более явного соблюдения ISP.

Архитектура

Любая связь между двумя любыми объектами в коде реализована в pull или push виде. Первый - это классический вызов одним классом метода другого, или обращении к какому-либо его полю. Самый яркий пример второго - события в C# - класс просто говорит "я сделал", а все кому это интересно реагируют на это соответственно своему поведению.

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

Часть 2: Разработка Механики Перемещения

Сначала напишем MoveController. Его реализация тупая как пробка - получаем ввод и, если он не нулевой, отправляем сообщение. Не забываем нормализовать вектор ввода с помощью .normalized, чтобы игрок не двигался быстрее по диагонали. Ввод будем получать с помощью реализации интерфейса ITickable - интерфейс Zenject'а, метод Tick() в котором будет вызываться каждый кадр - как Update() в MonoBehaviour классах.

HeroConfig - это ScriptableObject, в котором будут лежать настройки игрока. В данный момент, только скорость.

MoveMessage - само сообщение, в котором будет передаваться дельта перемещения.

public class MoveController : ITickable
{
    private readonly IMessagePublisher _publisher;
    private readonly HeroConfig _config;

    public MoveController(IMessagePublisher publisher, HeroConfig config)
    {
        _publisher = publisher;
        _config = config;
    }

    public void Tick()
    {
        Vector3 input = new(
            Input.GetAxis("Horizontal"),
            0f,
            Input.GetAxis("Vertical"));
        
        if (input != Vector3.zero)
            _publisher.Publish(new MoveMessage(input.normalized * _config.Speed * Time.deltaTime));
    }
}
[CreateAssetMenu(menuName = "Create HeroConfig", fileName = "HeroConfig", order = 0)]
public class HeroConfig : ScriptableObject
{
    public float Speed = 10;
}
public struct MoveMessage
{
    public Vector3 Delta;

    public MoveMessage(Vector3 delta) =>
        Delta = delta;
}

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

С помощью Zenject'а устанавливаем IMessageReceiver зависимостью и через методы Receive<T>() и Subscribe() подписываемся на получение сообщения о движении.

public class MoveComponent : MonoBehaviour
{
    [SerializeField] private Rigidbody _rigidbody;
    
    private IMessageReceiver _receiver;

    [Inject]
    private void Construct(IMessageReceiver receiver) => 
        _receiver = receiver;

    private void Start() => 
        _receiver.Receive<MoveMessage>().Subscribe(Move).AddTo(this);

    private void Move(MoveMessage moveMessage) => 
        _rigidbody.MovePosition(transform.position + moveMessage.Delta);
}

Теперь устанавливаем все зависимости в DI контейнер. Интерфейсы для шины событий установим через созданный брокер сообщений, HeroConfig добавим через ссылку в инспекторе, а MoveController'a не забудем установить через BindInterfacesAndSelfTo, чтобы у него вызвался метод Initialize.

public class MainSceneInstaller : MonoInstaller
{
    [SerializeField] private HeroConfig _heroConfig;

    public override void InstallBindings()
    {
        MessageBroker broker = new();

        Container.Bind<IMessagePublisher>().FromInstance(broker).AsSingle();
        Container.Bind<IMessageReceiver>().FromInstance(broker).AsSingle();

        Container.Bind<HeroConfig>().FromInstance(_heroConfig).AsSingle();
        Container.BindInterfacesAndSelfTo<MoveController>().AsSingle();
    }
}

С кодом покончили, перейдем к движку.

В первую очередь, создадим конфиг персонажа и добавим его в инсталлер на сцене.

Добавление конфига персонажа в инсталлер
Добавление конфига персонажа в инсталлер
Компоненты на игроке
Компоненты на игроке

Затем создадим персонажа (в данный момент подойдет и обычный куб) и добавим на него нужные компоненты.

Итог

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

То же самое, но в видео формате можете увидеть на моем Youtube канале. Узнать про много другое - работу в геймдеве, фриланс и менторинг в моем телеграме, а видеть контент раньше остальных - на Boosty.

Всем спасибо! Делайте крутые игры, не размывайте их логику и оставайтесь на связи!

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


  1. HexGrimm
    28.12.2023 12:41
    +1

    ни разу не "размыли логику" или не "спрятали зависимости"

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


    1. redhurt96 Автор
      28.12.2023 12:41
      -1

      А какие аргументы приведены в обратном направлении? Event Bus не лучше и не хуже любого другого подхода.

      А те же K-Syndicate, например, во главе с целым архитектором из Plarium'а, не думая его откидывают из-за несуществующей проблемы.
      https://www.youtube.com/shorts/WTF1IS4NTBM


      1. Prikalel
        28.12.2023 12:41

        не бы стал полагаться на инфу из Ютуб шортса )


  1. dalerank
    28.12.2023 12:41
    +1

    EB не подходит в этом качестве, он позволяет различным компонентам приложения обмениваться сообщениями, чтото вроде email. Удобен, универсален, но есть ряд недостатков, которые делают его не подходящим активых систем (без существенных доработок в виде приоритезации, фильтров, актуализации)

    Непредсказуемое время обработки: ЕВ основан на паттерне "подписка" на события, и время выполнения обработчика ивента может плавать от компонента к компоненту, без доработки в виде подсистемы тасок ваш персонаж будет иметь заметрный лаг инпута. В контексте игры непредсказуемую точность реакции.

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

    Слабая связанность систем: контроллер плеера в 99% требует непосредственного отслеживания состояния, т.е. позиции, скорости, анимаций и тд. Придется в EB протаскивать логику плеера. Оно вам надо?

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


    1. redhurt96 Автор
      28.12.2023 12:41

      Чтото сложнее передвижения кубика по уровню с таким подохдм сделать не получится

      Подожди следующие части)


      1. dalerank
        28.12.2023 12:41

        Но зачем натягивать сову на глобус, а затем мужественно преодолевать последствия? Ну просто это уже не один раз пройдено :) Ждем-с!


  1. Lekret
    28.12.2023 12:41
    +2

    И при этом ни разу не "размыли логику" или не "спрятали зависимости"

    Почему не размыли и не спрятали, если сделали и то и другое по факту? Какой код тогда имеет размытые зависимости, если с этим кодом всё в порядке?

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


    1. redhurt96 Автор
      28.12.2023 12:41
      -1

      А как бы мы ее не размыли, если бы делали без шины событий? Но при этом бы делали нормально)
      Сделали бы еще IMoveController, который бы пробросили в IMoveService, который бы через ICharacterRepository получал игрока и вызывал бы у него этот Move()
      Итого +4 класса ради того же результата, где сильнее размыта логика?)
      И тогда назови мне паттерн, который не размывает логику за счет увеличения количества промежуточных классов) Стратегия, фабрика, медиатор, декоратор, адаптер, мост, посетитель?


      1. HexGrimm
        28.12.2023 12:41
        +1

        Дело не только в количестве сущностей. Вопрос практический - современные IDE подсвечивают стек вызовов методов между классами при дебаге, и сканируют использования методов для навигации по коду. В случае с EB я не видел примеров удобной навигации, большую кодовую базу поддерживать будет тяжело. Единственный пример использования аналога EB я могу представить только когда количество методов у класса на столько большое, что только у него в API накладные расходы на EB меньше, чем на написание большого кол-ва сигнатур методов руками.


      1. Lekret
        28.12.2023 12:41
        +1

        Если у меня в игре много "потребителей" инпута, то я бы сделал интерфейс IInputListener и сервис InputListenerProvider, всё. Зачем мне делать 4 доп. класса не знаю.
        Получилось бы очень похоже, есть контроллер и логика, и есть третья сущность, про которую контроллер и логика знают, и которая обеспечивает между ними связь.
        Только в данном случае эта третья сущность не EventBus, а маленький провайдер, отдающий интерфейс, и я могу по имплементациям данного интерфейса сразу оказаться в методе обработчике, а не искать по usageам где у меня используется мой MoveMessage и где на него идёт подписка.
        Вышло длиннее? Не думаю. Потому что в примере из статьи не разобран главный кейс для которого нам в принципе нужна абстракция, а именно ситуация когда потребителей инпута может быть много. В таком случае при смене обработчика нам надо одним классом отписаться, затем другим классом подписаться, вместо простого вызова InputListenerProvider.ReplaceListener(this) в классе, который хочет слушать инпут.
        Ну да, теперь какой-то слой логики знает про InputListenerProvider, но от того, что этот же слой будет знать про MoveMessage фактическая зависимость от инпута никуда не уйдёт, просто станет неявной.

        Касательно паттернов. Из "промежуточных классов" я увидел только "адаптер", и может какой-нибудь "мост", но у этих паттернов есть своё применение.
        Причём все эти паттерны работают по прямому вызову методов, то есть моя логика знает и зависит от "стратегии" или от той же "фабрики", поэтому размывания фактически нет.
        Размыть зависимости может неправильное использование паттерна "наблюдатель", из которого EventBus по факту и появился, а всё остальное может размыть логику только если очень постараться, например если писать +4 класса на какую-то простую логику.
        Я же не против умеренного декаплинга, абстракций, переиспользования логики, умеренного соблюдения SRP, или тех же ивентов, да я даже не против самого EventBus, но имхо использовать его как в статье я бы не стал и считаю ошибочным.

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


  1. Adezar
    28.12.2023 12:41

    Спасибо! Можете дополнить статью комментариями что такое ITickable и ActorComponent?


    1. redhurt96 Автор
      28.12.2023 12:41

      Спасибо, поправил
      ActorComponent пока что лишний - залез из будущих реализаций.