Привет, Хабр! 👋
Меня зовут Игорь, и я Unity Developer. В этой статье хотел бы показать новый архитектурный паттерн, который я открыл для себя в разработке игр. Цель статьи — донести до читателя преимущества паттерна ESB и продемонстрировать его применение в моем пет-проекте.
Надеюсь, Unity разработчики не сожгут меня на вилах за такую вылазку... 😄
Содержание
Основная концепция
Идея паттерна Entity-State-Behaviour (ESB) заключается в том, что любой объект, систему или AI можно представить в виде сущности (Entity) с набором данных (State) и логики (Behaviour), но строгим разделением между ними.
Поскольку состояние и поведение жестко отделены друг от друга, то это позволяет повторно использовать элементы и изменять структуру игрового объекта в процессе выполнения программы. Подход дает большую гибкость и возможность быстро разрабатывать игровые взаимодействия.
Особенности паттерна
Сущность (Entity) является контейнером, который содержит в себе словарь данных
и список поведений
. В качестве стейта (State) в основном выступают реактивные свойства (Observables), на которые можно подписаться при их изменении. Поведение (Behaviour) обычно подписывается на изменение этих данных и выполняет некую работу, изменяя состояние других данных. Помимо подхода «Наблюдатель» поведение может заниматься и активным слушанием, то есть вызываться каждый кадр и обрабатывать входящий стейт.
Ниже я нарисовал упрощенную диаграмму классов, как это примерно выглядит у меня в фреймворке:
На диаграмме можно увидеть, что класс Entity состоит из трех блоков:
Блок State: Методы
GetValue(), TryGetValue(), AddValue(), DelValue()
относятся к состоянию игрового объекта. Например, если ли разработчик захочет добавить точки патрулирования, по которым нужно будет двигаться NPC, то он вызовет методEntity.AddValue()
, передав туда ключ и массив позицийБлок Behaviour: Методы
AddBehaviour(), DelBehaviour()
являются методами добавления и удаления поведения у игрового объекта. Например, если в какой-то момент NPC нужно добавить поведение патрулирования, то он вызоветEntity.AddBehaviour<PatrolEntityBehaviour>()
Блок Lifecycle: Методы
Init(), Enable(), Update(), FixedUpdate(), Disable(), Dispose()
являются методами жизненного цикла сущности. Например, когда разработчик захочет «включить» сущность, он вызоветEntity.Enable()
, и все поведения, реализующие интерфейсIEntityEnable
обработают событие активации объекта. Соответственно, если нужно выполнять бизнес-логику каждый кадр, то можно вызывать методUpdate()
, передавая deltaTime в качестве аргумента
Паттерн ESB является мультипарадигменным, поскольку базируется как на объектно-ориентированном, так и на процедурном программировании.
Небольшое пояснение: процедурное программирование — это парадигма, в которой основным строительным блоком является процедура или функция, которую можно переиспользовать в других сопрограммах. Процедура в отличие от функции ничего не возвращает, она получает входящий стейт и изменяет его, а функция, наоборот, — на основе входящего стейта делает расчет и возвращает новый стейт.
В процедурном подходе данные программы обычно расположены в центральном реестре. Из этого реестра данные передаются по сопрограммам с целью выполнить некую работу и изменить состояние системы.
В результате все поведения (Behaviour) — это и будут сопрограммы, которые будут делегировать работу статическим
методамфункциям и процедурам, для того чтобы эти элементы логики можно было повторно использовать и расширять в других Behaviours.
Преимущества
Основные преимущества паттерна Entity-State-Behaviour заключаются в следующем:
Модульность: Жесткое разделение состояния и поведения помогает создать модульную систему, где каждый компонент легко заменяем.
Расширяемость: Возможность легко добавлять новые виды поведения и состояния без необходимости переписывания кода
Гибкость: Паттерн позволяет менять структуру игрового объекта в процессе выполнения программы
Индивидуальность: Поскольку каждая сущность может иметь уникальные свойства и механики, то это позволяет точечно проектировать игровую логику под каждый игровой объект
Уменьшение сложности кода: Процедурное и функциональное программирование ориентируют разработчика на написании чистых функций и процедур вместо организации классов и объектов
Уменьшение количества кода: Паттерн ориентирован на обработку данных вместо работы с объектами, поэтому разработчик использует универсальные структуры данных и переиспользует ранее написанные функции и процедуры
Поддерживаемость и тестируемость: Упрощение отладки и тестирования достигается за счет четкого разделения кода
Как это работает в Unity
Дальше я хотел бы показать на примере прототипа RTS игры, как этот паттерн работает в Unity. Поскольку Unity ориентирован на компонентный подход, то мне пришлось написать свои компоненты Entity
иEntitityInstaller
, чтобы в дальнейшем полностью уйти от монобехов.
Скажу даже больше, со временем решение выросло до полноценного фреймворка, и я полностью отказался от DI и Zenject'а.
Для наглядности покажу игровой объект «Пехотинца»
На рис 4. справа изображен компонент Entity
, который хранит в себе State и Behaviour «Пехотинца».
Для того, чтобы удобнее было отлаживать игровые объекты, я сделал отдельный раздел «Debug», в котором можно увидеть текущий набор свойств и механик моего персонажа как в Edit, так и Play Mode.
Если взглянуть на рис. 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 хочу подсветить две вещи:
На 16-й строчке кода происходит получение функции
EnemyCondition
в виде данных, чтобы ее можно было использовать в качестве фильтра для поиска противника (строка 28). Таким способом разработчик может использовать полиморфизм функций, если поведение различных игровых объектов должно варьироваться в зависимости от их типа.Второй момент — в строке 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;
}
Процедурный подход имеет три преимущества по сравнению с ООП:
Статические методы проще переиспользовать и расширять вместо объектов, потому что статический метод не имеет состояния
Статические методы уменьшают сложность кода, поскольку область задачи ограничивается данными на входе и результатом на выходе
Глобальные функции проще тестировать, если они являются чистыми
Пример №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.
А еще статические методы можно подружить с 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
Каждый инсталлер получает на вход сущность и регистрирует в нее данные и логику.
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
через инспектор:
Тут хотел бы немного вернуться к классу 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, то я зарегистрирую его в консоль
После этого в сгенерированном классе EntityAPI
я увижу ключ «Habr» и его extension методы
Теперь можно написать инсталлер данных с ключом «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-кода и презентации покажу, как разрабатывать игровые объекты с нуля, а также отвечу на ваши вопросы)
---------------------------------