Éviter l'anti-blob de blob une approche pragmatique de la composition des entités

Organiser votre code de jeu en entités basées sur des composants, plutôt que de compter uniquement sur l'héritage de classe, est une approche populaire du développement de jeux. Dans ce tutoriel, nous verrons pourquoi vous pourriez le faire et installerons un moteur de jeu simple en utilisant cette technique..


introduction

Dans ce didacticiel, je vais explorer les entités de jeu à base de composants, voir pourquoi vous souhaitez les utiliser et suggérer une approche pragmatique pour vous immerger les pieds dans l'eau..

Comme il s’agit d’une histoire sur l’organisation et l’architecture du code, je commencerai par l’énonciation habituelle de la clause de non-responsabilité «sortez de prison»: c’est une façon de faire les choses, ce n’est pas «à sens unique» ni peut-être même le meilleur moyen, mais cela pourrait fonctionner pour vous. Personnellement, j'aime découvrir autant d'approches que possible et ensuite trouver ce qui me convient.


Aperçu du résultat final

Tout au long de ce didacticiel en deux parties, nous allons créer ce jeu Asteroids. (Le code source complet est disponible sur GitHub.) Dans cette première partie, nous allons nous concentrer sur les concepts de base et le moteur de jeu général..


Quel problème sommes-nous en train de résoudre??

Dans un jeu comme Astéroïdes, nous pourrions avoir quelques types de base de "choses" à l'écran: balles, astéroïdes, navire joueur et navire ennemi. Nous pourrions vouloir représenter ces types de base sous forme de quatre classes distinctes, chacune contenant tout le code nécessaire pour dessiner, animer, déplacer et contrôler cet objet..

Bien que cela fonctionne, il pourrait être préférable de suivre les Ne te répète pas (DRY) et essayez de réutiliser une partie du code entre chaque classe - après tout, le code permettant de déplacer et de dessiner une balle sera très similaire, voire identique, au code pour déplacer et dessiner un point astéroïde ou un navire.

Nous pouvons donc refactoriser nos fonctions de rendu et de mouvement dans une classe de base à partir de laquelle tout s’étend. Mais Navire et Ennemi également besoin de pouvoir tirer. À ce stade, nous pourrions ajouter le tirer fonction à la classe de base, en créant une classe "Giant Blob" capable de faire pratiquement tout, et de s’assurer que les astéroïdes et les puces n’appellent jamais leurs tirer une fonction. Cette classe de base devrait bientôt devenir très volumineuse et grossir à chaque fois que les entités doivent pouvoir faire de nouvelles choses. Ce n’est pas nécessairement faux, mais je trouve que les classes plus petites et plus spécialisées sont plus faciles à maintenir..

Alternativement, nous pouvons aller à la racine de l'héritage profond et avoir quelque chose comme EnemyShip étend le navire étend son extension ShootingEntity étend son entité. Encore une fois, cette approche n'est pas fausse et fonctionnera également très bien, mais à mesure que vous ajoutez plusieurs types d'entités, vous devrez constamment réajuster la hiérarchie de l'héritage pour gérer tous les scénarios possibles, et vous pouvez vous enfermer dans un coin. lorsqu'un nouveau type d'entité doit avoir la fonctionnalité de deux classes de base différentes, nécessitant plusieurs héritages (ce que la plupart des langages de programmation n'offrent pas).

J'ai utilisé l'approche de la hiérarchie profonde plusieurs fois moi-même, mais je préfère en réalité l'approche de Giant Blob, car au moins toutes les entités ont une interface commune et de nouvelles entités peuvent être ajoutées plus facilement (alors, que se passe-t-il si tous vos arbres ont A * pathfinding? !)

Il existe cependant une troisième voie…


Composition sur héritage

Si nous pensons au problème des astéroïdes en termes de choses que les objets pourraient devoir faire, nous pourrions obtenir une liste comme celle-ci:

  • bouge toi()
  • tirer()
  • prendreDommage ()
  • mourir()
  • rendre()

Plutôt que d’établir une hiérarchie d’héritage complexe pour laquelle les objets peuvent faire telle ou telle chose, modélisons le problème en termes de composants qui peuvent effectuer ces actions.

Par exemple, nous pourrions créer un Santé classe, avec les méthodes prendreDommage (), guérir() et mourir(). Ensuite, tout objet devant pouvoir subir des dégâts et mourir peut "composer" une instance de la Santé classe - où "composer" signifie fondamentalement "garder une référence à sa propre instance de cette classe".

Nous pourrions créer une autre classe appelée Vue pour s'occuper de la fonctionnalité de rendu, on appelle Corps pour gérer le mouvement et un appelé Arme gérer le tir.

La plupart des systèmes Entity sont basés sur le principe décrit ci-dessus, mais la manière dont vous accédez aux fonctionnalités contenues dans un composant diffère..

Mise en miroir de l'API

Par exemple, une approche consiste à reproduire l'API de chaque composant de l'entité, de sorte qu'une entité pouvant subir des dommages aurait une prendreDommage () fonction que lui-même appelle juste le prendreDommage () fonction de son Santé composant.

 classe Entity private var _health: Health; //… autre code… // fonction publique takeDamage (dmg: int) _health.takeDamage (dmg); 

Vous devez ensuite créer une interface appelée quelque chose comme IHealth pour que votre entité l'implémente, afin que d'autres objets puissent accéder au prendreDommage () une fonction. Voici comment un guide Java OOP pourrait vous conseiller de le faire.

getComponent ()

Une autre approche consiste simplement à stocker chaque composant dans une recherche clé-valeur, de sorte que chaque entité ait une fonction appelée quelque chose comme: getComponent ("composantName") qui renvoie une référence au composant particulier. Vous devez ensuite transtyper la référence pour revenir au type de composant souhaité - quelque chose comme:

 var santé: Santé = Santé (getComponent ("Santé"));

C’est essentiellement ainsi que fonctionne le système entité / comportement de Unity. C'est très flexible, car vous pouvez continuer à ajouter de nouveaux types de composants sans changer votre classe de base, ni créer de nouvelles sous-classes ou interfaces. Cela peut également être utile lorsque vous souhaitez utiliser des fichiers de configuration pour créer des entités sans recompiler votre code, mais je laisserai à quelqu'un d'autre le soin de comprendre..

Composants publics

L'approche que je privilégie consiste à laisser toutes les entités posséder une propriété publique pour chaque type majeur de composant et à laisser les champs nuls si la fonctionnalité n'a pas cette fonctionnalité. Lorsque vous souhaitez appeler une méthode particulière, vous devez simplement "atteindre" l'entité pour obtenir le composant avec cette fonctionnalité - par exemple, appelez ennemi.health.takeDamage (5) attaquer un ennemi.

Si vous essayez d'appeler health.takeDamage () sur une entité qui n'a pas de Santé composant, il sera compilé, mais vous obtiendrez une erreur d’exécution vous indiquant que vous avez fait quelque chose de stupide. Dans la pratique, cela se produit rarement, car il est assez évident de savoir quels types d’entités auront quels composants (par exemple, un arbre n’a pas d’arme!).

Certains défenseurs sévères de la programmation orientée objet pourraient dire que mon approche enfreint certains principes de la programmation orientée objet, mais je trouve que cela fonctionne très bien et il existe un très bon précédent dans l'histoire d'Adobe Flash..

Dans ActionScript 2, le MovieClip classe avait des méthodes pour dessiner des graphiques vectoriels: par exemple, vous pouvez appeler myMovieClip.lineTo () tracer une ligne. Dans ActionScript 3, ces méthodes de dessin ont été déplacées vers le Graphique classe, et chacun MovieClip Obtient un Graphique composant, auquel vous accédez en appelant, par exemple, myMovieClip.graphics.lineTo () de la même manière que j'ai décrite pour ennemi.health.takeDamage (). Si cela convient aux concepteurs de langage ActionScript, cela suffit pour moi.


Mon système (simplifié)

Ci-dessous, je vais détailler une version très simplifiée du système que j'utilise dans tous mes jeux. En termes de simplification, cela représente environ 300 lignes de code, contre 6 000 pour mon moteur complet. Mais nous pouvons en faire beaucoup avec seulement ces 300 lignes!

J'ai laissé juste assez de fonctionnalités pour créer un jeu fonctionnel, tout en gardant le code le plus court possible pour qu'il soit plus facile à suivre. Le code sera dans ActionScript 3, mais une structure similaire est possible dans la plupart des langues. Il existe quelques variables publiques qui pourraient être des propriétés (c'est-à-dire mises derrière obtenir et ensemble fonctions d'accesseur), mais comme cela est assez détaillé dans ActionScript, je les ai laissées comme variables publiques pour faciliter la lecture.

le IEntity Interface

Commençons par définir une interface que toutes les entités implémenteront:

 moteur de package import org.osflash.signals.Signal; / ** *… * @author Iain Lobb - [email protected] * / interface publique IEntity // ACTIONS function destroy (): void; function update (): void; function render (): void; // COMPONENTS function get body (): Body; ensemble de fonctions body (valeur: Body): void; fonction get physics (): Physique; Physique du jeu de fonctions (valeur: Physique): Fonction void get health (): Fonction de la santé configurée santé (valeur: Santé): Fonction void get arme (): Arme; ensemble de fonctions arme (valeur: Arme): void; function get view (): Voir; set de fonctions view (valeur: View): void; // fonction SIGNALS get entityCreated (): Signal; ensemble de fonctions entityCreated (valeur: Signal): void; fonction être détruite (): Signal; fonction définie détruite (valeur: Signal): vide; // fonction DEPENDENCIES get target (): Vector.; fonction définie objectifs (valeur: vecteur.):vide; fonction get group (): vecteur.; groupe de jeux de fonctions (valeur: vecteur.):vide; 

Toutes les entités peuvent effectuer trois actions: vous pouvez les mettre à jour, les rendre et les détruire.

Ils ont chacun des "emplacements" pour cinq composants:

  • UNE corps, position et taille de manutention.
  • la physique, mouvement de manutention.
  • santé, manipulation se blesser.
  • UNE arme, manipulation attaquant.
  • Et enfin un vue, vous permettant de rendre l'entité.

Tous ces composants sont facultatifs et peuvent rester nuls, mais en pratique, la plupart des entités auront au moins deux composants..

Un paysage statique avec lequel le joueur ne peut pas interagir (par exemple un arbre) aurait simplement besoin d’un corps et d’une vue. Il n'aurait pas besoin de physique car il ne bouge pas, il n'a pas besoin de santé car vous ne pouvez pas l'attaquer et il n'a certainement pas besoin d'arme. Le vaisseau du joueur dans Asteroids, quant à lui, aurait besoin des cinq composants, car il peut se déplacer, tirer et se blesser.

En configurant ces cinq composants de base, vous pouvez créer la plupart des objets simples dont vous pourriez avoir besoin. Parfois, cependant, ils ne suffiront pas et à ce stade, nous pouvons étendre les composants de base ou en créer de nouveaux - dont nous parlerons plus tard..

Ensuite, nous avons deux signaux: entité créée et détruit.

Les signaux sont une alternative open source aux événements natifs d'ActionScript, créés par Robert Penner. Ils sont très agréables à utiliser car ils vous permettent de transmettre des données entre le répartiteur et l’auditeur sans avoir à créer beaucoup de classes d’événements personnalisées. Pour plus d'informations sur leur utilisation, consultez la documentation..

le entité créée Signal permet à une entité de dire au jeu qu’une autre nouvelle entité doit être ajoutée - un exemple classique étant le cas où une arme à feu crée une balle. le détruit Signal informe le jeu (et tous les autres objets à l'écoute) que cette entité a été détruite.

Enfin, l'entité a deux autres dépendances facultatives: des cibles, qui est une liste d'entités qu'il pourrait vouloir attaquer, et groupe, qui est une liste des entités auxquelles il appartient. Par exemple, un navire joueur peut avoir une liste de cibles, qui serait tous les ennemis du jeu, et pourrait appartenir à un groupe qui contient également tous les autres joueurs et unités amies..

le Entité Classe

Maintenant regardons le Entité classe qui implémente cette interface.

 moteur de package import org.osflash.signals.Signal; / ** *… * @author Iain Lobb - [email protected] * / public class Entity implémente IEntity private var _body: Body; var_physique privée: Physique; santé privée: santé; var_weapon privée: Arme; private var _view: Voir; private var _entityCreated: Signal; private var _destroyed: Signal; var _targets privé: vecteur.; var_group privé: vecteur.; / * * Tout ce qui existe dans votre jeu est une entité! * / fonction publique Entity () entityCreated = nouveau signal (Entity); détruit = nouveau signal (entité);  fonction publique destroy (): void destroy.dispatch (this); si (groupe) group.splice (group.indexOf (this), 1);  public function update (): void if (physics) physics.update ();  fonction publique render (): void if (view) view.render ();  fonction publique get body (): Body return _body;  public function set body (valeur: Body): void _body = valeur;  fonction publique get physics (): Physics return _physics;  public function set physics (value: Physics): void _physics = value;  fonction publique get health (): Health return _health;  fonction publique définie santé (valeur: Santé): void _health = valeur;  fonction publique get arme (): Weapon return _weapon;  fonction publique set Arme (valeur: Arme): void _weapon = valeur;  fonction publique get view (): View return _view;  public set set function (valeur: View): void _view = valeur;  fonction publique get entityCreated (): Signal return _entityCreated;  ensemble de fonctions public entityCreated (valeur: Signal): void _entityCreated = valeur;  fonction publique est détruite (): Signal return _destroyed;  ensemble de fonctions public détruit (valeur: Signal): void _destroyed = valeur;  fonction publique get target (): Vector. return _targets;  fonction publique définie cibles (valeur: vecteur.): void _targets = value;  fonction publique get group (): Vector. return _group;  groupe de jeux de fonctions publiques (valeur: vecteur.): void _group = value; 

Cela a l'air long, mais la plupart d'entre elles ne concernent que les fonctions getter et setter (boo!). La partie importante à regarder concerne les quatre premières fonctions: le constructeur, où nous créons nos signaux; détruire(), où nous envoyons le signal détruit et retirons l'entité de sa liste de groupes; mettre à jour(), où nous mettons à jour tous les composants devant agir à chaque boucle de jeu - bien que, dans cet exemple simple, il s’agisse uniquement la physique composant - et enfin rendre(), où on dit à la vue de faire sa chose.

Vous remarquerez que nous n'instancions pas automatiquement les composants ici, dans la classe Entity. En effet, comme je l'ai expliqué précédemment, chaque composant est facultatif..

Les composants individuels

Examinons maintenant les composants un par un. Tout d'abord, la composante du corps:

 moteur de package / ** *… * @author Iain Lobb - [email protected] * / public class Body public var entity: Entity; public var x: Number = 0; public var y: Number = 0; angle var public: Nombre = 0; public var radius: Number = 10; / * * Si vous attribuez un corps à une entité, celui-ci peut prendre une forme physique dans le monde *, bien que pour le voir, vous aurez besoin d'une vue. * / fonction publique Corps (entité: entité) this.entity = entité;  fonction publique testCollision (otherEntity: Entity): Boolean var dx: Number; var dy: nombre; dx = x - otherEntity.body.x; dy = y - otherEntity.body.y; retourne Math.sqrt ((dx * dx) + (dy * dy)) <= radius + otherEntity.body.radius;   

Tous nos composants ont besoin d'une référence à leur entité propriétaire, que nous transmettons au constructeur. Le corps a alors quatre champs simples: une position x et y, un angle de rotation et un rayon pour stocker sa taille. (Dans cet exemple simple, toutes les entités sont circulaires!)

Ce composant a également une seule méthode: testCollision (), qui utilise Pythagore pour calculer la distance entre deux entités et la compare à leurs rayons combinés. (Plus d'infos ici.)

Ensuite, regardons le La physique composant:

 moteur de package / ** *… * @author Iain Lobb - [email protected] * / public class Physics public var entity: Entity; drag public var: Number = 1; public var velocityX: Number = 0; public var velocityY: Number = 0; / * * Fournit une étape physique de base sans détection de collision. * Étendre pour ajouter la gestion des collisions. * / fonction publique Physique (entité: Entité) this.entity = entité;  public function update (): void entity.body.x + = velocityX; entity.body.y + = vélocitéY; vélocitéX * = glisser; vélocitéY * = glisser;  fonction publique poussée (puissance: nombre): void velocityX + = Math.sin (-entity.body.angle) * puissance; vélocitéY + = Math.cos (-entity.body.angle) * puissance; 

En regardant le mettre à jour() fonction, vous pouvez voir que le vélocitéX et vélocitéY les valeurs sont ajoutées à la position de l'entité, ce qui la déplace, et la vitesse est multipliée par traîne, ce qui a pour effet de ralentir progressivement l'objet. le poussée() fonction permet un moyen rapide d'accélérer l'entité dans la direction à laquelle il fait face.

Ensuite, regardons le Santé composant:

 moteur de package import org.osflash.signals.Signal; / ** *… * @author Iain Lobb - [email protected] * / public class Health public var entity: Entity; public var hits: int; public var est mort: Signal; public var bless: Signal; fonction publique Health (entité: entité) this.entity = entité; mort = nouveau signal (entité); blessé = nouveau signal (entité);  fonction publique hit (dégâts: int): void hits - = dégâts; harm.dispatch (entité); si (frappe < 0)  died.dispatch(entity);    

le Santé composant a une fonction appelée frappé(), permettant à l'entité d'être blessé. Lorsque cela se produit, le les coups la valeur est réduite et tout objet d'écoute est notifié par l'envoi du blesser Signal. Si les coups sont inférieurs à zéro, l'entité est morte et nous expédions le décédés Signal.

Voyons ce qu'il y a à l'intérieur du Arme composant:

 moteur de package import org.osflash.signals.Signal; / ** *… * @author Iain Lobb - [email protected] * / public class Weapon public var entity: Entity; public var ammo: int; / * * L'arme est la classe de base pour toutes les armes. * / fonction publique Arme (entité: Entité) this.entity = entité;  fonction publique fire (): void ammo--; 

Pas grand chose ici! C’est parce que c’est vraiment une classe de base pour les armes réelles - comme vous le verrez dans le Pistolet exemple plus tard. Il y a un Feu() méthode que les sous-classes doivent remplacer, mais ici, elle réduit simplement la valeur de munitions.

La dernière composante à examiner est Vue:

 moteur de package import flash.display.Sprite; / ** *… * @author Iain Lobb - [email protected] * / public class Voir public var entity: Entity; échelle de var publique: Nombre = 1; public var alpha: Number = 1; public var sprite: Sprite; / * * View est un composant d'affichage qui rend une entité à l'aide de la liste d'affichage standard. * / fonction publique Voir (entité: Entité) this.entity = entité;  fonction publique render (): void sprite.x = entity.body.x; sprite.y = entity.body.y; sprite.rotation = entity.body.angle * (180 / Math.PI); sprite.alpha = alpha; sprite.scaleX = scale; sprite.scaleY = scale; 

Ce composant est très spécifique à Flash. Le principal événement ici est le rendre() fonction, qui met à jour un sprite Flash avec les valeurs de position et de rotation du corps, ainsi que les valeurs alpha et d'échelle qu'il stocke lui-même. Si vous souhaitez utiliser un système de rendu différent tel que copyPixels blitting ou Stage3D (ou bien un système pertinent pour un choix différent de plate-forme), vous voudriez adapter cette classe.

le Jeu Classe

Nous savons maintenant à quoi ressemble une entité et tous ses composants. Avant de commencer à utiliser ce moteur pour créer un exemple de jeu, examinons la dernière pièce du moteur - la classe Game qui contrôle l'ensemble du système:

 moteur de package import flash.display.Sprite; import flash.display.Stage; import flash.events.Event; / ** *… * @auteur Iain Lobb - [email protected] * / classe publique Le jeu étend Sprite entités publiques diverses: vecteur. = nouveau vecteur.(); public var isPaused: Boolean; statique public var stage: Stage; / * * Le jeu est la classe de base des jeux. * / fonction publique Game () addEventListener (Event.ENTER_FRAME, onEnterFrame); addEventListener (Event.ADDED_TO_STAGE, onAddedToStage);  fonction protégée onEnterFrame (event: Event): void if (isPaused) return; mettre à jour(); rendre();  fonction protégée update (): void pour chaque (entité var: entité dans entités) entity.update ();  fonction protégée render (): void pour chaque (entité var: entité dans entités) entity.render ();  fonction protégée onAddedToStage (event: Event): void Game.stage = stage; démarrer jeu();  fonction protégée startGame (): void  fonction protégée stopGame (): void pour chaque (entité var: entité dans les entités) if (entity.view) removeChild (entity.view.sprite);  entity.length = 0;  fonction publique addEntity (entité: Entité): Entité entités.push (entité); entity.destroyed.add (onEntityDestroyed); entity.entityCreated.add (addEntity); if (entity.view) addChild (entity.view.sprite); entité de retour;  fonction protégée onEntityDestroyed (entité: entité): void entités.splice (entités.indexOf (entité), 1); if (entity.view) removeChild (entity.view.sprite); entity.destroyed.remove (onEntityDestroyed); 

Il y a beaucoup de détails de mise en œuvre ici, mais prenons juste les points saillants.

Chaque image, la Jeu class boucle à travers toutes les entités et appelle leurs méthodes de mise à jour et de rendu. dans le addEntity fonction, nous ajoutons la nouvelle entité à la liste des entités, écoutons ses signaux et, si elle a une vue, ajoutons son sprite à la scène.

Quand onEntityDestroyed est déclenchée, nous retirons l'entité de la liste et retirons son sprite de la scène. dans le stopGame fonction, que vous appelez uniquement si vous voulez terminer le jeu, nous supprimons les sprites de toutes les entités de la scène et supprimons la liste des entités en définissant sa longueur sur zéro.


La prochaine fois…

Wow, nous l'avons fait! C'est le moteur de jeu entier! À partir de ce point de départ, nous pourrions créer de nombreux jeux d'arcade 2D simples sans beaucoup de code supplémentaire. Dans le prochain tutoriel, nous utiliserons ce moteur pour réaliser un shoot-'em-up de style astéroïde.