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

от автора

Автор статьи: Игорь Гулькин

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) магазина. Подробнее про урок можете узнать по этой ссылке.

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


ссылка на оригинал статьи https://habr.com/ru/articles/729888/