В этой статье я хочу обсудить шаблон проектирования «сущность-компонент-система» (Entity-Component-System, ECS). По этой теме можно найти много информации в Интернете, поэтому я не буду глубоко вдаваться в объяснения, а больше расскажу о моей собственной реализации.
Часть 1: Реализация шаблона Entity-Component-System на C++
Начнём сначала. Полный исходный код моей реализации ECS можно найти в репозитории на github.
Entity-Component-System, в основном используемый в играх — это шаблон проектирования, обеспечивающий огромную гибкость в проектировании общей архитектуры программного обеспечения[1]. Такие большие компании, как Unity, Epic или Crytek используют этот шаблон в своих фреймворках, чтобы предоставить разработчикам очень богатый возможностями инструмент, с помощью которого они могут разрабатывать собственное ПО. Прочитать более широкое обсуждение по этой тематике можно в этих постах [2,3,4,5].
Если вы изучите эти статьи, то заметите, что все они имеют общую цель: распределение различных проблем и задач между сущностями (Entities), компонентами (Components) и системами (Systems). Это три основные понятия этого шаблона, и связаны они между собой довольно свободно. Сущности обычно используются для создания уникального идентификатора, предоставления среде информации о существовании отдельного элемента и функции как своего рода корневого объекта, объединяющего множество компонентов. Компоненты — это не что иное, как объекты-контейнеры, не обладающие никакой сложной логикой. В идеале они являются объектами с простой структурой данных (plain old data objects, POD). Каждый тип компонента можно прикрепить к сущности, чтобы дать ей что-то вроде свойства. Допустим, к сущности можно прикрепить «Health-Component», что позволит сделать её смертной, дав ей здоровье, которое является обычным целочисленным или дробным значением в памяти.
Большинство статей, которые мне попадались, согласуются друг с другом относительно использования сущностей и компонентов, но мнения о системах разнятся. Некоторые люди считают, что системам известны только компоненты. Более того, некоторые говорят, что для каждого типа компонента должна быть своя система, например для «Collision-Components» должна быть «Collision-System», для «Health-Components» — «Health-System» и т.д. Такой подход довольно строг и не учитывает взаимодействие различных компонентов. Менее строгий подход заключается в том, что различные системы имеют дело с компонентами, о которых им должно быть известно. Например, «Physics-Systems» должна знать о «Collision-Components» и «Rigidbody-Components», потому что обе они скорее всего содержат необходимую для симуляции физики информацию. По моему скромному мнению, системы — это «замкнутые среды». То есть они не владеют ни сущностями, ни компонентами. Они имеют доступ к ним через независмые объекты-диспетчеры, которые в свою очередь управляют жизненным циклом сущностей и компонентов.
Это ставит перед нами интересный вопрос: как сущности, компоненты и системы обмениваются информацией, если они более-менее независимы друг от друга? Ответ может быть разным и зависит от реализации. В моей реализации ответом является event sourcing[6]. События распределяются через «диспетчер событий» («Event-Manager») и любой, кому интересны события, может получать передаваемую диспетчером информацию. Если у сущности, системы или даже компонента есть важное изменение состояния, о котором нужно сообщить, например, «изменилась позиция» или «игрок умер», они могут передать информацию «диспетчеру событий». Он передаст событие и все подписанные на это событие узнают о нём. Таким образом можно всё связать друг с другом.
Похоже, введение оказалось длиннее, чем я планировал, ну что поделаешь. Прежде чем мы углубимся в изучение кода, который, кстати, написан на C++11, я в общих чертах расскажу об основных характеристиках моей архитектуры:
- эффективность использования памяти — чтобы быстро создавать и удалять объекты сущностей, компонентов и систем, а также события, я не могу полагаться на стандартное управление динамической памятью. Решением в этом случае, конечно же, является собственный распределитель памяти.
- журналирование — чтобы отслеживать происходящее я использовал для журналирования log4cplus[7].
- масштабируемость — возможна удобная реализация новых типов сущностей, компонентов, систем и событий без каких-либо заданных верхних пределов, за исключением системной памяти
- гибкость — между сущностями, компонентами и системами нет никаких зависимостей (разумеется, у сущностей и компонентов есть своего рода зависимость, но они не содержат логики указателей друг на друга)
- простой поиск объектов/доступ к ним — простое получение объектов сущностей и их компонентов через EntityId или итератор компонентов для итерации всех компонентов определённого типа
- контроль за выполнением — системы имеют приоритеты и могут зависеть друг от друга, поэтому можно установить топологический порядок из выполнения
- простота использования — библиотеку запросто можно использовать с другим ПО, достаточно одного include.
На изображении ниже показана общая архитектура моей реализации Entity-Component-System:
Рисунок 01: схема архитектуры ECS (ECS.dll).
Как вы видите, на этой схеме есть четыре области разных цветов. Каждая область обозначает модульную часть архитектуры. В самом низу — на самом деле, на схеме в самом верху, она перевёрнута «вниз головой» — у нас находится управление памятью и журналирование (жёлтая область). Эти модули первого уровня работают с очень низкоуровневыми задачами. Они используют модули второго уровня в Entity-Component-System (синяя область) и event sourcing (красная область). Эти модули в основном имеют дело с задачами управления объектами. Поверх них находится модуль третьего уровня ECS_Engine (зелёная область). Этот глобальный объект движка верхнего уровня управляет всеми модулями второго уровня и занимается инициализацией и разрушением. Ну вот, это был краткий и очень абстрактный обзор, теперь давайте рассмотрим архитектуру подробнее.
Диспетчер памяти
Давайте начнём с Memory-Manager. Его реализация основана на статье[8], найденной мною на gamedev.net. Идея заключается в том, чтобы свести к абсолютному минимуму выделение и освобождение динамической памяти. Поэтому только при запуске приложения с помощью malloc выделяется большая область памяти. Эта память теперь будет управляться одним или несколькими распределителями. Существует множество типов распределителей[9] (линейный, стековый, свободный список) и каждый из них имеет свои плюсы и минусы (здесь я не буду их рассматривать). Но несмотря на то, что внутри они работают по-разному, все они имеют общий открытый интерфейс:
class Allocator
{
public:
virtual void* allocate(size_t size) = 0;
virtual void free(void* p) = 0;
};
Представленный выше фрагмент кода неполон, но в нём видны два основных общих метода, которые должен предоставлять каждый распределитель:
- allocate — выделяет определённое количество байтов и возвращает адрес памяти этой области
- free — освобождает ранее выделенную область памяти с заданным адресом.
С учётом сказанного, мы можем делать интересные вещи, например, объединять в цепочки несколько распределителей, вот так:
Рисунок 02: память, управляемая распределителем.
Как видно, один распределитель может получить свою область памяти, которой он будет управлять, от другого (родительского) распределителя, который, в свою очередь, может получить свою память от другого распределителя, и так далее. Таким образом можно выстраивать различные стратегии управления памятью.
Для реализации моей ECS я создал корневой стековый распределитель, получающий начальную выделенную область в 1 ГБ системной памяти. Модули второго уровня выделяют необходимое им количество памяти из корневого распределителя, и освобождают её только после завершения приложения.
Рисунок 03: Возможное распределение глобальной памяти.
На Рисунке 03 показано, как можно распределить память между модулями второго уровня: «Global-Memory-User A» может быть диспетчером сущностей, «Global-Memory-User B» — диспетчером компонентов, а «Global-Memory-User C» — диспетчером систем.
Журналирование
Я не буду много говорить о журналировании, потому что я просто воспользовался log4cplus[7], который делает за меня всю работу. Всё, что я сделал, это задал базовый класс Logger, определяющий объект log4cplus::Logger и несколько методов-обёрток, передающих простые вызовы журналирования типа «LogInfo()», «LogWarning()» и т.д.
Entity-Manager, IEntity, Entity<T>
Хорошо, теперь давайте поговорим о самом «мясе» архитектуры: синей области на Рисунке 01. Вы могли заметить схожую структуру всех объектов-диспетчеров и соответствующих им классов. Например, взгляните на классы EntityManager, IEntity и Entity<T>. Класс EntityManger должен управлять всеми объектами сущностей во время выполнения приложения. В его задачи входит создание, удаление и доступ к существующим объектам сущностей. IEntity — это класс-интерфейс, предоставляющий простейшие характеристики объекта сущности, такие как идентификатор объекта и идентификатор (статического) типа. Он является статическим, потому что не меняется после инициализации программы. Этот идентификатор класса постоянен при каждом запуске приложения и может изменяться только при изменении исходного кода.
class IEntity
{
// код неполон!
EntityId m_Id;
public:
IEntity();
virtual ~IEntity();
virtual const EntityTypeId GetStaticEntityTypeID() const = 0;
inline const EntityId GetEntityID() const { return this->m_Id; }
};
Идентификатор типа — это целочисленное значение, меняющееся для каждого конкретного класса сущностей. Это позволяет проверять тип объекта IEntity во время выполнения. Последним по списку, но не по значимости идёт класс-шаблон Entity<T>.
template<class T>
class Entity : public IEntity
{
// код неполон!
void operator delete(void*) = delete;
void operator delete[](void*) = delete;
public:
static const EntityTypeId STATIC_ENTITY_TYPE_ID;
Entity() {}
virtual ~Entity() {}
virtual const EntityTypeId GetStaticEntityTypeID() const override { return STATIC_ENTITY_TYPE_ID; }
};
// инициализация константы идентификатора типа сущностей
template
const EntityTypeId Entity::STATIC_ENTITY_TYPE_ID = util::Internal::FamilyTypeID::Get();
Основное назначение этого класса — инициализация уникального идентификатора типа конкретного класса сущностей. Здесь я воспользовался двумя фактами: во-первых, инициализацией константы[10] статичных переменных; во-вторых — природой работы классов-шаблонов. Каждая версия класса-шаблона Entity<T> будет иметь собственную статическую переменную STATIC_ENTITY_TYPE_ID. Она, в свою очередь, гарантированного будет инициализирована до выполнения всех динамических инициализаций. Фрагмент "util::Internal::FamilyTypeID::Get()" используется для реализации чего-то вроде механизма счётчика типов. Он инкрементирует счётчик каждый раз, когда он вызвается с другим T, но всегда возвращает одно значение, когда снова вызывается с одним T. Не уверен, есть ли у такого шаблона своё название, но он довольно удобен. На этом этапе я также избавился от оператора delete и delete[]. Таким образом я сделал невозможным их случайный вызов. Кроме того, благодаря этому (если ваш компилятор достаточно умён) будет выдаваться предупреждение при попытке использования оператора new или new[] для объектов сущностей, потому что противоположного им оператора уже нет. Эти операторы не должны использоваться, потому что всеми их задачами будет заниматься класс EntityManager. Итак, подведём итог того, чему мы уже научились. Класс диспетчера обеспечивает простейшие функции, такие как создание, удаление и доступ к объектам. Класс-интерфейс используется как самый корневой базовый класс и предоставляет уникальный идентификатор объекта и идентификатор типа. Класс-шаблон гарантирует правильную инициализацию идентификатора типа и устраняет оператор delete/delete[]. Тот же самый шаблон классов диспетчера, интерфейса и шаблона используются и для компонентов, систем и событий. Единственное, но важное отличие этих групп заключается в том, как классы-диспетчеры хранят свои объекты и осуществляют к ним доступ.
Давайте сначала рассмотрим класс EntityManager. На Рисунке 04 показана общая структура хранения элементов.
Рисунок 04: Абстрактная схема класса EntityManager и хранения его объектов.
При создании нового объекта сущности нужно использовать метод EntityManager::CreateEntity<T>(аргументы…). Этот общий метод сначала получает параметр шаблона, который является типом создаваемой конкретной сущности. Затем этот метод получает необязательные параметры (их может и не быть), которые передаются конструктору T. Передача этих параметров выполняется через шаблон с переменным количеством аргументов[11]. Во время создания внутри происходят следующие действия:
- Получается ObjectPool[12] для объектов сущностей типа T; если этот пул не существует, то создаётся новый
- Из этого пула выделяется память; ровно столько, сколько необходимо для хранения объекта T
- Перед вызовом конструктора T от диспетчера получается новый EntityId. Этот идентификатор с ранее выделенной памятью будет сохранён в таблицу поиска; таким образом мы сможем выполнять поиск экземпляра сущности с нужным идентификатором
- Затем вызывается замена оператора C++ new [13] с передаваемыми аргументами в качестве входных данных, для создания нового экземпляра T
- наконец, метод возвращает идентификатор сущности.
После создания нового экземпляра объекта сущности можно получить к нему доступ с помощью его уникального идентификатора объекта (EntityId) и EntityManager::GetEntity(EntityId id). Для разрушения экземпляра объекта сущности нужно вызвать метод EntityManager::DestroyEntity(EntityId id).
Класс ComponentManager работает подобным же образом, плюс ещё одно расширение. Кроме пулов объектов для хранения всевозможных объектов он должен представлять дополнительный механизм привязки компонентов к владеющим ими объектами сущностей. Это ограничение приводит к второму этапу поиска: сначала мы проверяем, существует ли сущность с заданным EntityId, и если есть, то проверяем, прикреплён ли к этой сущности определённый тип компонента, выполняя поиск в списке её компонентов.
Рисунок 05: Схема хранения объекта Component-Manager.
Метод ComponentManager::CreateComponent<T>(EntityId id, аргументы…) позволяет нам добавить к сущности определённый компонент. С помощью ComponentManager::GetComponent<T>(EntityId id) мы можем получать доступ к компонентам сущности, где T определяет тип компонента, к которому мы хотим получить доступ. Если компонент отсутствует, то возвращается nullptr. Для удаления компонента из сущности нужно использовать метод ComponentManager::RemoveComponent<T>(EntityId id). Но постойте, есть ещё кое-что. Ещё один способ доступа к компонентам — использование ComponentIterator<T>. Таким образом можно итерировать по всем существующим компонентам определённого типа T. Это может быть удобно, например, если такая система, как «Physics-System» должна применить гравитацию ко всем компонентам твёрдых тел.
Класс SystemManager не имеет каких-то особых дополнений для хранения систем и доступа к ним. Для хранения системы используется простое распределение памяти с идентификатором типа в качестве ключа.
Класс EventManager использует линейный распределитель, управляющий областью памяти. Эта память используется в качестве буфера событий. События сохраняются в этом буфере и позже передаются. При передаче события буфер очищается и в нём можно сохранять новые события. Это происходит как минимум раз за каждый кадр.
Рисунок 06: Пересмотренная схема архитектуры ECS
Надеюсь, теперь у вас есть какое-то представление о том, как работает структура моей ECS. Если нет, то не волнуйтесь, посмотрите на Рисунок 06 и вкратце её повторите. Как видно, EntityId достаточно важен, потому что мы будем использовать его для доступа к конкретному экземпляру объекта сущности и ко всем его компонентам. Все компоненты знают своего владельца, то есть имея объект компонента, можно легко получить сущность, запросив класс EntityManager с заданным идентификатором владельца этого компонента. Для передачи сущности мы никогда не передаём напрямую указатель, а используем события в сочетании с EntityId. Можно создать конкретное событие, допустим EntityDied, и это событие (которое должно быть объектом с простой структурой данных) имеет элемент типа EntityId. Теперь для уведомления всех получателей события (IEventListener), которые могут быть сущностями, компонентами или системами, мы используем EventManager::SendEvent<EntityDied>(entityId). Получатель события с другой стороны теперь может использовать переданный EntityId и запросить класс EntityManager для получения объекта сущности или класс ComponentManager для получения определённого компонента этой сущности. Причина этого окружного пути проста — в любой момент выполнения приложения можно какой-нибудь логикой удалить сущность или один из её компонентов. Поскольку мы не будем загромождать код дополнительными действиями по очистке, то воспользуемся EntityId. Если диспетчер возвращает nullptr для этого EntityId, то мы будем знать, что сущность или компонент уже не существует. Кстати, красный квадрат соответствует такому же на Рисунке 01 и обозначает границы ECS.
Объект Engine
Чтобы сделать всё немного удобнее, я создал объект движка. Объект движка обеспечивает лёгкую интеграцию и использование клиентского ПО. На стороне клиента достаточно добавить заголовочный файл «ECS/ECS.h» и вызвать метод ECS::Initialize(). Теперь статический глобальный объект движка будет инициализирован (ECS::ECS_Engine) и может использоваться на стороне клиента для получения доступа к классам диспетчеров. Более того, он предоставляет метод SendEvent<T> для рассылки сообщений и метод Update, который автоматически передаёт все события и обновляет все системы. ECS::Terminate() необходимо вызывать перед выходом из основной программы. Это гарантирует, что все полученные ресурсы будут освобождены. В представленном ниже фрагменте кода продемонстрировано простейшее использование глобального объекта движка.
#include <ECS/ECS.h>
int main(int argc,char* argv[])
{
// инициализация глобального объекта 'ECS_Engine'
ECS::Initialize();
const float DELTA_TIME_STEP = 1.0f / 60.0f; // 60 Гц
bool bQuit = false;
// выполняем основной цикл до выхода
while(bQuit == false)
{
// Обновление всех систем, передача всех событий из буфера,
// удаление разрушенных компонентов и сущностей ...
ECS::ECS_Engine->(DELTA_TIME_STEP);
/*
ECS::ECS_Engine->GetEntityManager()->...;
ECS::ECS_Engine->GetComponentManager()->...;
ECS::ECS_Engine->GetSystemManager()->...;
ECS::ECS_Engine->SendEvent<T>(...);
*/
// другая логика ...
}
// разрушение глобального объекта 'ECS_Engine'
ECS::Terminate();
return 0;
}
Заключение
Описанная в этой части статьи архитектура «сущность-компонент-система» полностью функциональна и готова к использованию. Но как обычно, всегда есть мысли по улучшению. Вот всего лишь некоторые из идей, которые пришли мне в голову:
- Сделать архитектуру потокобезопасной,
- Выполнять каждую систему или группу систем в потоках, с учётом их топологического порядка,
- Выполнить рефакторинг event-sourcing и управления памятью, чтобы использовать их как модули,
- Сериализация,
- Профилирование
- …
Я подготовил демо, чтобы вы могли увидеть мою ECS в действии:
Демо BountyHunter активно использует ECS и демонстрирует мощь этого шаблона. Во второй части поста я буду рассказывать о нём.
Справочные материалы
[1]https://en.wikipedia.org/wiki/Entity-component-system
[2]http://gameprogrammingpatterns.com/component.html
[4]https://github.com/junkdog/artemis-odb/wiki/Introduction-to-Entity-Systems
[5]http://scottbilas.com/files/2002/gdc_san_jose/game_objects_slides.pdf
[6]https://docs.microsoft.com/en-us/azure/architecture/patterns/event-sourcing
[7]https://sourceforge.net/p/log4cplus/wiki/Home/
[10]http://en.cppreference.com/w/cpp/language/constant_initialization
[11]https://en.wikipedia.org/wiki/Variadic_template
[12]http://gameprogrammingpatterns.com/object-pool.html
[13]http://en.cppreference.com/w/cpp/language/new
Часть 2: Игра BountyHunter
Теперь я хочу показать вам, как на самом деле использовать архитектуру для создания на ней игр. Признаю, моя игра выглядит не очень сложной, но если вы решитесь использовать мою реализацию для создания собственной игры вместо большого и сложного игрового движка типа Unity или Unreal, то можете упомянуть моё авторство. Чтобы продемонстрировать возможности моей ECS, мне достаточно было и такой игры. Если вы не поняли её правила, то вам поможет следующая картинка:
Рисунок 01: Цель и правила игры BountyHunter. Цель: каждый игрок стремится как можно быстрее пополнить собственные припасы. Правила: 1. Игра имеет ограничение по времени. По истечении времени игра завершается. 2. Касаясь добычи, сборщик подбирает её. 3. Сборщики могут переносить ограниченное количество добычи. Если предел достигнут, то сборщик больше не может собирать добычу, а при касании добычи она уничтожается. 4. Если сборщик касается своих или вражеских припасов, то вся собранная добыча сбрасывается в припасы и сборщик снова может собирать добычу. 5. При столкновении сборщиков они уничтожаются и через какое-то время возрождаются в точке своих припасов. Собранная добыча теряется. 6. Добыча появляется в случайной точке центральной части мира и существует в течение своего срока жизни, или пока не будет собрана. 7. Когда сборщик пересекает границы мира, то появляется с другого края экрана.
Изображение слева выглядит знакомо, потому что это более абстрактная схема игры, которую вы видели в видео. Правила достаточно понятны и говорят сами за себя. Как видите, у нас есть множество типов сущностей, живущих в игровом мире. Вам наверно интересно, из чего на самом деле они состоят? Из компонентов, разумеется. Некоторые типы компонентов общие для всех сущностей, другие же уникальны для других. Посмотрите на рисунок ниже.
Рисунок 02: Сущность и её компоненты.
Посмотрев на этот рисунок, можно легко увидеть отношения между сущностями и их компонентами (на рисунке отражено не всё!). Все сущности игры имеют Transform-Component. Поскольку сущности игры должны быть расположены где-то в мире, у них есть transform, описывающий положение, поворот и масштаб сущностей. Это может быть единственный компонент, прикреплённый к сущности. Например, объект камеры требует больше компонентов, но ей не нужен Material-Component, потому что она никогда не будет видима игроку (что может быть неверно, если вы используете постэффекты). С другой стороны, объекты сущностей Bounty (добыча) и Collector (сборщик) должны быть видны, поэтому им для отображения необходим Material-Component. Также они могут сталкиваться с другими объектами игры, а потому к ним прикреплён Collision-Component, описывающий их физическую форму. К сущности Bounty прикреплён ещё один компонент — Lifetime-Component. Этот компонент определяет оставшийся срок жизни объекта Bounty, когда срок его жизни заканчивается, то добыча уничтожается.
Итак, что же дальше? Создав все эти различные сущности с их собственными наборами компонентов, мы не доделали саму игру. Нам также нужен кто-то, знающий, как управлять каждым из них. Разумеется, я говорю о системах. Системы — это отличная вещь, их можно использовать для разделения всей логики игры на более мелкие элементы. Каждый элемент, работает с собственным аспектом игры. У нас может быть, или даже должна быть система Input-System, обрабатывающая все вводимые игроком данные. Render-System, переносящая все формы и цвета на экран. Respawn-System для возрождения мёртвых объектов игры. Ну, думаю, вы поняли идею. На рисунке ниже показана полная схема классов всех конкретных типов сущностей, компонентов и систем в BountyHunter.
Рисунок 03: Схема классов ECS BountyHunter (нажмите на изображение, чтобы увеличить его).
Теперь у нас есть сущности, компоненты и система (ECS), но нам не хватает… событий! Чтобы системы и сущности могли обмениваться данными, я создал коллекцию из 38 событий:
GameInitializedEvent GameRestartedEvent GameStartedEvent GamePausedEvent GameResumedEvent GameoverEvent GameQuitEvent PauseGameEvent ResumeGameEvent RestartGameEvent QuitGameEvent LeftButtonDownEvent LeftButtonUpEvent LeftButtonPressedEvent RightButtonDownEvent RightButtonUpEvent RightButtonPressedEvent KeyDownEvent KeyUpEvent KeyPressedEvent ToggleFullscreenEvent EnterFullscreenModeEvent StashFull EnterWindowModeEvent GameObjectCreated GameObjectDestroyed PlayerLeft GameObjectSpawned GameObjectKilled CameraCreated, CameraDestroyed ToggleDebugDrawEvent WindowMinimizedEvent WindowRestoredEvent WindowResizedEvent PlayerJoined CollisionBeginEvent CollisionEndEvent
И есть ещё многое, что мне потребовалось для реализации BountyHunter:
- общий фреймворк приложения — SDL2 для получения ввода игрока и настройки основного окна приложения.
- графика — я использовал собственный рендерер OpenGL, чтобы можно было выполнять рендеринг в это окно приложения.
- математика — для линейной алгебры я использовал glm.
- распознавание коллизий — для неё я использовал физику box2d.
- Конечный автомат — использовался для простого ИИ и состояний игры.
Разумеется, я не буду рассматривать все эти механики, потому что они заслуживают собственной статьи, которую я возможно напишу позже. Но если вы всё равно хотите о них узнать, то я не буду вам мешать и дам эту ссылку. Изучив все упомянутые мной характеристики, вы можете решить, что это может стать хорошим началом для вашего собственного игрового движка. В моём списке есть ещё несколько пунктов, которые я на самом деле не реализовал, просто потому, что хотел завершить прототип.
- Редактор для управления сущностями, компонентами, системами и другим
- Сохранение игры — сохранение сущностей и их компонентов в базу данных с помощью какой-нибудь библиотеки ORM (например, codesynthesis)
- Воспроизведение реплеев — запись событий во время выполнения и их воспроизведение в дальнейшем
- GUI — использование GUI-фреймворка (например, librocket) для создания интерактивного игрового меню
- Диспетчер ресурсов — синхронная и асинхронная загрузка ресурсов (текстур, шрифтов, моделей и т.д.) с помощью собственного диспетчера ресурсов
- Работа по сети — передача событий по сети и настройка многопользовательского режима
Я оставлю эти пункты как задание для вас, чтобы можно было доказать, насколько вы крутой программист.
Также мне нужно показать вам код, демонстрирующий использование моей ECS. Помните игровую сущность Bounty? Добыча — это мелкие жёлтые, крупные красные и промежуточные квадраты, случайно создаваемые в центре мира. Ниже показан фрагмент кода определения класса сущности Bounty.
// Bounty.h
class Bounty : public GameObject<Bounty>
{
private:
// кэшируем компоненты
TransformComponent* m_ThisTransform;
RigidbodyComponent* m_ThisRigidbody;
CollisionComponent2D* m_ThisCollision;
MaterialComponent* m_ThisMaterial;
LifetimeComponent* m_ThisLifetime;
// свойство класса bounty
float m_Value;
public:
Bounty(GameObjectId spawnId);
virtual ~Bounty();
virtual void OnEnable() override;
virtual void OnDisable() override;
inline float GetBounty() const { return this->m_Value; }
// вызывает OnEnable, задаёт случайно выбираемое значение добычи
void ShuffleBounty();
};
Код довольно прост и понятен. Я создал новую игровую сущность, полученную из GameObject<T> (который получен из ECS::Entity<T>) с самим классом (Bounty) в качестве T. Теперь ECS известен этот конкретный тип сущности и будет создан уникальный идентификатор (статического) типа. Также мы получаем доступ к удобным методам AddComponent<U>, GetComponent<U>, RemoveComponent<U>. Кроме компонентов, которые я скоро покажу, есть ещё одно свойство — значение добычи. Я не помню точно, почему я не выделил это свойство в отдельный компонент, например, в BountyComponent, потому что так было бы правильно. Вместо этого я сделал свойство значения добычи членом класса Bounty, посыпаю голову пеплом. Но на самом деле это только показывает огромную гибкость этого шаблона, не так ли? Точнее, его компонентов…
// Bounty.cpp
Bounty::Bounty(GameObjectId spawnId)
{
Shape shape = ShapeGenerator::CreateShape<QuadShape>();
AddComponent<ShapeComponent>(shape);
AddComponent<RespawnComponent>(BOUNTY_RESPAWNTIME, spawnId, true);
// кэшируем эти компоненты
this->m_ThisTransform = GetComponent<TransformComponent>();
this->m_ThisMaterial = AddComponent<MaterialComponent>(MaterialGenerator::CreateMaterial<defaultmaterial>());
this->m_ThisRigidbody = AddComponent<RigidbodyComponent>(0.0f, 0.0f, 0.0f, 0.0f, 0.0001f);
this->m_ThisCollision = AddComponent<CollisionComponent2d>(shape, this->m_ThisTransform->AsTransform()->GetScale(), CollisionCategory::Bounty_Category, CollisionMask::Bounty_Collision);
this->m_ThisLifetime = AddComponent<LifetimeComponent>(BOUNTY_MIN_LIFETIME, BOUNTY_MAX_LIFETIME);
}
// другие реализации ...
Я использовал конструктор, чтобы присоединить все компоненты, необходимые сущности Bounty. Заметьте, что при таком подходе создаётся заготовка объекта и он не гибок, то есть вы всегда получаете объект Bounty с теми же присоединёнными к нему компонентами. Хотя это достаточно хорошее решение для моей игры, для более сложной оно может оказаться ошибочным. В таком случае нужно реализовать шаблон «фабрика», создающий изменяемые объекты сущностей.
Как видно из кода выше, к сущности Bounty прикреплено довольно мало компонентов. У нас есть ShapeComponent и MaterialComponent для визуального отображения. RigidbodyComponent и CollisionComponent2D для физического поведения и реакции на коллизии. RespawnComponent, чтобы у Bounty была возможность возродиться после смерти. Последний по порядку, но не по значимости — LifetimeComponent, привязывающий существование сущности к определённому промежутку времени. TransformComponent автоматически привязывается к любой сущности, полученной из GameObject<T>. Вот и всё. Мы только добавили в игру новую сущность.
Теперь вы возможно хотите узнать, как использовать все эти компоненты. Позвольте мне показать два примера. Первый — это RigidbodyComponent. Этот компонент содержит информацию о физических характеристиках, например, о трении, плотности и линейном затухании. Более того, он используется в качестве класса-адаптера, применяемого для встраивания в игру физики box2d. RigidbodyComponent довольно важен, потому что используется для синхронизации transform физически симулируемого тела (которым владеет box2d) и TransformComponent сущности (которым владеет игра). Этот процесс синхронизации выполняется PhysicsSystem.
// PhysicsEngine.h
class PhysicsSystem : public ECS::System<PhysicsSystem>, public b2ContactListener
{
public:
PhysicsSystem();
virtual ~PhysicsSystem();
virtual void PreUpdate(float dt) override;
virtual void Update(float dt) override;
virtual void PostUpdate(float dt) override;
// Подключение механизма обработки событий физики box2d для сообщения о коллизиях
virtual void BeginContact(b2Contact* contact) override;
virtual void EndContact(b2Contact* contact) override;
}; // class PhysicsSystem
// PhysicsEngine.cpp
void PhysicsSystem::PreUpdate(float dt)
{
// Синхронизация преобразований физического твёрдого тела и TransformComponent
for (auto RB = ECS::ECS_Engine->GetComponentManager()->begin<RigidbodyComponent>(); RB != ECS::ECS_Engine->GetComponentManager()->end<RigidbodyComponent>(); ++RB)
{
if ((RB->m_Box2DBody->IsAwake() == true) && (RB->m_Box2DBody->IsActive() == true))
{
TransformComponent* TFC = ECS::ECS_Engine->GetComponentManager()->GetComponent<TransformComponent>(RB->GetOwner());
const b2Vec2& pos = RB->m_Box2DBody->GetPosition();
const float rot = RB->m_Box2DBody->GetAngle();
TFC->SetTransform(glm::translate(glm::mat4(1.0f), Position(pos.x, pos.y, 0.0f)) * glm::yawPitchRoll(0.0f, 0.0f, rot) * glm::scale(TFC->AsTransform()->GetScale()));
}
}
}
// другие реализации ...
Из представленной выше реализации можно заметить три различные функции обновления. При обновлении систем первыми вызываются все методы PreUpdate всех систем, затем Update, и после них методы PostUpdate. Поскольку PhysicsSystem вызывается до всех TransformComponent, касающихся системы, представленный выше код обеспечивает синхронизацию transform. Здесь можно также увидеть в действии ComponentIterator. Вместо запрашивания каждой сущности в мире о том, имеет ли она RigidbodyComponent, мы запрашиваем ComponentManager предоставить нам ComponentIterator для типа RigidbodyComponent. Получив RigidbodyComponent, мы легко можем получить идентификатор сущности и ещё раз попросить ComponentManager дать нам и TransformComponent для этого идентификатора.
Как я и обещал, давайте рассмотрим и второй пример. RespawnComponent используется для сущностей, которые должны возрождаться после смерти. Этот компонент предоставляет пять свойств, которые можно использовать для настройки поведения возрождения сущности. Можно выбрать автоматическое возрождение сущности после смерти, задать время, через которое она будет возрождена, а также позицию и ориентацию возрождения. Сама логика возрождения реализована в RespawnSystem.
// RespawnSystem.h
class RespawnSystem : public ECS::System<RespawnSystem>, protected ECS::Event::IEventListener
{
private:
// ... всё остальное
Spawns m_Spawns;
RespawnQueue m_RespawnQueue;
// Механизм обработки событий
void OnGameObjectKilled(const GameObjectKilled* event);
public:
RespawnSystem();
virtual ~RespawnSystem();
virtual void Update(float dt) override;
// другое ...
}; // class RespawnSystem
// RespawnSystem.cpp
// примечание: это только псевдокод!
voidRespawnSystem::OnGameObjectKilled(const GameObjectKilled * event)
{
// проверяем, имеет ли сущность возможность возрождения
RespawnComponent* entityRespawnComponent = ECS::ECS_Engine->GetComponentManager()->GetComponent<RespawnComponent>(event->m_EntityID);
if(entityRespawnComponent == nullptr || (entityRespawnComponent->IsActive() == false) || (entityRespawnComponent->m_AutoRespawn == false))
return;
AddToRespawnQeueue(event->m_EntityID, entityRespawnComponent);
}
void RespawnSystem::Update(float dt)
{
foreach(spawnable in this->m_RespawnQueue)
{
spawnable.m_RemainingDeathTime -= dt;
if(spawnable.m_RemainingDeathTime <= 0.0f)
{
DoSpawn(spawnable);
RemoveFromSpawnQueue(spawnable);
}
}
}
Представленный выше код неполон, но даёт представление о важных строках кода. RespawnSystem содержит и обновляет очередь EntityId вместе с их RespawnComponent. Новые сущности ставятся в очередь, когда системы получают событие GameObjectKilled. Система проверяет, обладает ли убитая сущность возможностью возрождения, то есть присоединён ли к ней RespawnComponent. Если да, то сущность ставится в очередь на возрождение, в противном случае игнорируется. В методе обновления RespawnSystem, который вызывается каждый кадр, система уменьшает изначальное время возрождения RespawnComponent поставленных в очередь сущностей. Если время возрождения снижается до нуля, то сущность возрождается и удаляется из очереди возрождения.
Знаю, статья была довольно короткой. но надеюсь, она дала вам примерное представление о том, как всё работает в мире ECS. Прежде чем закончить пост, я хочу поделиться с вами собственным опытом. Работа с ECS — это огромное удовольствие. В игру удивительно просто добавлять новые возможности даже с помощью сторонних библиотек Я просто добавил новые компоненты и системы, которые связывают новые возможности с игрой, при этом у меня никогда не было чувства, что я зашёл в тупик. Разделение всей игровой логики на несколько систем интуитивно понятно и не вызывает никаких сложностей при работе с ECS. Код выглядит гораздо понятнее и становится удобнее в поддержке, потому что мы избавились от всех «спагетти-зависимостей» указателей. Event sourcing — очень мощная и полезная техника обмена данными между системами/сущностями, но это обоюдоострое лезвие, и может со временем вызвать проблемы. Я говорю об условиях вызова событий. Если вы когда-нибудь работали с редактором Unity или Unreal Engine, то будете довольны реализации такого редактора. Эти редакторы сильно увеличивают продуктивность, потому что на создание новых объектов ECS уходит гораздо меньше времени, чем на написание всех этих строчек кода вручную. Но когда вы создадите мощный фундамент из объектов сущностей, компонентов, систем и событий, то их соединение и создание на их основе чего-то качественного будет простой задачей.
Автор: PatientZero