Привет, Хабр! ????
Меня зовут Игорь, и я 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 фреймворка — тоже хорошо, но важно помнить, что фреймворк всегда накладывает ограничения, и их нужно понимать при проектировании архитектуры.
На этом все, спасибо за внимание! :)
Тем, кто глубоко интересуется разработкой игр, хочу порекомендовать онлайн‑курсы от OTUS, где практикующие эксперты делятся самой актуальной информацией. В рамках курсов также проходят бесплатные уроки, на которые может зарегистрироваться любой желающий. Вот ближайшие из них:
12 октября: Состояния игровых объектов
18 октября: Рабочие будни игрового сценариста и нарративного дизайнера — на примере Ведьмак 3 Дикая Охота
26 октября: Профессия разработчик на Unreal Engine: задачи и перспективы
2 ноября: Тестировщик игр: настоящее и будущее профессии в 2023 году
Комментарии (5)
OusIU
12.10.2023 16:10Спасибо за статью. Как раз начинаю углублять код и избавляться от MonoBehavior. Изучу, и попробую сделать что то своё пока попроще
Geek_and_Cat
Спасибо за статью, я тоже делаю на Unity нечто концептуально похожее, может когда-нибудь напишу про это)