Usando el sistema de entidad basado en componentes prácticamente

59

Ayer, leí una presentación de GDC Canadá sobre el sistema de entidades de Atributo / Comportamiento y creo que es bastante bueno. Sin embargo, no estoy seguro de cómo usarlo de manera práctica, no solo en teoría. En primer lugar, te explicaré rápidamente cómo funciona este sistema.


Cada entidad del juego (objeto del juego) se compone de atributos (= datos, a los que se puede acceder mediante comportamientos, pero también mediante 'código externo') y comportamientos (= lógica, que contienen OnUpdate()y OnMessage()). Entonces, por ejemplo, en un clon de Breakout, cada ladrillo estaría compuesto de (¡ejemplo!): PositionAttribute , ColorAttribute , HealthAttribute , RenderableBehaviour , HitBehaviour . El último podría verse así (es solo un ejemplo no funcional escrito en C #):

void OnMessage(Message m)
{
    if (m is CollisionMessage) // CollisionMessage is inherited from Message
    {
        Entity otherEntity = m.CollidedWith; // Entity CollisionMessage.CollidedWith
        if (otherEntity.Type = EntityType.Ball) // Collided with ball
        {
            int brickHealth = GetAttribute<int>(Attribute.Health); // owner's attribute
            brickHealth -= otherEntity.GetAttribute<int>(Attribute.DamageImpact);
            SetAttribute<int>(Attribute.Health, brickHealth); // owner's attribute

            // If health is <= 0, "destroy" the brick
            if (brickHealth <= 0)
                SetAttribute<bool>(Attribute.Alive, false);
        }
    }
    else if (m is AttributeChangedMessage) // Some attribute has been changed 'externally'
    {
        if (m.Attribute == Attribute.Health)
        {
            // If health is <= 0, "destroy" the brick
            if (brickHealth <= 0)
                SetAttribute<bool>(Attribute.Alive, false);
        }
    }
}

Si está interesado en este sistema, puede leer más aquí (.ppt).


Mi pregunta está relacionada con este sistema, pero generalmente cada sistema de entidad basado en componentes. Nunca he visto cómo ninguno de estos realmente funciona en juegos de computadora reales, porque no puedo encontrar ningún buen ejemplo y si encuentro uno, no está documentado, no hay comentarios y, por lo tanto, no lo entiendo.

Entonces, ¿qué quiero preguntar? Cómo diseñar los comportamientos (componentes). He leído aquí, en GameDev SE, que el error más común es hacer muchos componentes y simplemente "hacer que todo sea un componente". He leído que se sugiere no hacer la representación en un componente, sino hacerlo fuera de él (por lo tanto, en lugar de RenderableBehaviour , tal vez debería ser RenderableAttribute , y si una entidad tiene RenderableAttribute establecido en verdadero, entonces Renderer(clase no relacionada con componentes, pero para el propio motor) ¿debería dibujarlo en la pantalla?).

Pero, ¿qué hay de los comportamientos / componentes? Digamos que tengo un nivel, y en el nivel, hay un Entity button, Entity doorsy Entity player. Cuando el jugador choca con el botón (es un botón de piso, que se alterna por presión), se presiona. Cuando se presiona el botón, se abren las puertas. Bueno, ahora como hacerlo?

Se me ocurrió algo como esto: el jugador tiene CollisionBehaviour , que comprueba si el jugador choca con algo. Si choca con un botón, envía un CollisionMessagea la buttonentidad. El mensaje contendrá toda la información necesaria: quién colisionó con el botón. El botón tiene ToggleableBehaviour , que recibirá CollisionMessage. Verificará con quién chocó y si el peso de esa entidad es lo suficientemente grande como para alternar el botón, el botón se alterna. Ahora, establece el atributo ToggledAttribute en verdadero. De acuerdo, pero ¿y ahora qué?

¿Debería el botón enviar otro mensaje a todos los demás objetos para decirles que se ha activado? Creo que si hiciera todo de esta manera, tendría miles de mensajes y sería bastante complicado. Entonces, quizás esto sea mejor: las puertas comprueban constantemente si el botón que está vinculado a ellas está presionado o no, y cambia su atributo abierto en consecuencia. Pero entonces significa que el OnUpdate()método de las puertas hará algo constantemente (¿es realmente un problema?).

Y el segundo problema: ¿qué pasa si tengo más tipos de botones? Uno es presionado por la presión, el segundo se alterna disparándole, el tercero se alterna si se vierte agua sobre él, etc. Esto significa que tendré que tener comportamientos diferentes, algo como esto:

Behaviour -> ToggleableBehaviour -> ToggleOnPressureBehaviour
                                 -> ToggleOnShotBehaviour
                                 -> ToggleOnWaterBehaviour

¿Es así como funcionan los juegos reales o simplemente soy estúpido? Tal vez podría tener solo un ToggleableBehaviour y se comportará de acuerdo con ButtonTypeAttribute . Entonces, si es un ButtonType.Pressure, hace esto, si es un ButtonType.Shot, hace otra cosa ...

Entonces que quiero? Me gustaría preguntarle si lo estoy haciendo bien, o simplemente soy estúpido y no entiendo el punto de los componentes. No encontré ningún buen ejemplo de cómo funcionan realmente los componentes en los juegos, encontré solo algunos tutoriales que describen cómo hacer el sistema de componentes, pero no cómo usarlo.

TomsonTom
fuente

Respuestas:

46

Los componentes son geniales, pero puede llevar algún tiempo encontrar una solución que se sienta bien para usted. No te preocupes, llegarás allí. :)

Componentes organizadores

Estás bastante en el camino correcto, diría. Trataré de describir la solución a la inversa, comenzando con la puerta y terminando con los interruptores. Mi implementación hace un uso intensivo de los eventos; a continuación describo cómo puede usar los eventos de manera más eficiente para que no se conviertan en un problema.

Si tiene un mecanismo para conectar entidades entre ellos, me gustaría que el interruptor notifique directamente a la puerta que ha sido presionada, entonces la puerta puede decidir qué hacer.

Si no puede conectar entidades, su solución está bastante cerca de lo que yo haría. Me gustaría que la puerta escuchara un evento genérico ( SwitchActivatedEvent, tal vez). Cuando los interruptores se activan, publican este evento.

Si tiene más de un tipo de interruptor, yo también lo tendría PressureToggle, WaterToggley un ShotTogglecomportamiento, pero no estoy seguro de que la base ToggleableBehavioursea ​​buena, así que lo eliminaría (a menos, por supuesto, que tenga un buen razón para mantenerlo).

Behaviour -> ToggleOnPressureBehaviour
          -> ToggleOnShotBehaviour
          -> ToggleOnWaterBehaviour

Manejo eficiente de eventos

En cuanto a preocuparse de que haya demasiados eventos volando, hay una cosa que podría hacer. En lugar de que se notifique a cada componente de cada evento que ocurra, haga que el componente verifique si es el tipo correcto de evento, aquí hay un mecanismo diferente ...

Usted puede tener una EventDispatchercon un subscribemétodo que se ve algo como esto (pseudocódigo):

EventDispatcher.subscribe(event_type, function)

Luego, cuando publica un evento, el despachador verifica su tipo y solo notifica aquellas funciones que se han suscrito a ese tipo particular de evento. Puede implementar esto como un mapa que asocie tipos de eventos con listas de funciones.

De esta manera, el sistema es significativamente más eficiente: hay muchas menos llamadas de función por evento, y los componentes pueden estar seguros de que recibieron el tipo correcto de evento y no tienen que verificarlo dos veces.

Publiqué una implementación simple de esto hace algún tiempo en StackOverflow. Está escrito en Python, pero quizás aún pueda ayudarte:
https://stackoverflow.com/a/7294148/627005

Esa implementación es bastante genérica: funciona con cualquier tipo de función, no solo con funciones de componentes. Si no necesita eso, en lugar de function, podría tener un behaviorparámetro en su subscribemétodo: la instancia de comportamiento que debe notificarse.

Atributos y comportamientos

He llegado a usar atributos y comportamientos yo mismo , en lugar de componentes viejos y simples. Sin embargo, según su descripción de cómo usaría el sistema en un juego Breakout, creo que está exagerando.

Utilizo atributos solo cuando dos comportamientos necesitan acceso a los mismos datos. El atributo ayuda a mantener los comportamientos separados y las dependencias entre los componentes (ya sean atributos o comportamientos) no se enredan, porque siguen reglas muy simples y claras:

  • Los atributos no usan ningún otro componente (ni otros atributos ni comportamientos), son autosuficientes.

  • Los comportamientos no usan ni conocen otros comportamientos. Solo conocen algunos de los atributos (aquellos que necesitan estrictamente).

Cuando algunos datos solo son necesarios para uno y solo uno de los comportamientos, no veo ninguna razón para ponerlos en un atributo, dejo que el comportamiento los contenga.


@ Comentario Heishe de

¿No ocurriría ese problema también con los componentes normales?

De todos modos, no tengo que verificar los tipos de eventos porque cada función seguramente recibirá el tipo de evento correcto, siempre .

Además, las dependencias de los comportamientos (es decir, los atributos que necesitan) se resuelven en la construcción, por lo que no tiene que buscar los atributos en cada actualización.

Y, por último, uso Python para el código lógico de mi juego (sin embargo, el motor está en C ++), por lo que no hay necesidad de lanzarlo. Python hace su tarea de escribir pato y todo funciona bien. Pero incluso si no usara un lenguaje para escribir pato, haría esto (ejemplo simplificado):

class SomeBehavior
{
  public:
    SomeBehavior(std::map<std::string, Attribute*> attribs, EventDispatcher* events)
        // For the purposes of this example, I'll assume that the attributes I
        // receive are the right ones. 
        : health_(static_cast<HealthAttribute*>(attribs["health"])),
          armor_(static_cast<ArmorAttribute*>(attribs["armor"]))
    {
        // Boost's polymorphic_downcast would probably be more secure than
        // a static_cast here, but nonetheless...
        // Also, I'd probably use some smart pointers instead of plain
        // old C pointers for the attributes.

        // This is how I'd subscribe a function to a certain type of event.
        // The dispatcher returns a `Subscription` object; the subscription 
        // is alive for as long this object is alive.
        subscription_ = events->subscribe(event::type<DamageEvent>(),
            std::bind(&SomeBehavior::onDamageEvent, this, _1));
    }

    void onDamageEvent(std::shared_ptr<Event> e)
    {
        DamageEvent* damage = boost::polymorphic_downcast<DamageEvent*>(e.get());
        // Simplistic and incorrect formula: health = health - damage + armor
        health_->value(health_->value() - damage->amount() + armor_->protection());
    }

    void update(boost::chrono::duration timePassed)
    {
        // Behaviors also have an `update` function, just like
        // traditional components.
    }

  private:
    HealthAttribute* health_;
    ArmorAttribute* armor_;
    EventDispatcher::Subscription subscription_;
};

A diferencia de los comportamientos, los atributos no tienen ninguna updatefunción: no es necesario, su propósito es mantener los datos, no realizar una lógica de juego compleja.

Todavía puede hacer que sus atributos realicen alguna lógica simple. En este ejemplo, un HealthAttributepodría garantizar que 0 <= value <= max_healthsiempre sea cierto. También puede enviar un correo electrónico HealthCriticalEventa otros componentes de la misma entidad cuando cae por debajo, digamos, un 25 por ciento, pero no puede realizar una lógica más compleja que eso.


Ejemplo de una clase de atributo:

class HealthAttribute : public EntityAttribute
{
  public:
    HealthAttribute(Entity* entity, double max, double critical)
        : max_(max), critical_(critical), current_(max)
    { }

    double value() const {
        return current_;
    }    

    void value(double val)
    {
        // Ensure that 0 <= current <= max 
        if (0 <= val && val <= max_)
            current_ = val;

        // Notify other components belonging to this entity that
        // health is too low.
        if (current_ <= critical_) {
            auto ev = std::shared_ptr<Event>(new HealthCriticalEvent())
            entity_->events().post(ev)
        }
    }

  private:
    double current_, max_, critical_;
};
Paul Manta
fuente
¡Gracias! Esto es exactamente una respuesta que quería. También me gusta su idea de EventDispatcher mejor que el simple mensaje que pasa a todas las entidades. Ahora, hasta lo último que me dijiste: básicamente dices que Health and DamageImpact no tiene que ser atributos en este ejemplo. Entonces, en lugar de atributos, ¿serían solo variables privadas de los comportamientos? ¿Eso significa que el "DamageImpact" pasaría a través del evento? Por ejemplo EventArgs.DamageImpact? Eso suena bien ... Pero si quisiera que el ladrillo cambiara de color según su salud, entonces la Salud tendría que ser un atributo, ¿verdad? ¡Gracias!
TomsonTom
2
@TomsonTom Sí, eso es todo. Hacer que los eventos contengan los datos que los oyentes necesitan saber es una muy buena solución.
Paul Manta
3
¡Esta es una respuesta genial! (como es su pdf) - Cuando tenga la oportunidad, ¿podría explicar un poco cómo maneja el renderizado con este sistema? Este modelo de atributo / comportamiento es completamente nuevo para mí, pero muy intrigante.
Michael
1
@TomsonTom Acerca del renderizado, vea la respuesta que le di a Michael. En cuanto a las colisiones, personalmente tomé un atajo. Usé una biblioteca llamada Box2D que es bastante fácil de usar y maneja las colisiones mucho mejor de lo que podría. Pero no uso la biblioteca directamente en el código lógico de mi juego. Cada uno Entitytiene un EntityBody, que abstrae todos los bits feos. Los comportamientos pueden leer la posición desde el EntityBody, aplicarle fuerzas, usar las articulaciones y los motores que tiene el cuerpo, etc. Tener una simulación física de tan alta fidelidad como Box2D ciertamente trae nuevos desafíos, pero son bastante divertidos.
Paul Manta
1
@thelinuxlich ¡Entonces eres el desarrollador de Artemis! : D He visto el esquema Component/ Systemmencionado varias veces en los tableros. Nuestras implementaciones de hecho tienen bastantes similitudes.
Paul Manta