Как стать автором
Обновить

Архитектура игр в Unity с использованием Zenject

Уровень сложностиСредний
Время на прочтение4 мин
Количество просмотров1K

Всем привет!

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

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

Мотивация

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

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

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

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

Начнем с простого, но не менее важного. Структура проекта.
Я перепробовал разные иерархии папки проекта, но сейчас остановился на структуре, которая была описана в статье @vangogih про архитектуру проекта https://habr.com/ru/articles/769044/, где у нас игра находится не в корневой папке Assets, а в отдельной папке, у меня она называется _Game (нижнее подчеркивание нужно, чтобы папка находилась на вершине иерархии).
Следом за игровой папкой у меня идут разные плагины и инструменты.

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

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

Теги:
Хабы:
0
Комментарии2

Публикации

Работа

Ближайшие события