Всем привет!

Меня зовут Яков, не сказал бы, что я супер разработчик игр (в общей сумме у меня 20 тыс игроков со всех игр выложенных на Яндекс.Игры), но хотел бы поделиться опытом создания гибкой архитектуры игр в Unity с использованием DI-контейнера Zenject.

В статье не будет основ работы с Zenject, поэтому я надеюсь что вы уже знаете данный фреймворк.

Мотивация

Вы спросите меня:

Почему я решил написать данную статью?

Все просто, либо я не умею гуглить, либо в интернете нет подробной информации, как правильно организовывать и работать с архитектурой на базе Zenject.

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

Начнем с самого простого, но не менее значимого — структуры проекта.

Я экспериментировал с разными способами организации папок в проекте, но в итоге остановился на подходе, описанном в статье @vangogih об архитектуре проекта. Согласно этой методике, игра располагается не в корневой папке Assets, а в отдельной директории, которую я назвал _Game (подчеркивание необходимо, чтобы папка была на верхнем уровне иерархии).

После игровой папки следуют различные плагины и инструменты.

Структура папки _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 — мощный инструмент, но для его использования нужна продуманная архитектура. Иначе контейнер не улучшит, а навредит игре.

Это моя первая статья. Надеюсь, я ясно и подробно раскрыл тему. Если у вас есть вопросы, пишите в комментариях.

Удачи!