0
\$\begingroup\$

One of the advantages that's often brought up when talking about ECS-systems is that they allow a higher degree of flexibility in regards to Data vs Code. Without the limitation of hard-coded types, you can mix-and-match from your component types to create different compositions of components at runtime, for example using configuration files.

This all sounds great, but I'm not sure I fully understand what this looks like in practice, code-wise. Should the configuration files for entity types (be able to) just list all the component types an entity contains? In that case, how do we match the identifiers in the configuration files to the correct component types? Would it look something like this?

{
    "name" : "goblin-grunt",
    "composition" : [
        "render-comp",
        "physics-comp",
        "hostile-mob-comp"
    ]

}

And then something like this (pseudo-code):

switch(composition)
{
case PhysicsComp::id:
    ...
    break;
case RenderComp::id:
    ...
    break;
...
}

This to me looks a bit messy to maintain, since you would have to update that switch-statement for every new component type, as well as having to add a static constexpr id field for every type.

But maybe this is generally how it's done?

\$\endgroup\$
2
  • \$\begingroup\$ You'd presumably want to store more than just the type of each component, but also the values of its relevant parameters/properties/fields. That likely means a more involved deserialization system involving something like reflection or code generation to route each serialized member to its proper memory location, without hand-coding a new case every time you add a property. \$\endgroup\$
    – DMGregory
    Jan 15, 2022 at 13:46
  • \$\begingroup\$ @DMGregory Hm, not sure I understand. How would deserialization through reflection help with matching configuration data to component types? Not too familiar with reflection, so that's probably why \$\endgroup\$
    – JensB
    Jan 15, 2022 at 15:23

1 Answer 1

1
\$\begingroup\$

Should the configuration files for entity types (be able to) just list all the component types an entity contains?

No. As it's been pointed out in the comments, "You'd presumably want to store more than just the type of each component, but also the values of its relevant parameters/properties/fields."

So, for example, your JSON file could look like this:

{
    "name" : "goblin-grunt",
    "composition" : [
        "render-comp" : 
          [ "model" : "goblin-grunt.obj", 
            "base-scale" : 1.2 ],
        "physics-comp" : 
          [ "model" : "goblin-grunt.phy", 
            "base-scale" : 1.2 ],
        "hostile-mob-comp" : 
          [ "behaviour": "aggressive", 
            "walk-speed": 1, 
            "run-speed": 1.7, 
            "max-pursue-distance": 50 ]
    ]
}

Now the "fun" part comes in how you compose your entity with those components in the code.

This to me looks a bit messy to maintain, since you would have to update that switch-statement for every new component type, as well as having to add a static constexpr id field for every type.

Yes, and there are other ways.

And those ways will depend on how you write your data, and where you need it once it's loaded. Using reflection in c++ can be a challenge, and I suppose you'll need to spend a lot of time to implement one that works for you/your project/your team if you want to go that way.

Alternatively, you can use a parser function/object that you'll write yourself for each component type.

But you don't have to use a switch the way you've shown us.

I would suggest you explore the following pattern (I'm sure it has a name but I don't know what it is so I can't web-search for it and refer to it). Roughly, you have a singleton (unique object/globally accessible) class, let's call it ComponentParsersRegistry which will act as a hub for all the component parsers your infrastructure has.

Then you'll have classes that will instantiate themselves at program start up (those are created in the .cpp files, they're in the global scope). Upon construction, those classes will make the ComponentParsersRegistry aware that they exist and associate a "string" (e.g. "render-comp") to a parser function for that component, which will take a JSON object as input and return an optionally populated component.

The code goes roughly like this:

In ComponentParsersRegistry.h:

class ComponentParsersRegistry : private boost::noncopyable
{
public:

  static ComponentParsersRegistry& GetInstance() { 
    if ( not TheInstance ) 
      TheInstance = new ComponentParsersRegistry(); 
    return *TheInstance; 
  }
  
  std::map<std::string, std::function<std::unique_ptr<Component>(const JSON::Object&)> mParsers;
  
private:
  
  ComponentParsersRegistry() = default;

  static ComponentParsersRegistry* TheInstance; // Make sure you initialize this to nullptr in the cpp.
  
}

In ComponentRender.h:

struct ComponentRender : public Component
{
  /// Configuration
  /// @{
  std::filesystem::path mModelPath;
  double mBaseScale { 1.0 };
  /// @}
  
  /// Run-time stuff
  /// @{
  myNs::Matrix mTransformMatrix;
  myNs::ModelNode mLoadedModelNode;
  /// @}
}

In ParserComponentRender.h:

#include "ComponentParsersRegistry.h"
#include "ComponentRender.h"

struct ParserComponentRender
{
  ParserComponentRender() {
    ComponentParsersRegistry::GetInstance().mParsers["render-comp"] = std::bind( ParserComponentRender::parse, this, std::placeholders::_1);
  }
  
  std::unique_ptr<Component> parse( const JSON::Object& aJSONObject )
  {
    auto componentRender = std::make_unique<ComponentRender>();
    
    // try and parse the JSON into the struct.
    
    if ([there are no parsing issue])
      return std::move( componentRender );
      
    // issues with the parsing.
    return nullptr;
  }
}

In ParserComponentRender.cpp:

// instantiate the object
ParserComponentRender parserComponentRender(); // This will invoke the ctor and register the parser into the hub.

In EntityParser.cpp:

#include "ComponentParsersRegistry.h"

void EntityParser::parseEntityFromJSON( Entity& aEntity, const JSON::Object& aEntityJSONObject )
{
  for ( const auto& [key, value] : aEntityJSONObject )
  {
    if ( auto it = ComponentParsersRegistry::GetInstance().mParsers.find(key);
         it != ComponentParsersRegistry::GetInstance().mParsers.end() )
    {
      std::unique_ptr<Component> component = it.second(value);
      if (component)
      {
        aEntity.mComponents.emplace_back(std::move( component ) );
      }
    }
  }
}

This design will let you add new parsers locally without the need to interfere with other parts of the parsing. Now please keep in mind that I don't know how you've designed your structure yet, and a single part of the code can't be taken independently from the rest of the code. Here I assume that you store your Components in your Entity, but that might not be the case in your own implementation, and so you'll need to change how you're doing this.

\$\endgroup\$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .