Event Bus и расширяемые игры. Часть 1
В последнее время среди игровых разработчиков возрос интерес к паттерну «Шина Событий». Этот паттерн часто ругают за его тенденцию к «размыванию логики» и «скрытию зависимостей». Однако, несмотря на критику, полный отказ от этого паттерна также глуп как и написание кода в блокноте вместо специализированной 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, чтобы игрок не двигался быстрее по диагонали.
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
public class MoveComponent : ActorComponent
{
[SerializeField] private Rigidbody _rigidbody;
private IMessageReceiver _receiver;
[Inject]
private void Construct(IMessageReceiver receiver) =>
_receiver = receiver;
private void Start() =>
_receiver.Receive().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().FromInstance(broker).AsSingle();
Container.Bind().FromInstance(broker).AsSingle();
Container.Bind().FromInstance(_heroConfig).AsSingle();
Container.BindInterfacesAndSelfTo().AsSingle();
}
}
С кодом покончили, перейдем к движку.
В первую очередь, создадим конфиг персонажа и добавим его в инсталлер на сцене.
Добавление конфига персонажа в инсталлер
Компоненты на игроке
Затем создадим персонажа (в данный момент подойдет и обычный куб) и добавим на него нужные компоненты.
Итог
С помощью шины событий мы реализовали такую простую механику как движение персонажа. И при этом ни разу не «размыли логику» или не «спрятали зависимости». В следующих частях нам предстоит сделать еще кучу всего — противников, боевку, добавить визуальные эффекты и анимации.
То же самое, но в видео формате можете увидеть на моем Youtube канале. Узнать про много другое — работу в геймдеве, фриланс и менторинг в моем телеграме, а видеть контент раньше остальных — на Boosty.
Всем спасибо! Делайте крутые игры, не размывайте их логику и оставайтесь на связи!