![](https://habrastorage.org/getpro/habr/upload_files/388/2bf/e21/3882bfe215a2a6f001d6151fea108c70.png)
![](https://habrastorage.org/getpro/habr/upload_files/c11/523/1ef/c115231ef34a8a937484a2cc3261bf29.jpg)
Автор статьи: Игорь Гулькин
Senior Unity Developer
Всем привет! 👋
Меня зовут Игорь Гулькин, и я Unity разработчик. За свои 5 лет накопилось много опыта, поэтому в этой статье хотел бы поделиться принципами и подходами, с помощью которых можно реализовать архитектуру игры просто и гибко без фреймворка. Цель доклада, дать не просто готовое решение, а показать ход мыслей и паттерны, с помощью которых ее можно выстроить. Если вы не читали первую и вторую части, то рекомендую начать с них :).
Давайте посмотрим, как у нас выглядит архитектура в конце второй части:
![](https://habrastorage.org/getpro/habr/upload_files/d7f/1c6/58b/d7f1c658b790a015aadd9ad1954eca3d.png)
Выглядит не очень понятно, давайте рефакторить с помощью шаблонов 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
на сцену:
![](https://habrastorage.org/getpro/habr/upload_files/49f/bfe/1fa/49fbfe1fa8ae5b4f500efe05f651c7d9.png)
Теперь давайте глянем на инсталлеры в схеме:
![](https://habrastorage.org/getpro/habr/upload_files/ab2/3db/fab/ab23dbfab847c66e4532b21af01b738a.png)
С инсталлерами все немного интереснее, тут уже придется применить несколько шаблонов GRASP, но самый ключевой из них будет Indirection. В качестве посредника между GameContext
и компонентами системы будет некий GameContextInstaller
.
![](https://habrastorage.org/getpro/habr/upload_files/c89/aaa/3e3/c89aaa3e37f0bea4963f1fb33669665c.png)
Теперь 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
на сцену:
![](https://habrastorage.org/getpro/habr/upload_files/b91/474/e3c/b91474e3cf481541b20acaf1ad900068.png)
Запускаем игру. Сейчас регистрация компонентов системы происходит в методе GameContextInstaller.Awake()
, а внедрение зависимости — в методе GameContextInstaller.Start()
. Дальше в “монобехе” GameContext через контекстное меню вызываем метод GameContext.StartGame()
. Вуаля, все работает!
В результате наша система выглядит так:
![](https://habrastorage.org/getpro/habr/upload_files/417/f38/bd0/417f38bd03984f52089fdf1b8deb9d7d.png)
Оптимизация игры
Теперь было было здорово, чтобы наша игровая логика не зависела от монобехов. Во-первых, такие классы проще переиспользовать и тестировать, так как можно создать экземпляр в любом месте кода. Во-вторых, уйдя от монобехов и гейм-объектов, можно оптимизировать память и производительность. В третьих, появляется возможность работать в многопоточном коде, поскольку 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#, то и все внесение изменений тоже будет решаться на уровне кода… Тем самым это уменьшает необходимость трогать игровые объекты при переписывании кода, и позволяет другим специалистам, таким как левел-дизайнерам работать на сцене параллельно с большей уверенностью, что они ничего не сломают :).
В результате оптимизации видим, что теперь монобехи больше не нужны, так как с игровой логикой теперь можно работать на уровне кода:
![](https://habrastorage.org/getpro/habr/upload_files/793/3e6/2e5/7933e62e53453f104f6864249333269d.png)
Проверяем работоспособность игры… Все работает!
Единая точка входа
Несложно обнаружить, что на игровой сцене нет единой точки входа, и запуск игры происходит как попало:
Компоненты регистрируются в методе
GameContextInstaller.Awake()
.Внедрение зависимостей происходит в методе
GameContextInstaller.Start()
.А игра вообще запускается вручную через инспектор.
Пока что это все выглядит немного ridiculous. Так что давайте сделаем единую точку входа в игру.
Для того, чтобы сделать процесс запуска игры последовательным, нужно поместить вызов всех действий в одном месте.
![](https://habrastorage.org/getpro/habr/upload_files/960/010/de1/960010de19ca980d9eb33f0548f6acbf.png)
Например, давайте выделим ответственность новому классу 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
на сцену, определяем список задач:
![](https://habrastorage.org/getpro/habr/upload_files/c6a/ecd/859/c6aecd859b746703bac641a905f01779.png)
Запускаем игру… Все работает :)
Теперь можно убрать класс 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;
}
}
Финальная схема выглядит так:
![](https://habrastorage.org/getpro/habr/upload_files/cc2/715/de5/cc2715de5888552881cef7c9486e9f92.png)
Таким образом, у нас и получилась единая точка входа в игру. И если, например, необходимо еще подгрузить интерфейс игры и другие объекты на этапе загрузки, то эти процессы можно сделать до внедрения зависимостей:
![](https://habrastorage.org/getpro/habr/upload_files/0f6/627/ef1/0f6627ef1228a5f52cda359c98c7843b.png)
А если нужно сделать несколько этапов пост инициализации: например, загрузить прогресс игрока — то это можно сделать после внедрения зависимостей.
![](https://habrastorage.org/getpro/habr/upload_files/92a/42a/73e/92a42a73e0e467ea3c84ace6a13ed949.png)
В общем теперь вы сами, как разработчик, решаете, когда и как в вашей игре должен происходить запуск. Самое главное — вы всегда знаете, что у вас есть GameLauncher, который является единой точкой входа в игру, а GameContext управляет ее процессом и хранит в себе сервисы.
Выводы
Таким образом, мы реализовали простую и оптимизированную архитектуру игры на Unity без Zenject на примере перемещения кубика.
![](https://habrastorage.org/getpro/habr/upload_files/e9d/e03/1e2/e9de031e250f78cd1fc930d5f3f177b0.png)
Теперь в нашей архитектуре:
Есть
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) магазина. Подробнее про урок можете узнать по этой ссылке.
На этом у меня все, всем спасибо за внимание :)