Komponentenbasiertes Entitätssystem praktisch einsetzen

59

Gestern habe ich eine Präsentation von GDC Canada über das Attribut- / Verhaltensentitätssystem gelesen und finde es ziemlich gut. Ich bin mir jedoch nicht sicher, wie ich es praktisch anwenden soll, nicht nur theoretisch. Zunächst erkläre ich Ihnen kurz, wie dieses System funktioniert.


Jede Spieleinheit (Spielobjekt) besteht aus Attributen (= Daten, auf die durch Verhalten, aber auch durch 'externen Code' zugegriffen werden kann) und Verhalten (= Logik, die OnUpdate()und enthalten OnMessage()). So würde beispielsweise in einem Breakout-Klon jeder Baustein aus folgenden Bestandteilen bestehen (Beispiel!): PositionAttribute , ColorAttribute , HealthAttribute , RenderableBehaviour , HitBehaviour . Das letzte könnte so aussehen (es ist nur ein nicht funktionierendes Beispiel 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);
        }
    }
}

Wenn Sie an diesem System interessiert sind, können Sie hier mehr lesen (.ppt).


Meine Frage bezieht sich auf dieses System, aber im Allgemeinen auf jedes komponentenbasierte Entitätssystem. Ich habe noch nie gesehen, wie eines davon in echten Computerspielen wirklich funktioniert, weil ich keine guten Beispiele finde und wenn ich eines finde, ist es nicht dokumentiert, es gibt keine Kommentare und deshalb verstehe ich es nicht.

Also, was möchte ich fragen? So gestalten Sie die Verhaltensweisen (Komponenten). Ich habe hier auf GameDev SE gelesen, dass der häufigste Fehler darin besteht, viele Komponenten zu erstellen und einfach "alles zu einer Komponente zu machen". Ich habe gelesen, dass empfohlen wird, das Rendern nicht in einer Komponente, sondern außerhalb der Komponente durchzuführen (daher sollte es anstelle von RenderableBehaviour möglicherweise RenderableAttribute sein , und wenn für eine Entität RenderableAttribute auf true festgelegt ist Renderer(Klasse, die nicht mit " true" zusammenhängt) Komponenten, sondern auf den Motor selbst) sollte es auf dem Bildschirm zeichnen?).

Aber wie steht es mit den Verhaltensweisen / Komponenten? Lassen Sie uns sagen , dass ich ein Niveau haben, und in der Ebene, gibt es eine Entity button, Entity doorsund Entity player. Wenn der Spieler mit dem Knopf kollidiert (es ist ein Bodenknopf, der durch Druck umgeschaltet wird), wird er gedrückt. Wenn der Knopf gedrückt wird, werden die Türen geöffnet. Nun, wie geht das?

Ich habe mir so etwas ausgedacht: Der Spieler hat CollisionBehaviour , das prüft, ob der Spieler mit etwas kollidiert. Wenn er mit einer Schaltfläche kollidiert, sendet er ein CollisionMessagean die buttonEntität. Die Nachricht enthält alle notwendigen Informationen: wer mit dem Button kollidiert ist. Der Button hat ToggleableBehaviour , das empfangen wird CollisionMessage. Es wird geprüft, mit wem es kollidiert hat, und wenn das Gewicht dieser Entität groß genug ist, um die Schaltfläche umzuschalten, wird die Schaltfläche umgeschaltet. Jetzt wird das ToggledAttribute der Schaltfläche auf true gesetzt. Okay, aber was jetzt?

Sollte die Schaltfläche eine weitere Nachricht an alle anderen Objekte senden, um ihnen mitzuteilen, dass sie umgeschaltet wurden? Ich denke, wenn ich alles so machen würde, hätte ich Tausende von Nachrichten und es würde ziemlich chaotisch werden. Vielleicht ist dies besser: Die Türen prüfen ständig, ob der mit ihnen verknüpfte Knopf gedrückt ist oder nicht, und ändern das OpenedAttribute entsprechend. Aber dann bedeutet es, dass die OnUpdate()Methode der Türen ständig etwas bewirkt (ist das wirklich ein Problem?).

Und das zweite Problem: Was ist, wenn ich mehr Arten von Knöpfen habe? Einer wird durch Druck gedrückt, der zweite durch Schießen, der dritte durch Gießen mit Wasser usw. Dies bedeutet, dass ich mich anders verhalten muss, in etwa so:

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

Funktionieren echte Spiele so oder bin ich einfach nur dumm? Vielleicht könnte ich nur ein ToggleableBehaviour haben und es wird sich entsprechend dem ButtonTypeAttribute verhalten . Also, wenn es ein ist ButtonType.Pressure, tut es das, wenn es ein ist ButtonType.Shot, tut es etwas anderes ...

Also, was will ich? Ich möchte Sie fragen, ob ich es richtig mache oder ob ich einfach nur dumm bin und den Punkt der Komponenten nicht verstanden habe. Ich habe kein gutes Beispiel dafür gefunden, wie die Komponenten in Spielen wirklich funktionieren. Ich habe nur einige Tutorials gefunden, die beschreiben, wie man das Komponentensystem herstellt, aber nicht, wie man es benutzt.

TomsonTom
quelle

Antworten:

46

Komponenten sind großartig, aber es kann einige Zeit dauern, bis eine Lösung gefunden ist, die sich für Sie gut anfühlt. Mach dir keine Sorgen, du wirst dorthin gelangen. :)

Komponenten organisieren

Du bist so ziemlich auf dem richtigen Weg, würde ich sagen. Ich werde versuchen, die Lösung in umgekehrter Reihenfolge zu beschreiben, beginnend mit der Tür und endend mit den Schaltern. In meiner Implementierung werden Ereignisse stark genutzt. Im Folgenden beschreibe ich, wie Sie Ereignisse effizienter nutzen können, damit sie nicht zum Problem werden.

Wenn Sie einen Mechanismus zum Verbinden von Entitäten zwischen ihnen haben, muss der Schalter der Tür direkt mitteilen, dass sie gedrückt wurde, und dann kann die Tür entscheiden, was zu tun ist.

Wenn Sie keine Entitäten verbinden können, kommt Ihre Lösung dem, was ich tun würde, ziemlich nahe. Ich würde die Tür auf ein allgemeines Ereignis lauschen lassen ( SwitchActivatedEventvielleicht). Wenn die Schalter aktiviert werden, geben sie dieses Ereignis bekannt.

Wenn Sie mehr als eine Art von Schalter haben, würde ich haben PressureToggle, WaterToggleund ein ShotToggleVerhalten auch, aber ich bin nicht sicher , dass die Basis ToggleableBehaviourjeder gut ist, so würde ich mit dem tun , weg (es sei denn, natürlich, haben Sie eine gute Grund für die Aufbewahrung).

Behaviour -> ToggleOnPressureBehaviour
          -> ToggleOnShotBehaviour
          -> ToggleOnWaterBehaviour

Effizientes Eventhandling

Wenn Sie befürchten, dass zu viele Ereignisse stattfinden, können Sie eines tun. Anstatt dass jede Komponente über jedes einzelne Ereignis benachrichtigt wird, überprüfen Sie anhand eines anderen Mechanismus, ob es sich um den richtigen Ereignistyp handelt.

Sie können eine EventDispatchermit einer subscribeMethode haben, die ungefähr so ​​aussieht (Pseudocode):

EventDispatcher.subscribe(event_type, function)

Wenn Sie dann ein Ereignis veröffentlichen, überprüft der Dispatcher seinen Typ und benachrichtigt nur die Funktionen, die diesen bestimmten Ereignistyp abonniert haben. Sie können dies als Map implementieren, die Ereignistypen mit Funktionslisten verknüpft.

Auf diese Weise ist das System wesentlich effizienter: Es gibt viel weniger Funktionsaufrufe pro Ereignis, und die Komponenten können sicher sein, dass sie den richtigen Ereignistyp erhalten haben und müssen nicht noch einmal nachprüfen.

Ich habe vor einiger Zeit eine einfache Implementierung auf StackOverflow veröffentlicht. Es ist in Python geschrieben, kann Ihnen aber möglicherweise trotzdem helfen:
https://stackoverflow.com/a/7294148/627005

Diese Implementierung ist ziemlich allgemein: Sie funktioniert mit jeder Art von Funktion, nicht nur mit Funktionen von Komponenten. Wenn Sie das nicht benötigen, können Sie stattdessen functioneinen behaviorParameter in Ihrer subscribeMethode haben - die Verhaltensinstanz, die benachrichtigt werden muss.

Attribute und Verhaltensweisen

Ich bin gekommen, um Attribute und Verhaltensweisen selbst zu verwenden , anstatt einfache alte Komponenten. Aus Ihrer Beschreibung, wie Sie das System in einem Breakout-Spiel verwenden würden, geht jedoch hervor, dass Sie es übertreiben.

Ich verwende Attribute nur, wenn zwei Verhaltensweisen Zugriff auf dieselben Daten benötigen. Das Attribut hilft, das Verhalten getrennt zu halten, und die Abhängigkeiten zwischen Komponenten (sei es Attribut oder Verhalten) werden nicht verwickelt, da sie sehr einfachen und klaren Regeln folgen:

  • Attribute verwenden keine anderen Komponenten (weder andere Attribute noch Verhalten), sie sind autark.

  • Verhaltensweisen verwenden andere Verhaltensweisen nicht oder kennen sie nicht. Sie kennen nur einige der Attribute (die sie unbedingt benötigen).

Wenn einige Daten nur von einem einzigen Verhalten benötigt werden, sehe ich keinen Grund, sie in ein Attribut einzufügen. Ich lasse das Verhalten es beibehalten.


@ Heishe Kommentar

Tritt dieses Problem nicht auch bei normalen Bauteilen auf?

Wie auch immer, ich muss Ereignistypen nicht überprüfen , weil jede Funktion, ist sich sicher , die richtige Art der Veranstaltung erhalten immer .

Außerdem werden die Abhängigkeiten der Verhalten (dh die Attribute, die sie benötigen) bei der Erstellung aufgelöst, sodass Sie nicht bei jeder Aktualisierung nach Attributen suchen müssen.

Zum Schluss verwende ich Python für meinen Spielelogikcode (die Engine ist jedoch in C ++), sodass kein Casting erforderlich ist. Python macht seine Enten-Tipp-Sache und alles funktioniert gut. Aber selbst wenn ich keine Sprache mit Duck-Typing verwenden würde, würde ich dies tun (vereinfachtes Beispiel):

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

Im Gegensatz zu Verhalten haben Attribute keine updateFunktion - sie müssen nicht, sie dienen dazu, Daten zu speichern und keine komplexe Spielelogik auszuführen.

Sie können immer noch Ihre Attribute eine einfache Logik ausführen lassen. In diesem Beispiel HealthAttributekönnte a sicherstellen, dass dies 0 <= value <= max_healthimmer der Fall ist. Es kann auch ein HealthCriticalEventan andere Komponenten derselben Entität senden , wenn es beispielsweise 25 Prozent unterschreitet, aber es kann keine komplexere Logik ausführen.


Beispiel einer Attributklasse:

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
quelle
Danke! Dies ist genau eine Antwort, die ich wollte. Mir gefällt auch Ihre Vorstellung von EventDispatcher besser als die einfache Weitergabe von Nachrichten an alle Entitäten. Nun zum letzten, was Sie mir gesagt haben: Sie sagen im Grunde, dass Health und DamageImpact in diesem Beispiel keine Attribute sein müssen. Anstelle von Attributen wären sie also nur private Variablen des Verhaltens? Das heißt, dass der "DamageImpact" durch das Event weitergereicht würde? Zum Beispiel EventArgs.DamageImpact? Das hört sich gut an ... Aber wenn ich wollte, dass der Stein die Farbe entsprechend seiner Gesundheit ändert, dann müsste Gesundheit ein Attribut sein, oder? Danke!
TomsonTom
2
@TomsonTom Ja, das war's. Es ist eine sehr gute Lösung, wenn die Ereignisse alle Daten enthalten, die die Zuhörer wissen müssen.
Paul Manta
3
Das ist eine großartige Antwort! (wie ist Ihr pdf) - Wenn Sie eine Chance haben, könnten Sie ein wenig darüber erläutern, wie Sie mit dem Rendern mit diesem System umgehen ? Dieses Attribut- / Verhaltensmodell ist für mich völlig neu, aber sehr faszinierend.
Michael
1
@TomsonTom Zum Rendern siehe die Antwort, die ich Michael gegeben habe. Für Kollisionen habe ich persönlich eine Abkürzung genommen. Ich habe eine Bibliothek namens Box2D verwendet, die ziemlich einfach zu bedienen ist und Kollisionen viel besser handhabt, als ich könnte. Aber ich benutze die Bibliothek nicht direkt in meinem Spiellogikcode. Jeder Entityhat eine EntityBody, die alle hässlichen Teile abstrahiert. Das Verhalten kann dann die Position auslesen EntityBody, Kräfte auf sie ausüben, die Gelenke und Motoren des Körpers verwenden usw. Eine so originalgetreue Physiksimulation wie Box2D bringt sicherlich neue Herausforderungen mit sich, aber sie macht Spaß, imo.
Paul Manta
1
@thelinuxlich Du bist also der Entwickler von Artemis! : D Ich habe das Component/ System-Schema, auf das verwiesen wird, ein paar Mal auf den Brettern gesehen. Unsere Implementierungen haben in der Tat einige Ähnlichkeiten.
Paul Manta