Нодовая архитектура на Unity

—

от автора

Привет, Хабр! ?

Меня зовут Игорь, и я Unity Developer. В этой статье я хотел бы поделиться кастомной архитектурой, которую сделал в процессе разработки своей RTS игры.

Скажу сразу: что основные концепции и принципы уже используются в различных DI фреймворках, таких как Zenject & VContainer. Поэтому чего-то феноменального в этой статье вы не увидите. Но, поскольку я люблю делать свои велосипеды, то в свою архитектуру я привнес парочку интересных вещей, которых нет в других DI фреймворках на Unity. Ну шо, поехали 🙂

Введение

Итак, в начале разработки своей RTS игры, я уже понимал, что в моем проекте архитектура должна выполнять ряд требований. Ключевое из них — это быть простой и понятной, чтобы я, как разработчик, фокусировался на написании бизнес-логики, а не на инфраструктурных вещах. Вот какие у меня получились критерии:

  1. Архитектура должна использовать Dependency Injection. По своему 5-ти летнему опыту разработки игр скажу, что использование механизма внедрения зависимостей увеличивает поддержку и тестируемость код-базы, поскольку каждый класс содержит в себе только бизнес-логику и необходимые зависимости. Когда есть DI, то не нужно создавать синглтоны на каждый чих или получать доп. зависимость на сервис-локатор. Использование архитектуры с DI упрощает поиск багов в классах, поскольку все зависимости определены в его конструкторе или методе пост-инъекции Construct().

  2. Архитектура должна реализовывать механизм обработки игровых событий. Любая игра — это процесс. Поэтому у игры должны быть события и состояния старта, паузы, окончание и так далее. Поэтому было бы здорово иметь систему, которая может переключать состояния игры и оповещать об этом всех подписчиков.

  3. Архитектура по-минимуму должна зависеть от монобехов. Считаю, что это очень хорошая практика писать код без монобехов, потому что обычные C# классы более гибкие в использовании, их легче тестировать, проще переиспользовать, так как они не завязаны на GameObject’ы и монобехи. И еще такой подход дает небольшой буст к оптимизации памяти и производительности.

  4. Архитектура должна поддерживать несколько игроков. Поскольку игра будет мультиплеерной / против ИИ, то очень важно сделать так, чтобы у каждого игрока была своя подсистема контроллеров, менеджеров и игровых объектов, которыми он управляет.

Таким образом, у меня получилась своя нодовая архитектура, которая решает все эти задачи. Ну что ж, давайте смотреть 🙂

Что такое нодовая архитектура

Нодовая архитектура

Нодовая архитектура

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

Приведу простой пример:

Давайте предположим, что мы делаем стратегию, в которой 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() распространяется по всем узлам, начиная с корневого, и компонентам системы, которые реализуют эти интерфейсы.

Вызов Update в архитектуре

Вызов 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, где практикующие эксперты делятся самой актуальной информацией. В рамках курсов также проходят бесплатные уроки, на которые может зарегистрироваться любой желающий. Вот ближайшие из них:


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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *