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

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

Комментарии (5)


  1. Geek_and_Cat
    12.10.2023 16:10

    Спасибо за статью, я тоже делаю на Unity нечто концептуально похожее, может когда-нибудь напишу про это)


  1. pqbd
    12.10.2023 16:10
    +2

    <sarcasm>кому теперь интересно, как там на unity</sarcasm>


    1. StarKRE Автор
      12.10.2023 16:10

      )))


  1. OusIU
    12.10.2023 16:10

    Спасибо за статью. Как раз начинаю углублять код и избавляться от MonoBehavior. Изучу, и попробую сделать что то своё пока попроще


  1. Deirel
    12.10.2023 16:10

    Неплохо, выглядит аккуратно, читать приятно)