
Автор статьи: Игорь Гулькин
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 будут интерфейсы IGameServiceProvider, IGameListenerProvider, 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); }
Теперь нам нужно написать классы, которые будут реализовывать эти контракты.
В нашем проекте, фактически есть два модуля: система игрока и система пользовательского ввода. Поэтому для каждого модуля и напишем свою реализацию
-
В классе
PlayerInstallerбудем описывать систему игрока. -
В классе
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#, то и все внесение изменений тоже будет решаться на уровне кода… Тем самым это уменьшает необходимость трогать игровые объекты при переписывании кода, и позволяет другим специалистам, таким как левел-дизайнерам работать на сцене параллельно с большей уверенностью, что они ничего не сломают :).
В результате оптимизации видим, что теперь монобехи больше не нужны, так как с игровой логикой теперь можно работать на уровне кода:

Проверяем работоспособность игры… Все работает!
Единая точка входа
Несложно обнаружить, что на игровой сцене нет единой точки входа, и запуск игры происходит как попало:
-
Компоненты регистрируются в методе
GameContextInstaller.Awake(). -
Внедрение зависимостей происходит в методе
GameContextInstaller.Start(). -
А игра вообще запускается вручную через инспектор.
Пока что это все выглядит немного 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 на примере перемещения кубика.

Теперь в нашей архитектуре:
-
Есть
GameLauncher, который является точкой входа в игру и делает запуск по списку задач. -
Есть
GameContext, который управляет состоянием игры и вызывает Update у элементов системы. -
Есть механизм разрешения зависимостей, который реализован в коде через инсталлеры и сервис-локатор.
-
Вся игровая логика реализована на классах C#.
-
Самое главное — у разработчика есть полный контроль над системой, и он может затачивать такую архитектуру под свой проект, тем самым влиять на оптимизацию в отличие от использования 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(); }
Такую архитектуру я использую в своих проектах:
Поэтому нет предела совершенству, самое главное, выбирайте архитектуру игры с умом, ведь у каждого проекта есть свои требования, дедлайн и команда, как бы это банально не звучало .
Прилагаю ссылку на код к статье.
Также хочу порекомендовать вам еще один полезный урок, где изучим паттерн Model-View-Presenter на примере попапа (pop-up) магазина. Подробнее про урок можете узнать по этой ссылке.
На этом у меня все, всем спасибо за внимание 🙂
ссылка на оригинал статьи https://habr.com/ru/articles/729888/
Добавить комментарий