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

Меня зовут Игорь, и я Unity Developer. В этой статье хотел бы показать новый архитектурный паттерн, который я открыл для себя в разработке игр. Цель статьи — донести до читателя преимущества паттерна ESB и продемонстрировать его применение в моем пет-проекте.

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

Содержание

Основная концепция

Рис. 1. Паттерн Entity-State-Behaviour
Рис. 1. Паттерн Entity-State-Behaviour

Идея паттерна Entity-State-Behaviour (ESB) заключается в том, что любой объект, систему или AI можно представить в виде сущности (Entity) с набором данных (State) и логики (Behaviour), но строгим разделением между ними.

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

Особенности паттерна

Сущность (Entity) является контейнером, который содержит в себе словарь данных и список поведений. В качестве стейта (State) в основном выступают реактивные свойства (Observables), на которые можно подписаться при их изменении. Поведение (Behaviour) обычно подписывается на изменение этих данных и выполняет некую работу, изменяя состояние других данных. Помимо подхода «Наблюдатель» поведение может заниматься и активным слушанием, то есть вызываться каждый кадр и обрабатывать входящий стейт.

Ниже я нарисовал упрощенную диаграмму классов, как это примерно выглядит у меня в фреймворке:

Рис 2. Диаграмма классов в паттерне ESB
Рис 2. Диаграмма классов в паттерне ESB

На диаграмме можно увидеть, что класс Entity состоит из трех блоков:

  1. Блок State: Методы GetValue(), TryGetValue(), AddValue(), DelValue() относятся к состоянию игрового объекта. Например, если ли разработчик захочет добавить точки патрулирования, по которым нужно будет двигаться NPC, то он вызовет метод Entity.AddValue(), передав туда ключ и массив позиций

  2. Блок Behaviour: Методы AddBehaviour(), DelBehaviour() являются методами добавления и удаления поведения у игрового объекта. Например, если в какой-то момент NPC нужно добавить поведение патрулирования, то он вызовет Entity.AddBehaviour<PatrolEntityBehaviour>()

  3. Блок Lifecycle: Методы Init(), Enable(), Update(), FixedUpdate(), Disable(), Dispose() являются методами жизненного цикла сущности. Например, когда разработчик захочет «включить» сущность, он вызоветEntity.Enable(), и все поведения, реализующие интерфейс IEntityEnable обработают событие активации объекта. Соответственно, если нужно выполнять бизнес-логику каждый кадр, то можно вызывать метод Update(), передавая deltaTime в качестве аргумента

Паттерн ESB является мультипарадигменным, поскольку базируется как на объектно-ориентированном, так и на процедурном программировании.

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

В процедурном подходе данные программы обычно расположены в центральном реестре. Из этого реестра данные передаются по сопрограммам с целью выполнить некую работу и изменить состояние системы.

В результате все поведения (Behaviour) — это и будут сопрограммы, которые будут делегировать работу статическим методам функциям и процедурам, для того чтобы эти элементы логики можно было повторно использовать и расширять в других Behaviours.

Преимущества

Основные преимущества паттерна Entity-State-Behaviour заключаются в следующем:

  1. Модульность: Жесткое разделение состояния и поведения помогает создать модульную систему, где каждый компонент легко заменяем.

  2. Расширяемость: Возможность легко добавлять новые виды поведения и состояния без необходимости переписывания кода

  3. Гибкость: Паттерн позволяет менять структуру игрового объекта в процессе выполнения программы

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

  5. Уменьшение сложности кода: Процедурное и функциональное программирование ориентируют разработчика на написании чистых функций и процедур вместо организации классов и объектов

  6. Уменьшение количества кода: Паттерн ориентирован на обработку данных вместо работы с объектами, поэтому разработчик использует универсальные структуры данных и переиспользует ранее написанные функции и процедуры

  7. Поддерживаемость и тестируемость: Упрощение отладки и тестирования достигается за счет четкого разделения кода

Как это работает в Unity

Дальше я хотел бы показать на примере прототипа RTS игры, как этот паттерн работает в Unity. Поскольку Unity ориентирован на компонентный подход, то мне пришлось написать свои компоненты Entity иEntitityInstaller, чтобы в дальнейшем полностью уйти от монобехов.

Рис 3. Мой пет-проект RTS
Рис 3. Мой пет-проект RTS

Скажу даже больше, со временем решение выросло до полноценного фреймворка, и я полностью отказался от DI и Zenject'а.

Для наглядности покажу игровой объект «Пехотинца»

Рис. 4. Компонент Entity в инспекторе
Рис. 4. Компонент Entity в инспекторе

На рис 4. справа изображен компонент Entity, который хранит в себе State и Behaviour «Пехотинца».

Для того, чтобы удобнее было отлаживать игровые объекты, я сделал отдельный раздел «Debug», в котором можно увидеть текущий набор свойств и механик моего персонажа как в Edit, так и Play Mode.

Рис 5. Раздел "Debug" сущности "Пехотица"
Рис 5. Раздел «Debug» сущности «Пехотица»

Если взглянуть на рис. 5, то

  • справа сверху показан стейт персонажа

  • справа снизу показано поведение персонажа.

Чаще всего в качестве данных выступают Observables , Subjects Reactive Properties & Collections. Такой реактивный объект имеет в себе локальный стейт, на который можно подписаться при его изменении.

Конкретно в моем атомарном фреймворке исторически сложилось, что такие реактивные объекты называются AtomicVariable<T> и AtomicCollection<T>. Но хочу вас обрадовать: если вы знакомы с библиотекой UniRx, то можно использовать ReactiveProperty<T> и ReactiveCollection<T>.

Также стейт сущности может включать в себя и Unity компоненты, например: Transform, Rigidbody, Animator и GameObject.

Ниже покажу парочку примеров с кастомными данными, которые мне понадобилось сделать в прототипе

//Данные для обнаружения других сущностей в определенном радиусе
[Serializable]
public sealed class SensorData
{
    public float radius;
    public LayerMask layerMask;
}

//Универсальный буффер, куда можно складывать массивы данных, без аллокаций
[Serializable]
public sealed class BufferData<T>
{
    public T[] values;
    public int size;
}

//Данные для производства юнитов с помощью казарм или заводов
[Serializable]
public sealed class TrainingUnitData
{
    public AtomicVariable<bool> paused = new();
    public AtomicVariable<UnitInfo> selectedUnit = new();
    public AtomicVariable<UnitInfo> trainingUnit = new();
    public AtomicTimer timer = new();
}

//Данные для постройки строения
[Serializable]
public sealed class ConstructionData
{
    public SceneEntity resultPrefab;
    public AtomicTimer timer = new();
    public float healthRange;
    public float healthAcc;
}

Надеюсь, с данными более менее понятно. Теперь хотел бы показать, как выглядят поведения игровых объектов

Пример №1. Механика синхронизации Transform:

[Serializable]
public sealed class SyncTransformEntityBehaviour : IEntityInit, IEntityUpdate
{
    private Transform _transform;
    private IAtomicValue<float3> _position;
    private IAtomicValue<quaternion> _rotation;

    public void Init(IEntity entity)
    {
        _transform = entity.GetTransform();
        _position = entity.GetPosition();
        _rotation = entity.GetRotation();
    }
    
    public void OnUpdate(IEntity entity, float deltaTime)
    {
        _transform.SetPositionAndRotation(_position.Value, _rotation.Value);
    }
}

В методе Init() происходит кэширование данных в локальные поля из сущности. Метод OnUpdate() синхронизирует позицию объекта на сцене каждый кадр.

Примечание: На 10-12 строке читатель может заметить, что обращение к данным происходит не через метод Entity.GetValue(int key), как я описывал выше, а через extension метод, который сам и является ключом. Как эта магия работает опишу дальше...

Пример №2. Механика окрашивания персонажа

[Serializable, ExecuteAlways]
public sealed class PlayerColorEntityBehaviour :  IEntityInit, IEntityDispose
{
    private IAtomicValueObservable<PlayerAffilation> _playerAffilation;
    private MeshRenderer _meshRenderer;

    public void Init(IEntity entity)
    {
        _meshRenderer = entity.GetMeshRenderer();

        _playerAffilation = entity.GetPlayerAffilation();
        _playerAffilation.Subscribe(this.OnPlayerChanged);

        this.OnPlayerChanged(_playerAffilation.Value)
    }

    public void Dispose(IEntity entity)
    {
        _playerAffilation.Unsubscribe(this.OnPlayerChanged);
    }

    private void OnPlayerChanged(PlayerAffilation player)
    {
        PlayerInfo info = PlayerAffilationConfig.Instance.GetPlayerInfo(player);
        _meshRenderer.material = info.material;
    }
}

В этом примере хочу показать, что механика игрового объекта может быть реактивной и работать через паттерн Observer.

На 12-й строчке кода в методе Init() происходит подписка на реактивное свойство _playerAffilation. Когда информация о принадлежности игрока меняется, то вызывается метод OnPlayerChanged(), который перекрашивает игровой объект в другой материал.

Небольшая ремарка: Над классом PlayerColorEntityBehaviour стоит атрибут [ExecuteAlways], который делает так, что эта механика работает еще и в Edit Mode...

Пример №3. AI Механика выбора противника

[Serializable]
public sealed class EnemySensorEntityBehaviour : 
    IEntityInit, 
    IEntityFixedUpdate
{
    private BufferData<IEntity> _entityBuffer;
    private IAtomicValue<float3> _myPosition;
    
    private IAtomicFunction<IEntity, bool> _enemyCondition;
    private IAtomicVariable<IEntity> _targetEnemy;

    public void Init(IEntity entity)
    {
        _entityBuffer = entity.GetEntityBuffer();
        _position = entity.GetPosition();
        _enemyCondition = entity.GetEnemyCondition();
        _targetEnemy = entity.GetTargetEnemy();
    }

    public void OnFixedUpdate(IEntity entity, float deltaTime)
    {
        if (_entityBuffer.Exists(_targetEnemy.Value))
        {
            return;
        }

       _entityBuffer.FindClosest(
            _position.Value, out IEntity target, _enemyCondition.Invoke
        );

       _targetEnemy.Value = target;
    }
}

В примере №3 хочу подсветить две вещи:

  1. На 16-й строчке кода происходит получение функции EnemyCondition в виде данных, чтобы ее можно было использовать в качестве фильтра для поиска противника (строка 28). Таким способом разработчик может использовать полиморфизм функций, если поведение различных игровых объектов должно варьироваться в зависимости от их типа.

  2. Второй момент — в строке 22 и 27 происходят вызовы методов_entityBuffer.Exists() и _entityBuffer.FindClosest() . На самом деле это статические функции, которые написаны в процедурной парадигме вместо ООП

Пример №4 Функция поиска ближайшей сущности

public static bool FindClosest(
    this BufferData<IEntity> buffer,
    float3 center,
    out IEntity target,
    Func<IEntity, bool> filter
)
{
    return FindClosest(buffer.values, buffer.size, center, out target, filter);
}

public static bool FindClosest(
    IEntity[] buffer,
    int count,
    float3 center,
    out IEntity target,
    Func<IEntity, bool> filter
)
{
    target = null;

    if (count == 0)
    {
        return false;
    }

    float minDistance = float.MaxValue;

    for (int i = 0; i < count; i++)
    {
        IEntity entity = buffer[i];
        if (!filter.Invoke(entity))
        {
            continue;
        }

        float3 entityPosition = entity.GetPosition().Value;
        float distanceSq = math.lengthsq(entityPosition - center);

        if (distanceSq <= minDistance)
        {
            minDistance = distanceSq;
            target = entity;
        }
    }

    return target != null;
}

Процедурный подход имеет три преимущества по сравнению с ООП:

  1. Статические методы проще переиспользовать и расширять вместо объектов, потому что статический метод не имеет состояния

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

  3. Глобальные функции проще тестировать, если они являются чистыми

Пример №5. Функция проверки Entity в буффере

public static bool Exists(this BufferData<IEntity> buffer, IEntity target)
{
    return Exists(buffer.values, buffer.size, target);
}

public static bool Exists(this IEntity[] buffer, int count, IEntity target)
{
    if (target == null)
    {
        return false;
    }

    for (int i = 0; i < count; i++)
    {
        if (buffer[i] == target)
        {
            return true;
        }
    }

    return false;
}

Здесь я хочу показать, что поведения (Behaviours), в идеале, не должны в себе иметь логику. Вместо этого их задача должна сводится к тому, чтобы вытащить из сущности необходимые данные и делегировать это статическим методам, чтобы эту логику можно было переиспользовать в других механиках.

Также отмечу, что это очень хорошо работает с тестами и Test-Driven Development.

Рис 6. Пример тестирования чистых функций
Рис 6. Пример тестирования чистых функций

А еще статические методы можно подружить с Burst Compiler

[BurstCompile]
public static class RotationCases
{
    [BurstCompile]
    public static void RotateTowards(
        in quaternion currentRotation,
        in float3 direction,
        in float deltaTime,
        in float speed,
        out quaternion result
    )
    {
        float3 upAxis = new float3(0, 1, 0);
        Сщquaternion targetRotation = quaternion.LookRotation(direction, upAxis);
        float percent = speed * deltaTime;
        result = math.slerp(currentRotation, targetRotation, percent);
    }
}

Именно поэтому я стараюсь использовать float3 и quaternion вместо стандартных структур Vector3 и Quaternion

Установка сущностей

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

На рис. 7 можно посмотреть, как выглядит Installer для «Пехотинца». Справа можно увидеть раздел <Base>, в котором персонаж имеет такие данные, как Transform, PlayerAffilation, Name и UnitGroundType

Рис. 7. Инсталлер "Пехотинца"
Рис. 7. Инсталлер «Пехотинца»

Каждый инсталлер получает на вход сущность и регистрирует в нее данные и логику.

public interface IEntityInstaller
{
    void Install(Entity entity);
}

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

[Serializable]
public sealed class DamageInstaller : IEntityInstaller
{
    [SerializeField] private int initialDamage;

    public void Install(IEntity entity)
    {
        entity.AddValue(EntityAPI.Damage, 
            new AtomicVariable<int>(this.initialDamage));
    }
}

Затем перейдет в редактор Unity и подключит класс DamageInstaller в компонент EntityInstaller через инспектор:

Рис. 8. Подключение DamageInstaller к сущности
Рис. 8. Подключение DamageInstaller к сущности

Тут хотел бы немного вернуться к классу DamageInstaller и пояснить, как у меня в проекте хранится ключ EntityAPI.Damage (строка 8)

[Serializable]
public sealed class DamageInstaller : IEntityInstaller
{
    [SerializeField] private int initialDamage;

    public void Install(IEntity entity)
    {
        entity.AddValue(EntityAPI.Damage, 
            new AtomicVariable<int>(this.initialDamage));
    }
}

EntityAPI — это статический класс, который содержит в себе уникальные ключи, для для каждого типа данных

public static class EntityAPI
{
    public const int Health = 1; // Health
    public const int PlayerAffilation = 2; // AtomicVariable<PlayerAffilation>
    public const int MoneyIncome = 3; // AtomicVariable<int>
    public const int MoneyIncomePeriod = 4; // AtomicTimer
    public const int MoveSpeed = 6; // AtomicVariable<float>
    public const int MoveCondition = 7; // IAtomicExpression<bool>
    public const int Position = 8; // IAtomicVariableObservable<float3>
    public const int TransformRadius = 9; // IAtomicValue<float>
    public const int Rotation = 5; // IAtomicVariableObservable<quaternion>
    public const int Transform = 11; // Transform
    public const int AttackDistance = 12; // IAtomicVariable<float>
    public const int AttackTargetRequest = 13; // IAtomicEvent<IEntity>
    public const int MoveStepEvent = 14; // IAtomicEvent<float3>
    public const int Damage = 15; // IAtomicVariable<int>
    
    //etc...
}

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

[Serializable]
public sealed class DamageEntityInstaller : IEntityInstaller
{
    [SerializeField]
    private int value = 1;
    
    public void Install(IEntity entity)
    {
        //Метод AddDamage сгенерирован
        entity.AddDamage(new AtomicVariable<int>(this.value)); 
    }
}

Таким образом, сгенерированный метод AddDamage() уже внутри себя содержит ключ EntityAPI.Damage . Ниже привел сгенерированный класс

//CODEGEN: DON'T MODIFY
public static class EntityAPI
{
    //Keys:
    public const int Damage = 15; // IAtomicVariable<int>
  
  
    //Extensions:
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static IAtomicVariable<int> GetDamage(this IEntity obj) 
      => obj.GetValue<IAtomicVariable<int>>(Damage);
    
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static bool TryGetDamage(this IEntity obj, out IAtomicVariable<int> value) 
      => obj.TryGetValue(Damage, out value);
    
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static bool AddDamage(this IEntity obj, IAtomicVariable<int> value) 
      => obj.AddValue(Damage, value);
    
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static bool DelDamage(this IEntity obj) 
      => obj.DelValue(Damage);
    
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static void SetDamage(this IEntity obj, IAtomicVariable<int> value) 
      => obj.SetValue(Damage, value);
}

Подход с extension методами улучшает читаемость кода и ускоряет разработку.

Для генерации ключей и extension методов я разработал отдельную консоль (Editor Window), в которую разработчик может легко добавлять новые ключи, указывая там название и тип

Например, если в игре понадобиться ключ «Habr» с типом GameObejct, то я зарегистрирую его в консоль

Рис. 10. Добавление ключа "Habr" в консоль
Рис. 10. Добавление ключа "Habr" в консоль

После этого в сгенерированном классе EntityAPI я увижу ключ «Habr» и его extension методы

Рис. 11. Сгенерированный ключ "Habr" и его методы
Рис. 11. Сгенерированный ключ "Habr" и его методы

Теперь можно написать инсталлер данных с ключом «Habr» и зарегистрировать его в игровой объект!

Теперь, допустим, нужно сделать поведение, которое будет каждый кадр выводить в консоль «Hello Habr». Тогда код будет выглядеть так

[Serializable]
public sealed class HabrBehaviour : IEntityInit, IEntityUpdate
{
    private GameObject _habr;
    
    public void Init(IEntity entity)
    {
        _habr = entity.GetHabr();
    }

    public void OnUpdate(IEntity entity, float deltaTime)
    {
        Debug.Log($"HELLO {_habr.name}!");
    }
}

Если нужно зарегистрировать HabrBehaviour через инсталлер, то это будет выглядеть так

[Serializable]
public sealed class HabrInstaller : IEntityInstaller
{
    [SerializeField]
    private GameObject habr;
    
    public void Install(IEntity entity)
    {
        entity.AddHabr(habr);
        entity.AddBehaviour<HabrBehaviour>(); //+
    }
}

Вот таким образом можно собирать игровые объекты как конструктор.

Выводы

Если говорить субъективно, то, на мой взгляд, это самый удобный архитектурный паттерн, для разработки игр. Он хорошо ложится не только на разработку игровых объектов, но и на разработку систем, искусственного интеллекта и даже UI.

Принцип разделения данных и логики фокусирует разработчика оперировать базовыми структурами данных и глобальными функциями вместо классов и объектов, что в уменьшает сложность и количество кода и увеличивает переиспользуемость и тестируемость этих компонентов.

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

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

На этом у меня все, жду ваши комментарии! Спасибо за внимание :)

Небольшой анонс

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

Для тех, кому интересно посмотреть, как разрабатывать коргеймплей на паттерне Entity-State-Behaviour, приглашаю вас на стрим, который будет 20 июля в 19:00 по МСК. Там в режиме Live-кода и презентации покажу, как разрабатывать игровые объекты с нуля, а также отвечу на ваши вопросы)

---------------------------------

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


  1. SadOcean
    19.07.2024 21:47
    +9

    Ох, пошла моя любимая тема. Жара, так сказать.

    Заранее прошу прощения.

    Итак приступим:

    То что вы предложили, выглядит довольно интересно, своего рода идеологический esc без ecs - плоская структура данных, разделение данных и функций по их обработке, вторая структура индексации (ключи) поверх основной (ссылки в ОП).

    Я часто критикую ecs, и во многом ее минусы переносятся и на вашу архитектуру, но, должен признать, у ECS есть несколько плюсов - явный Стейт (который легко сохранять и синхронизировать по сети, к примеру) и нацеленность на производительность за счёт эффективного использования Кеша процессора через организацию данных. Последнее и создаёт большую часть особенностей работы с ecs и боли.

    Ваш подход призван решить эти проблемы удобства, но убивает основное преимущество - производительность. Статичные функции в burst не решат проблему, если каждое обращение к стейту это запрос в словарь за сложным объектом, его анбоксинг и приведение (а компилятор добавляет там проверки). Это буквально противоположная сути ecs действие: ecs пытается исправить то, что ООП объекты расположены в памяти случайно, увеличивая время доступа к свойствам. ECS организует структуры в ленты массивов, которые гарантированно попадут в кеш. Ваш подход добавляет ещё слой RAM запросов.

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

    По моему мнению гораздо большая проблема - это организация.

    На словах плоская структура объектов и супер позднее связывание переменных может звучать хорошо, это то, что нравится людям в ECS - можно обратиться к любому свойству из любой части. Попытка организации кода в ООП стиле приведет к проблемам зависимостей, дырявым абстракциям, сложностям достать вот эту внутреннюю деталь юнита вот в этот эффект. Для взаимодействия нужно собирать объекты в разные списки, вводить промежуточные сущности и т.д. плюс есть всякие Solid, паттерны и рекомендации, которым нужно следовать.

    Тут есть ощущение что все сложно там и все просто тут.

    На самом деле Разработчиков не интересуют ни объекты, ни функции в чистом виде, его интересуют абстрактные сущности, например, как ваш юнит наносит урон вражескому. Задача - добавить новый тип урона. Какая организация кода быстрее обеспечит это (не во вред другим функциям) - такая и лучше.

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

    Так как последовательность операций конфигурируеиая, может даже не хватить просто кода.

    Чтобы восстановить работу этой сущности нужно:

    • Найти Стейт

    • Найти все связанные поведения

    • Найти все связанные стейты, которые могут меняться помимо основного

    • Посмотреть варианты конфигураций порядка повелений

    • Создать у себя в голове ментальную модель того, как это работает, разбросанную по десятку файлов кода и конфигураций

    • Надеяться, что нет мало заметного сайд эффекта и никто не изменит порядок операций

    На самом деле ООП даже с нарушенными паттернами может быть проще по организации, чем ваш подход. Можно использовать анемичные объекты (без поведения) и выносить поведение в некие системы или процессоры. Есть паттерн такой - DCI, для представления процесса как объекта (помогает в ответах на такие вопросы)

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

    Опять же, выглядит неплохо, но есть и минусы. Дело в том, что DRY нужно применять с осторожностью, иногда объекты выглядят одинаково и имеют одинаковые поля, но они - разные. Это быстро выясняется, когда появляются новые неиспользуемые поля, специальные флаги, делающие функции не универсальными и т.д. Момент можно пропустить и потом получить печальные god-обьекты, которые являются сложными конфигурируемыми мутантами со всем поведением разом.

    Аналогично с плоской структурой - это достоинство, когда ты хочешь через голову владельца армии посчитать патроны в карабине первого солдата. Но в остальном просто необходимо организовать армию так, что солдаты знают командира, карабин лежит в рюкзаке солдата и заряжен патронами. С плоской структурой нужно вручную следить за этими связями, нельзя использовать проверку на null для снаряжённого карабина. Вообще можно снарядить кабана в слот карабина и заметить фейл только на проде. Нет инструментов и паттернов для организации, как будто работаешь с void* ссылками в си и руками разыменуешь данные.

    Третий момент - реактивные поля.

    Идея звучит неплохо, но может порождать ад колбеков: так как порядок колбеков обычно не определен и объекты подписываются в случайном порядке, может получиться ситуация, когда флоу работает по разному а зависимости от порядка подписок. Это создаёт сложные баги и сложный для понимания код, потому что нельзя отделить те компоненты, которые управляют потоком, от тех, которые просто обновляют интерфейс.

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

    Итак, резюмируя:

    • ECS с удобствами, но без преимуществ ecs

    • Плохая производительность

    • Бойлер Плейт код (частично решается кодогенерацией, но все равно, get entity.getComponent на каждый чих)

    • Боль с пониманием кода из за конфигурируемого порядка операций и супер декомпозиции

    • Ад зависимостей

    • Сложности с организацией кода

    • Есть и плюсы:

    • Явный плоский Стейт, удобно хранить и передавать (Этого можно добиться и другими инструментами)

    • Не так больно, как ECS

    • С тестированием проще, это правда

    • В общем готов обсудить детали, тема на самом деле интересная. Простите за форматирование, пишу с телефона, не мог молчать)


  1. Lekret
    19.07.2024 21:47
    +2

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

    Если говорить про сам подход, то он мало в чём выигрывает у ECS, я бы даже сказал, что он во многом проигрывает, даже обычному ООП.

    Подход берёт у ECS несколько частей, кроме самых важных, например фильтры, которые фактически бесплатно дают возможность работать с множествами сущностей по набору компонентов.

    Так же подход имеет заведомо медленную и наивную реализацию для хранения данных. Я даже не буду говорить про скорость ECS и заточку под кеш, это не главный плюс ECS, но классический ООП вариант без индирекшена на Dictionary и тот будет быстрее, зачем продвигать использование неоптимальных решений для системы, которая является ядром всей игры, я не знаю.
    С таким подходом фразы а-ля "статический метод можно сделать burst, поэтому использую quaternion" звучат как "дом без фундамента пошёл трещинами, будем использовать скотч".

    Раз уж поиск компонентов сделан не по типу, а под каждый тип выделен int, то проще не использовать Dictionary, а например внутри каждой сущности сущности сделать массив object[MaxComponentId + 1], где null это отсутствие компонента (раз уж сделали ссылочную обёртку на примитивы, давайте использовать).
    Вот так мы изобрели хранение компонентов, как это делает Entitas, массив быстрее создаётся, быстрее работает, а ещё мы либо не мучаем GC на ресайзе Dictionary, либо, если он имеет capacity с запасом, тупо экономим на памяти в несколько раз, и даже логику менять не пришлось, кроме кода отрисовки в редакторе.


  1. vdshat
    19.07.2024 21:47

    Любой паттерн имеет плюсы и минусы. В зависимости от конкретного места и плюс может окащаться минусом и наоборот. К сожалению, в статье мы видим только плюсы. По моему опыту, наибольшую проблему данного подхода представляет, как раз, непредсказуемость поведения, а в некоторых случаях кумулятивный эффект. У нас в одном UI проекте разработчик решил все переписать на подобные рельсы и UI зажил своей жизнью. Какое-то время с этим мирились и исправляли, расширяли сущьности. Катастрофа случилась после подключения drag-n-drop, т.к. вообще никто не мог предсказать как какой элемент себя поведет, если провести курсором через все элемениы, хотя поодиночке работало, вроде, нормально.