Как стать автором
Обновить

Unity. Ленивый ECS

Разработка игр *C# *Unity *
Из песочницы

ECS - Entity-Component-System - довольно удобный способ проектирования архитектуры игр. Вынесение данных в компоненты, помещённые в сущности и управляемые системами, позволяет получить необходимое, даже самое сложное, поведение объектов "жонглированием" компонентов внутри них. Именно на него пал наш взор при разработке небольшой пошаговой стратегии на Unity.

Для тех, кто не касался данной темы, вкратце объясню как вообще устроен ECS: вы делите весь ваш проект на три аспекта. Первый - сущности (Entities) - просто набор "пустышек" (контейнеров компонентов), которые не обладают никакой логикой. Они нужны исключительно для хранения компонентов (Components) - второй аспект. Это объекты, содержащие данные, однако, тоже не имеющие какой-либо логики (разве что кроме простейшей, например компонент "HealthComponent" может уметь возвращать текущее здоровье в процентах, не более). А управляются компоненты системами (Systems), которые реализуют всю логику на основе данных, полученных из компонентов. Например, система "HealthSystem" может проверять текущее здоровье у всех "HealthComponent", и инициировать проигрывание анимации смерти у сущности, на которой висит "HealthComponent", здоровье которого опустилось до нуля.

После небольшого расследования нашей отважной командой выяснилось, что в первую очередь, большинство предлагаемых ECS могут быть использованы в Unity, однако они к ней не привязаны, что подразумевает необходимость их как-либо настраивать для нормальной работы. Но зачем разбираться с настройкой, если можно не разбираться с настройкой? На том и решили мы написать свой ленивый ECS конкретно для Unity.

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

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

Небольшой спойлер. На выходе получилась примерно такая архитектура:

Часть архитектуры игры
Часть архитектуры игры

Перейдём же к этапам реализации! Берём в руки клавиатуру и начинаем писать. Начнём с того, что системы и компоненты существуют где-то в эфемерном пространстве, а к ним должен быть лёгкий и достаточно быстрый доступ, так что хотелось бы где-то вести массивы ссылок на них, и этим "где-то" у нас будет ECSInstance - статический класс с двумя полями: список компонентов и список систем. Код его может выглядеть так:

public class ECSInstance
{
    private static ECSInstance instance;
  
    public List<ECSComponent> Components;
    public List<ECSSystem> Systems;
  
    private ECSInstance()
    {
        Components = new List<ECSComponent>();
        Systems = new List<ECSSystem>();
    }
    public static ECSInstance Instance()
    {
        if(instance == null)
            instance = new ECSInstance();
        return instance;
    }
}

Теперь к нему можно получить доступ отовсюду в коде, и его единственный экземпляр будет сохраняться и существовать на протяжении всей игры. Синглтон прекрасно подошёл на роль паттерна для данного класса. Однако, как вы можете заметить, эти списки пустые, и нигде в рамках класса не заполняются. С этим мы разберёмся позже, когда дойдём до рассмотрения компонентов. И, пока что, не совсем удобно доставать отсюда конкретные компоненты или системы, так что мы написали два класса, которые с этим помогают справиться. Знакомьтесь с ними, первый - это класс ECSFilter. Он нужен для удобного доступа к компонентам:

public class ECSFilter
{
    public List<ECSComponent> Components;
  
    public ECSFilter(ECSComponent[] components) { Components = new List<ECSComponent>(components); }); }
    public ECSFilter(List<ECSComponent> components) { Components = components; }
    public ECSFilter() { Components = ECSInstance.Instance().Components; }
    
    public ECSFilter OfType<T>()
    {
        List<ECSComponent> new_list = new List<ECSComponent>();
        foreach(var c in Components)
            if(c.GetType() == typeof(T))
                new_list.Add(c);
        return new ECSFilter(new_list);
    }
  
    public ECSFilter WithoutType<T>()
    {
        List<ECSComponent> new_list = new List<ECSComponent>();
        foreach (var c in Components)
            if (c.GetType() != typeof(T))
                new_list.Add(c);
        return new ECSFilter(new_list);
    }

    public List<T> GetComponents<T>() where T: ECSComponent
    {
        List<T> new_list = new List<T>();
        foreach (var c in Components)
            if (c.GetType() == typeof(T))
                new_list.Add((T)c);
        return new_list;
    }
  
    public List<T> GetComponents<T>(Func<T, bool> predicate) where T: ECSComponent
    {
        List<T> new_list = new List<T>();
        foreach (var c in Components)
            if (c.GetType() == typeof(T))
                if(predicate.Invoke((T)c))
                    new_list.Add((T)c);
        return new_list;
    }
}

За счёт собственной коллекции, фильтр может быть применим к набору компонентов, не связанных с ECSInstance, и занимается выделением из компонентов тех, которые удовлетворяют условиям. Методы OfType и WithoutType возвращают сам фильтр, так что могут быть вызваны в цепочке:

...
ECSFilter filter = new ECSFilter();
List<ECSComponent> = filter.OfType<Moveable>().WithoutType<Selectable>().Components;
...

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

public class ECSService : MonoBehaviour
{
    void InitSystems()
    {
        var Systems = ECSInstance.Instance().Systems;
        
        Systems.Add(new InputSystem(this));
        Systems.Add(new SelectionSystem(this));
        Systems.Add(new MoveSystem(this));
        Systems.Add(new AttackSystem(this));
        //Systems.Add(...
    }

    void Awake()
    {
        InitSystems();
    }

    void Start()
    {
        foreach (var s in ECSInstance.Instance().Systems)
            s.Init();
    }

    void Update()
    {
        foreach (var s in ECSInstance.Instance().Systems)
            s.Run();
    }

    public T GetSystem<T>() where T: IECSSystem
    {
        foreach(var s in ECSInstance.Instance().Systems)
            if (s.GetType() == typeof(T))
                return (T)s;
        return null;
    }
}

Как видно, именно в этом классе "берёт начало" выполнение ежекадровой логики каждой системы. Я уверен, что цикл foreach по всем системам с выполнением функции не является очень оптимальным решением, однако, не стоит забывать, что ECS у нас ленивый :). При создании систем сервис оставляет внутри них ссылку на себя же, чтобы система имела доступ к своим "собратьям". Насколько я помню, в ООП этот приём называют "инъекция зависимости".

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

Все компоненты у нас наследуются от ECSComponent, который реализует добавление себя в список при создании, а также правильное добавление нового компонента:

public class ECSComponent : MonoBehaviour
{
    private void Awake()
    {
        ECSInstance.Instance().Components.Add(this);
    }

    public void AddComponent<T>(T component) where T: ECSComponent
    {
        gameObject.AddComponent<T>();
        ECSInstance.Instance().Components.Add(component);
    }

    public void RemoveComponent<T>(T component) where T: ECSComponent
    {
        ECSInstance.Instance().Components.Remove(component);
        Destroy(component);
    }
}

Это, конечно, добавляет небольшое неудобство, так как такие важные компоненты как transform, renderer, и другие стандартные компоненты Unity не входят в список и никак не смогут быть оттуда получены. Однако, обращением непосредственно к gameObject наших компонентов мы вполне справляемся.

Наконец, ECSSystem - суперкласс систем:

public class IECSSystem
{
    public ECSService Service;
  
    public IECSSystem(ECSService service) { Service = service; }
  
    public virtual void Run() { }
    public virtual void Init() { }
}

Ничего сверхъестественного, просто пара методов - один из которых будет вызываться на старте (Unity сигнал Start Monobehaviour объектам), а другой - каждый фрейм. Тут же виден и сервис, о котором было сказано выше.

Ну вот и всё, вам осталось лишь запустить на карту вашего будущего воина/дерево/полторашку лимонада в виде пустышки, написать свой первый компонент (например, перемещения) и в первой системе написать что-то вроде...

public override void Run()
{
    ECSFilter f = new ECSFilter();
    List<Movable> components = f.GetComponents<Movable>();
    foreach (var c in components)
        UpdateComponent(c);
}

Если вы оцените данный материал, в дальнейшем хотелось бы выпустить отдельную статью, в которой рассмотреть взаимодействие ECS и UI в рамках Unity проекта. Непременно ждём ваш фидбек. Удачи вам в ваших начинаниях!

Теги:
Хабы:
Всего голосов 4: ↑3 и ↓1 +2
Просмотры 126
Комментарии Комментарии 1