
Всем привет!
Меня зовут Яков, не сказал бы, что я супер разработчик игр (в общей сумме у меня 20 тыс игроков со всех игр выложенных на Яндекс.Игры), но хотел бы поделиться опытом создания гибкой архитектуры игр в Unity с использованием DI-контейнера Zenject.
В статье не будет основ работы с Zenject, поэтому я надеюсь что вы уже знаете данный фреймворк.
Мотивация
Вы спросите меня:
Почему я решил написать данную статью?
Все просто, либо я не умею гуглить, либо в интернете нет подробной информации, как правильно организовывать и работать с архитектурой на базе Zenject.
Структура проекта
Начнем с простого, но не менее важного. Структура проекта.
Я перепробовал разные иерархии папки проекта, но сейчас остановился на структуре, которая была описана в статье @vangogih про архитектуру проекта https://habr.com/ru/articles/769044/, где у нас игра находится не в корневой папке Assets, а в отдельной папке, у меня она называется _Game (нижнее подчеркивание нужно, чтобы папка находилась на вершине иерархии).
Следом за игровой папкой у меня идут разные плагины и инструменты.

Папка Scripts, хранит в себе еще 3 основные папки.
1) Common, папка в которой я храню, скрипты расширения, обычно это, какие-либо статичные классы и методы, а так же необходимые для игры утилиты.
2) Core - это папка для хранения данных, инсталлеров и общей логики для всей игры.
3) 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 - это мощный инструмент, для которого требуется архитектура, ведь если бы ее не было, то контейнер бы только навредил игре, а не помог.
Это мой первый опыт написания статей. Надеюсь, что я подробно и понятно описал тему. Если есть вопросы, пишите в комментариях.
Удачи!