Diseñando un juego basado en componentes

16

Estoy escribiendo un tirador (como 1942, gráficos 2D clásicos) y me gustaría usar un enfoque basado en componentes. Hasta ahora pensé en el siguiente diseño:

  1. Cada elemento del juego (dirigible, proyectil, powerup, enemigo) es una entidad

  2. Cada entidad es un conjunto de componentes que pueden agregarse o eliminarse en tiempo de ejecución. Los ejemplos son Posición, Sprite, Salud, IA, Daño, BoundingBox, etc.

La idea es que Airship, Projectile, Enemy, Powerup NO sean clases de juego. Una entidad solo se define por los componentes que posee (y que pueden cambiar con el tiempo). Entonces, la aeronave del jugador comienza con los componentes Sprite, Posición, Salud y Entrada. Un powerup tiene Sprite, Position, BoundingBox. Y así.

El bucle principal gestiona la "física" del juego, es decir, cómo interactúan los componentes entre sí:

foreach(entity (let it be entity1) with a Damage component)
    foreach(entity (let it be entity2) with a Health component)
    if(the entity1.BoundingBox collides with entity2.BoundingBox)
    {
        entity2.Health.decrease(entity1.Damage.amount());
    }

foreach(entity with a IA component)
    entity.IA.update(); 

foreach(entity with a Sprite component)
    draw(entity.Sprite.surface()); 

...

Los componentes están codificados en la aplicación principal de C ++. Las entidades se pueden definir en un archivo XML (la parte IA en un archivo lua o python).

El bucle principal no se preocupa mucho por las entidades: solo administra componentes. El diseño del software debe permitir:

  1. Dado un componente, obtenga la entidad a la que pertenece

  2. Dada una entidad, obtiene el componente de tipo "tipo"

  3. Para todas las entidades, haz algo

  4. Para todos los componentes de la entidad, haga algo (por ejemplo: serializar)

Estaba pensando en lo siguiente:

class Entity;
class Component { Entity* entity; ... virtual void serialize(filestream, op) = 0; ...}
class Sprite : public Component {...};
class Position : public Component {...};
class IA : public Component {... virtual void update() = 0; };

// I don't remember exactly the boost::fusion map syntax right now, sorry.
class Entity
{
   int id; // entity id
   boost::fusion::map< pair<Sprite, Sprite*>, pair<Position, Position*> > components;
   template <class C> bool has_component() { return components.at<C>() != 0; }
   template <class C> C* get_component() { return components.at<C>(); }
   template <class C> void add_component(C* c) { components.at<C>() = c; }
   template <class C> void remove_component(C* c) { components.at<C>() = 0; }
   void serialize(filestream, op) { /* Serialize all componets*/ }
...
};

std::list<Entity*> entity_list;

Con este diseño puedo obtener # 1, # 2, # 3 (gracias a los algoritmos boost :: fusion :: map) y # 4. Además, todo es O (1) (ok, no exactamente, pero sigue siendo muy rápido).

También hay un enfoque más "común":

class Entity;
class Component { Entity* entity; ... virtual void serialize(filestream, op) = 0; ...}
class Sprite : public Component { static const int type_id = 0; };
class Position : public Component { static const int type_id = 1; };

class Entity
{
   int id; // entity id
   std::vector<Component*> components;
   bool has_component() { return components[i] != 0; }
   template <class C> C* get_component() { return dynamic_cast<C> components[C::id](); } // It's actually quite safe
...
};

Otro enfoque es deshacerse de la clase Entity: cada tipo de Componente vive en su propia lista. Entonces, hay una lista de Sprite, una lista de salud, una lista de daños, etc. Sé que pertenecen a la misma entidad lógica debido a la identificación de la entidad. Esto es más simple, pero más lento: los componentes de IA necesitan acceso básicamente a todos los componentes de la otra entidad y eso requeriría buscar en la lista de componentes de cada uno en cada paso.

¿Qué enfoque crees que es mejor? ¿El mapa boost :: fusion es adecuado para usarse de esa manera?

Emiliano
fuente
2
¿Por qué un voto negativo? ¿Qué hay de malo con esta pregunta?
Emiliano

Respuestas:

6

Descubrí que el diseño basado en componentes y el diseño orientado a datos van de la mano. Usted dice que tener listas homogéneas de componentes y eliminar el objeto de entidad de primera clase (en lugar de optar por una ID de entidad en los componentes mismos) será "más lento", pero eso no es ni aquí ni allá ya que no ha perfilado ningún código real que implementa ambos enfoques para llegar a esa conclusión. De hecho, casi puedo garantizarle que homogeneizar sus componentes y evitar la virtualización pesada tradicional será más rápido debido a las diversas ventajas del diseño orientado a datos: paralelización más fácil, utilización de caché, modularidad, etc.

No digo que este enfoque sea ideal para todo, pero los sistemas de componentes que son básicamente colecciones de datos que necesitan las mismas transformaciones que se realizan en cada cuadro, simplemente gritan para estar orientados a los datos. Habrá momentos en que los componentes necesitan comunicarse con otros componentes de diferentes tipos, pero esto será un mal necesario de cualquier manera. Sin embargo, no debería impulsar el diseño, ya que hay formas de resolver este problema, incluso en el caso extremo de que todos los componentes se procesen en paralelo, como las colas de mensajes y futuros .

Definitivamente, Google busca un diseño orientado a datos en lo que se refiere a sistemas basados ​​en componentes, porque este tema surge mucho y hay bastante discusión y datos anecdóticos.

Skyler York
fuente
¿Qué quieres decir con "orientado a datos"?
Emiliano
Hay mucha información en Google, pero aquí aparece un artículo decente que debería proporcionar una visión general de alto nivel, seguido de una discusión en relación con los sistemas componentes: gamesfromwithin.com/data-oriented-design , gamedev. net / topic / ...
Skyler York
No puedo estar de acuerdo con todo lo que se dice sobre el DOD, ya que creo que no se puede completar por sí solo, quiero decir que solo el DOD puede sugerir un muy buen enfoque para almacenar datos, pero para llamar a funciones y procedimientos, debe usar procedimientos o procedimientos. Aproximación de OOP, quiero decir que el problema es cómo combinar estos dos métodos para aprovechar al máximo el rendimiento y la facilidad de codificación, por ejemplo. en la estructura, sugiero que habrá un problema de rendimiento cuando todas las entidades no compartan algunos componentes, pero se puede resolver fácilmente usando DOD, solo tiene que hacer diferentes matrices para diferentes tipos de entidades.
Ali1S232
Esto no responde a mi pregunta directamente, pero es muy informativo. Recordé algo sobre Dataflows en mis días de universidad. Es la mejor respuesta hasta ahora, y "gana".
Emiliano
-1

si tuviera que escribir un código de este tipo, preferiría usar este enfoque (y no estoy usando ningún impulso si es importante para ti), ya que puede hacer todo lo que quieras, pero el problema es cuando hay demasiadas entretenciones que no comparten algunos componentes, encontrar los que los consuman llevará algún tiempo. Aparte de eso, no hay otro problema que pueda tener:

// declare components here------------------------------
class component
{
};

class health:public component
{
public:
    int value;
};

class boundingbox:public component
{
public :
    int left,right,top,bottom;
    bool collision(boundingbox& other)
    {
        if (left < other.right || right > other.left)
            if (top < other.bottom || bottom > other.top)
                return true;
        return false;
    }
};

class damage : public component
{
public:
    int value;
};

// declare enteties here------------------------------

class entity
{
    virtual int id() = 0;
    virtual int size() = 0;
};

class aircraft :public entity, public health,public boundingbox
{
    virtual int id(){return 1;}
    virtual int size() {return sizeof(*this);};
};

class bullet :public entity, public damage, public boundingbox
{
    virtual int id(){return 2;}
    virtual int size() {return sizeof(*this);};
};

int main()
{
    entity* gameobjects[3];
    gameobjects[0] = new aircraft;
    gameobjects[1] = new bullet;
    gameobjects[2] = new bullet;
    for (int i=0;i<3;i++)
        for(int j=0;j<3;j++)
            if (dynamic_cast<boundingbox*>(gameobjects[i]) && dynamic_cast<boundingbox*>(gameobjects[j]) &&
                dynamic_cast<boundingbox*>(gameobjects[i])->collision(*dynamic_cast<boundingbox*>(gameobjects[j])))
                if (dynamic_cast<health*>(gameobjects[i]) && dynamic_cast<damage*>(gameobjects[j]))
                    dynamic_cast<health*>(gameobjects[i])->value -= dynamic_cast<damage*>(gameobjects[j])->value;
}

En este enfoque, cada componente es una base para una entidad, por lo que, dado el componente, su puntero también es una entidad. lo segundo que pide es tener acceso directo a los componentes de algunas entidades, por ejemplo. cuando necesito acceder a daños en una de mis entidades que uso dynamic_cast<damage*>(entity)->value, así que sientity tiene un componente de daño, devolverá el valor. si no está seguro de si entitytiene daños en los componentes o no, puede verificar fácilmente si el if (dynamic_cast<damage*> (entity))valor de retorno dynamic_castes siempre NULL si el lanzamiento no es válido y tiene el mismo puntero pero con el tipo solicitado si es válido. para hacer algo con todo lo entitiesque tiene component, puede hacerlo como a continuación

for (int i=0;i<enteties.size();i++)
    if (dynamic_cast<component*>(enteties[i]))
        //do somthing here

Si hay alguna otra pregunta, estaré encantado de responder.

Ali1S232
fuente
¿Por qué obtuve el voto negativo? ¿Qué estaba mal con mi solución?
Ali1S232
3
Su solución no es realmente una solución basada en componentes, ya que los componentes no están separados de sus clases de juego. Todas sus instancias dependen de la relación IS A (herencia) en lugar de una relación HAS A (composición). Hacerlo de la forma de composición (las entidades abordan varios componentes) le brinda muchas ventajas sobre un modelo de herencia (que generalmente es la razón por la que usa componentes). Su solución no ofrece ninguno de los beneficios de una solución basada en componentes e introduce algunas peculiaridades (herencia múltiple, etc.). Sin localidad de datos, sin actualización de componentes por separado. Sin modificación del tiempo de ejecución de los componentes.
nulo
En primer lugar, la pregunta solicita la estructura de que cada instancia de componente solo está relacionada con una entidad, y puede activar y desactivar componentes agregando solo una bool isActiveclase commponente base. Todavía hay necesidad de introducir componentes utilizables cuando se están definiendo entretenimientos, pero no lo considero un problema, y ​​todavía tiene actualizaciones complejas separadas (recuerde algo así dynamic_cast<componnet*>(entity)->update().
Ali1S232
y estoy de acuerdo en que todavía habrá un problema cuando quiera tener un componente que pueda compartir datos, pero considerando lo que pidió, supongo que no habrá un problema para eso, y nuevamente hay algunos trucos para ese problema también que si usted Quiero que pueda explicar.
Ali1S232
Si bien estoy de acuerdo en que es posible implementarlo de esta manera, no creo que sea una buena idea. Sus diseñadores no pueden componer objetos ellos mismos, a menos que tengan una clase über que herede todos los componentes posibles. Y si bien puede llamar a la actualización en un solo componente, no tendrá un buen diseño en memoria, en un modelo compuesto, todas las instancias de componentes del mismo tipo se pueden mantener cercanas en la memoria e iterar sin errores de caché. También confía en RTTI, que es algo que generalmente se desactiva en los juegos debido a razones de rendimiento. Un buen diseño de objeto ordenado lo arregla principalmente.
nulo