Zuweisen von Entitäten innerhalb eines Entitätssystems

8

Ich bin mir nicht sicher, wie ich meine Entitäten innerhalb meines Entitätssystems zuordnen / ähneln soll. Ich habe verschiedene Möglichkeiten, aber die meisten scheinen Nachteile zu haben. In allen Fällen ähneln Entitäten einer ID (Ganzzahl) und sind möglicherweise einer Wrapper-Klasse zugeordnet. Diese Wrapper-Klasse verfügt über Methoden zum Hinzufügen / Entfernen von Komponenten zur / von der Entität.

Bevor ich die Optionen erwähne, ist hier die Grundstruktur meines Entitätssystems:

  • Entität
    • Ein Objekt, das ein Objekt im Spiel beschreibt
  • Komponente
    • Wird zum Speichern von Daten für die Entität verwendet
  • System
    • Enthält Entitäten mit bestimmten Komponenten
    • Wird verwendet, um Entitäten mit bestimmten Komponenten zu aktualisieren
  • Welt
    • Enthält Entitäten und Systeme für das Entitätssystem
    • Kann Entites erstellen / zerstören und Systeme hinzufügen / entfernen lassen

Hier sind meine Optionen, an die ich gedacht habe:

Option 1:

Speichern Sie nicht die Entity-Wrapper-Klassen, sondern nur die nächsten IDs / gelöschten IDs. Mit anderen Worten, Entitäten werden nach Wert zurückgegeben, wie folgt:

Entity entity = world.createEntity();

Dies ist sehr ähnlich zu entityx, außer dass ich einige Fehler in diesem Design sehe.

Nachteile

  • Es kann doppelte Entity-Wrapper-Klassen geben (da der Copy-Ctor implementiert werden muss und Systeme Entitäten enthalten müssen)
  • Wenn eine Entität zerstört wird, haben die Wrapper-Klassen für doppelte Entitäten keinen aktualisierten Wert

Option 2:

Speichern Sie die Entity-Wrapper-Klassen in einem Objektpool. dh Entitäten werden per Zeiger / Referenz zurückgegeben, wie folgt:

Entity& e = world.createEntity();

Nachteile

  • Wenn es doppelte Entitäten gibt, kann bei der Zerstörung einer Entität dasselbe Entitätsobjekt erneut verwendet werden, um eine andere Entität zuzuweisen.

Option 3:

Verwenden Sie unformatierte IDs und vergessen Sie die Wrapper-Entitätsklassen. Ich denke, der Nachteil ist die Syntax, die dafür erforderlich sein wird. Ich denke darüber nach, da es am einfachsten und einfachsten zu implementieren scheint. Ich bin mir wegen der Syntax ziemlich unsicher.

dh Um eine Komponente mit diesem Design hinzuzufügen, sieht es folgendermaßen aus:

Entity e = world.createEntity();
world.addComponent<Position>(e, 0, 3);

Wie dazu bestimmt:

Entity e = world.createEntity();
e.addComponent<Position>(0, 3);

Nachteile

  • Syntax
  • Doppelte IDs
miguel.martin
quelle

Antworten:

11

Ihre IDs sollten eine Mischung aus Index und Version sein . Auf diese Weise können Sie IDs effizient wiederverwenden, die ID verwenden, um Komponenten schnell zu finden, und Ihre "Option 2" lässt sich viel einfacher implementieren (obwohl Option 3 mit etwas Arbeit viel schmackhafter gemacht werden kann).

struct entity {
  uint16 version;
  /* and other crap that doesn't belong in components */
};

std::vector<entity> pool;
std::vector<uint16> freelist;
typedef uint32 entity_id; /* this shoudl be a wrapper class */

entity_id createEntity()
{
  uint16 index;
  if (!freelist.empty())
  {
    pool.push_back(entity());
    freelist.push_back(pool.size() - 1);
  }
  index = freelist.pop_back();

  return (pool[id].version << 16) | index;
}

void deleteEntity(entity_id id)
{
   uint16 index = id & 0xFFFF;
   ++pool[index].version;
   freelist.push_back(index);
}

entity* getEntity(entity_id id)
{
  uint16 index = id & 0xFFFF;
  uint16 version = id >> 16;
  if (index < pool.size() && pool[index].version == version)
    return &pool[index];
  else
    return NULL;
 }

Dadurch wird eine neue 32-Bit-Ganzzahl zugewiesen, die eine Kombination aus einem eindeutigen Index (der für alle Live-Objekte eindeutig ist) und einem Versions-Tag (der für alle Objekte eindeutig ist, die diesen Index jemals belegt haben) ist.

Beim Löschen einer Entität erhöhen Sie die Version. Wenn Sie nun Verweise auf diese ID haben, hat diese nicht mehr das gleiche Versions-Tag wie die Entität, die diesen Platz im Pool belegt. Alle Anrufversuche getEntity(oder ein isEntityValidoder was auch immer Sie bevorzugen) schlagen fehl. Wenn Sie an dieser Position ein neues Objekt zuweisen, schlagen die alten IDs weiterhin fehl.

Sie können so etwas für Ihre "Option 2" verwenden, um sicherzustellen, dass es ohne Bedenken über alte Entitätsreferenzen funktioniert. Beachten Sie, dass Sie niemals einen speichern dürfen, entity*da diese möglicherweise verschoben werden ( pool.push_back()der gesamte Pool könnte neu zugewiesen und verschoben werden!) Und entity_idstattdessen nur für langfristige Referenzen verwendet werden. Verwenden Sie getEntitydiese Option , um ein Objekt mit schnellerem Zugriff nur im lokalen Code abzurufen. Sie können auch ein std::dequeoder ähnliches verwenden, um eine Ungültigmachung des Zeigers zu vermeiden, wenn Sie dies wünschen.

Ihre "Option 3" ist eine absolut gültige Wahl. Es ist an sich nichts Falsches daran, world.foo(e)stattdessen zu verwenden e.foo(), zumal Sie wahrscheinlich worldtrotzdem auf die Referenz verweisen möchten und es nicht unbedingt besser (wenn auch nicht unbedingt schlechter) ist, diese Referenz in der Entität selbst zu speichern.

Wenn Sie wirklich möchten, dass die e.foo()Syntax erhalten bleibt, ziehen Sie einen "intelligenten Zeiger" in Betracht, der dies für Sie erledigt. Aufbauend auf dem Beispielcode, den ich oben aufgegeben habe, könnten Sie etwas haben wie:

class entity_ptr {
  world* _world;
  entity_id _id;

public:
  entity_ptr() : _id(0) { }
  entity_ptr(world& world, entity_id id) : _world(&world), _id(id) { }

  bool empty() const { return _world != NULL && _world->getEntity(_id) != NULL; }
  void clear() { _world = NULL; _id = 0; }
  entity* get() { assert(!empty()); return _world->getEntity(_id); }
  entity* operator->() { return get(); }
  entity& operator*() { return *get(); }
  // add const method where appropriate
};

Jetzt haben Sie die Möglichkeit, einen Verweis auf eine Entität zu speichern, die eine eindeutige ID verwendet und mit dem ->Operator entityganz natürlich auf die Klasse (und jede darauf erstellte Methode) zugreifen kann. Das _worldMitglied kann auch ein Singleton oder ein Global sein, wenn Sie es vorziehen.

Ihr Code verwendet nur eine entity_ptranstelle aller anderen Entitätsreferenzen und geht. Sie können der Klasse sogar eine automatische Referenzzählung hinzufügen, wenn Sie möchten (etwas zuverlässiger, wenn Sie den gesamten Code auf C ++ 11 aktualisieren und Verschiebungssemantik und rWert-Referenzen verwenden), sodass Sie sie einfach entity_ptrüberall verwenden können und nicht mehr lange nachdenken müssen über Referenzen und Eigentum. Oder, und das ist es, was ich bevorzuge, machen Sie eine separate owning_entityund weak_entityTypen mit nur den früheren verwaltenden Referenzzählern, damit Sie das Typsystem verwenden können, um zwischen Handles zu unterscheiden, die eine Entität am Leben erhalten, und solchen, die nur auf sie verweisen, bis sie zerstört wird.

Beachten Sie, dass der Overhead sehr gering ist. Die Bitmanipulation ist billig. Die zusätzliche Suche in den Pool ist keine echte Kosten, wenn Sie entityohnehin bald darauf auf andere Felder zugreifen . Wenn Ihre Entitäten wirklich nur IDs und nichts anderes sind, kann es zu einem zusätzlichen Aufwand kommen. Persönlich scheint mir die Idee eines ECS, bei dem Entitäten nur IDs sind und nichts anderes, ein bisschen ... akademisch zu sein. Es gibt mindestens ein paar Flags, die Sie in der allgemeinen Entität speichern möchten, und größere Spiele benötigen wahrscheinlich eine Sammlung der Komponenten der Entität (inline verknüpfte Liste, wenn nichts anderes) für Tools und Serialisierungsunterstützung.

Als ziemlich letzte Anmerkung habe ich absichtlich nicht initialisiert entity::version. Es spielt keine Rolle. Egal wie die ursprüngliche Version lautet, solange wir sie jedes Mal erhöhen, wenn es uns gut geht. Wenn es nahe kommt, 2^16wird es einfach herumlaufen. Wenn Sie am Ende so herumlaufen, dass alte IDs gültig bleiben, wechseln Sie zu größeren Versionen (und 64-Bit-IDs, falls erforderlich). Um sicher zu gehen, sollten Sie entity_ptr wahrscheinlich jedes Mal löschen, wenn Sie es überprüfen und es leer ist. Sie könnten empty()dies für Sie mit einem veränderlichen tun _world_und _idnur vorsichtig mit dem Einfädeln sein.

Sean Middleditch
quelle
Warum nicht die ID in der Entitätsstruktur enthalten? Ich bin ziemlich verwirrt. Könnten Sie auch std :: shared_ptr / schwach_ptr für owning_entityund verwenden weak_entity?
miguel.martin
Sie können stattdessen die ID enthalten, wenn Sie möchten. Der einzige Punkt ist, dass sich der Wert der ID ändert, wenn eine Entität im Slot zerstört wird, während die ID auch den Index des Slots für eine effiziente Suche enthält. Sie können verwenden shared_ptrund sich weak_ptrjedoch bewusst sein, dass sie für individuell zugewiesene Objekte bestimmt sind (obwohl sie benutzerdefinierte Löscher haben können, um dies zu ändern) und daher nicht die effizientesten Typen sind. weak_ptrinsbesondere kann nicht tun, was Sie wollen; Es verhindert, dass eine Entität vollständig freigegeben / wiederverwendet wird, bis alle weak_ptrzurückgesetzt werden, während weak_entitydies nicht der Fall ist .
Sean Middleditch
Es wäre viel einfacher, diesen Ansatz zu erklären, wenn ich ein Whiteboard hätte oder nicht viel zu faul wäre, um dies in Paint oder so etwas zu erstellen. :) Ich denke, die Visualisierung der Struktur macht es außerordentlich klar.
Sean Middleditch
gamesfromwithin.com/managing-data-relationships Dieser Artikel scheint etwas zu präsentieren - was das gleiche ist, was Sie in Ihrer Antwort gesagt haben, meinen Sie das?
miguel.martin
1
Ich bin der Autor von EntityX , und die Wiederverwendung von Indizes hat mich eine Weile gestört . Basierend auf Ihrem Kommentar habe ich EntityX so aktualisiert, dass es auch eine Version enthält. Danke @SeanMiddleditch!
Alec Thomas
0

Ich arbeite gerade an etwas Ähnlichem und habe eine Lösung verwendet, die Ihrer Nummer 1 am nächsten kommt.

Ich habe EntityHandleInstanzen von der zurückgegeben World. Jedes EntityHandlehat einen Zeiger auf das World(in meinem Fall nenne ich es einfach EntityManager), und die Datenmanipulations- / Abrufmethoden in EntityHandlesind tatsächlich Aufrufe von World: z. B. um Componenteiner Entität ein hinzuzufügen , können Sie aufrufen EntityHandle.addComponent(component), was wiederum aufruft World.addComponent(this, component).

Auf diese Weise werden die EntityWrapper-Klassen nicht gespeichert, und Sie vermeiden den zusätzlichen Syntaxaufwand, den Sie mit Option 3 erhalten. Außerdem wird das Problem "Wenn eine Entität zerstört wird, haben die Wrapper-Klassen für doppelte Entitäten keinen aktualisierten Wert" vermieden ", weil sie alle auf die gleichen Daten verweisen.

vijoc
quelle
Was passiert, wenn Sie ein anderes EntityHandle so erstellen, dass es derselben Entität ähnelt, und dann versuchen, eines der Handles zu löschen? Das andere Handle hat immer noch dieselbe ID, was bedeutet, dass es eine tote Entität "behandelt".
miguel.martin
Das stimmt, die anderen verbleibenden Handles zeigen dann auf die ID, die eine Entität nicht mehr "enthält". Natürlich sollten Situationen vermieden werden, in denen Sie eine Entität löschen und dann versuchen, von einer anderen Stelle aus darauf zuzugreifen. Dies Worldkönnte beispielsweise eine Ausnahme auslösen, wenn versucht wird, Daten zu manipulieren / abzurufen, die einer "toten" Entität zugeordnet sind.
Vijoc
Während es am besten vermieden wird, wird dies in der realen Welt passieren. Skripte halten sich an Referenzen, "intelligente" Spielobjekte (wie das Suchen von Raketen) halten sich an Referenzen usw. Sie benötigen wirklich ein System, das entweder in allen Fällen mit veralteten Referenzen richtig umgehen kann oder das schwache Spuren aufspürt und auf Null setzt Verweise.
Sean Middleditch
Die Welt könnte beispielsweise eine Ausnahme auslösen, wenn versucht wird, Daten zu manipulieren / abzurufen, die einer "toten" Entität zugeordnet sind. Nicht, wenn die alte ID jetzt einer neuen Entität zugewiesen ist.
miguel.martin