В этой статье я бы хотел поговорить об архитектурном решении для Unity - GUA, объяснить логику работы. Если мы перейдём по ссылке нас встречает великолепное readme, где описаны правила работы с данным решением, но я бы хотел разобрать их подробнее с примерами. Пусть это будет бесплатной рекламой для автора.

  • Чтобы начать работу достаточно просто создать проект, скачать и скопировать папку GUA в папку Assets проекта.


  • Одним из преимуществ этой архитектуры является единая точка входа, чего нам всем частенько не хватает в unity. Когда unity закончит подготовку, нам станет доступно окно редактора стартера (Create/GUA/Creator).

Окно редактора стартера
Окно редактора стартера

Оно упрощает процесс создания стартера. Думаю, интуитивно понятно, что делает каждая из настроек, кроме разве что Create Editor Script, но это мы разберём немного позже. По нажатию Create сгенерируется файл следующего содержания

using GUA.Invoke;
using GUA.System;
using UnityEngine;

namespace Example
{
    public class ExampleStarter : MonoBehaviour
    {
        private readonly GSystem _system = new GSystem();

        // [Header("Emitters")]
        // [SerializeField] private SomeEmitter someEmitter;

        // [Header("Data")] 
        // [SerializeField] private SomeData someData;

        private void Start()
        {
            Application.targetFrameRate = 60;
            InitializeAssistants();
        
            // GDataPool.Set(someEmitter);
        
            // GDataPool.Set(someData);

            // _system.Add(new SomeSystem());
      
            _system.Initialize();
        }

        private void InitializeAssistants()
        {
            _ = GInvoke.Instance;
        }

        private void Update() => _system.Run();

        private void FixedUpdate() => _system.FixedRun();

        private void OnApplicationQuit() => _system.ApplicationQuit();

        private void OnDestroy() => _system.Destroy();
    }
}

По сути в нашей системе только этот класс использует методы Monobehavior: Start() и Update(). Все скрипты, которые мы создаём, могут реализовывать 4 интерфейса:

  1. ISystem - не требует ничего, но позволяет встраивать систему в список систем.

  2. IStartSystem - то же, что и ISystem, но требует реализации функции Start()

  3. IRunSystem - то же, что и ISystem, но требует реализации функции Run()

  4. IFixedRunSystem - то же, что и ISystem, но требует реализации функции FixedRun()

Всё, что нам остаётся сделать, чтобы воспользоваться старым добрым Start, реализовать в нашем скрипте интерфейс IStartSystem и в стартере добавить нашу систему в список систем в стартере.

using GUA.System;

namespace Example
{
    public class ExampleSystem : IStartSystem
    {
        public void Start()
        {
            // Your start logic
        }
    }
}
private void Start()
{
    Application.targetFrameRate = 60;
    InitializeAssistants();

    _system.Add(new ExampleSystem());
      
    _system.Initialize();
}

Остальные интерфейсы реализуются аналогичным способом.

Такой способ реализации позволяет нам чётко контролировать, какие системы и в каком порядке будут вызваны на сцене.


На этом месте возникает вопрос. А как же наши систему будут оперировать объектами на сцене, если их нельзя наследовать от Monobehavior? Ответ прост...

  • Service Locator

    Архитектура предоставляет нам доступ к пулу данных. Чтобы им воспользоваться, мы создаём некую прослойку - Emitter, он просто хранит в себе ссылки на объекты на сцене, а системы получают объект-Emitter из пула и работают с данными через него. (чем-то похоже на логику scriptable object только для конкретной сцены)

    Давайте будем задавать hp нашему игроку на старте. Реализация будет состоять из GameStarter (Точка входа), PlayerHealthbarSystem (Система для контроля hp игрока), PlayerHealthbarEmitter (Прослойка с данными) и компонента PlayerHealthbar, который непосредственно ничего не выполняет, но назначен на некоторый объект на сцене. В коде это будет выглядеть примерно так:

using GUA.Data;
using GUA.Invoke;
using GUA.System;
using UnityEngine;

namespace Example
{
    public class GameStarter : MonoBehaviour
    {
        private readonly GSystem _system = new GSystem();

        [Header("Emitters")]
        [SerializeField] private PlayerHealthbarEmitter playerHealthbarEmitter;

        private void Start()
        {
            Application.targetFrameRate = 60;
            InitializeAssistants();

            GDataPool.Set(playerHealthbarEmitter);

            _system.Add(new PlayerHealthbarSystem());
      
            _system.Initialize();
        }

        private void InitializeAssistants()
        {
            _ = GInvoke.Instance;
        }

        private void Update() => _system.Run();

        private void FixedUpdate() => _system.FixedRun();

        private void OnApplicationQuit() => _system.ApplicationQuit();

        private void OnDestroy() => _system.Destroy();
    }
}
using GUA.Data;
using GUA.System;

namespace Example
{
    public class PlayerHealthbarSystem : IStartSystem
    {
        private readonly PlayerHealthbarEmitter _healthbarEmitter = 
        GDataPool.Get<PlayerHealthbarEmitter>();

        public void Start()
        {
            _healthbarEmitter.Healthbar.SetHealthPoints(100);
        }
    }
}
using UnityEngine;

namespace Example
{
    public class PlayerHealthbarEmitter : MonoBehaviour
    {
        public PlayerHealthbar Healthbar;
    }
}
using UnityEngine;

namespace Example
{
    public class PlayerHealthbar : MonoBehaviour
    {
        private int _healthPoints;

        public void SetHealthPoints(int healthPoints)
        {
            _healthPoints = healthPoints;
            Debug.Log(_healthPoints);
        }
    }
}

Результат работы

Система не находится непосредственно на сцене, но оперирует её объектами. Здесь стоит отметить, что GDataPool является словарём, поэтому несколько одинаковых компонентов положить туда не получится, однако можно укомплектовать их в другой уникальный компонент.

  • Аналог корутин

    Последнее из заменителей встроенных в Unity методов - это GInvoke, аналог корутин. Давайте вызовем вывод в консоль некоторого сообщения через 1.5 секунды после старта. Система будет выглядель так

using GUA.System;
using GUA.Invoke;
using UnityEngine;

namespace Example
{
    public class ExampleSystem : IStartSystem
    {
        public void Start()
        {
            Debug.Log("Start");
            GInvoke.Instance.Delay(() => SomeAction(), 1.5f);
            
            // Аналогичная запись
            GInvoke.Instance.Delay(() =>
            {
                SomeAction();
            }, 1.5f);
        }

        private void SomeAction()
        {
            Debug.Log("Action");
        }
    }
}

Если не забудем добавить систему в список в стартере, получим:

Сообщения выводятся в консоль через 1.5 секунды после старта
Сообщения выводятся в консоль через 1.5 секунды после старта
  • Общение между системами

    Существует аналог GDataPool, но для обмена данными между системами. GEventPool позволяет отправлять готовые объекты с данными, а все системы, подписанные на сообщения смогут их получить и обработать. В качестве событий настоятельно рекомендуется использовать структуры вместо классов.

    Для примера создадим систему врага и отправим сообщение об атаке, чтобы система игрока смогла принять урон.

using GUA.System;
using GUA.Invoke;
using GUA.Event;

namespace Example
{
    public class EnemySystem : IStartSystem
    {
        private readonly int _damageAmount = 10;

        public void Start()
        {
            GInvoke.Instance.Delay(() => CauseDamage(), 2f);
        }

        private void CauseDamage()
        {
            GEventPool.SendMessage(new DamageEvent { DamageAmount = _damageAmount });
        }
    }
}
using GUA.Data;
using GUA.System;
using GUA.Event;

namespace Example
{
    public struct DamageEvent
    {
        public int DamageAmount;
    }

    public class PlayerHealthbarSystem : IStartSystem
    {
        private int _health;
        private readonly PlayerHealthbarEmitter _healthbarEmitter = 
        GDataPool.Get<PlayerHealthbarEmitter>();

        public void Start()
        {
            _health = 100;
            UpdateHealth();

            GEventPool.AddListener<DamageEvent>(e => TakeDamage(e.DamageAmount));
        }

        private void TakeDamage(int amount)
        {
            _health -= amount;
            UpdateHealth();
        }

        private void UpdateHealth()
        {
            _healthbarEmitter.Healthbar.SetHealthPoints(_health);
        }
    }
}
Одна система наносит урон, а другая применяет этот урон
Одна система наносит урон, а другая применяет этот урон

Вот таким нехитрым способом реализуется система обмена сообщениями между системами.

  • Singleton

    Здесь всё очень просто, мы наследуем класс SomeClass от обёртки Singleton<SomeClass> и наслаждаемся готовым синглтоном.

using GUA.Extension;
using UnityEngine;

namespace Example
{
    public class SingletonExample : Singleton<SingletonExample>
    {
        public void SomeAction()
        {
            Debug.Log("Some action");
        }
    }
}
using GUA.System;

namespace Example
{
    public class ExampleSystem : IStartSystem
    {
        public void Start()
        {
            SingletonExample.Instance.SomeAction();
        }
    }
}
Результат работы singleton
Результат работы singleton

На этом прелести архитектуры заканчиваются. Надеюсь, я смог понятно и интересно описать работу с данной архитектурой. В одной из следующих статей будут рассмотрены плюсы и минусы такого подхода и мы ещё больше углубимся в проектирование систем Unity.

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


  1. dnnkeeper
    09.08.2021 19:45

    Интересно узнавать о таких решениях, но для себя я не могу оправдать переход к подобным архитектурам, поскольку они в некоторой мере противостоят идее локальности систем и провоцируют писать связный код который потом трудно будет превратить, например, в независимый пекедж, работающий в новой среде. Важное преимущество работы в Unity - возможность конструировать логику игры из модулей в самой сцене или в префабе без необходимости перекомпиляции кода путем лишь добавления новых компонентов и взаимных локальных ссылок. Можно менять интерфейс и игровые механики налету, подгружать новые уровни без обновления приложения, через addressables например. В этом мне помогает Unity Atoms, например. И я пока предпочитаю именно такую архитектуру которая эксплуатирует это преимущество и не провоцирует изобретению параллельной логики которую еще и непросто бывает понять со стороны. Но DI системы тоже по-своему соблазнительны, конечно.


    1. RudeGalaxy1010 Автор
      09.08.2021 23:16

      Согласен, думаю будет интересно порассуждать на эту тему в какой-нибудь из следующих статей, спасибо за идею)


  1. WeslomPo
    12.08.2021 07:52
    +1

    Строго рекомендуете использовать в качестве Event-ов struct-ы, а при вызове GEventPool.SendMessage - упаковываете её в объект. Ну и в чем тогда смысл?

    Далее, вы утверждаете что вот это DI:

    private readonly PlayerHealthbarEmitter _healthbarEmitter = GDataPool.Get<PlayerHealthbarEmitter>();

    По факту это сервис локатор, что не является DI. Вот [SerializeField] - это DI. Вы не знаете как, кто (и при определенной сноровке что) просунет вам в переменную. В вашем же случае объект сам просит конкретную реализацию объекта из конкретного места.

    Чтобы сделать инъекцию зависимости без контейнера зависимостей, просто передавайте зависимости в конструктор, либо в поле, либо в метод.

    Например:

    public class PlayerHealthbarSystem : IStartSystem {

    private int _health;

    private readonly IHealthEmitter _healthEmitter;

    public PlayerHealthbarSystem(IHealthEmmiter healthEmmiter)

    _healthEmmiter = healthEmmiter;

    }

    ...

    }

    Вот теперь мы не знаем кто, как и "что конкретно" передаст нам в качестве зависимости. Ну и инъекция при помощи контейнера для бедных:

    _system.Add(new PlayerHealthbarSystem(playerHealthbarEmitter);

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

    Как то что рекомендовать кому-то к реализации в своих проектах - плохая идея. Во-первых продукт сырой, во-вторых решает всего пару проблем (организация очереди вычислений, передача сообщений, единая организация кода), при этом недостаточно правильным образом.

    Несмотря на то что утверждаете что используете DI - используете ServiceLocator. Это чревато тем, что когда захотите дёрнуть какую-нибудь систему из одного проекта в другой, вам придется, помимо явных зависимостей, тащить за собой весь ваш фреймворк, либо организовывать заглушки для SL.

    В шину событий рекомендуете посылать структуры, но сразу же их упаковываете в объект. Сама по себе шина событий достаточно неприятная штука. Я бы не рекомендовал пользоваться ей на широкую ногу, закапаетесь в лапшу из вызовов событий. Шаг в сторону типизированной шины - правильный, но сама по себе шина источник кодовой запутанности.

    Несколько явных Singletone-ов - отказаться от такой архитектуры в дальнейшем будет очень затруднительно, придется клещами потом выдирать эти куски кода из проекта. Синглтоны - плохо, уже столько статей про это написано, а вы в своей архитектуре их сразу же наплодили кучку.

    Попробуйте Zenject - в нем уже все эти вопросы решены. Даже шина событий есть, только, я её не рекомендую использовать. И даже синглтон есть, но это если поискать :). Это хороший DI контейнер, поможет вам понять что это и как с этим жить, научит архитектуре получше. Есть множество других, но Zenject наиболее взрослый.

    Попробуйте Entitas - он стал бесплатным, а лучше попробуйте EntitasRedux - у него все проще с настройкой проекта и правильное именование компонентов сущности. ECS - это отличное архитектурное решение, признанное мировым сообществом разработки игр, Entitas - один из самых простых для осваивания фреймворков. Есть еще для Unity - Leo ECS, Svelto ECS и многие другие. Сам использую Entitas, и вообще приверженец кодогенерации.

    В заключение, как я уже говорил, ваш фреймворк неплохое начало для осознания того, какой должна быть архитектура вашего проекта. Вы уже начали осознавать что двигаться путем который предлагали разработчики Unity в разработке игр - гиблое дело, но не нашли то как можно делать по другому. Собственно попробуйте сделать проект с минимальным количеством MonoBehaviour, используйте конструкторы (и методы, где нет возможности их использовать) для инъекции зависимостей, используйте больше интерфейсов, попробуйте TDD подход для решения какой либо задачи (без использования MonoBehaviour). Разберитесь что такое struct и чем его едят. Попробуйте Data Driven подход к разработке, попробуйте какой-нибудь фреймворк ECS помимо юнитивского (ибо он архисложный, ещё по этому же не рекомендую Svelto).


    1. RudeGalaxy1010 Автор
      12.08.2021 12:51

      Мне показалось интересным рассказать про этот проект, т.к. пришлось работать с ним на проектах. Я всë ещë в поисках оптимального решения. Думаю, когда изучу достаточно вариантов, напишу об этом. Спасибо за фидбэк