Привет, Хабр! ?
Меня зовут Игорь, и я Unity Developer. В этой статье я хотел бы поделиться кастомной архитектурой, которую сделал в процессе разработки своей RTS игры.
Скажу сразу: что основные концепции и принципы уже используются в различных DI фреймворках, таких как Zenject & VContainer. Поэтому чего-то феноменального в этой статье вы не увидите. Но, поскольку я люблю делать свои велосипеды, то в свою архитектуру я привнес парочку интересных вещей, которых нет в других DI фреймворках на Unity. Ну шо, поехали :)
Введение
Итак, в начале разработки своей RTS игры, я уже понимал, что в моем проекте архитектура должна выполнять ряд требований. Ключевое из них — это быть простой и понятной, чтобы я, как разработчик, фокусировался на написании бизнес-логики, а не на инфраструктурных вещах. Вот какие у меня получились критерии:
Архитектура должна использовать Dependency Injection. По своему 5-ти летнему опыту разработки игр скажу, что использование механизма внедрения зависимостей увеличивает поддержку и тестируемость код-базы, поскольку каждый класс содержит в себе только бизнес-логику и необходимые зависимости. Когда есть DI, то не нужно создавать синглтоны на каждый чих или получать доп. зависимость на сервис-локатор. Использование архитектуры с DI упрощает поиск багов в классах, поскольку все зависимости определены в его конструкторе или методе пост-инъекции Construct().
Архитектура должна реализовывать механизм обработки игровых событий. Любая игра — это процесс. Поэтому у игры должны быть события и состояния старта, паузы, окончание и так далее. Поэтому было бы здорово иметь систему, которая может переключать состояния игры и оповещать об этом всех подписчиков.
Архитектура по-минимуму должна зависеть от монобехов. Считаю, что это очень хорошая практика писать код без монобехов, потому что обычные C# классы более гибкие в использовании, их легче тестировать, проще переиспользовать, так как они не завязаны на GameObject'ы и монобехи. И еще такой подход дает небольшой буст к оптимизации памяти и производительности.
Архитектура должна поддерживать несколько игроков. Поскольку игра будет мультиплеерной / против ИИ, то очень важно сделать так, чтобы у каждого игрока была своя подсистема контроллеров, менеджеров и игровых объектов, которыми он управляет.
Таким образом, у меня получилась своя нодовая архитектура, которая решает все эти задачи. Ну что ж, давайте смотреть :)
Что такое нодовая архитектура

Если говорить вкратце, нодовая архитектура — это архитектура, которая представлена в виде дерева-графа, где узлами являются домены, а ребрами — отношения между этими доменами: родительский / дочерний. Каждый домен является своего рода контекстом, который содержит в себе зарегистрированные компоненты системы и выполняет над ними инфраструктурную логику.
Приведу простой пример:
Давайте предположим, что мы делаем стратегию, в которой 2 игрока играют друг против друга. У каждого игрока есть своя армия, свои ресурсы, свои контроллеры управления и свой интерфейс. Также в игре есть и глобальные системы, например, система спауна игровых объектов или сервис, через который можно найти любой игровой объект на сцене.
Таким образом у нас есть дерево, которое состоит из родительского контекста игры и двух дочерних узлов игроков.

Таким образом, на домене каждого игрока будут располагаться локальные компоненты, а на домене игры — глобальные.
//Домен игры с глобальными компонетами public sealed class GameContext : ContextNode { //Регистрируем зависимости: protected override void OnInstall() { this.RegisterInstance(new ObjectSpawner()); //Создает объекты на сцене this.RegisterInstance(new ObjectService()); //Ищет объекты на сцене //И так далее... } }
//Домен каждого игрока с локальными компонентами public sealed class PlayerContext : ContextNode { //Регистрируем зависимости: protected override void OnInstall() { this.RegisterInstance(new PlayerResources()); //Ресурсы игрока this.RegisterInstance(new PlayerUnitStack()); //Выделенные юниты this.RegisterInstance(new PlayerInput()); //Управление вводом this.RegisterInstance(new PlayerInterface()); //Интерфейс //И так далее... } }
Поскольку в архитектуре используется механизм внедрения зависимостей, то ключевая задача заключается в том, чтобы каждый компонент системы правильно получил свои зависимости. И вот как он выглядит:

Когда происходит DI, то первым делом внедрение зависимостей заходит в корень дерева, а затем распространяется по дочерним узлам. Но при этом компоненты дочерних узлов могут получать зависимости, как на своем домене, так и на родительских.
Такой подход очень удобен, потому что можно легко масштабировать кол-во игроков в игре, не переписывая код-архитектуры.
Теперь приведу более конкретный пример из разработки своей RTS игры. Например, у каждого игрока есть стек выделенных юнитов, которыми он управляет. Чтобы отдать приказ юнитам переместиться в определенную точку на карте, нужен класс PlayerUnitMover, который и будет выполнять эту ответственность.
public sealed class PlayerUnitMover { // Зарегистрирован в домене игрока private readonly PlayerUnitStack unitStack; //Зарегистрирован в домене игры private readonly ObjectManager objectManager; //Метод пост-инъекции, куда приходят зависимости: [GameInject] public void Construct( PlayerUnitStack unitStack, ObjectManager objectManager ) { this.unitStack = unitStack; this.objectManager = objectManager; } public void MoveSelectedUnits(Vector3 destination) { if (this.unitStack.IsNotEmpty()) { var selectedUnits = this.unitStack.GetUnits(); this.objectManager.MoveObjects(selectedUnits, destination); } } }
Класс PlayerUnitMover отвечает за перемещение выделенных юнитов у конкретного игрока, которые находятся в классе PlayerUnitStack, а ObjectManager отдает различные приказы игровым объектом, которые существуют на сцене, на более низком уровне (на ECS), и ему не важно, кто отдал приказ конкретным юнитам.
В метод пост-инъекции Construct(), происходит внедрение зависимостей через аргументы метода. Зависимость на компонент PlayerUnitStack — получает из того же домена игрока, а ObjectManager — из домена игры.
В целом такой подход уже реализован в фреймворках Zenject и VContainer, поэтому построение игры в виде дерева подсистем используется достаточно часто.
Пуш игровых событий
Как я писал выше в требованиях, архитектура должна поддерживать обработку различных игровых событий в игре, таких как старт, пауза, завершение и другие. По идее было бы здорово, чтобы каждый компонент системы умел обрабатывать эти события и переключать свое состояние в зависимости от состояния игры.
Поэтому в моей архитектуре пришлось реализовать систему пуша игровых событий по всему дереву.
В корневой узел отправляется событие игры, а затем это событие распространяется по всем компонентам и дочерним узлам.

Приведу пример, как это сделано в моей архитектуре, на примере контроллера выбора юнита левой кнопкой мыши.
public sealed class ClickUnitController { private MouseInput mouseInput; //Инпут мыши private UnitSelector unitSelector; //Выделяет юнита //Вызывается, когда происходит инициализация игры [GameInject] public void Construct(MouseInput mouseInput, UnitSelector unitSelector) { this.mouseInput = mouseInput; this.unitSelector = unitSelector; } //Вызывается, когда происходит старт игры [GameStart] public void Enable() { this.mouseInput.OnLeftButtonClicked += this.OnLeftButtonClicked; } //Вызывается, когда происходит окончание игры [GameFinish] public void Disable() { this.mouseInput.OnLeftButtonClicked -= this.OnLeftButtonClicked; } private void OnLeftButtonClicked(Vector2 clickPosition) { this.unitSelector.SelectUnit(clickPosition); } }
В данном классе ClickUnitController есть методы Enable(), Disable(), которые вызываются в момент старта и завершения игры. Аннотации [GameStart], [GameFinish] можно придумывать в самому в зависимости от системы игры, наследуясь от базового атрибута ContextEvent:
//Абстрактное событие [MeansImplicitUse(ImplicitUseKindFlags.Access)] [AttributeUsage(AttributeTargets.Method)] public abstract class ContextEvent : Attribute { }
Например, в моем проекте есть следующие события-аннотации:
//Событие внедрения зависимостей public sealed class GameInject : ContextEvent { } //Событие старта игры public sealed class GameStart : ContextEvent { } //Событие паузы игры public sealed class GamePause : ContextEvent { } //Событие возобновления игры public sealed class GameResume : ContextEvent { } //Событие завершения игры public sealed class GameFinish : ContextEvent { }
Чтобы пушнуть то или иное событие игры, достаточно получить ссылку на родительский узел и вызвать метод ContextNode.PushEvent<T>();
//Менеджер игры: public sealed class GameManager : MonoBehaviour { [SerializeField] private ContextNode context; //Корневой узел public void ConstructGame() { this.context.PushEvent<GameInject>(); } public void StartGame() { this.context.PushEvent<GameStart>(); } public void PauseGame() { this.context.PushEvent<GamePause>(); } public void ResumeGame() { this.context.PushEvent<GameResume>(); } public void FinishGame() { this.context.PushEvent<GameFinish>(); } }
Особенность такого подхода заключается в том, что когда происходит пуш события, то вместе с ним происходит и внедрение зависимостей. То есть, если сделать метод Enable() с аргументом MouseInput, то при вызове события [GameStart] произойдет Dependency Injection.
[GameStart] public void Enable(MouseInput mouseInput) { mouseInput.OnLeftButtonClicked += this.OnLeftButtonClicked; }
Таким образом, мы сразу убиваем двух зайцев: и события игры вызываем и зависимости внедряем ;)
Уход от монобехов. Один апдейт на всю игру
Как опытный разработчик, считаю, что если есть возможность уходить от монобехов и апдейтов юнити, то лучше так и сделать в проекте. Классы без монобехов легче поддаются тестированию, их проще переиспользовать в других проектах, поскольку эти классы содержат в себе только бизнес-логику, и не привязаны к монобехам и инспектору.
Проблема в том, что когда над Unity проектом работает больше 10 человек одновременно, то возникают сложности работы со сценами и объектами, поскольку помимо разработки компонентов системы и настройки их на сцене есть еще гейм-дизайнеры и левел-дизайнеры, которые настраивают баланс, собирают уровни на сцене и так далее. Поэтому хорошим решением будет разделить зоны ответственности в команде так, чтобы программисты решали все свои задачи в коде, а дизайнерам и художникам отдать сцены на растерзание...
К тому же классы монобехи занимают больше памяти, а вызов апдейта у каждого скрипта требует больше производительности. Поэтому такие системные классы, как контроллеры, менеджеры и сервисы написаны на обычных C# классах, а монобехи используются только в тех случаях, когда действительно нужно обработать Unity событие, например, OnTriggerEnter/Exit или OnCollisionEnter/Exit и так далее.
В качестве примера приведу класс, который отвечает за выделение юнитов прямоугольником:
public sealed class RectSelectionInput : IContextUpdate { public event Action OnStarted; public event Action OnFinished; private MouseInput mouse; private bool isSelecting; private Vector2 startPoint; private Vector2 endPoint; [GameInject] public void Construct(MouseInput mouse) { this.mouse = mouse; } //Вызывается вместо Update void IContextUpdate.OnUpdate() { var buttonState = this.mouse.LeftButton; if (buttonState == MouseInput.ButtonState.DOWN) { this.isSelecting = true; this.startPoint = this.mouse.Position; this.endPoint = this.startPoint; this.OnStarted?.Invoke(); } else if (buttonState == MouseInput.ButtonState.PRESS) { this.endPoint = this.mouse.Position; } else if (buttonState == MouseInput.ButtonState.UP) { this.isSelecting = false; this.endPoint = this.mouse.Position; this.OnFinished?.Invoke(); } } }
В данном примере, мы видим, что класс RectSelectionInput реализует интерфейс IContextUpdate, через который архитектура вызывает метод Update(). Таким образом, вместо того, чтобы писать монобехи, достаточно реализовать один из интерфейсов:
//Вызывает Update public interface IContextUpdate { void OnUpdate(); } //Вызывает FixedUpdate public interface IContextFixedUpdate { void OnFixedUpdate(); } //Вызывает LateUpdate public interface IContextLateUpdate { void OnLateUpdate(); }
На самом деле, вызов апдейтов в архитектуре происходит практически таким же образом, как и отправка событий. Вызов Update() распространяется по всем узлам, начиная с корневого, и компонентам системы, которые реализуют эти интерфейсы.

Чтобы вызвать апдейт, достаточно получить ссылку на родительский узел и вызвать метод ContextNode.OnUpdate();
//Менеджер игры public sealed class GameManager : MonoBehaviour { [SerializeField] private ContextNode context; //Корневой узел //Unity callback private void Update() { this.context.OnUpdate(); } //Unity callback private void FixedUpdate() { this.context.OnFixedUpdate(); } //Unity callback private void LateUpdate() { this.context.OnLateUpdate(); } }
Результат: ContextNode в 380 строк
Итак, фактически вся архитектура держится на одном классе ContextNode. ContextNode представляет собой домен, который имеет список дочерних доменов и ссылку на родительский домен. Дальше можно прочитать код самому:
public class ContextNode : MonoBehaviour { [SerializeField] private List<ContextNode> children; //Дочерние узлы private ContextNode parent; //Родительский узел //Зарегистрированные зависимости private readonly List<object> instances = new(); private readonly List<IContextUpdate> updaters = new(); private readonly List<IContextFixedUpdate> fixedUpdaters = new(); private readonly List<IContextLateUpdate> lateUpdaters = new(); #region Install //Инициализация дерева public void Install() { this.OnInstall(); for (int i = 0, count = this.children.Count; i < count; i++) { var node = this.children[i]; node.parent = this; node.Install(); } } //Деинициализация дерева public void Uninstall() { for (int i = 0, count = this.children.Count; i < count; i++) { var node = this.children[i]; node.parent = null; node.Uninstall(); } this.OnUninstall(); } //Использовать для регистрации зависимостей в наследниках protected virtual void OnInstall() { } protected virtual void OnUninstall() { } #endregion #region Register //Регистрирует зависимости в домен public void RegisterInstances(IEnumerable<object> instances) { foreach (var instance in instances) { this.RegisterInstance(instance); } } public void RegisterInstance(object instance) { if (instance == null) { return; } this.instances.Add(instance); if (instance is IContextUpdate updater) { this.updaters.Add(updater); } if (instance is IContextFixedUpdate fixedUpdater) { this.fixedUpdaters.Add(fixedUpdater); } if (instance is IContextLateUpdate lateUpdater) { this.lateUpdaters.Add(lateUpdater); } } #endregion #region Push //Отправляет событие во все компоненты, которые зарегистрированы в домене public void PushEvent<T>() where T : ContextEvent { this.PsuhEventToInstances<T>(); for (int i = 0, count = this.children.Count; i < count; i++) { var node = this.children[i]; node.PushEvent<T>(); } } private void PsuhEventToInstances<T>() where T : ContextEvent { for (int i = 0, count = this.instances.Count; i < count; i++) { var instance = this.instances[i]; this.PushEventToInstance<T>(instance); } } //Отправляет событие в компонент через рефлексию private void PushEventToInstance<T>(object instance) where T : ContextEvent { var type = instance.GetType(); while (type != null && type != typeof(object) && type != typeof(MonoBehaviour)) { var methods = type.GetMethods( BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly ); for (int i = 0, count = methods.Length; i < count; i++) { var method = methods[i]; if (method.GetCustomAttribute<T>() != null) { this.InvokeInstanceMethod(instance, method); } } type = type.BaseType; } } //Вызывает событие у метода компонента через рефлексию + делает DI private void InvokeInstanceMethod(object instance, MethodInfo method) { var parameters = method.GetParameters(); var count = parameters.Length; var args = new object[count]; for (var i = 0; i < count; i++) { var parameter = parameters[i]; args[i] = this.ResolveInstance(parameter.ParameterType); } method.Invoke(instance, args); } #endregion #region Instances //Ищет зависимость в домене или у родителей public T ResolveInstance<T>() { var node = this; while (node != null) { if (node.FindInstance<T>(out var instance)) { return instance; } node = node.parent; } throw new Exception($"Can't resolve instance {typeof(T).Name}!"); } public object ResolveInstance(Type type) { if (type == typeof(ContextNode)) { return this; } var node = this; while (node != null) { if (node.FindInstance(type, out var instance)) { return instance; } node = node.parent; } throw new Exception($"Can't resolve instance {type.Name}!"); } public IEnumerable<T> ResolveInstances<T>() { var node = this; while (node != null) { if (node.FindInstance<T>(out var instance)) { yield return instance; } node = node.parent; } } //Создает объект через конструктор и внедряет туда зависимости public T NewInstance<T>() { var objectType = typeof(T); var constructors = objectType.GetConstructors( BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly ); if (constructors.Length != 1) { throw new Exception($"Undefined constructor for type {objectType.Name}"); } var constructor = constructors[0]; var parameters = constructor.GetParameters(); var args = new object[parameters.Length]; for (int i = 0, count = parameters.Length; i < count; i++) { var parameter = parameters[i]; args[i] = this.ResolveInstance(parameter.ParameterType); } return (T) constructor.Invoke(args); } private bool FindInstance<T>(out T instance) { for (int i = 0, count = this.instances.Count; i < count; i++) { var current = this.instances[i]; if (current is T tInstance) { instance = tInstance; return true; } } instance = default; return false; } private bool FindInstance(Type targetType, out object instance) { for (int i = 0, count = this.instances.Count; i < count; i++) { instance = this.instances[i]; var instanceType = instance.GetType(); if (targetType.IsAssignableFrom(instanceType)) { return true; } } instance = default; return false; } #endregion #region Node //Можно получить дочерние узлы по типу public T[] GetChildren<T>() { var result = new List<T>(); for (int i = 0, count = this.children.Count; i < count; i++) { var node = this.children[i]; if (node is T tNode) { result.Add(tNode); } } return result.ToArray(); } //Можно найти дочерний узел по условию public T GetChild<T>(Predicate<T> predicate = null) where T : ContextNode { if (predicate == null) { predicate = _ => true; } for (int i = 0, count = this.children.Count; i < count; i++) { var node = this.children[i]; if (node is not T tNode) { continue; } if (predicate.Invoke(tNode)) { return tNode; } } throw new Exception($"Node of type {typeof(T).Name} is not found!"); } public void AddChild(ContextNode node) { this.children.Add(node); node.parent = this; node.Install(); } public void RemoveChild(ContextNode node) { if (this.children.Remove(node)) { node.parent = null; } } #endregion #region Unity //Можно вызывать public virtual void OnUpdate() { for (int i = 0, count = this.updaters.Count; i < count; i++) { var listener = this.updaters[i]; listener.OnUpdate(); } for (int i = 0, count = this.children.Count; i < count; i++) { var child = this.children[i]; child.OnUpdate(); } } public virtual void OnFixedUpdate() { for (int i = 0, count = this.fixedUpdaters.Count; i < count; i++) { var listener = this.fixedUpdaters[i]; listener.OnFixedUpdate(); } for (int i = 0, count = this.children.Count; i < count; i++) { var child = this.children[i]; child.OnFixedUpdate(); } } public virtual void OnLateUpdate() { for (int i = 0, count = this.lateUpdaters.Count; i < count; i++) { var listener = this.lateUpdaters[i]; listener.OnLateUpdate(); } for (int i = 0, count = this.children.Count; i < count; i++) { var child = this.children[i]; child.OnLateUpdate(); } } #endregion }
Выводы
Вот мы и рассмотрели реализацию нодовой архитектуры, которая решает поставленные задачи:
Использование Dependency Injection
Обработка игровых событий
Слабо-связанна с Unity
Поддерживает несколько игроков
На мой взгляд решение получилось лаконичным в 400 строк кода и три скрипта:

И все-таки меня часто спрашивают: «Почему своя архитектура, а не Zenject или VContainer?»
Ответ: Я просто люблю свои велосипеды, потому что свое решение всегда можно улучшить и адаптировать под нужды проекта. Использование DI фреймворка — тоже хорошо, но важно помнить, что фреймворк всегда накладывает ограничения, и их нужно понимать при проектировании архитектуры.
На этом все, спасибо за внимание! :)
Если вы хотите глубоко разобраться в создании собственных архитектурных решений для Unity, приглашаем на курс Unity Game Developer. Professional.
А тем, кто настроен на серьезное системное обучение, рекомендуем рассмотреть Подписку — выбираете курсы под свои задачи, экономите на обучении, получаете профессиональный рост. Узнать подробнее

