Utilisation pratique du système d'entité à base de composants

59

Hier, j'ai lu une présentation de GDC Canada sur le système d'entités attribut / comportement et je trouve ça plutôt bien. Cependant, je ne sais pas comment l'utiliser de manière pratique, pas seulement en théorie. Tout d'abord, je vais vous expliquer rapidement le fonctionnement de ce système.


Chaque entité de jeu (objet de jeu) est composée d' attributs (= données, accessibles par comportement, mais également par "code externe") et de comportements (= logique, qui contient OnUpdate()et OnMessage()). Ainsi, par exemple, dans un clone Breakout, chaque brique serait composée de (exemple!): PositionAttribute , ColorAttribute , HealthAttribute , RenderableBehaviour , HitBehaviour . Le dernier pourrait ressembler à ceci (c'est juste un exemple non fonctionnel écrit 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 ce système vous intéresse, vous pouvez en lire plus ici (.ppt).


Ma question concerne ce système, mais généralement tous les systèmes d’entités basés sur des composants. Je n'ai jamais vu comment ces solutions fonctionnent vraiment dans les vrais jeux informatiques, car je ne trouve aucun bon exemple et si j'en trouve un, ce n'est pas documenté, il n'y a pas de commentaire et je ne le comprends donc pas.

Alors, qu'est-ce que je veux demander? Comment concevoir les comportements (composants). J'ai lu ici, sur GameDev SE, que l'erreur la plus commune est de créer de nombreux composants et simplement de "transformer tout en un composant". J'ai lu qu'il est suggéré de ne pas effectuer le rendu dans un composant, mais de le faire en dehors de celui-ci (donc au lieu de RenderableBehaviour , il devrait être RenderableAttribute , et si une entité a pour valeur RenderableAttribute , alors Renderer(classe non liée à composants, mais au moteur lui-même) devrait dessiner sur l'écran?).

Mais, qu'en est-il des comportements / composants? Disons que j'ai un niveau, et dans le niveau, il y a un Entity button, Entity doorset Entity player. Lorsque le joueur entre en collision avec le bouton (c'est un bouton de plancher, qui est basculé par pression), il est enfoncé. Lorsque le bouton est enfoncé, il ouvre les portes. Eh bien, maintenant, comment le faire?

J'ai proposé quelque chose comme ceci: le joueur a CollisionBehaviour , qui vérifie si le joueur entre en collision avec quelque chose. S'il se heurte à un bouton, il envoie un CollisionMessageà l' buttonentité. Le message contiendra toutes les informations nécessaires: qui est entré en collision avec le bouton. Le bouton a ToggleableBehaviour , qui recevra CollisionMessage. Il vérifiera avec qui il est entré en collision et si le poids de cette entité est suffisamment important pour faire basculer le bouton, le bouton devient basculé. Désormais, il définit l' attribut ToggledAttribute du bouton sur true. D'accord, mais quoi maintenant?

Le bouton doit-il envoyer un autre message à tous les autres objets pour leur dire qu'il a été basculé? Je pense que si je faisais tout ce genre de choses, j'aurais des milliers de messages et ça deviendrait assez compliqué. Alors, c’est peut-être mieux: les portes vérifient constamment si le bouton qui leur est lié est enfoncé ou non, et modifie son attribut OpenedAttribute en conséquence. Mais alors cela signifie que la OnUpdate()méthode des portes fera constamment quelque chose (est-ce vraiment un problème?).

Et le deuxième problème: si j’ai plus de types de boutons. L'un d'eux est pressé par la pression, le second est basculé en tirant dessus, le troisième est basculé si de l'eau est versée dessus, etc. Cela signifie que je devrai avoir différents comportements, quelque chose comme ceci:

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

Est-ce ainsi que les vrais jeux fonctionnent ou suis-je juste stupide? Peut-être que je pourrais avoir un seul comportement ToggleableBehaviour et qu'il se comportera selon le ButtonTypeAttribute . Donc si c'est un ButtonType.Pressure, ça fait ça, si c'est un ButtonType.Shot, ça fait autre chose ...

Alors qu'est-ce que je veux? Je voudrais vous demander si je le fais bien, ou si je suis juste stupide et je n'ai pas compris le sens des composants. Je n'ai trouvé aucun bon exemple de la manière dont les composants fonctionnent réellement dans les jeux. J'ai juste trouvé quelques tutoriels décrivant comment créer le système de composants, mais pas comment l'utiliser.

TomsonTom
la source

Réponses:

46

Les composants sont excellents, mais il peut prendre un certain temps pour trouver une solution qui vous ressemble. Ne vous inquiétez pas, vous y arriverez. :)

Composants d'organisation

Vous êtes à peu près sur la bonne voie, je dirais. Je vais essayer de décrire la solution en sens inverse, en commençant par la porte et en terminant par les interrupteurs. Mon implémentation utilise beaucoup les événements; Je décris ci-dessous comment utiliser les événements plus efficacement pour qu’ils ne deviennent pas un problème.

Si vous disposez d'un mécanisme pour connecter des entités entre elles, le commutateur avertirait directement la porte qu'il a été enfoncé, cette dernière peut alors décider quoi faire.

Si vous ne pouvez pas connecter des entités, votre solution est assez proche de ce que je ferais. J'aurais la porte à l'écoute d'un événement générique ( SwitchActivatedEvent, peut-être). Lorsque les commutateurs sont activés, ils publient cet événement.

Si vous avez plus d'un type de commutateur, je l' aurais PressureToggle, WaterToggleet un ShotTogglecomportement aussi, mais je ne suis pas sûr que la base ToggleableBehaviourest tout bon, donc je éliminons que ( à moins, bien sûr, vous avez une bonne raison de le garder).

Behaviour -> ToggleOnPressureBehaviour
          -> ToggleOnShotBehaviour
          -> ToggleOnWaterBehaviour

Gestion efficace des événements

En ce qui concerne le fait qu’il y ait trop d’événements, il ya une chose à faire. Au lieu que chaque composant soit averti de chaque événement qui se produit, demandez-lui de vérifier si le type d'événement est correct, voici un mécanisme différent ...

Vous pouvez avoir un EventDispatcheravec une subscribeméthode qui ressemble à ceci (pseudocode):

EventDispatcher.subscribe(event_type, function)

Ensuite, lorsque vous publiez un événement, le répartiteur en vérifie le type et ne notifie que les fonctions qui se sont abonnées à ce type d’événement. Vous pouvez l'implémenter en tant que carte associant des types d'événements à des listes de fonctions.

De cette façon, le système est nettement plus efficace: il y a beaucoup moins d'appels de fonction par événement et les composants peuvent être sûrs qu'ils ont reçu le bon type d'événement sans avoir à vérifier.

J'ai posté une simple implémentation de cela il y a quelque temps sur StackOverflow. C'est écrit en Python, mais peut-être que ça peut encore vous aider:
https://stackoverflow.com/a/7294148/627005

Cette implémentation est assez générique: elle fonctionne avec n’importe quel type de fonction, pas seulement avec des fonctions de composants. Si vous n'en avez pas besoin function, vous pourriez avoir un behaviorparamètre dans votre subscribeméthode: l'instance de comportement à notifier.

Attributs et comportements

J'ai moi - même utilisé des attributs et des comportements , au lieu de vieux composants simples. Cependant, d'après votre description de la manière dont vous utiliseriez le système dans un jeu en petits groupes, je pense que vous en faites trop.

J'utilise des attributs uniquement lorsque deux comportements nécessitent l'accès aux mêmes données. L'attribut aide à garder les comportements séparés et les dépendances entre les composants (qu'ils soient attributaires ou comportementaux) ne s'emmêlent pas, car ils suivent des règles très simples et claires:

  • Les attributs n'utilisent pas d'autres composants (ni autres attributs, ni comportements), ils sont autosuffisants.

  • Les comportements n'utilisent ni ne connaissent d'autres comportements. Ils ne connaissent que certains attributs (ceux dont ils ont strictement besoin).

Quand certaines données ne sont nécessaires que par un et un seul des comportements, je ne vois aucune raison de les mettre dans un attribut, j'ai laissé le comportement le conserver.


Commentaire de @ heishe

Ce problème ne se produirait-il pas également avec les composants normaux?

Quoi qu'il en soit, je n'ai pas besoin de vérifier les types d'événement car chaque fonction est sûre de recevoir le bon type d'événement, toujours .

En outre, les dépendances des comportements (c'est-à-dire les attributs dont ils ont besoin) sont résolues lors de la construction. Vous n'avez donc pas à rechercher des attributs à chaque mise à jour.

Et enfin, j'utilise Python pour mon code de logique de jeu (le moteur est cependant en C ++), il n'y a donc pas besoin de casting. Python fait sa dactylographie et tout fonctionne bien. Mais même si je n'utilisais pas de langage avec la frappe de canard, je le ferais (exemple simplifié):

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

Contrairement aux comportements, les attributs n’ont aucune updatefonction - ils n’en ont pas besoin, leur but est de stocker des données, et non d’exécuter une logique de jeu complexe.

Vous pouvez toujours demander à vos attributs de suivre une logique simple. Dans cet exemple, a HealthAttributepourrait s’assurer que cela 0 <= value <= max_healthest toujours vrai. Il peut également envoyer un message HealthCriticalEventà d'autres composants de la même entité lorsqu'il passe en dessous de, disons, 25%, mais il ne peut pas effectuer de logique plus complexe que cela.


Exemple de classe d'attribut:

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
la source
Je vous remercie! C'est exactement une réponse que je voulais. J'aime aussi mieux votre idée d’EventDispatcher que la simple transmission de messages à toutes les entités. Maintenant, jusqu'à la dernière chose que vous m'avez dite: vous dites essentiellement que Health et DamageImpact ne doivent pas nécessairement être des attributs dans cet exemple. Ainsi, au lieu d'attributs, seraient-ils simplement des variables privées des comportements? Cela signifie que "DamageImpact" serait passé à travers l'événement? Par exemple, EventArgs.DamageImpact? Cela sonne bien ... Mais si je voulais que la brique change de couleur en fonction de sa santé, alors la santé devrait être un attribut, non? Je vous remercie!
TomsonTom
2
@ TomsonTom Oui, c'est tout. Faire en sorte que les événements contiennent toutes les données dont les auditeurs ont besoin est une très bonne solution.
Paul Manta
3
C'est une excellente réponse! (comme votre pdf) - Si vous en avez l'occasion, pourriez-vous expliquer un peu comment vous gérez le rendu avec ce système? Ce modèle attribut / comportement est tout à fait nouveau pour moi, mais très intriguant.
Michael
1
@TomsonTom À propos du rendu, voir la réponse que j'ai donnée à Michael. En ce qui concerne les collisions, j'ai personnellement pris un raccourci. J'ai utilisé une bibliothèque appelée Box2D qui est assez facile à utiliser et gère les collisions bien mieux que je ne le pourrais. Mais je n'utilise pas directement la bibliothèque dans mon code de logique de jeu. Chaque Entitya un EntityBody, qui fait abstraction de tous les bits laids. Les comportements peuvent alors lire la position à partir de EntityBody, appliquer des forces, utiliser les articulations et les moteurs du corps, etc. Avoir une simulation physique aussi fidèle que Box2D apporte certes de nouveaux défis, mais ils sont plutôt amusants, imo.
Paul Manta
1
@thelinuxlich Vous êtes donc le développeur d'Artemis! : D J'ai vu le schéma Component/ Systemréférencé à quelques reprises sur les forums. Nos implémentations présentent en effet pas mal de similitudes.
Paul Manta