Utilizzando praticamente il sistema di entità basato su componenti


59

Ieri ho letto una presentazione di GDC Canada sul sistema di entità Attributo / Comportamento e penso che sia piuttosto eccezionale. Tuttavia, non sono sicuro di come usarlo praticamente, non solo in teoria. Prima di tutto, ti spiegherò rapidamente come funziona questo sistema.


Ogni entità di gioco (oggetto di gioco) è composta da attributi (= dati, a cui è possibile accedere da comportamenti, ma anche da "codice esterno") e comportamenti (= logica, che contengono OnUpdate()e OnMessage()). Quindi, ad esempio, in un clone Breakout, ogni mattone sarebbe composto da (esempio!): PositionAttribute , ColorAttribute , HealthAttribute , RenderableBehaviour , HitBehaviour . L'ultimo potrebbe assomigliare a questo (è solo un esempio non funzionante scritto in 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);
        }
    }
}

Se sei interessato a questo sistema, puoi leggere di più qui (.ppt).


La mia domanda è relativa a questo sistema, ma generalmente a tutti i sistemi di entità basati su componenti. Non ho mai visto come uno di questi funzioni davvero nei giochi per computer reali, perché non riesco a trovare buoni esempi e se ne trovo uno, non è documentato, non ci sono commenti e quindi non lo capisco.

Allora, cosa voglio chiedere? Come progettare i comportamenti (componenti). Ho letto qui, su GameDev SE, che l'errore più comune è quello di creare molti componenti e semplicemente "rendere tutto un componente". Ho letto che si suggerisce di non eseguire il rendering in un componente, ma di farlo al di fuori di esso (quindi invece di RenderableBehaviour , dovrebbe forse essere RenderableAttribute e se un'entità ha RenderableAttribute impostato su true, allora Renderer(classe non correlata a componenti, ma per il motore stesso) dovrebbe disegnarlo sullo schermo?).

Ma che dire dei comportamenti / componenti? Diciamo di lasciare che ho un piano, e nel livello, c'è un Entity button, Entity doorse Entity player. Quando il giocatore si scontra con il pulsante (è un pulsante sul pavimento, che viene attivato dalla pressione), viene premuto. Quando il pulsante viene premuto, apre le porte. Bene, ora come si fa?

Ho escogitato qualcosa del genere: il giocatore ha CollisionBehaviour , che controlla se il giocatore si scontra con qualcosa. Se si scontra con un pulsante, invia CollisionMessagea buttonall'entità. Il messaggio conterrà tutte le informazioni necessarie: chi si è scontrato con il pulsante. Il pulsante ha ToggleableBehaviour , che riceverà CollisionMessage. Controllerà con chi si è scontrato e se il peso di quell'entità è abbastanza grande da attivare il pulsante, il pulsante viene attivato. Ora, imposta il ToggledAttribute del pulsante su true. Va bene, ma adesso?

Il pulsante dovrebbe inviare un altro messaggio a tutti gli altri oggetti per dire che è stato attivato? Penso che se facessi tutto così, avrei migliaia di messaggi e diventerebbe piuttosto confuso. Quindi forse questo è meglio: le porte controllano costantemente se il pulsante che è collegato a loro è premuto o meno e cambia il suo OpenedAttribute di conseguenza. Ma allora significa che il OnUpdate()metodo delle porte farà costantemente qualcosa (è davvero un problema?).

E il secondo problema: cosa succede se ho più tipi di pulsanti. Uno viene premuto dalla pressione, il secondo viene attivato sparando su di esso, il terzo viene attivato se si versa acqua su di esso, ecc. Ciò significa che dovrò avere comportamenti diversi, qualcosa del genere:

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

È così che funzionano i giochi reali o sono solo stupido? Forse potrei avere solo un comportamento commutabile e si comporterà secondo l' attributo ButtonType . Quindi se è un ButtonType.Pressure, lo fa, se è un ButtonType.Shot, fa qualcos'altro ...

Quindi cosa voglio? Vorrei chiederti se lo sto facendo bene, o sono solo stupido e non ho capito il punto dei componenti. Non ho trovato alcun buon esempio di come funzionano realmente i componenti nei giochi, ho trovato solo alcuni tutorial che descrivono come realizzare il sistema dei componenti, ma non come usarlo.

Risposte:


46

I componenti sono fantastici, ma può richiedere del tempo per trovare una soluzione che ti fa sentire bene. Non preoccuparti, ci arriverai. :)

Organizzazione dei componenti

Sei praticamente sulla buona strada, direi. Proverò a descrivere la soluzione al contrario, iniziando dalla porta e finendo con gli interruttori. La mia implementazione fa ampio uso degli eventi; di seguito descrivo come è possibile utilizzare gli eventi in modo più efficiente in modo che non diventino un problema.

Se si dispone di un meccanismo per il collegamento di entità tra loro, farei in modo che l'interruttore notifichi direttamente alla porta che è stata premuta, quindi la porta può decidere cosa fare.

Se non riesci a connettere entità, la tua soluzione è abbastanza vicina a ciò che farei. Vorrei che la porta ascoltasse un evento generico ( SwitchActivatedEvent, forse). Quando gli switch vengono attivati, pubblicano questo evento.

Se hai più di un tipo di interruttore, avrei PressureToggle, WaterTogglee anche un ShotTogglecomportamento, ma non sono sicuro che la base ToggleableBehavioursia buona, quindi eliminerei quello (a meno che, ovviamente, tu non abbia un buon motivo per mantenerlo).

Behaviour -> ToggleOnPressureBehaviour
          -> ToggleOnShotBehaviour
          -> ToggleOnWaterBehaviour

Gestione efficiente degli eventi

Per quanto riguarda la preoccupazione che ci siano troppi eventi che volano in giro, c'è una cosa che potresti fare. Invece di avere la notifica di ogni componente di ogni singolo evento che si verifica, quindi fai controllare il componente se è il giusto tipo di evento, ecco un meccanismo diverso ...

Puoi avere un metodo EventDispatchercon un subscribeaspetto simile al seguente (pseudocodice):

EventDispatcher.subscribe(event_type, function)

Quindi, quando pubblichi un evento, il dispatcher controlla il suo tipo e notifica solo quelle funzioni che si sono abbonate a quel particolare tipo di evento. È possibile implementarlo come una mappa che associa tipi di eventi a elenchi di funzioni.

In questo modo, il sistema è significativamente più efficiente: ci sono molte meno chiamate di funzione per evento e i componenti possono essere sicuri di aver ricevuto il giusto tipo di evento e di non dover ricontrollare.

Ho pubblicato una semplice implementazione di questo tempo fa su StackOverflow. È scritto in Python, ma forse può ancora aiutarti:
https://stackoverflow.com/a/7294148/627005

L'implementazione è piuttosto generica: funziona con qualsiasi tipo di funzione, non solo con le funzioni dei componenti. Se non ti serve, invece function, potresti avere un behaviorparametro nel tuo subscribemetodo: l'istanza del comportamento che deve essere notificata.

Attributi e comportamenti

Sono arrivato a usare attributi e comportamenti da solo , anziché semplici vecchi componenti. Tuttavia, dalla tua descrizione di come useresti il ​​sistema in un gioco Breakout, penso che tu stia esagerando.

Uso gli attributi solo quando due comportamenti devono accedere agli stessi dati. L'attributo aiuta a mantenere separati i comportamenti e le dipendenze tra i componenti (siano essi attributi o comportamenti) non si impigliano, perché seguono regole molto semplici e chiare:

  • Gli attributi non utilizzano altri componenti (né altri attributi né comportamenti), sono autosufficienti.

  • I comportamenti non usano né conoscono altri comportamenti. Conoscono solo alcuni degli attributi (quelli di cui hanno strettamente bisogno).

Quando alcuni dati sono necessari solo per uno e solo uno dei comportamenti, non vedo alcun motivo per inserirli in un attributo, lascio che il comportamento li mantenga.


@ commento di heishe

Questo problema non si verificherebbe anche con componenti normali?

In ogni caso, non ho per controllare i tipi di eventi, perché ogni funzione è sicuro di ricevere il giusto tipo di evento, da sempre .

Inoltre, le dipendenze dei comportamenti (cioè gli attributi di cui hanno bisogno) vengono risolte durante la costruzione, quindi non è necessario cercare gli attributi ogni volta che si aggiorna.

E, infine, uso Python per il mio codice di logica di gioco (il motore è in C ++), quindi non è necessario eseguire il casting. Python fa la sua cosa da scrivere in anatra e tutto funziona bene. Ma anche se non usassi una lingua con la tipizzazione anatra, farei questo (esempio semplificato):

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 differenza dei comportamenti, gli attributi non hanno alcuna updatefunzione: non è necessario, il loro scopo è conservare i dati, non eseguire complesse logiche di gioco.

Puoi comunque fare in modo che i tuoi attributi eseguano una logica semplice. In questo esempio, è HealthAttributepossibile che 0 <= value <= max_healthsia sempre vero. Può anche inviare un HealthCriticalEventad altri componenti della stessa entità quando scende al di sotto, diciamo, del 25 percento, ma non può eseguire logiche più complesse di così.


Esempio di una classe di attributi:

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_;
};

Grazie! Questa è esattamente una risposta che volevo. Mi piace anche la tua idea di EventDispatcher meglio del semplice passaggio di messaggi a tutte le entità. Ora, per l'ultima cosa che mi hai detto: in pratica dici che Health and DamageImpact non devono essere attributi in questo esempio. Quindi, invece di attributi, sarebbero solo variabili private dei comportamenti? Ciò significa che "DamageImpact" sarebbe passato attraverso l'evento? Ad esempio EventArgs.DamageImpact? Suona bene ... Ma se volessi che il mattone cambiasse colore in base alla sua salute, allora la Salute avrebbe dovuto essere un attributo, giusto? Grazie!
Tomson Tom

2
@ Tomson Tom Sì, tutto qui. Avere gli eventi in possesso di tutti i dati che gli ascoltatori devono sapere è un'ottima soluzione.
Paul Manta,

3
Questa è un'ottima risposta! (come è il tuo pdf) - Quando hai una possibilità, potresti approfondire un po 'come gestisci il rendering con questo sistema? Questo modello di attributo / comportamento è completamente nuovo per me, ma molto intrigante.
Michael,

1
@TomsonTom Riguardo al rendering, vedi la risposta che ho dato a Michael. Per quanto riguarda le collisioni, ho preso personalmente una scorciatoia. Ho usato una libreria chiamata Box2D che è abbastanza facile da usare e gestisce le collisioni molto meglio di quanto potessi. Ma non uso la libreria direttamente nel mio codice di logica di gioco. Ognuno Entityha un EntityBody, che estrae tutti i pezzi brutti. I comportamenti possono quindi leggere la posizione da EntityBody, applicare forze su di essa, usare le articolazioni e i motori del corpo, ecc. Avere una simulazione fisica ad alta fedeltà come Box2D porta sicuramente nuove sfide, ma sono abbastanza divertenti, imo.
Paul Manta,

1
@thelinuxlich Quindi sei lo sviluppatore di Artemis! : D Ho visto lo schema Component/ a cui si fa Systemriferimento alcune volte sulle schede. Le nostre implementazioni hanno effettivamente alcune somiglianze.
Paul Manta,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.