Ce tutoriel a été commencé pour le site Zeste de savoir, à l'initiative de valent20000, qui souhaitait écrire un tutoriel sur l'ECS en C++ avec Anax. Au final, comme j'ai écrit la majorité de ce tutoriel, mais que je n'ai pas le temps pour le moment de le finir, je le publie ici, dans l'état. J’essaierais de le compléter, un jour…
Lorsque l'on commence un nouveau jeu, on n'est pas confronté en général au syndrome de la page blanche. Au contraire, on a souvent beaucoup d'idées sur ce que le jeu contiendra, sur des éléments de gameplay, sur ce qui composera le jeu. Il faut même parfois mettre de côté des idées, partir petit et ajouter progressivement des éléments à son jeu.
Par contre, lorsque l'on commence à entrer dans le code du jeu, on est parfois perdu. Quels sont éléments à créer pour concevoir un jeu ? Comment ces éléments interagissent ? Comment éviter de devoir recommencer son jeu plusieurs fois parce que l'on a fait de mauvais choix ?
Il ne faut pas se faire d'illusions, créer un jeu est une tâche très complexe et très longue. Les blogs relatant les mauvaises expériences sont nombreuses (lire en particulier la série d'articles Erreurs classiques des créateurs de jeux vidéo amateurs.
Un moteur de jeux est quelque chose de complexe en soi. La meilleure approche pour créer un jeu est d'utiliser un moteur de jeux existant, qui vous propose déjà une architecture prête à l'utilisation. Il en existe plusieurs moteurs de jeux gratuits, très bons pour débuter et pour aller plus loin. Si votre objectif est la création d'un jeu, c'est cette approche qu'il vous faut suivre. Ne rejetez surtout pas les moteurs de jeux existants pour de mauvaises raisons, comme “je veux tout créer moi même pour avoir plus de liberté”. En pratique, si vous faites cela, vous passerez plus de temps à régler les problèmes d'implémentation de votre moteur de jeux plutôt que de réellement concevoir votre jeu.
Cet article présente une approche possible pour concevoir un moteur de jeux, appelée “Entity-Component-System” ou ECS. Il n'est pas possible de présenter toutes les problématiques que l'on peut rencontrer avec l'ECS, ni aborder toutes les implémentations possibles de l'ECS pour tous les types de jeux possibles. Le but est avant tout de vous donner les bases de compréhension de cette architecture et des pistes de réflexions pour l'adapter selon vos besoins.
Ce tutoriel se décompose en quatre parties :
Avant de commencer à étudier le fonctionnement des ECS, il est important de rappeler les critères de qualités logicielles qui permettent d'orienter les choix de design des moteurs de jeux et de survoler les problèmes présentés par les approches classiques des moteurs de jeux.
Un moteur de jeux est un programme comme un autre, il doit donc suivre dans l'idéal les critères habituels de qualité logiciel. Cependant, il y a deux points qui ont une importance particulière pour les jeux.
Ce point dépend fortement du type de jeux que l'on souhaite créer et les plateformes sur lesquelles le jeu sera utilisé. Une attention particulière doit être portée si on souhaite créer un jeu complexe dans un univers 3D riche. La réalisation de rendus 3D réalistes consomme énormément de ressources de la carte graphique, tandis que des fonctionnalités comme l'intelligence artificielle ou des simulations physiques réalistes (mouvements des cheveux et des vêtements, environnements destructibles) consommeront davantage le processeur central. Même un simple jeu 2D peut se révéler être un challenge en termes d'optimisation des performances si l'on souhaite le faire tourner sur un téléphone mobile ou une box connectée à une télévision.
La maintenabilité désigne la facilité à corriger les problèmes existants et l'évolutivité à ajouter de nouvelles fonctionnalités. L'une des plus grosses erreurs que l'on rencontre dans les projets de jeux amateurs est probablement l'envie de créer un jeu complet et riche dès le départ. Les amateurs ont généralement une vision de ce qu'ils veulent obtenir (plus ou moins), mais pas de la démarche correcte pour y parvenir.
Dans le développement logiciel classique, la question ne se pose généralement pas. Il est nécessaire de livrer des versions intermédiaires du logiciel, fonctionnelles mais ne contenant pas la totalité des fonctionnalités. Chaque nouvelle livraison corrigera les bugs de la version précédente et ajoutera de nouvelles fonctionnalités. Cette différence de démarche peut être résumée par la figure suivante :
Dans les jeux vidéos, l'évolutivité prend un sens particulier. Les jeux sont (dans le meilleur des cas) développés par des équipes hétérogènes, composées en particulier de développeurs (vous) et de game designers (souvent appelé “les autres” ou encore “the bad guys” ;) ). Les game designers ont besoin de pouvoir modifier le comportement des éléments du jeu le plus facilement possible, en général en passant par un langage de script comme lua. Il faut dans ce cas éviter les conceptions qui nécessiterait de devoir recompiler pour modifier les comportements.
Ces critères de qualité logiciel ne sont pas rappelés sans raison. Lorsque l'on doit faire des choix sur la conception a adopter pour concevoir un moteur de jeux, il est nécessaire de conserver ces critères en tête. Dans un monde idéal, on pourrait choisir de respecter tous les critères de qualité et de les appliquer à 100%. Dans le monde réel, ce n'est pas possible.
En particulier, le critère de performances s'oppose souvent au critère de maintenabilité. Une autre erreur classique est de recherche en premier lieu la performance maximale, quitte à sacrifier la lisibilité du code et augmenter les risques d'erreur. Ainsi, en C++, on rencontre souvent du code old-school (mélange de C++ et de C bas niveau), ce qui pose régulièrement des problèmes d'exécution (c'est probablement le pire type d'erreurs, puisque ces erreurs ne produisent pas de messages d'erreurs claires, produisent un comportement indéterminé non reproductible, il est parfois très difficile de trouver la source de l'erreur).
Ceux qui suivent cette approche font deux erreurs :
std::vector
ou d'utiliser les pointeurs intelligents versus les pointeurs nus), cela ne veut pas dire que ces pseudo-optimisations auront un impact perceptible par le joueur.On voit ici que les critères de maintenabilité et d'évolutivité du code sont prioritaires dans les choix de conception d'un moteur de jeux (et d'un logiciel en général). Lors de l'étude des ECS, nous essayerons d'analyser l'impact des différentes implémentations possibles selon ces critères.
Un moteur de jeux est quelque chose de complexe. La raison est simple : un jeu en lui même est quelque chose de complexe. Si on prend en compte que les éléments visuels d'un jeu, on doit gérer l'environnement (sol, arbres, rivières, élément de décors avec les quels le joueur peut interagir ou non), les personnages et monstres (déplacements dans l'univers du jeu, animations du corps, comportement), l'interface utilisateur (barres de vie, noms, dégâts subis et point de vie générées), l'enrichissement graphique (effets visuels pour les sorts lancés, flous, particules), la simulation physique (collisions et chutes, animation des cheveux et des vêtements, moteur de particules pour simuler le feu et la fumée).
A cela, il faut aussi ajouter tout ce qui n'est pas visible directement, mais a un impact important sur le plaisir du joueur : le réseau (mise à jour du jeu, jeux à plusieurs), les fichiers (enregistrer les parties en cours, avoir un chargement fluide des données pour éviter les ralentissements), gérer les entrées (clavier, souris, manette de jeux, écran tactile, kinect), les sons (musiques d'ambiance, bruitages, détection des ennemis par le bruit), intelligence artificielle (comportement des personnes non joueur et des ennemis), le scénario (raconter une histoire, suivre un script).
Pour s'en convaincre, il suffit de voir la structure d'un tel moteur, extrait du livre “Game Engine Architecture” :
(Extrait du livre “Game Engine Architecture”)
Fondamentalement, un moteur de jeux est donc un ensemble d'éléments de jeux (entités), qui interagissent ensemble. Une boucle parcourt tous ces éléments pour les mettre à jour régulièrement en fonction des actions du ou des joueurs.
Les approches possibles pour créer un jeu sont nombreuses, en particulier à l'époque où l'on n'avait pas encore pensé à créer des moteurs de jeux réutilisables et où chaque jeu repartait de zéro. Le but de cette partie n'est pas d'être exhaustive, nous allons présenter que les approches objets les plus classiques.
Remarque : ces approches ne sont pas forcément nommées de manière aussi formelle que ce que nous présentons ici. Ces noms ont été choisis pour faciliter la description.
Pour ceux qui ont suivi le cours de C++ du site OpenClassroom (ou du Site du Zéro), cette approche est bien connue, puisque ce cours l'utilise comme exemple pour apprendre la programmation objet. L'idée est que lorsque deux classes ont des propriétés communes, il est possible de créer une classe parente qui contiendra ces propriétés communes. Les deux classes dérivent de cette classe parente et implémentent uniquement les propriétés qui sont spécifiques à chaque classe.
Par exemple, un joueur et un ennemi sont deux personnages, ils se différencient par le fait que le premier est contrôlé par le joueur, le second par une intelligence artificielle. Il est donc possible d'écrire une classe parente Personnage
, qui implémente par exemple le rendu 3D d'un personnage, l'animation des mouvements, les collisions avec les décors. A côté de cela, la classe Player
implémente la gestion des entrées (clavier, souris, manette de jeux) et la classe Enemy
implémente l'intelligence artificielle.
On procède de la même manière pour tous les objets. Ainsi, la classe Personnage
pourra dériver de la classe Mobile
, qui implémenter ce qui peut bouger, qui dérive elle-même de Drawable
, qui implémente tout ce qui peut être dessiné à l'écran, qui dérive de Objet
, qui représente n'importe quel élément du jeu.
(Source : http://cowboyprogramming.com/2007/01/05/evolve-your-heirachy/)
En termes d'implémentation, cette approche est relativement simple à concevoir. Il suffit de regrouper les méthodes et attributs communs dans une classe et de dériver de cette classe. Tous les objets sont parcourus par une boucle, qui appelle une fonction commune de mise à jour (classiquement, on appelle cette fonction update
).
class Object { public: virtual void init(); // initialise les objets virtual void update(); // met à jour les objets }; class NonDrawable : public Objet { }; class Drawable : public Objet { public: void init() override; void update() override; void init3D(); // initialise le contexte 3D void draw3D(); // dessine l'élément 3D private: 3dData data_; // données graphiques 3D (triangles, textures, etc) }; class Mobile : public Drawable { public: void init() override; void update() override; void initAnimation(); // initialise la première position de l'animation void nextAnimation(); // passe à la position suivante de l'animation private: Animations animations_; // données permettant d'animer les objets }; class Player : public Mobile { public: void init() override; void update() override; private: Input input_; // données permettant de gérer les entrées }; class NonPlayer : public Mobile { public: void init() override; void update() override; private: IA ia_; // intelligence artificielle }; int main() { // création de tous les éléments du jeux std::vector<std::shared_ptr<Object>> objects; // initialisation des objets for (auto & object: objects) { object->init(); } // mise à jour des éléments du jeu while (bool continue{true}) { for (auto & object: objects) { object->update(); } } }
Cette approche peut sembler intéressante en termes de non répétition du code (si un code est commun à deux classes, il suffit de le déplacer dans la classe parente) et d'évolutivité (si on veut ajouter une nouvelle classe, il suffit de la faire hériter d'une classe existante).
En pratique, ce n'est pas le cas. Pour pouvoir déplacer un code commun entre deux classes dans une classe parente, encore faut-il qu'il existe une classe parente. Il y a toujours la possibilité de remonter jusque la classe Object
, mais on se retrouve au final à avoir une super-classe (on parle de god-object) qui remplit tous les rôles. Ce type de classe entre en violation directement des principe de responsabilité unique (SRP - Single Responsabilty Principle) et ouvert-fermé (OCP - Open-Close Principle) :
(Voir les références en annexes pour des sources sur les principes SOLID.)
Le design pattern Visitor permet de parcourir une collection hétérogène, c'est-à-dire une collection composé d'objets de types différents, et d'appliquer un traitement sur les objets d'un certain type. Il peut donc être intéressant de l'utiliser ici.
Prenons par exemple la hiérarchie de classe décrite précédemment. La collection objects
est un vector
contenant des objets de type NonDrawable
et Drawable
. On souhaite affiche les éléments graphiques, pour cela, on écrit une boucle qui parcourt tous les éléments et appelle la fonction draw3d
. Le problème est que les objets NonDrawable
ne possèdent pas cette fonction, on ne peut appeler que les fonctions déclarées dans l'interface de la classe Object
.
Cependant, les objets sont polymorphiques, on va pouvoir les convertir (cast) si cela est possible. On va donc pouvoir écrire quelque chose comme cela :
for (object & : objects) { auto drawable = dynamic_cast<shared_ptr<Drawable>>(object); if (drawable) { drawable->draw3d(); } }
Autrement dit, on essaie de convertir l'objet en Drawable
et si cette conversion réussit, alors on appelle la fonction draw3d
.
Ce code est problématique, parce qu'il implique de devoir connaître le type de l'objet que l'on manipule pour pouvoir appliquer un traitement (ce qui est contraire au principes de programmation objet et implique que le RTTI - Run-Time Type Information - soit activé, ce qui diminue les performances).
Le design pattern Visitor apporte une solution à ce problème, basé uniquement sur l'appel de fonctions virtuelles. Pour cela, il faut commencer par ajouter une fonction accept
dans Object
pour accepter les visiteurs et qui ne fait rien (mais elle ne doit pas être virtuelle pure).
class Object { public: virtual void accept(std::weak_ptr<Visitor> visitor) {} };
Les visiteurs implémentent le traitement à appliquer selon le type réel des objets. Pour appliquer ce traitement, il faut surcharger cette fonction accept
dans les classes qui doivent accepter les visiteurs. Les visiteurs possède une fonction particulière pour chaque classe qui accepte les visiteurs, il faut donc appeler cette fonction dans la fonction accept
. Par exemple, pour que les classes Drawable
et NonPlayer
acceptent les visiteurs :
class Drawable : public Objet { public: void accept(std::weak_ptr<Visitor> visitor) override { assert(visitor); visitor.lock()->visitDrawable(this); } }; class NonPlayer : public Mobile { public: void accept(std::weak_ptr<Visitor> visitor) override { assert(visitor); visitor.lock()->visitMobile(this); } };
Pour terminer, il faut écrire les visiteurs, en commençant par la classe parente Visitor
, qui propose toutes les fonctions virtuelles visitXxx
possibles. Ensuite, on peut implémenter les fonctions visitXxx
spécifiques pour une classe visitée dans un visiteur dédié à chacune d'elle.
class Visitor { public: virtual void visitDrawable(std::weak_ptr<Drawable> drawable) {} virtual void visitMobile(std::weak_ptr<Mobile> mobile) {} }; class VisitorForDrawable : public Visitor { void visitDrawable(std::weak_ptr<Drawable> drawable) override { // applique un traitement sur les objets Drawable ici } }; class VisitorForMobile : public Visitor { void visitMobile(std::weak_ptr<Mobile> mobile) override { // applique un traitement sur les objets Mobile ici } };
Dans la boucle principale, si l'on souhaite appliquer un traitement sur un certain type d'objets, il suffit donc de créer le visiteur correspondant à ce type d'objet et d'appeler la fonction accept
sur l'ensemble des objets. Pour les objets de type correspondant, la fonction virtuelle accept
de la classe dérivée sera appelée, ce qui appellera la fonction visitXxx
du visiteur. Pour les autres classes, ça sera la fonction accept
de la classe parente Object
qui sera appelée, qui se fait rien.
int main() { auto visitor = make_shared<VisitorForDrawable>(); for (auto & object: objects) { object->accept(visitor); } }
L'intérêt du design pattern Visitor est que si l'on veut ajouter un comportement particulier, on ne doit modifier que la classe qui accepte ce comportement, en ajoutant une fonction accept
, et créer une nouvelle classe VisitorForXxx
correspondant à ce comportement.
Le problème de ce design pattern est qu'il faut que la classe Visitor
connaissent toutes les fonctions VisitXxx
possibles, ce qui complexifie le code.
La problématique des hiérarchies d'héritage est qu'il est parfois difficile de trouver une classe parent dans laquelle implémenter une fonctionnalité et c'est la classe Object
qui récupère au final toutes ces fonctionnalités. Une solution consiste à séparer ces fonctionnalités dans des classes indépendantes, que l'on appelle composant. Chaque composant est chargé d'implémenter un type de comportement.
Par exemple, on peut implémenter de comportement Drawable
, Movable
, Attack
, etc. On peut également implémenter plus finement les comportements, comme par exemple si a un ennemi qui attaque en fonçant sur sa cible et un autre qui attaque en contournant sa cible, on pourra écrire des comportement DirectAttack
et ForwardAttack
.
La première approche pour créer des entités composées de ces éléments est de faire hériter les classes de ces composants, en utilisant l'héritage multiple. La seconde est de créer une entité par composition des composants. Nous allons voir la première approche dans cette partie et la seconde dans la prochaine partie.
L'héritage multiple est une fonctionnalité du C++ qui permet de faire hériter une classe de plusieurs autres classes. L'interface de la classe finale contient donc toutes les interfaces de ses classes parentes.
class A { public: void foo(); }; class B { public: void bar(); }; class C : public A, public B { }; int main() { C c; c.foo(); // ok, C contient A c.bar(); // ok, C contient B }
Pour créer les composants, on créé une classe spécifique pour chaque comportement que l'on souhaite implémenter. Par exemple, si on veut créer des éléments affichables, on peut créer une classe Drawable
contenant les fonctions init3d
et paint3d
déjà décrite dans la première partie. On peut également créer une hiérarchie de comportements, mais en évitant les hiérarchies profondes. Par exemple, pour l'intelligence artificielle, on peut créer un comportement Attack
, puis deux autres classes qui dérive de celle-ci, DirectAttack
et ForwardAttack
. Pour un objet dont la classe hérite d'une comportement donné, on peut appeler directement les fonctions correspondantes à ce comportement.
Il ne sera pas possible d'utiliser le design pattern Visitor avec cette approche, puisqu'il n'est pas possible d'écrire une fonction accept
dans chaque comportement. Il faudra donc utiliser dynamique_cast
pour vérifier les classes qui possèdent ou non un comportement donné.
class Object {}; class Drawable { public: void init3d(); void paint3d(); } class Attack { public: int health() const; // connaître le nombre de point de vie restant void attack(int damage); // applique des dégâts }; int main() { std::vector<std::shared_ptr<Object>> objects; for (auto & object: objects) { auto drawable = dynamic_cast<std::shared_ptr<Drawable>>(object); if (drawable) { drawable->paint3d(); } } }
Le principal problème avec cette approche est que la liste des comportements appliquées à une entité est déterminée à la compilation. Il n'est pas possible par exemple qu'une entité acquière la compétence Vol
si on n'a pas ajouté ce comportement dans la liste des classes parentes. Il n'est alors pas possible aux game designers de modifier les comportements sans passer par la modification du code existant et la recompilation du programme.
A lieu d'utiliser l'héritage, qui impose une structure définie à la compilation, il est également possible de créer une liste de composants par composition. Pour cela, on va utiliser une classe parente pour tous les composants et utiliser dynamic_cast
pour tenter de convertir le composant en son type réel (ou utiliser une fonction type()
qui retourne le type réel du composant).
class Component { public: enum class Type : int { Unknow, Graphics, Animation, /* autant que l'on veut */, UserDefined }; virtual int type() const { return Type::Unknow; } }; class GraphicsComponent : public Component { public: int type() const override { return Type::Graphics; } void draw(); }; class Entity { public: auto begin { return components.begin(); } auto end { return components.end(); } private: std::vector<std::shared_ptr<Component>> components; }; class World { public: void update() { for (auto const& entity: entities) { for (auto const& component: entity) { if (component->type() == Component::Type::Graphics) { auto c = static_cast<shared_ptr<GraphicsComponent>>(component); c->draw(); } } } } private: std::vector<std::shared_ptr<Entity>> entities; };
Cette approche présente également le problème de devoir connaître le type réel des classes pour pouvoir les utiliser. Encore une fois, le design pattern Visitor pourra faciliter les choses.
Il est également possible de séparer les composants en catégories, ce qui peut simplifier l'héritage entre les classes. Par exemple, au lieu de faire dériver tous les composants de Component
et de tout mettre dans un vector
unique, on va créer un attribut pour chaque type de composant que l'entité peut contenir.
class GraphicsComponent { public: void draw(); }; class Entity { std::unique_ptr<GraphicsComponent> graphics; std::unique_ptr<IAComponent> ia; std::unique_ptr<CollisionComponent> collision; };
Il suffit alors de tester si un composant est valide ou non pour savoir si l'entité contient ce composant. On peut également appeler directement ces composants.
class World { public: void update() { for (auto const& entity: entities) { if (entity->graphics()) { entity->graphics()->draw(); } } } private: std::shared_ptr<Component> component; };
Un dernier mot rapide sur le design pattern Strategy. Ce design pattern sert à modifier dynamiquement le comportement d'une classe, en utilisant une seconde classe qui contient l'implémentation réelle. L'équivalent compile-time de ce design pattern est la classe de Politique.
class ImplementationInterface { public: virtual void do_function() = 0; }; class Implementation : public ImplementationInterface { public: void do_function() override { /* do something */ }; }; class ModifiableComportement { public: void function() { impl->do_function(); } private: std::unique_ptr<ImplementationInterface> impl;
Notez aussi que l'on utilise aussi le design pattern NVI - Non Virtual Interface dans ce code.
Dans les codes d'exemples précédents, certains composants sont en fait des applications de ce design pattern. Le comportement réelle des entités est déterminée par ses composants, il suffit de créer un nouveau type de composant pour modifier le comportement ce l'entité. Cela permet d'avoir un code facilement évolutif et permet aux game designers de “construire” une entité à partir de ces briques élémentaires, sans avoir besoin de connaître comment sont implémentés ces composants.
On voit qu'il existe de nombreuses implémentations possibles pour concevoir un moteur de jeux. Selon les contraintes, ces approches peuvent être suffisantes. Dans le cas contraire, l'ECS est une solution supplémentaire, qui présente d'autres avantages.
Les approches décrites dans le chapitre précédent ont une chose en commun : elles sont basées sur la programmation orientée objet. L'ECS est au contraire basé sur la programmation orienté données Data-driven programming.
Nous allons voir dans un premier temps a quoi correspond cette approche et ce qui la distingue de la programmation objet. Nous verrons ensuite comment implémenter concrètement un ECS en C++.
Importance du DDP? Exemple pour illustrer avec comparaison avec la complexité algo
Vous avez peut etre vu la notation O(n) pour la complexité algo.
1. O(n) avec constante, depend de matos. Certains algo vont utiliser des fonctionnalités du CPU et donc etre plus performant, meme en ayant une O(n) plus mauvaise. En particulier vectorisation, mem cache, GPU, etc. qui fait que certains algos ont un moins bon O(n) mais sont plus performant en pratique (meilleur utilisation des caches, meilleur utilisation des threads, etc)
2. O(n) mesure un comportement asymptomatique, donc n→infini. Mais en pratique, ce n'est jamais infini, et dans dans un domaine particulier de n, un algo peut etre plus performant. Par exemple, algo plus performant sur petite taille de n. Un exemple concret, certains algo de tri utilisent un methode pour faire un premier tri en bloc de taille N, puis utilisent un autre algo pour trier chaque block (de petite taille).
3. O(n) est une mesure de la complexité. Autre : big-theta, big-omega, etc. https://en.wikipedia.org/wiki/Big_O_notation. Pour rappel, avec une liste spécifique de valeurs de taille n, il y aura un temps mesuré t. Chaque liste de valeurs va donner des temps différents. Pour chaque algo, il y a une donc un t_min et un t_max pour chaque valeur de n et l'ensemble des mesures de perfs d'un algo est un enveloppe. Les notations O, theto, omega, etc. mesure un parametre particulier de cette envoloppe : par exemple le plus mauvais cas, le cas moyen, etc.
Du coup, pour des données reels dans un cas d'utilisation particulier, il est possible de ne pas être dans l'enveloppe complete, mais dans un sous groupe, et dans ce cas, la mesure de complexité peut etre mauvais. Par exemple, certains algos de tris sont plus efficace quand la majorité des valeurs sont deja trié et qu'il y a peu de valeur a déplacer (ce qui arrive par exemple quand vous ajoutez de nouvelles données dans un tableau deja trié).
Pour resumer, il y a donc des caractéristiques sur les donne2es dans un contexte et cas d'utilisation particulier, qui peu influencer le choix de l'algo, le choix des structures de données, etc. Le principe de data driven dev est d'optimiser le choix des algos par rapport aux donne2es.
Note : c'est important ausi dans les tests. On test souvent en utilisant des valeurs particuliere ou des valeurs aleatoire, mais cela peut etre important aussi de faire des tests avec des données reelles.
La programmation orienté objet se décrit en termes de services rendus par les classes et d'interactions entre les objets. Elle se base pour celas sur l'encapsulation de comportements (et donc incidemment sur l'encapsulation de données, mais cela n'est qu'une conséquence de l'encapsulation, pas le besoin primaire de l'encapsulation).
Prenons un exemple concret : une épée dans un jeu. Cette arme rend le service suivant : “faire mal” (que l'on traduit généralement en “faire des dégâts” ou “diminuer les points de vie de la cible”… mais c'est plus amusant de dire “faire mal” :)). Et elle va interagir avec plusieurs autres éléments du jeu : au moins le propriétaire de l'arme et celui qui reçoit les dégâts (parfois, c'est la même personne…).
Le code correspondant est donc le suivant :
class Sword { public: void attack(Target & target); };
Remarque : on voit parfois le type de code suivant :
class Sword { public: int getDegats() const; }; class Target { public: int getPV() const; void setPV(int pv); private: int pv; }; int main() { Target target; Sword sword; target.setPV(target.getPV() - sword.getDegat()); }
Dit autrement, cela veut dire que la cible possède des points de vie, l'arme possède des points de dégâts et pour calculer le résultat d'une attaque, on calcule la différence entre les deux. Même si l'idée est correcte (c'est comme cela que l'on procède lorsque l'on joue à un jeu de rôle sur plateau), ce type de code ne respecte pas l'encapsulation des comportements et encore moins la loi de Demeter.
Ce type de code ne présente pas les critères de qualité logiciel demandé par la programmation objet. Dit autrement, cela ne sert à rien de faire une classe qui ne sert qu'à présenter les données qu'elle contient.
Dans l'approche orientée données, on va concevoir les choses comme un flux de données, qui vont subir un certains nombres de modifications.
ECS peut être représenté comme un tableau avec les entités et systèmes en ligne et colonne, les composants sont a l'intersection des 2.
Définition générale :
Nombreuses sources simplifient l'entité à un simple identifiant. Exemple :
using entity = int; // C++03 // typedef int entity
Sous forme d'une classe. Création via factory, pour garantir l'unicité de l'id
class entity { int id {}; }; class entity_factory { int create(); };
Code plus évolué, avec classe ID (permet de cacher le type réel de ID, qui peut être un nombre, plusieurs nombres, un string, etc. Juste un besoin = tester l'égalité ?)
class ID { public: bool operator==(ID const& id) const; static ID generate(); };
Plusieurs méthodes pour créer un id unique. 1. avoir un static, l'incrémenter à chaque utilisation. Problème : si id est supprimé, non réutilisé. 2. Parcourir la liste des id affectés et choisir le premier libre. Problème : prend du temps.
Remarque : prendre un id int32 et incrémenter, peut sembler ok. Mais MMO avec plusieurs millions de joueurs et qui ne redémarre jamais (donc non réinitialisation des id)
Autre approche : entité est une liste de composants
Entity monster = new Entity(); monster.attach(new StatsPart(100, 2)); // If we don't want our monster to fly, simply uncomment this line. monster.attach(new FlyingPart(20)); // If we don't want our monster to cast spells, simply uncomment this line. monster.attach(new SpellsPart(5)); monster.attach(new MonsterControllerPart(target)); monster.initialize(); return monster;
Qui est propriétaire des composants ?
vector<component>
dans entityvector<component>
dans system et vector<reference_wrapper<component»
dans entityvector<shared_ptr<component»
dans entity et vector<weak_ptr<component»
dans system (ou l'inverse)vector<any<component»
vector<component_system>
, comment les liés dans entité ? (avec any<component>
?)Utilisation une pool de composant. Nécessite objet parent Component
Ou boost::properties
? (liste de string + valeurs)
class component : non_copyable { entity e; system s; // nécessaire ? }; class component_pool { vector<component> components; void push_back(component); // accès aux composants vector<reference_wrapper<component>> extract_by_entity(entity const&); vector<reference_wrapper<component>> extract_by_system(system const&); }; class component_factory { map<string, component> components; map<string, pair<component, system>> components; // lier un composant à un système ? map<int, component> components; // utilisation de int au lieu de string ? // avec utilisant d'un "dico" pour faire le lien "nom" et "id" void register(string const& name, component c, /* system const& s ? */); component create(string const& name); }; class composant_identifier { map<string, int> names; // ou vector<string> ? void register(string const& name, int id); int register(string const& name); // id donné par la classe int id(string const& name); };
Besoin de supprimer un système ?
namespace component { class component { int entity_id {}; virtual void update() = 0; }; class render {}; class ia {}; class physique {}; } namespace system { class system { vector<unique_ptr<component>> components; void update() { foreach(begin(components), end(components), update); } }; class render { virtual void update() override; }; class physique { virtual void update() override; }; class ia { virtual void update() override; }; } class entity { int id {}; }; vector<unique_ptr<system>> systems; foreach(begin(systems), end(systems), update);
On peut distinguer les interactions suivantes :
C'est le plus compliqué. Pour faire leur boulots, certains systèmes doivent accéder aux informations de plusieurs composants. Il est possible d'implémenter cela en mettant dans les différentes éléments un “lien” vers un autre élément (par exemple une référence dans un composant vers un autre composant, conserver un identifiant de l'entité, etc)
Conceptuellement, les composants sont liés à une entité. Comme doit on implémenter ce “lien”. Et pourquoi faire ?
Le premier besoin est que si on supprime une entité, il faut aussi supprimer ses composants. On peut régler cela en conservant un tableau de composants dans les entités (mais cela impose que toutes les entités dérivent d'un même god object “composant” et que l'entité ne soit pas un simple id) ou avoir un id dans chaque composant (on aura aussi probablement un god-component).
Le second besoin est que pour que les systèmes fassent leur boulot, ils doivent accéder aux informations de plusieurs composants. Dit autrement, pour les interactions “horizontales”, cela veut dire qu'a partir d'un composant donnée, il faut pouvoir aller chercher les autres composants d'une entité. Il est possible de :
Pour le pointeur/référence, une remarque. Si on créé un composant “ia” qui a une référence vers un composant “position”, le builder du composant “ia” sait qu'il a besoin du composant “position”, cela ne pose pas de problème qu'il “demande” au système “position” de lui fournit cette référence sur le composant “position” qui l'intéresse. Pour la mise à jour du lien, c'est plus compliqué. Si on détruit le composant “position”, il n'y a a priori aucune raison que le deleter du composant “position” sache qu'il est référencé par une autre composant. Pourtant, il faut quand même que le composant “ia” sache qu'il n'a plus accès au composant “position”.
Comment résoudre cela ? Peut être un élément “cleaner”, qui détruit les composants inutiles lorsque l'on détruit un composant (on peut imaginer qu'un composant qui utilise un autre composant ne peut plus fonctionner si le premier est supprimé. Par exemple, un composant “ia” qui attaque un personne devrait être détruit et remplacer par un composant “ia” “je glande sur place” si le personnage utilise une magie pour disparaître). Ou un DP observer ?
A priori, non pour toutes (entre entité ↔ système ou entre composant ↔ composant de systèmes et entités différentes). Le seul cas que je verrais d'interactions diagonale, c'est si un composant doit accéder directement aux informations d'une autre entités. Par exemple, les composants “IA” qui utilisent l'algo A* et qui ont tous besoin de connaître la position de l'entité “player”.
Mais cela ajoute un couplage entre les composants qui n'est pas simple à résoudre. Il est possible de régler cela en créant une référence vers l'entité “player” ou le composant “position” de l'entité “player” (que faire s'il y a plusieurs “players” ?) et utiliser un builder pour créer les composants en attachant l'entité “player” à ces composants directement lors de la création des objets.
Mais on peut vouloir conserver plus de souplesse dans le code. Par exemple, on peut imaginer que les entités “monstre” n'attaquent pas directement les entités “player”, mais qu'il y a un système de détection (cône de vision des monstres, détection au son, etc) qui détermine pour chaque entité (aussi bien pour les “monstres” qui détecte le “player” que le “player” qui détecte un ennemi) qui détecte qui. Ensuite, le système “IA” interagit avec les composants “détection” et “position” pour déterminer les déplacements.
Donc le lien directe à un autre composant ou une entité particulière dans les composants est à éviter.
Quand et comment ?
entity player { 1 }; vector<string> const components { "render", "movable" }; class component_factory { map<string, pair<unique_ptr<component>, reference_wraper<system>> components; public: void register(name, component, system) { components.insert(name, make_pair(component, system)); } void create(entity, name) { component c = components[name]; c.entity_id = entity.id; system.register(c); } } class entity_builder { int last_id {}; component_factory f {}; public: entity create(vector<string> c) { entity e { ++last_id }; foreach(begin(c), end(c), [](auto name) { f.create(entity, name); } return e; } }
Intérêt factory : pouvoir ajouter dynamiquement des composants = plugins
Liste de composants constituant une entité = scriptable. On peut écrire :
Entity { name: player Component { type: "ia" paramètre_1: 123 paramètre_2: 3.1415 } Component { type: "render" mesh: "player.3ds" } }
Quand et comment ?
Suppression entité : supprimer les composants correspondant
Comment faire un system d'input dans un ECS ? Plusieurs ? (Chaque joueur controle son perso, ou un joueur peut changer de personnage controlé)
Qu'est ce que l'on peut/doit mettre dans les composants ? Comment choisir ?
Dépend de la granularité que l'on veut. Par exemple, une arme avec des modificateurs (magie, flèches spéciales, etc). Premier cas, composant “normal”, avec modificateur créé à la création de la classe (non modifiable)
class component { string name {}; int damage {}; }; component c("arc", 10); component c("arc de feu", 15);
2 : liste fixe de modificateurs. Peut ajouter ou supprimer des propriétés pendant le cours du jeu, mais liste des propriétés est fixe
class component { string name {}; int damage {}, int magic {}; int arrow {} }; component c("arc", 10); component c("arc de feu", 10, 5);
3 : liste dynamique de modificateurs. Liste des propriétés peut être modifiable, mais pas les méthodes de calculs (par exemple ici, dégat = damage + somme modificateurs)
class component { string name {}; int damage {}; vector<pair<string, int> modifiateurs }; component c("arc", 10); component c("arc", 10, { "de feu", 5});
4 : liste de components. Arme = entité, Dégât = system, Modificateur de dégâts = component. Possibilité de modifier les méthodes de calculs des modificateurs (par exemple, un sort qui n'ajoute pas de dégâts, mais qui double les dégats de feu)
class FireDamage { int modifier {}; int damage_modifier(int damage) { return damage + modifier; } }; component_factory.register("magie feu", FireDamage(5)); entity e = entity_builder.create("arc", { "magie feu" });
Possibilité de créer des composants en utilisant des hértiage, composition, template, sous-composants plus simple, etc.
Parfois, besoin interaction. Comment les faire interagir ?
Par exemple render à besoin de savoir la position de l'entité (pour la positionner dans la scène), l'état (afficher le sprite correspondant à un personnage qui marche, qui attend, qui cours, etc)
Par exemple : Un Collisions system doit gerer comment une collisions avec un ExplosiveComponent ?)
exemple : player meurt (composant “vie” == 0), comment changer le composant “etat” ? D'autres composants à changer ?
Mettre les données dans les composants et accès aux composants par plusieurs systèmes. Comment ?
Autre : event ? signaux-slots ?
comment lire un fichier et créer les ECS ? Exemple d'implémentation simple avec fichier texte
Aller plus loin : xml, lua, etc.
Besoins :
Entity villager = createVillager(); Entity monster = createMonster(villager); // very basic game loop while (true) { villager.update(1); monster.update(1); Thread.sleep(1000); }
Exemple: 3 persos A (marchent à côté du char), B (dans le char), C (dans la tourelle), 1 char avec tourelle.
Entité | Composant position relative | Composant “Transporte quelque chose” |
---|---|---|
A | x_a, y_a | |
B | x_b, y_b | char |
C | x_c, y_c | tour |
char | x_char, y_char | |
tourelle | x_tour, y_tour | char |
Au final, la position absolue (X, Y) des objets dans le monde est calculée en fonction des positions relatives et des dépendances :
#include <cassert> #include <iostream> #include <vector> #include <algorithm> /******* Entity *******/ using Entity = size_t; /******* Entities *******/ class Entities { public: Entities(size_t size = 1000); Entity create(); void remove(Entity entity); void print() const; private: std::vector<Entity> m_entities{}; // sorted array (use std::set?) }; Entities::Entities(size_t size) { m_entities.reserve(size); } Entity Entities::create() { const auto it = std::adjacent_find(begin(m_entities), end(m_entities), [](Entity lhs, Entity rhs){ return (lhs+1 != rhs); }); if (it == end(m_entities)) { m_entities.push_back(m_entities.size()); return m_entities.back(); } else { const auto result = m_entities.insert(it+1, (*it)+1); return *result; } } void Entities::remove(Entity entity) { const auto it = std::find(begin(m_entities), end(m_entities), entity); if (it != end(m_entities)) { m_entities.erase(it); } } void Entities::print() const { for(auto i: m_entities) { std::cout << i << ' '; }; std::cout << std::endl; } /******* Component *******/ class Component { protected: Component(Entity entity); // disable instanciation of base Component public: Entity entity() const; private: Entity m_entity{}; }; Component::Component(Entity entity) : m_entity(entity) { } Entity Component::entity() const { return m_entity; } struct PositionComponent : public Component { int x{}; int y{}; }; struct StateComponent : public Component { enum class State { Waiting, Walking, Running, Dead }; State state { Waiting }; }; /******* Components *******/ template<class ConcretComponent> class Components { public: Components(size_t size = 1000); ConcretComponent& create(Entity entity); void remove(Entity entity); protected: // can add algos in derived classes std::vector<ConcretComponent> m_components{}; // not sorted array }; template<class ConcretComponent> Components::Components(size_t size) { m_components.reserve(size); } template<class ConcretComponent> ConcretComponent& Components::create(Entity entity) { m_components.push_back(ConcretComponent(entity)); } template<class ConcretComponent> void Components::remove(Entity entity) { std::remove_if(begin(m_components), end(m_components), [entity](const ConcretComponent &component){ return (component.entity() == entity); }); } /******* System *******/ class System { public: private: Components<T> m_components; } class System2 { public: System2(Components<T>& attachedComponents); private: Components<T>& m_components; } /******* Main *******/ int main() { // entities Entities entities; auto me = entities.create(); auto you = entities.create(); entities.print(); std::vector<Entity> badGuys; for(auto i=0; i<10; ++i) { badGuys.push_back(entities.create()); } entities.print(); entities.remove(you); // you're killed!!! entities.print(); you = entities.create(); // but you're a survivor entities.print(); }
Comment implémenter dans un ECS…
camera, meshs, tiles
culling, quadtree/octree, scenegraph
player, ennemis, terrain
collision, animation, physique
state machine
Que peut nous apprendre l'ECS sur le conception d'applications ?
Conception objet
Il ne faut pas se faire d'illusions : maintenant que vous avez lu cet article, vous ne savez toujours pas comment implémenter un ECS. Il va falloir vous lancer, faire des erreurs et recommencer pour réellement comprendre. Bref, acquérir de l'expérience.
Les questions de savoir comment organiser votre ECS, quels systèmes créer, que mettre dans chaque composant, sont des questions qui n'ont pas de réponses faciles et définitives. Cela dépend de votre expérience, de votre compréhension de l'ECS, de vos préférences personnelles.
Bon courage pour vos futurs jeux.