Как стать автором
Обновить
638.5
OTUS
Цифровые навыки от ведущих экспертов

Архитектура на Unity без Zenject. Часть 3

Уровень сложностиПростой
Время на прочтение11 мин
Количество просмотров5.9K
Автор статьи: Игорь Гулькин

Senior Unity Developer

Всем привет! 👋

Меня зовут Игорь Гулькин, и я Unity разработчик. За свои 5 лет накопилось много опыта, поэтому в этой статье хотел бы поделиться принципами и подходами, с помощью которых можно реализовать архитектуру игры просто и гибко без фреймворка. Цель доклада, дать не просто готовое решение, а показать ход мыслей и паттерны, с помощью которых ее можно выстроить. Если вы не читали первую и вторую части, то рекомендую начать с них :).

Давайте посмотрим, как у нас выглядит архитектура в конце второй части:

Выглядит не очень понятно, давайте рефакторить с помощью шаблонов GRASP с целью упрощения понимания…

Рефакторинг архитектуры

Первым делом мы можем использовать шаблон Pure Fabrication и объединить логику классов GameMachine и GameLocator в некий Фасад, который назовем GameContext:

public sealed class GameContext : MonoBehaviour, IGameLocator, IGameMachine
    {
        public GameState GameState
        {
            get { return this.gameMachine.GameState; }
        }

        private readonly GameMachine gameMachine = new();

        private readonly GameLocator serviceLocator = new();
       public GameContext()
        {
            this.serviceLocator.AddService(this.gameMachine);
        }

        [ContextMenu("Start Game")]
        public void StartGame()
        {
            this.gameMachine.StartGame();
        }

        [ContextMenu("Pause Game")]
        public void PauseGame()
        {
            this.gameMachine.PauseGame();
        }

        [ContextMenu("Resume Game")]
        public void ResumeGame()
        {
           this.gameMachine.ResumeGame();
        }

        [ContextMenu("Finish Game")]
        public void FinishGame()
        {
            this.gameMachine.FinishGame();
        }

        public void AddListener(object listener)
        {
            this.gameMachine.AddListener(listener);
        }

        public void RemoveListener(object listener)
        {
            this.gameMachine.RemoveListener(listener);
        }
        
        public void AddService(object service)
        {
            this.serviceLocator.AddService(service);
        }

        public void RemoveService(object service)
        {
            this.serviceLocator.RemoveService(service);
        }

        public T GetService<T>()
        {
            return this.serviceLocator.GetService<T>();
        }
    }

Применив шаблон GRASP, получаем один монобех, который содержит всю логику для работы с сервисами и состоянием игры. При этом GameContext реализует интерфейсы IGameMachine и IGameLocator, но бизнес-логику делегирует теперь уже обычным классам GameMachine и GameLocator.

Добавим GameContext на сцену:

Теперь давайте глянем на инсталлеры в схеме:

С инсталлерами все немного интереснее, тут уже придется применить несколько шаблонов GRASP, но самый ключевой из них будет Indirection. В качестве посредника между GameContext и компонентами системы будет некий GameContextInstaller.

Теперь GameContextInstaller будет регистрировать все сервисы и листенеры и заниматься внедрением зависимостей. А вместо классов GameObservableInstaller, GameServiceInstaller и GameAssembler будут интерфейсы IGameServiceProviderIGameListenerProvider, IGameConstructor. Вот как это будет выглядеть в коде:

public sealed class GameContextInstaller : MonoBehaviour
    {
        [SerializeField]
        private GameContext gameContext;

        [SerializeField]
        private MonoBehaviour[] installers;

        private void Awake()
        {
            foreach (var installer in this.installers)
            {
                if (installer is IGameServiceProvider serviceProvider)
                {
                    this.gameContext.AddServices(serviceProvider.GetServices());
                }

                if (installer is IGameListenerProvider listenerProvider)
                {
                    this.gameContext.AddListeners(listenerProvider.GetListeners());
                }
            }
        }

        private void Start()
        {
            foreach (var installer in this.installers)
            {
                if (installer is IGameConstructor constructor)
                {
                    constructor.ConstructGame(this.gameContext);
                }
            }
        }
    }

В методе GameContextInstaller.Awake() происходит регистрация сервисов и слушателей в GameContext, а в методе GameContextInstaller.Start() происходит внедрение зависимости.

Теперь пару слов про новые интерфейсы:

IGameListenerProvider предоставляет слушателей для регистрации в GameContext.

public interface IGameListenerProvider
    {
        IEnumerable<object> GetListeners();
    }

IGameServiceProvider предоставляет сервисы для регистрации в GameContext.

public interface IGameServiceProvider
    {
        IEnumerable<object> GetServices();
    }

IGameConstructor занимается разрешением зависимостей, получая центральный реестр в качестве аргумента.

 public interface IGameConstructor
    {
        void ConstructGame(IGameLocator serviceLocator);
    }

Теперь нам нужно написать классы, которые будут реализовывать эти контракты. 

В нашем проекте, фактически есть два модуля: система игрока и система пользовательского ввода. Поэтому для каждого модуля и напишем свою реализацию

  1. В классе PlayerInstaller будем описывать систему игрока.

  2. В классе InputInstaller будем описывать систему ввода.

Прилагаю код:

public sealed class PlayerInstaller : MonoBehaviour,
        IGameServiceProvider,
        IGameListenerProvider,
        IGameConstructor
    {
        [SerializeField]
        private Player player;

        [SerializeField]
        private MoveController moveController;
        
        //TODO: Подключить контроллер камеры:
        //[SerializeField]
        //private CameraController cameraController;

        IEnumerable<object> IGameServiceProvider.GetServices()
        {
            yield return this.player;
        }

        IEnumerable<object> IGameListenerProvider.GetListeners()
        {
            yield return this.moveController;
            //yield return this.cameraController;
        }

        void IGameConstructor.ConstructGame(IGameLocator serviceLocator)
        {
            var keyboardInput = serviceLocator.GetService<IMoveInput>();
            this.moveController.Construct(keyboardInput, this.player);
            
            //var camera = serviceLocator.GetService<WorldCamera>();
            // this.cameraController.Construct(camera, this.player)
        }
    }


public sealed class InputInstaller : MonoBehaviour,
        IGameServiceProvider,
        IGameListenerProvider
    {
        [SerializeField]
        private KeyboardInput keyboardInput;

        //TODO: подключить ввод с мыши
        //[SerializeField]
        //private MouseInput mouseInput;

        IEnumerable<object> IGameServiceProvider.GetServices()
        {
            yield return this.keyboardInput;
            //yield return this.mouseInput;
        }

        IEnumerable<object> IGameListenerProvider.GetListeners()
        {
            yield return this.keyboardInput;
            //yield return this.mouseInput;
        }
    }

Отлично, код написан! Добавляем GameContextInstaller, PlayerInstaller и InputInstaller на сцену:

Запускаем игру. Сейчас регистрация компонентов системы происходит в методе GameContextInstaller.Awake(), а внедрение зависимости — в методе GameContextInstaller.Start(). Дальше в “монобехе” GameContext через контекстное меню вызываем метод GameContext.StartGame(). Вуаля, все работает!

В результате наша система выглядит так:

Оптимизация игры

Теперь было было здорово, чтобы наша игровая логика не зависела от монобехов. Во-первых, такие классы проще переиспользовать и тестировать, так как можно создать экземпляр в любом месте кода. Во-вторых, уйдя от монобехов и гейм-объектов, можно оптимизировать память и производительность. В третьих, появляется возможность работать в многопоточном коде, поскольку GameObject’ы и  “монобехи” можно использовать только в главном потоке

Таким образом, MoveController и KeyboardInput можно сделать обычными классами и создавать экземпляры этих классов прямо в инсталлере: 

 public sealed class InputInstaller : MonoBehaviour,
        IGameServiceProvider,
        IGameListenerProvider
    {
        private readonly KeyboardInput keyboardInput = new();

//Other code…
    }

public sealed class PlayerInstaller : MonoBehaviour,
        IGameServiceProvider,
        IGameListenerProvider,
        IGameConstructor
    {
        private readonly MoveController moveController = new();

        //private readonly CameraController cameraController = new();

//Other code…
           }

Но у KeyboardInput есть метод Update(), который вызывается из движка, чтобы трекать пользовательский ввод. Этот нюанс тоже можно решить, если сделать специальный интерфейс IUpdateGameListener, который будет вызывать GameContext:

public interface IUpdateGameListener
    {
        void OnUpdate(float deltaTime);
    }

Таким образом, KeyboardInput будет выглядеть так:

 public sealed class KeyboardInput :
        IMoveInput,
        IStartGameListener,
        IUpdateGameListener,
        IFinishGameListener
    {
        public event Action<Vector3> OnMove;

        private bool isActive;

        void IUpdateGameListener.OnUpdate(float deltaTime)
        {
            if (this.isActive)
            {
                this.HandleKeyboard();
            }
        }

//Other code…
}

А в класс GameContext добавляем следующий код:

public sealed class GameContext : MonoBehaviour, IGameLocator, IGameMachine
    {
        private readonly List<IUpdateGameListener> updateListeners = new();

        private void Awake()
        {
            this.enabled = false;
        }

        //Вызывается только, если игра запущена
        private void Update()
        {
            var deltaTime = Time.deltaTime;
            for (int i = 0, count = this.updateListeners.Count; i < count; i++)
            {
                var listener = this.updateListeners[i];
                listener.OnUpdate(deltaTime);
            }
        }

//Other code…
}

Ремарка: при необходимости можно также прикрутить методы FixedUpdate() и LateUpdate(), реализовав соответствующие интерфейсы: IFixedUpdateListener и ILateUpdateListener

Еще одним преимуществом написания кода без монобехов будет разделение ответственности на уровне проекта. Поскольку код игры пишется на чистом C#, то и все внесение изменений тоже будет решаться на уровне кода… Тем самым это уменьшает необходимость трогать игровые объекты при переписывании кода, и позволяет другим специалистам, таким как левел-дизайнерам работать на сцене параллельно с большей уверенностью, что они ничего не сломают :).

В результате оптимизации видим, что теперь монобехи больше не нужны, так как с игровой логикой теперь можно работать на уровне кода:

Проверяем работоспособность игры… Все работает!

Единая точка входа

Несложно обнаружить, что на игровой сцене нет единой точки входа, и запуск игры происходит как попало:

  1. Компоненты регистрируются в методе GameContextInstaller.Awake().

  2. Внедрение зависимостей происходит  в методе GameContextInstaller.Start().

  3. А игра вообще запускается вручную через инспектор.

Пока что это все выглядит немного ridiculous. Так что давайте сделаем единую точку входа в игру.

Для того, чтобы сделать процесс запуска игры последовательным, нужно поместить вызов всех действий в одном месте.

Например, давайте выделим ответственность новому классу GameLauncher, который будет заниматься запуском игры в одном месте.

 public sealed class GameLauncher : MonoBehaviour
    {
        [SerializeField]
        private GameContextInstaller installer;

        [SerializeField]
        private GameContext context;

        [ContextMenu("Launch Game")]
        public void LaunchGame()
        {
            this.installer.RegisterComponents();
            this.installer.ConstructGame();
            this.context.StartGame();
        }
    }

Если посмотреть на класс GameLauncher, можно заметить, что методы класса GameContextInstaller.RegisterComponents() и GameContextInstaller.ConstructGame() стали публичными. Теперь они вызываются в методе GameLauncher.LaunchGame(), а не через методы Awake() и Start(), как было ранее. Это сделано, для того, чтобы у разработчика всегда был контроль над управлением системы, и это хорошо :)

Опытные читатели, взглянув на класс GameLauncher, быстро заметят, что этот класс нарушает 2-й принцип SOLID Open-Closed, так при добавлении или удалении команды в процесс загрузки, нам придется изменять этот класс. Поэтому давайте отрефакторим GameLauncher, используя полиморфизм, чтобы порядок инициализации был более гибким:

public abstract class GameTask : ScriptableObject
    {
        public abstract Task Do();
    }

public sealed class GameLauncher : MonoBehaviour
    {
        [SerializeField]
        private bool autoRun = true;
        
        [SerializeField]
        private List<GameTask> taskList;

        private async void Start()
        {
            if (this.autoRun)
            {
                await this.LaunchGame();
            }
        }

        [ContextMenu("Launch Game")]
        public async Task LaunchGame()
        {
            foreach (var task in this.taskList)
            {
                await task.Do();
            }
        }
    }

Теперь наш пайплайн запуска состоит из асинхронных задач и может запускаться автоматически с помощью галочки autoRun. Асинхронная загрузка позволяет подгружать ресурсы и прогресс игрока в других потоках, что может потребоваться на боевом проекте.

Каждую задачу можно сделать ScriptableObject’ом, чтобы потом легко ее можно было добавить в список загрузки. Давайте посмотрим, как это выглядит в коде:

Задача регистрации компонентов:

 [CreateAssetMenu(
        fileName = "Task «Register Components»",
        menuName = "GameTasks/Task «Register Components»"
    )]
    public sealed class GameTask_RegisterComponents : GameTask
    {
        public override Task Do()
        {
            var installer = FindObjectOfType<GameContextInstaller>();
installer.RegisterComponents();
            return Task.CompletedTask;
        }
    }

Задача разрешения зависимостей:

 [CreateAssetMenu(
        fileName = "Task «Construct Game»",
        menuName = "GameTasks/Task «Construct Game»"
    )]
    public sealed class GameTask_ConstructGame : GameTask
    {
        public override Task Do()
        {
            var installer = FindObjectOfType<GameContextInstaller>();
            installer.ConstructGame();
            return Task.CompletedTask;
        }
    }

Задача запуска игры:

  [CreateAssetMenu(
        fileName = "Task «Start Game»",
        menuName = "GameTasks/Task «Start Game»"
    )]
    public sealed class GameTask_StartGame : GameTask
    {
        public override Task Do()
        {
            var gameContext = FindObjectOfType<GameContext>();
            gameContext.StartGame();
            return Task.CompletedTask;
        }
    }

Ремарка: В данном примере мы видим использование методов GameObject.FindObjectOfType, которые помогают найти ключевые компоненты на сцене. Несмотря на то, что производительность метода GameObject.FindObjectOfType напрямую зависит от кол-ва объектов на сцене, на этапе запуска это будет не критично, так как процесс загрузки не относиться к самому геймплею игры.

Ремарка: Если потребуется задача оптимизации, то можно сделать поиск объектов через метод GameObject.FindWithTag(“Game”). Поскольку Unity делает кеширование объектов по тегу в массивы, то такой подход будет гораздо быстрее. Ну или в самом-самом крайнем случае сделайте синглтон GameContext.Instance, к которому можно обращаться только на этапе загрузки…

Ну что ж, добавляем GameLauncher на сцену, определяем список задач:

Запускаем игру… Все работает :)

Теперь можно убрать класс GameContextInstaller и перенести его логику в задачи, чтобы упростить систему:

[CreateAssetMenu(
        fileName = "Task «Construct Game»",
        menuName = "GameTasks/Task «Construct Game»"
    )]
    public sealed class GameTask_ConstructGame : GameTask
    {
        public override Task Do()
        {
            var gameContext = GameObject
                .FindGameObjectWithTag(TagManager.GAME_CONTEXT)
                .GetComponent<GameContext>();
            
            var installers = GameObject
                .FindGameObjectsWithTag(TagManager.GAME_INSTALLER);   
            
            foreach (var installer in installers)
            {
                if (installer.TryGetComponent(out IGameServiceProvider serviceProvider))
                {
                    gameContext.AddServices(serviceProvider.GetServices());
                }

                if (installer.TryGetComponent(out IGameListenerProvider listenerProvider))
                {
                    gameContext.AddListeners(listenerProvider.GetListeners());
                }
            }
            
            foreach (var installer in installers)
            {
                if (installer.TryGetComponent(out IGameConstructor constructor))
                {
                    constructor.ConstructGame(gameContext);
                }
            }
            
            return Task.CompletedTask;
        }
    }

 [CreateAssetMenu(
        fileName = "Task «Start Game»",
        menuName = "GameTasks/Task «Start Game»"
    )]
    public sealed class GameTask_StartGame : GameTask
    {
        public override Task Do()
        {
            GameObject
                .FindGameObjectWithTag(TagManager.GAME_CONTEXT)
                .GetComponent<GameContext>()
                .StartGame();

            return Task.CompletedTask;
        }
    }

Финальная схема выглядит так:

Таким образом, у нас и получилась единая точка входа в игру. И если, например, необходимо еще подгрузить интерфейс игры и другие объекты на этапе загрузки, то эти процессы можно сделать до внедрения зависимостей:

А если нужно сделать несколько этапов пост инициализации: например, загрузить прогресс игрока — то это можно сделать после внедрения зависимостей.

В общем теперь вы сами, как разработчик, решаете, когда и как в вашей игре должен происходить запуск. Самое главное — вы всегда знаете, что у вас есть GameLauncher, который является единой точкой входа в игру, а GameContext управляет ее процессом и хранит в себе сервисы.

Выводы

Таким образом, мы реализовали простую и оптимизированную архитектуру игры на Unity без Zenject на примере перемещения кубика.

Теперь в нашей архитектуре:

  1. Есть GameLauncher, который является точкой входа в игру и делает запуск по списку задач.

  2. Есть GameContext, который управляет состоянием игры и вызывает Update у элементов системы.

  3. Есть механизм разрешения зависимостей, который реализован в коде через инсталлеры и сервис-локатор.

  4. Вся игровая логика реализована на классах C#.

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

Ремарка: в будущем, если вы хотите описывать ваши инсталлеры более декларативно, то вам потребуется рефлексия и атрибуты для того, чтобы собирать слушателей и сервисы, а также же делать внедрение зависимостей. Например, у меня в проектах это выглядит примерно так:

public sealed class PlayerInstaller : GameInstaller
    {
        [GameService]
        [SerializeField]
        private Player player;

        [GameListener]
        private readonly MoveController moveController = new();

        [GameListener]
        private readonly CameraController cameraController = new()
    }

 public sealed class InputInstaller : GameInstaller
    {
        [GameService, GameListener]
        private readonly KeyboardInput keyboardInput = new();

        [GameService, GameListener]
        private readonly MouseInput mouseInput = new();
    }

Такую архитектуру я использую в своих проектах: 

  1. Tank Puzzlers

  2. Tribal Land

  3. Демо-проект для курса

Поэтому нет предела совершенству, самое главное, выбирайте архитектуру игры с умом, ведь у каждого проекта есть свои требования, дедлайн и команда, как бы это банально не звучало .

Прилагаю ссылку на код к статье.

Также хочу порекомендовать вам еще один полезный урок, где изучим паттерн Model-View-Presenter на примере попапа (pop-up) магазина. Подробнее про урок можете узнать по этой ссылке.

На этом у меня все, всем спасибо за внимание :)

Теги:
Хабы:
Всего голосов 8: ↑6 и ↓2+5
Комментарии18

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS