|
JustAnotherDnDGame 0.1.0
Jeu de rôle tactique au d20, vue de dessus, en C++/Qt
|
Cette page explique le patron d'architecture Entity-Component-System (ECS ⧉) depuis ses principes, puis détaille l'implémentation maison de Source/Core/Ecs.
Dans un moteur orienté objet « classique », on modéliserait naturellement un personnage par une classe Character héritant de GameObject, avec des méthodes comme update(), render(), takeDamage(). Très vite, ce modèle par héritage devient un obstacle dans un jeu :
L'ECS répond en séparant radicalement trois notions que l'orienté objet mélange dans une seule classe :
Un « personnage joueur » n'est alors qu'une entité qui possède les composants Transform, Velocity, Collider, Sprite et Player — une combinaison de données, pas une classe dédiée. Un interrupteur est une entité avec Transform, Collider, Sprite mais sans Player. Ajouter un comportement à un sous-ensemble d'entités revient à écrire un nouveau système qui parcourt les composants pertinents, sans toucher au reste. La règle d'or à retenir : les données vivent dans les composants, la logique vit dans les systèmes ; un composant ne contient jamais de comportement, un système ne stocke jamais d'état de jeu à demeure (il le lit/écrit dans les composants, qui restent la seule source de vérité).
Une entité (core::Entity) est un handle générationnel : une paire {index, generation}.
Une entité, à elle seule, ne « fait » rien : elle ne devient un personnage, un décor ou un interrupteur que par les composants qu'on lui attache.
core::World est le point d'entrée unique de la simulation : il possède les entités, les composants et les systèmes. API essentielle :
Chaque type de composant obtient sa propre pool, créée à la demande au premier addComponent<T> : le World ne connaît pas à l'avance la liste des types de composants qui existeront, il les découvre à l'usage (via std::type_index comme clé).
Le besoin est double et a priori contradictoire :
Le sparse set ⧉ concilie les deux avec deux tableaux :
has(entity) et get(entity) passent donc par le tableau creux (accès en O(1), temps constant) ; l'itération complète passe par le tableau dense (cache-friendly, aucune entité absente à sauter).
add(entity, component) ajoute simplement en fin de tableau dense et enregistre sa position dans le tableau creux — coût O(1).
remove(entity) est plus subtil : retirer un élément au milieu d'un tableau dense en décalant tout ce qui suit coûterait O(n). La technique du swap-and-pop l'évite :
Résultat : le tableau dense reste sans trou en coût O(1), au prix de changer l'ordre d'itération (l'élément déplacé change de position). Comme les systèmes ne dépendent jamais de cet ordre, ce n'est jamais un problème.
Imaginons trois entités A, B, C ayant chacune un composant Velocity, dans cet ordre d'insertion : dense = [Va, Vb, Vc], entités = [A, B, C]. On retire le composant de B (position 1) :
C a « pris la place » de B dans le tableau dense — l'itération reste dense et rapide, et get(C) continue de fonctionner grâce au tableau creux mis à jour.
⚠️ Conséquence pour qui écrit un système : une référence obtenue par get() (ou dans une vue) est invalidée par tout add() ou remove() ultérieur sur la même pool (le tableau dense peut réallouer ou déplacer ses éléments). Ne jamais conserver une telle référence au-delà de telles opérations.
Un système a typiquement besoin d'itérer sur « toutes les entités qui ont à la fois tel et tel composant » (par exemple Transform et Velocity pour un déplacement). World::view<A, B, …>() construit une core::View qui joint les pools demandées et n'expose que l'intersection — les entités présentes dans toutes.
Pour rester efficace, la vue ne parcourt pas la pool la plus grande en testant les autres : elle choisit comme « pilote » la plus petite des pools demandées (celle avec le moins de composants) et ne teste l'appartenance aux autres pools que pour les entités de ce pilote. Le coût de l'itération est ainsi borné par la plus petite population parmi les types demandés, jamais par la plus grande — l'intersection ne peut de toute façon pas être plus grande que son plus petit opérande.
Deux syntaxes équivalentes :
C'est exactement le motif de tous les systèmes du moteur : une vue, une lambda, la logique de mise à jour. core::MovementSystem n'est rien de plus qu'une enveloppe autour de cet exemple.
⚠️ Contrat d'itération : à l'intérieur d'un each ou d'une boucle sur une vue, on ne modifie que la valeur des composants obtenus. Ajouter/retirer un composant ou détruire une entité pendant l'itération invaliderait la vue (les pools sous-jacentes peuvent bouger, cf. swap-and-pop ci-dessus) — ce type de modification structurelle doit être différé après l'itération.
World::addSystem(std::unique_ptr<ISystem>) enregistre un système ; World::update(fixedDelta) exécute tous les systèmes enregistrés, dans l'ordre d'enregistrement, une fois par pas de temps fixe (Boucle de jeu et pas de temps fixe). Cet ordre est significatif et fait partie du contrat de déterminisme (EX-NFR-002) : deux systèmes qui lisent et écrivent les mêmes composants doivent s'exécuter dans un ordre stable pour produire toujours le même résultat (voir l'« ordre d'un pas » détaillé dans Physique du personnage pour un exemple concret à l'intérieur d'un seul système).
Certains systèmes (comme core::CharacterPhysicsSystem) ont besoin de données supplémentaires que l'ISystem générique ne transporte pas (la grille de collision, l'intention d'entrée) : ils exposent alors leur propre méthode update(...) avec une signature dédiée, appelée directement par l'orchestration plutôt que via le mécanisme générique addSystem/World::update. Dans les deux cas, le principe reste identique : la logique parcourt des vues et modifie des composants.
core::AnimationSystem (LOT-18) illustre concrètement pourquoi l'ordre compte : il lit Player::grounded, calculé par CharacterPhysicsSystem pour dériver le clip d'animation actif (repos/course/saut). hmi::GameSession::update l'appelle donc après la physique, dans le même pas — l'inverser lirait l'état du pas précédent (décalage d'une frame). C'est aussi un exemple de système qui ne modifie aucun état de simulation au sens strict (position, vitesse) : il ne fait que projeter un état déjà déterminé (Player/Velocity) vers un état de présentation (core::Animation), consommé ensuite par HMI pour choisir la bonne région d'atlas (Rendu 2D : de l'ECS à l'écran).