Event Bus и расширяемые игры. Часть 1

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

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

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

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

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

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

Теория

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

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

Архитектура

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

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

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

Сначала напишем MoveController. Его реализация тупая как пробка - получаем ввод и, если он не нулевой, отправляем сообщение. Не забываем нормализовать вектор ввода с помощью .normalized, чтобы игрок не двигался быстрее по диагонали.

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() и Subscribe() подписываемся на получение сообщения о движении.

public class MoveComponent : ActorComponent { [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.

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

1212
24 комментария

Так мило, что в геймдев начинают приходить практики программирования из 90х :)

2
Ответить

Хотя конечно интересно услышать про хоть одну новую «практику программирования из 2020-х или 2010-х»
Тогда прям сразу сяду за статью, как применить ее в геймдеве)

3
Ответить

Лучше поздно, чем никогда)

Ответить

Охренеть как мног комментариев.

2
Ответить

Раньше пробовал для обмена сообщениями использовать push модель. В итоге, при разрастании кодовой базы появляется куча неявных зависимсотей и поток управления превращается в запутанное спагетти. Сейчас использую pull модель. Да, клиенсткому коду приходится делать больше телодвижений, но поток управления остается простым, что в крупной кодовой базе важнее. ИМХО.

1
Ответить

Медиатор/Посредник

А разве не Observer? Ну и вообще зачем надо 3 плагина ради этого городить если можно сделать простенький статический класс с двумя коллекциями делегатов и привязку событий делать на енамках. Моя простенькая реализация (подсмотренная на одном собесе пару лет назад и передопиленная до плагина в юнити)

https://github.com/noldowalker/CustomEventSystem

Ответить

3 плагина не для этого
Zenject это DI контейнер, он не связан с шиной событий
DoTween для процедурных анимаций
Но согласен, это не совсем очевидно из статьи
А в целом, не хочу учить людей писать велосипеды. Если есть устоявшиеся фреймворки, то лучше пользоваться ими.
Иначе с тем же успехом веб разработчик может выпустить статью «напишем свой реакт»

Ответить