Архитектура игр в Unity с использованием Zenject
Всем привет!
Меня зовут Яков, не сказал бы, что я супер разработчик игр (в общей сумме у меня 20 тыс игроков со всех игр выложенных на Яндекс.Игры), но хотел бы поделиться опытом создания гибкой архитектуры игр в Unity с использованием DI-контейнера Zenject.
В статье не будет основ работы с Zenject, поэтому я надеюсь что вы уже знаете данный фреймворк.
Мотивация
Вы спросите меня:
Почему я решил написать данную статью?
Все просто, либо я не умею гуглить, либо в интернете нет подробной информации, как правильно организовывать и работать с архитектурой на базе Zenject.
Структура проекта
Начнем с самого простого, но не менее значимого — структуры проекта.
Я экспериментировал с разными способами организации папок в проекте, но в итоге остановился на подходе, описанном в статье @vangogih об архитектуре проекта. Согласно этой методике, игра располагается не в корневой папке Assets, а в отдельной директории, которую я назвал _Game (подчеркивание необходимо, чтобы папка была на верхнем уровне иерархии).
После игровой папки следуют различные плагины и инструменты.
Папка Scripts хранит в себе еще 3 основные папки.
Common — папка, в которой я храню скрипты расширения, обычно это какие-либо статичные классы и методы, а также необходимые для игры утилиты.
Core — это папка для хранения данных, инсталлеров и общей логики для всей игры.
Feature — тут у меня находится основная логика игровых механик.
Как понять, какую логику мне хранить в папке Core и Feature
В Core мы храним общие принципы игры. Это те элементы, которые можно использовать в разных ситуациях. Например, UI — интерфейс пользователя, который нельзя отнести только к игроку, так как он отображает не только его здоровье, но и кнопки для перехода в меню и миникарту.
В Feature же мы храним логику, предназначенную для конкретных задач. Так, Player содержит правила, которые применяются исключительно для игрока.
Работа с Zenject создание и инъекция сервисов
Наконец мы перешли к практике. Давайте посмотрим из чего состоит моя папка Player
Мы видим, что у нас есть сервисы передвижения и подбора.
Сервис - это определенная функциональность фичи, которая состоит из интерфейса и его реализации.
Рассмотрим сервис передвижения.
Интерфейс IMovement
public interface IMovement
{
void Initialize(Transform groundCheck, SpriteRenderer spriteRenderer, Rigidbody2D rb);
void Move(float speed, float moveX);
void Jump(float jumpForce);
}
Реализация Movement
public class Movement : IMovement
{
private const float Gravity = -15f;
private const float GroundRange = .1f;
private Rigidbody2D rb;
private Transform groundCheck;
private SpriteRenderer spriteRenderer;
private Vector3 velocity;
private bool isGrounded;
public void Initialize(Transform groundCheck, SpriteRenderer spriteRenderer, Rigidbody2D rb)
{
rb = rb;
groundCheck = groundCheck;
spriteRenderer = spriteRenderer;
}
public void Move(float speed, float moveX) { }
public void Jump(float jumpForce) { }
private void SetGravity() { }
private void Flip(float moveX) { }
}
Сервисы не являются классами типа MonoBehaviour. Это позволяет нам легко тестировать код и повышает производительность игры.
Так как сервисы — это обычные классы, а для взаимодействия с игровыми объектами нужен MonoBehaviour, создадим точку для выполнения сервисов. Этой точкой станет класс PlayerBehaviour.cs.
public class PlayerBehaviour : MonoBehaviour
{
[Header("Input Settings")]
[SerializeField] private float speed = 3f;
[SerializeField] private float jumpForce = 1f;
[Header("Movement Settings")]
[SerializeField] private Transform groundCheck;
[SerializeField] private SpriteRenderer spriteRenderer;
[SerializeField] private Rigidbody2D rb;
[Header("Tilemap Settings")]
[SerializeField] private Tile selectTile;
[SerializeField] private float range = 1.8f;
[SerializeField] private float safeZone = .45f;
private InputProvider _input;
private IMovement _movement;
private IInventory _inventory;
private ITilemapInteract _tilemapInteract;
[Inject]
public void Construct(
InputProvider input,
IMovement movement,
IInventory inventory,
ITilemapInteract tilemapInteract)
{
_input = input;
_movement = movement;
_inventory = inventory;
_tilemapInteract = tilemapInteract;
}
public void Start()
{
_movement.Initialize(groundCheck, spriteRenderer, rb);
_tilemapInteract.Initialize(selectTile, range, safeZone);
}
private void FixedUpdate()
{
_movement.Move(speed, _input.GetMove.x);
if (_input.Jump)
_movement.Jump(jumpForce);
}
private void Update()
{
_inventory.UpdateSlot();
_tilemapInteract.Update(transform);
}
}
В этом классе мы используем Zenject для внедрения зависимостей и работаем с интерфейсами. Это позволяет нам отделить логику в классе.
Теперь давайте посмотрим на инсталлер игрока.
public class PlayerInstaller : MonoInstaller
{
private PlayerBehaviour _player;
public override void InstallBindings()
{
Container.Bind<PlayerBehaviour>().FromComponentInNewPrefabResource("Player").AsSingle().NonLazy();
Container.Bind<IMovement>().To<Movement>().AsSingle();
Container.Bind<ITilemapInteract>().To<TilemapInteract>().AsSingle();
Container.Bind<IPickup>().To<ItemPickup>().AsSingle();
Container.Bind<IBuilding>().To<Building>().AsSingle();
Container.Bind<IMining>().To<Mining>().AsSingle();
}
}
Про инсталлятор я особо ничего не скажу — его работа и так очевидна. Мы привязываем реализацию к нашему интерфейсу, и таких реализаций может быть много. Преимущество инсталлятора в том, что не нужно менять код поведения игрока, если, например, меняется способ передвижения. Достаточно заменить реализацию в инсталляторе, и всё готово.
Заключение
Zenject — мощный инструмент, но для его использования нужна продуманная архитектура. Иначе контейнер не улучшит, а навредит игре.
Это моя первая статья. Надеюсь, я ясно и подробно раскрыл тему. Если у вас есть вопросы, пишите в комментариях.
Удачи!