Comment faire progresser un état de jeu de composant d'entité dans un jeu au tour par tour?


9

Jusqu'à présent, les systèmes de composants d'entité que j'ai utilisés ont fonctionné principalement comme l'artemis de Java:

  • Toutes les données dans les composants
  • Systèmes indépendants sans état (au moins dans la mesure où ils ne nécessitent pas d'entrée lors de l'initialisation) itérant sur chaque entité qui contient uniquement les composants qui intéressent un système particulier
  • Tous les systèmes traitent leurs entités d'un seul coup, puis tout recommence.

Maintenant, j'essaie d'appliquer cela à un jeu au tour par tour pour la première fois, avec des tonnes d'événements et de réponses qui doivent se produire dans un ordre défini les uns par rapport aux autres, avant que le jeu puisse continuer. Un exemple:

Le joueur A reçoit des dégâts d'une épée. En réponse à cela, l'armure de A entre en jeu et réduit les dégâts subis. La vitesse de déplacement de A est également réduite en raison de son affaiblissement.

  • Le dommage subi est ce qui déclenche toute l'interaction
  • L'armure doit être calculée et appliquée aux dégâts entrants avant que les dommages ne soient appliqués au joueur.
  • La réduction de la vitesse de déplacement ne peut être appliquée à une unité qu'après que les dégâts ont été réellement infligés, car elle dépend du montant final des dégâts.

Les événements peuvent également déclencher d'autres événements. La réduction des dégâts de l'épée à l'aide d'une armure peut faire éclater l'épée (cela doit avoir lieu avant que la réduction des dégâts ne soit terminée), ce qui peut à son tour provoquer des événements supplémentaires en réponse, essentiellement une évaluation récursive des événements.

Dans l'ensemble, cela semble entraîner quelques problèmes:

  1. Beaucoup de cycles de traitement gaspillés: la plupart des systèmes (sauf pour les choses qui fonctionnent toujours, comme le rendu) n'ont tout simplement rien à faire quand ce n'est pas "à leur tour" de fonctionner et passent la plupart du temps à attendre que le jeu entre. un état de travail valide. Cela gâche tous ces systèmes avec des chèques qui continuent de croître en taille à mesure que plus d'états sont ajoutés au jeu.
  2. Pour savoir si un système peut traiter des entités présentes dans le jeu, ils ont besoin d'un moyen de surveiller d'autres états d'entité / système non liés (le système responsable des dégâts doit savoir si une armure a été appliquée ou non). Cela embrouille les systèmes avec de multiples responsabilités, ou crée le besoin de systèmes supplémentaires sans autre but que d'analyser la collection d'entités après chaque cycle de traitement et de communiquer avec un ensemble d'auditeurs en leur disant quand il est possible de faire quelque chose.

Les deux points ci-dessus supposent que les systèmes fonctionnent sur le même ensemble d'entités, qui finissent par changer d'état en utilisant des indicateurs dans leurs composants.

Une autre façon de le résoudre serait d'ajouter / supprimer des composants (ou de créer des entités entièrement nouvelles) à la suite d'un travail de systèmes unique pour faire progresser l'état des jeux. Cela signifie que chaque fois qu'un système possède une entité correspondante, il sait qu'il est autorisé à la traiter.

Cela rend cependant les systèmes responsables du déclenchement des systèmes ultérieurs, ce qui rend difficile de raisonner sur le comportement des programmes car les bogues n'apparaîtront pas comme le résultat d'une seule interaction système. L'ajout de nouveaux systèmes devient également plus difficile car ils ne peuvent pas être mis en œuvre sans savoir exactement comment ils affectent les autres systèmes (et les systèmes précédents pourraient devoir être modifiés pour déclencher les états qui intéressent le nouveau système), ce qui va un peu à l'encontre de l'objectif d'avoir des systèmes séparés avec une seule tâche.

Est-ce quelque chose avec lequel je devrai vivre? Chaque exemple ECS que j'ai vu a été en temps réel, et il est vraiment facile de voir comment cette boucle d'une itération par jeu fonctionne dans de tels cas. Et j'en ai toujours besoin pour le rendu, cela semble vraiment inapproprié pour les systèmes qui interrompent la plupart des aspects de lui-même chaque fois que quelque chose se produit.

Existe-t-il un modèle de conception pour faire avancer l'état du jeu qui convient à cela, ou devrais-je simplement déplacer toute la logique hors de la boucle et la déclencher à la place uniquement lorsque cela est nécessaire?


Vous ne voulez pas vraiment voter pour qu'un événement se produise. Un événement ne se produit que lorsqu'il se produit. Artemis ne permet-il pas aux systèmes de communiquer entre eux?
Sidar

C'est le cas, mais uniquement en les couplant à l'aide de méthodes.
Aeris130

Réponses:


3

Mon conseil ici vient d'une expérience passée sur un projet RPG où nous avons utilisé un système de composants. Je dirai que je détestais travailler dans ce code côté jeu car c'était du code spaghetti. Je n'offre donc pas beaucoup de réponse ici, juste une perspective:

La logique que vous décrivez pour gérer les dégâts de l'épée d'un joueur ... il semble qu'un seul système devrait être en charge de tout cela.

Quelque part, il y a une fonction HandleWeaponHit (). Il accéderait au composant armure de l'entité joueur pour obtenir l'armure appropriée. Il accéderait au composant d'arme de l'entité d'armes attaquante pour peut-être briser l'arme. Après avoir calculé les dommages finaux, il toucherait le composant de mouvement pour que le joueur atteigne la réduction de vitesse.

Quant aux cycles de traitement gaspillés ... HandleWeaponHit () ne doit être déclenché qu'en cas de besoin (lors de la détection du coup d'épée).

Peut-être que le point que j'essaie de faire est: vous voulez sûrement un endroit dans le code où vous pouvez mettre un point d'arrêt, le frapper, puis passer à travers toute la logique qui est censée s'exécuter lorsqu'un coup d'épée se produit. En d'autres termes, la logique ne doit pas être dispersée dans les fonctions tick () de plusieurs systèmes.


Le faire de cette façon rendrait la fonction hit () baloon à mesure que plus de comportement serait ajouté. Disons qu'il y a un ennemi qui tombe de rire chaque fois qu'une épée frappe une cible (n'importe quelle cible) dans sa ligne de vue. HandleWeaponHit devrait-il vraiment être responsable du déclenchement?
Aeris130

1
Vous avez une séquence de combat étroitement imbriquée, alors oui, le coup est responsable du déclenchement des effets. Pas tout doit être réparti en petits systèmes, que cette poignée d' un système parce qu'il est vraiment votre « Combat System » et il gère ... Combat ...
Patrick Hughes

3

C'est une question vieille d'un an mais maintenant je fais face aux mêmes problèmes avec mon jeu fait maison tout en étudiant l'ECS, donc de la nécromanie. J'espère que cela se terminera par une discussion ou au moins quelques commentaires.

Je ne sais pas si cela viole les concepts ECS, mais que faire si:

  • Ajouter un EventBus pour permettre aux systèmes d'émettre / s'abonner à des objets d'événement (des données pures en fait, mais pas un composant je suppose)
  • Créer des composants pour chaque état intermédiaire

Exemple:

  • UserInputSystem déclenche un événement d'attaque avec [DamageDealerEntity, DamageReceiverEntity, Skill / Weapon used info]
  • CombatSystem y est abonné et calcule les chances d'évasion de DamageReceiver. Si l'évasion échoue, elle déclenche un événement de dommages avec les mêmes paramètres
  • DamageSystem est abonné à un tel événement et donc déclenché
  • DamageSystem utilise la force, les dommages BaseWeapon, son type, etc. et l'écrit dans un nouveau composant IncomingDamageComponent avec [DamageDealerEntity, FinalOutgoingDamage, DamageType] et le rattache à l'entité / entités récepteur de dommages
  • DamageSystem déclenche un OutgoingDamageCalculated
  • ArmorSystem est déclenché par celui-ci, récupère une entité réceptrice ou recherche par cet aspect IncomingDamage dans les entités pour ramasser IncomingDamageComponent (le dernier pourrait être probablement meilleur pour plusieurs attaques avec propagation) et calcule l'armure et les dégâts qui lui sont appliqués. Déclenche en option des événements pour l'éclatement de l'épée
  • ArmorSystems supprime IncomingDamageComponent dans chaque entité et le remplace par DamageReceivedComponent avec les nombres calculés finaux qui affecteront les HP et la réduction de vitesse des blessures
  • ArmorSystems envoie un événement IncomingDamageCalculated
  • Le système de vitesse est abonné et recalcule la vitesse
  • HealthSystem est abonné et diminue les HP réels
  • etc
  • En quelque sorte nettoyer

Avantages:

  • Le système se déclenche en fournissant des données intermédiaires pour les événements de chaîne complexes
  • Découplage par EventBus

Les inconvénients:

  • Je sens que je mélange deux façons de passer les choses: dans les paramétreurs d'événements et dans les composants temporaires. ce pourrait être un endroit faible. En théorie, pour garder les choses homogènes, je pourrais déclencher simplement des événements d'énumération sans données afin que les systèmes trouvent les paramètres implicites dans les composants de l'entité par aspect ... Je ne sais pas si c'est bien cependant
  • Je ne sais pas comment savoir si tous les SystemsHave potentiellement intéressés ont traité IncomingDamageCalculated afin qu'il puisse être nettoyé et laisser le prochain tour se produire. Peut-être une sorte de vérification dans CombatSystem ...

2

Publier la solution sur laquelle je me suis finalement installé, semblable à celui de Yakovlev.

En gros, j'ai fini par utiliser un système d'événements car je trouvais très intuitif de suivre sa logique au fil des tours. Le système a fini par être responsable des unités en jeu qui adhéraient à la logique au tour par tour (joueur, monstres et tout ce avec quoi ils peuvent interagir), les tâches en temps réel telles que le rendu et l'interrogation des entrées ont été placées ailleurs.

Les systèmes implémentent une méthode onEvent qui prend un événement et une entité en entrée, signalant que l'entité a reçu l'événement. Chaque système souscrit également à des événements et des entités avec un ensemble spécifique de composants. Le seul point d'interaction disponible pour les systèmes est le gestionnaire d'entités singleton, utilisé pour envoyer des événements à des entités et pour récupérer des composants à partir d'une entité spécifique.

Lorsque le gestionnaire d'entité reçoit un événement couplé à l'entité à laquelle il est envoyé, il place l'événement à l'arrière d'une file d'attente. Bien qu'il y ait des événements dans la file d'attente, l'événement le plus important est récupéré et envoyé à chaque système qui souscrit à l'événement et s'intéresse à l'ensemble de composants de l'entité qui reçoit l'événement. Ces systèmes peuvent à leur tour traiter les composants de l'entité et envoyer des événements supplémentaires au gestionnaire.

Exemple: Le joueur subit des dégâts, donc l'entité du joueur reçoit un événement de dégâts. Le DamageSystem s'abonne aux événements de dommages envoyés à toute entité avec le composant d'intégrité et dispose d'une méthode onEvent (entité, événement) qui réduit l'intégrité du composant d'entités du montant spécifié dans l'événement.

Cela facilite l'insertion d'un système d'armure qui souscrit aux événements de dégâts envoyés aux entités avec un composant d'armure. Sa méthode onEvent réduit les dégâts dans l'événement par la quantité d'armure dans le composant. Cela signifie que la spécification de l'ordre dans lequel les systèmes reçoivent les événements a une incidence sur la logique du jeu, car le système d'armure doit traiter l'événement de dégâts avant le système de dégâts pour fonctionner.

Parfois, un système doit cependant sortir de l'entité réceptrice. Pour continuer ma réponse à Eric Undersander, il serait trivial d'ajouter un système qui accède à la carte du jeu et recherche des entités avec le FallsDownLaughingComponent dans x espaces de l'entité qui subit des dommages, puis leur envoie un FallDownLaughingEvent. Ce système devrait être programmé pour recevoir l'événement après le système de dommages, si l'événement de dommages n'a pas été annulé à ce stade, les dommages ont été infligés.

Un problème est survenu: comment s'assurer que les événements de réponse sont traités dans l'ordre dans lequel ils sont envoyés, étant donné que certaines réponses peuvent engendrer des réponses supplémentaires. Exemple:

Le joueur se déplace, provoquant un événement de mouvement envoyé à l'entité des joueurs et récupéré par le système de mouvement.

En file d'attente: mouvement

Si le mouvement est autorisé, le système ajuste la position des joueurs. Sinon (le joueur a tenté de se déplacer vers un obstacle), il marque l'événement comme annulé, ce qui oblige le responsable de l'entité à le rejeter au lieu de l'envoyer aux systèmes suivants. À la fin de la liste des systèmes intéressés par l'événement se trouve le TurnFinishedSystem, qui confirme que le joueur a passé son tour à déplacer le personnage et que son tour est maintenant terminé. Il en résulte un événement TurnOver envoyé à l'entité du joueur et placé dans la file d'attente.

En file d'attente: TurnOver

Disons maintenant que le joueur a marché sur un piège, causant des dégâts. Le TrapSystem reçoit le message de mouvement avant le TurnFinishedSystem, donc l'événement de dégâts est envoyé en premier. Maintenant, la file d'attente ressemble à ceci:

En file d'attente: Damage, TurnOver

Tout va bien jusqu'à présent, l'événement de dommage sera traité, puis le tour se terminera. Cependant, que se passe-t-il si des événements supplémentaires sont envoyés en réponse aux dommages? Maintenant, la file d'attente des événements ressemblerait à:

En file d'attente: Damage, TurnOver, ResponseToDamage

En d'autres termes, le tour se terminait avant que toute réponse aux dommages ne soit traitée.

Pour résoudre ce problème, j'ai fini par utiliser deux méthodes d'envoi d'événements: envoyer (événement, entité) et répondre (événement, eventToRespondTo, entité).

Chaque événement conserve un enregistrement des événements précédents dans une chaîne de réponse, et chaque fois que la méthode respond () est utilisée, l'événement auquel on répond (et chaque événement de sa chaîne de réponse) se retrouve en tête de la chaîne dans l'événement utilisé pour répondre avec. L'événement de mouvement initial n'a pas de tels événements. La réponse aux dégâts ultérieure a l'événement de mouvement dans sa liste.

En plus de cela, un tableau de longueur variable est utilisé pour contenir plusieurs files d'attente d'événements. Chaque fois qu'un événement est reçu par le gestionnaire, l'événement est ajouté à une file d'attente à un index dans le tableau qui correspond à la quantité d'événements dans la chaîne de réponse. Ainsi, l'événement de mouvement initial est ajouté à la file d'attente à [0], et les dommages, ainsi que les événements TurnOver sont ajoutés à une file d'attente distincte à [1] car ils ont tous deux été envoyés en réponse au mouvement.

Lorsque les réponses à l'événement de dommage sont envoyées, ces événements contiennent à la fois l'événement de dommage lui-même et le mouvement, les mettant dans une file d'attente à l'index [2]. Tant que l'index [n] a des événements dans sa file d'attente, ces événements seront traités avant de passer à [n-1]. Cela donne un ordre de traitement de:

Mouvement -> Dégâts [1] -> ResponseToDamage [2] -> [2] est vide -> TurnOver [1] -> [1] est vide -> [0] est vide

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.