Entity component system core
Let's get to the essence of how our game entities are going to be represented. In order to achieve highest maintainability and code compartmentalization, it's best to use composition. The entity component system allows just that. For the sake of keeping this short and sweet, we're not going to be delving too deep into the implementation. This is simply a quick overview for the sake of being familiar with the code that will be used down the line.
The ECS pattern consists of three cornerstones that make it possible: entities, components, and systems. An entity, ideally, is simply an identifier, as basic as an integer. Components are containers of data that have next to no logic inside them. There would be multiple types of components, such as position, movable, drawable, and so on, that don't really mean much by themselves, but when composed, will form complex entities. Such composition would make it incredibly easy to save the state of any entity at any given time.
There are many ways to implement components. One of them is simply having a base component class, and inheriting from it:
class C_Base{ public: C_Base(const Component& l_type): m_type(l_type){} virtual ~C_Base(){} Component GetType() const { return m_type; } friend std::stringstream& operator >>( std::stringstream& l_stream, C_Base& b) { b.ReadIn(l_stream); return l_stream; } virtual void ReadIn(std::stringstream& l_stream) = 0; protected: Component m_type; };
The Component
type is simply an enum class that lists different types of components we can have in a project. In addition to that, this base class also offers a means of filling in component data from a string stream, in order to load them more easily when files are being read.
In order to properly manage sets of components that belong to entities, we would need some sort of manager class:
class EntityManager{ public: EntityManager(SystemManager* l_sysMgr, TextureManager* l_textureMgr); ~EntityManager(); int AddEntity(const Bitmask& l_mask); int AddEntity(const std::string& l_entityFile); bool RemoveEntity(const EntityId& l_id); bool AddComponent(const EntityId& l_entity, const Component& l_component); template<class T> void AddComponentType(const Component& l_id) { ... } template<class T> T* GetComponent(const EntityId& l_entity, const Component& l_component){ ... } bool RemoveComponent(const EntityId& l_entity, const Component& l_component); bool HasComponent(const EntityId& l_entity, const Component& l_component) const; void Purge(); private: ... };
As you can see, this is a fairly basic approach at managing these sets of data we call entities. The EntityId
data type is simply a type definition for an unsigned integer. Creation of components happens by utilizing a factory pattern, lambdas and templates. This class is also responsible for loading entities from files that may look a little like this:
Name Player Attributes 255 |Component|ID|Individual attributes| Component 0 0 0 1 Component 1 Player Component 2 0 Component 3 128.0 1024.0 1024.0 1 Component 4 Component 5 20.0 20.0 0.0 0.0 2 Component 6 footstep:1,4 Component 7
The Attributes
field is a bit mask, the value of which is used to figure out which component types an entity has. The actual component data is stored in this file as well, and later loaded through the ReadIn
method of our component base class.
The last piece of the puzzle in ECS design is systems. This is where all of the logic happens. Just like components, there can be many types of systems responsible for collisions, rendering, movement, and so on. Each system must inherit from the system's base class and implement all of the pure virtual methods:
class S_Base : public Observer{
public:
S_Base(const System& l_id, SystemManager* l_systemMgr);
virtual ~S_Base();
bool AddEntity(const EntityId& l_entity);
bool HasEntity(const EntityId& l_entity) const;
bool RemoveEntity(const EntityId& l_entity);
System GetId() const;
bool FitsRequirements(const Bitmask& l_bits) const;
void Purge();
virtual void Update(float l_dT) = 0;
virtual void HandleEvent(const EntityId& l_entity,
const EntityEvent& l_event) = 0;
protected:
...
};
Systems have signatures of components they use, as well as a list of entities that meet the requirements of said signatures. When an entity is being modified by the addition or removal of a component, every system runs a check on it in order to add it to or remove it from itself. Note the inheritance from the Observer
class. This is another pattern that aids in communication between entities and systems.
An Observer
class by itself is simply an interface with one purely virtual method that must be implemented by all derivatives:
class Observer{ public: virtual ~Observer(){} virtual void Notify(const Message& l_message) = 0; };
It utilizes messages that get sent to all observers of a specific target. How the derivative of this class reacts to the message is completely dependent on what it is.
Systems, which come in all shapes and sizes, need to be managed just as entities do. For that, we have another manager class:
class SystemManager{
public:
...
template<class T>
void AddSystem(const System& l_system) { ... }
template<class T>
T* GetSystem(const System& l_system){ ... }
void AddEvent(const EntityId& l_entity, const EventID& l_event);
void Update(float l_dT);
void HandleEvents();
void Draw(Window* l_wind, unsigned int l_elevation);
void EntityModified(const EntityId& l_entity,
const Bitmask& l_bits);
void RemoveEntity(const EntityId& l_entity);
void PurgeEntities();
void PurgeSystems();
private:
...
MessageHandler m_messages;
};
This too utilizes the factory pattern, in that types of different classes are registered by using templates and lambdas, so that they can be constructed later, simply by using a System
data type, which is an enum class
. Starting to see the pattern?
The system manager owns a data member of type MessageHandler
. This is another part of the observer pattern. Let us take a look at what it does:
class MessageHandler{
public:
bool Subscribe(const EntityMessage& l_type,
Observer* l_observer){ ... }
bool Unsubscribe(const EntityMessage& l_type,
Observer* l_observer){ ... }
void Dispatch(const Message& l_msg){ ... }
private:
Subscribtions m_communicators;
};
Message handlers are simply collections of Communicator
objects, as shown here:
using Subscribtions =
std::unordered_map<EntityMessage,Communicator>;
Each possible type of EntityMessage
, which is just another enum class, is tied to a communicator that is responsible for sending out a message to all of its observers. Observers can subscribe to or unsubscribe from a specific message type. If they are subscribed to said type, they will receive the message when the Dispatch
method is invoked.
The Communicator
class itself is fairly simple:
class Communicator{
public:
virtual ~Communicator(){ m_observers.clear(); }
bool AddObserver(Observer* l_observer){ ... }
bool RemoveObserver(Observer* l_observer){ ... }
bool HasObserver(const Observer* l_observer) const { ... }
void Broadcast(const Message& l_msg){ ... }
private:
ObserverContainer m_observers;
};
As you can gather, it supports the addition and removal of observers, and offers a way to broadcast a message to all of them. The actual container of observers is simply a vector of pointers:
// Not memory-owning pointers. using ObserverContainer = std::vector<Observer*>;