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

Переизобрел ECS. Паттерн Entity-State-Behaviour

Уровень сложностиСредний
Время на прочтение13 мин
Количество просмотров4.4K

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

Меня зовут Игорь, и я 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-кода и презентации покажу, как разрабатывать игровые объекты с нуля, а также отвечу на ваши вопросы)

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

Теги:
Хабы:
Всего голосов 7: ↑6 и ↓1+8
Комментарии5

Публикации

Истории

Работа

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань