I'm currently building a lightweight ECS system (keeping it hard-coded to the game for now, will look into separating into an engine once it's up and running). I'm taking some inspiration from Unity, as I have a lot of experience working with it, so I decided to keep track of components in an internally linked list, where the Transform component is the head. A GameObject is an empty container with a Transform component, and since Transforms can't (shouldn't) be removed, I figured it would be efficient to use it to keep track of the component linked list head.
However, instead of following the conventional data pattern of defining a Node class that contains a pointer to its data, I decided to try and integrate the linked pointers directly into the runtime node types through inheritance. Something tells me this is a bad practice, but it seems to satisfy data-oriented design philosophies, and I can't see any explicit pitfalls yet. There is a little lack of safety, but there are a couple asserts to ensure polymorphic compatibility. Also, having the nodes delete themselves without controlling their own allocation is definitely a bad smell, but is there really an issue if it's made explicit to the programmer to always dynamically allocate nodes that get pushed into the list?
Anyway here's the code, feel free to rip it apart and expose all of my bad C++ practices:
#ifndef _LINKED_NODE_H_
#define _LINKED_NODE_H_
#pragma once
#include <vector>
#include <typeinfo>
class LinkedNode
{
private:
LinkedNode* prev = nullptr;
LinkedNode* next = nullptr;
protected:
virtual ~LinkedNode() { };
void push_back(LinkedNode* node)
{
LinkedNode* curr = this;
while (curr->next != nullptr)
{
curr = curr->next;
}
curr->next = node;
curr->next->prev = curr;
curr->next->next = nullptr;
}
// returns first found node of specified runtime type
template <class DerivedType>
LinkedNode* get_node_by_type()
{
LinkedNode* curr = this;
while (this != nullptr)
{
if (typeid(curr) == typeid(DerivedType))
{
return curr;
}
curr = curr->next;
}
return nullptr;
}
// returns all nodes of specified runtime type
template <class DerivedType>
std::vector<LinkedNode*> get_nodes_by_type()
{
std::vector<LinkedNode*> nodes;
LinkedNode* curr = this;
while (curr != nullptr)
{
if (typeid(curr) == typeid(DerivedType))
{
nodes.push_back(curr);
}
curr = curr->next;
}
return nodes;
}
// removes this item from list
void remove()
{
if (prev)
prev->next = next;
if (next)
next->prev = prev;
delete this; // IT'S DEFINED BEHAVIOUR JUST ALLOCATE IT DYNAMICALLY
}
// delete all nodes
void clear_list()
{
LinkedNode* curr = this;
while (curr->next != nullptr)
{
curr->next->remove();
}
delete this; // IT'S DEFINED BEHAVIOUR JUST ALLOCATE IT DYNAMICALLY
}
};
#endif
And yeah, I'm not using smart pointers, I just want to get it functioning with raw pointers first then I'll work on ownership and safety.
The component class is just a container base that inherits from LinkedNode. The rest of its information should be arbitrary to this pattern so I won't include it.
The GameObject class then implements component functions like so:
#ifndef _GAME_OBJECT_H_
#define _GAME_OBJECT_H_
#pragma once
#include "Object.h"
#include "StandardTypes.h"
#include "LinkedNode.h"
class Component;
class Transform;
class GameObject final : public Object
{
public:
GameObject();
~GameObject();
// All GOs need a transform, so it is the head of Component LinkedList
Transform* transform;
// Add existing component
void AddComponent(Component* component);
// Add new component by type
template <class ComponentType>
void AddComponent()
{
bool isComponent = std::is_base_of<Component, ComponentType>::value;
ASSERT(isComponent, "Trying to add a non-Component to a GameObject");
// this won't work unless GameObject knows all complete component types
transform->push_back(new ComponentType());
}
// Get first component of runtime type "type"
Component* GetComponent(std::string type); // TODO: implement (RTTI class table)
// Get first component of specified runtime type
template <class ComponentType>
ComponentType* GetComponent()
{
bool isComponent = std::is_base_of<Component, ComponentType>::value;
ASSERT(isComponent, "Calling GetComponent<> with a non-Component type.");
return static_cast<ComponentType*>(transform->get_node_by_type<ComponentType>());
}
// Get all components of specified runtime type
template <class ComponentType>
std::vector<ComponentType*> GetComponents()
{
bool isComponent = std::is_base_of<Component, ComponentType>::value;
ASSERT(isComponent, "Calling GetComponent<> with a non-Component type.");
// is there a better way to do this???
// does this even work???
std::vector<LinkedNode*> nodes = transform->get_nodes_by_type<Component>();
std::vector<ComponentType*> components;
components.reserve(nodes.size());
for (int i = 0; i < components.size(); i++)
components[i] = static_cast<ComponentType*>(nodes[i]);
return components;
}
};
#endif
But doesn't static_cast<> require a complete type? Should dynamic_cast<> be used here? I was trying to be as lean as possible with this structure so I wanted to avoid inheritance tree searching, but I think it might be the only option excluding some sort of bonkers C++ witchcraft.
And it shouldn't be too relevant, but for completeness, GameObject.cpp:
#include "GameObject.h"
#include "Component.h"
#include "Transform.h"
GameObject::GameObject()
{
transform = new Transform();
}
GameObject::~GameObject()
{
transform->clear_list();
}
void GameObject::AddComponent(Component* component)
{
transform->push_back(component);
}
Component* GameObject::GetComponent(std::string type)
{
// TODO: implement (RTTI class table)
return nullptr;
}
TLDR; is this approach a terrible idea that should be scrapped and replaced with a conventional LinkedList structure, or is it acceptable assuming it's used correctly? Also please pick apart any bad C++ practices I've used (except lack of smart pointers).