Comment éviter les dépendances circulaires entre Player et World?

60

Je travaille sur un jeu en 2D où vous pouvez monter, descendre, aller à gauche et à droite. J'ai essentiellement deux objets de logique de jeu:

  • Joueur: A une position par rapport au monde
  • Monde: Dessine la carte et le joueur

Jusqu'à présent, Monde dépend du joueur (c.-à-d. Qu'il y a une référence), il a donc besoin de sa position pour déterminer où dessiner le personnage du joueur et quelle partie de la carte dessiner.

Maintenant, je veux ajouter une détection de collision pour empêcher le joueur de se déplacer à travers les murs.

Le moyen le plus simple auquel je puisse penser est de demander au joueur de demander au monde si le mouvement prévu est possible. Mais cela introduirait une dépendance circulaire entre Player et World (c’est-à-dire que chacun détient une référence à l’autre), ce qui semble mériter d’être évité. La seule façon dont je suis venu à l’esprit est de laisser le Monde déplacer le joueur , mais je trouve cela peu intuitif.

Quelle est ma meilleure option? Ou éviter une dépendance circulaire n'en vaut-il pas la peine?

futlib
la source
4
Pourquoi pensez-vous qu'une dépendance circulaire est une mauvaise chose? stackoverflow.com/questions/1897537/…
Fuhrmanator
@ Fuhrmanator Je ne pense pas que ce soit généralement une mauvaise chose, mais je devrais rendre les choses un peu plus complexes dans mon code pour en introduire un.
futlib
Je me suis fâché contre un billet sur notre petite discussion, mais rien de nouveau: yannbane.com/2012/11/… ...
jcora

Réponses:

61

Le monde ne devrait pas se dessiner; le moteur de rendu devrait dessiner le monde. Le joueur ne doit pas se dessiner tout seul. le moteur de rendu doit dessiner le joueur par rapport au monde.

Le joueur devrait interroger le monde sur la détection des collisions; ou peut-être que les collisions devraient être gérées par une classe séparée qui vérifierait la détection des collisions non seulement contre le monde statique mais aussi contre d'autres acteurs.

Je pense que le monde ne devrait probablement pas être au courant du joueur; ce devrait être un primitif de bas niveau et non un objet divin. Le joueur devra probablement invoquer certaines méthodes de World, peut-être indirectement (détection de collision, recherche d'objets interactifs, etc.).

Liosa
la source
25
@ snake5 - Il y a une différence entre "peut" et "devrait". N'importe quoi peut dessiner n'importe quoi - mais lorsque vous devez modifier le code qui traite du dessin, il est beaucoup plus facile d'accéder à la classe "Renderer" plutôt que de rechercher le "Tout" qui est en train de dessiner. "obsédé par la compartimentation" est un autre mot pour "cohésion".
Nate
16
@ Mr.Beast, non, il ne l'est pas. Il préconise un bon design. Cramming tout dans une gaffe d'une classe n'a aucun sens.
jcora
23
Whoa, je ne pensais pas que cela déclencherait une telle réaction :) Je n'ai rien à ajouter à la réponse, mais je peux expliquer pourquoi je l'ai donnée - parce que je pense que c'est plus simple. Pas «correct» ou «correct». Je ne voulais pas que ça sonne comme ça. C'est plus simple pour moi, car si je me trouve confronté à des classes comportant trop de responsabilités, une scission est plus rapide que de forcer le code existant à être lisible. J'aime le code en morceaux que je peux comprendre et le refactorisation en réponse à des problèmes tels que celui rencontré par @futlib.
Liosan
12
@ snake5 Dire que l'ajout de classes supplémentaires entraîne une charge supplémentaire pour le programmeur est souvent totalement faux, selon mon expérience. À mon avis, les classes de lignes 10x100 avec des noms informatifs et des responsabilités bien définies sont plus faciles à lire et moins onéreuses pour le programmeur qu'une classe de dieu à 1 000 lignes.
Martin
7
En tant que note sur ce qui dessine quoi, une Renderersorte est nécessaire, mais cela ne signifie pas que la logique de la manière dont chaque chose est rendue est gérée par le Renderer, chaque chose à dessiner devrait probablement hériter d'une interface commune telle que IDrawableou IRenderable(ou une interface équivalente dans la langue que vous utilisez). Le monde pourrait être le Renderer, je suppose, mais il semblerait qu’il outrepasserait sa responsabilité, surtout s’il était déjà un IRenderablesoi.
zzzzBov
35

Voici comment un moteur de rendu typique gère ces choses:

Il existe une distinction fondamentale entre l'emplacement d'un objet dans l'espace et la manière dont l'objet est dessiné.

  1. Dessiner un objet

    Vous avez généralement une classe Renderer qui effectue cela. Il prend simplement un objet (Modèle) et dessine à l'écran. Il peut avoir des méthodes telles que drawSprite (Sprite), drawLine (..), drawModel (Model), tout ce dont vous avez besoin. C'est un moteur de rendu, donc il est supposé faire toutes ces choses. Il utilise également toutes les API que vous avez en dessous afin que vous puissiez avoir par exemple un rendu qui utilise OpenGL et un qui utilise DirectX. Si vous souhaitez porter votre jeu sur une autre plate-forme, il vous suffit d'écrire un nouveau moteur de rendu et de l'utiliser. C'est si facile.

  2. Déplacer un objet

    Chaque objet est attaché à quelque chose que nous aimons appeler SceneNode . Vous y parvenez grâce à la composition. Un SceneNode contient un objet. C'est ça. Qu'est-ce qu'un SceneNode? C'est une classe simple contenant toutes les transformations (position, rotation, échelle) d'un objet (généralement par rapport à un autre SceneNode) ainsi que l'objet réel.

  3. Gérer les objets

    Comment sont gérées SceneNodes? À travers un SceneManager . Cette classe crée et garde une trace de chaque SceneNode dans votre scène. Vous pouvez lui demander un SceneNode spécifique (généralement identifié par un nom de chaîne tel que "Player" ou "Table") ou une liste de tous les nœuds.

  4. Dessiner le monde

    Cela devrait être assez évident maintenant. Il suffit de parcourir chaque SceneNode de la scène et de le dessiner au bon endroit. Vous pouvez le dessiner au bon endroit en faisant en sorte que le moteur de rendu stocke les transformations d'un objet avant de le restituer.

  5. Détection de collision

    Ce n'est pas toujours trivial. Habituellement, vous pouvez interroger la scène pour savoir quel objet se trouve à un certain point de l'espace ou quels objets un rayon intersectera. De cette façon, vous pouvez créer un rayon de votre joueur dans la direction du mouvement et demander au responsable de la scène quel est le premier objet que le rayon intersecte. Vous pouvez ensuite choisir de déplacer le joueur vers la nouvelle position, de le déplacer légèrement (pour le placer à côté de l'objet en collision) ou de ne pas le déplacer du tout. Assurez-vous que ces requêtes sont gérées par des classes séparées. Ils doivent demander au SceneManager une liste de SceneNodes, mais la tâche suivante consiste à déterminer si ce SceneNode recouvre un point dans l'espace ou se croise avec un rayon. N'oubliez pas que SceneManager crée et stocke uniquement des nœuds.

Alors, quel est le joueur et quel est le monde?

Le lecteur peut être une classe contenant un SceneNode, qui contient à son tour le modèle à restituer. Vous déplacez le lecteur en modifiant la position du nœud de la scène. Le monde est simplement une instance du SceneManager. Il contient tous les objets (via SceneNodes). Vous gérez la détection de collision en effectuant des requêtes sur l'état actuel de la scène.

Ceci est loin d’être une description complète ou précise de ce qui se passe dans la plupart des moteurs, mais cela devrait vous aider à comprendre les principes fondamentaux et à comprendre pourquoi il est important de respecter les principes de la POO soulignés par SOLID . Ne vous résignez pas à l'idée qu'il est trop difficile de restructurer votre code ou que cela ne vous aidera pas vraiment. Vous gagnerez beaucoup plus à l'avenir en concevant avec soin votre code.

locus racine
la source
+1 - Je me suis trouvé en train de construire mes systèmes de jeu et je trouve cela assez flexible.
Cypher
+1, bonne réponse. Plus concret et précis que le mien.
jcora
+1, j'ai tellement appris de cette réponse et elle a même eu une fin inspirante. Merci @rootlocus
joslinm
16

Pourquoi voudriez-vous éviter cela? Les dépendances circulaires doivent être évitées si vous souhaitez créer une classe réutilisable. Mais le joueur n'est pas une classe qui doit être réutilisable. Voulez-vous jamais utiliser le Player sans monde? Probablement pas.

N'oubliez pas que les classes ne sont rien d'autre que des ensembles de fonctionnalités. La question est simplement de savoir comment diviser la fonctionnalité. Faites ce que vous devez faire. Si vous avez besoin d’une décadence circulaire, qu’il en soit ainsi. (Il en va de même pour toutes les fonctionnalités de POO, soit dit en passant. Codez les choses de manière à ce qu'elles servent un but, ne suivez pas les paradigmes à l'aveuglette.)

Éditer
Ok, pour répondre à la question: vous pouvez éviter que le joueur ait besoin de connaître le monde pour les contrôles de collision en utilisant des rappels:

World::checkForCollisions()
{
  [...]
  foreach(entityA in entityList)
    foreach(entityB in entityList)
      if([... entityA and entityB have collided ...])
         entityA.onCollision(entityB);
}

Player::onCollision(other)
{
  [... react on the collision ...]
}

Le monde que vous avez décrit dans la question peut être manipulé par le monde entier si vous exposez la vitesse des entités:

World::calculatePhysics()
{ 
  foreach(entityA in entityList)
    foreach(entityB in entityList)
    {
      [... move entityA according to its velocity as far as possible ...]
      if([... entityA has collided with the world ...])
         entityA.onWorldCollision();
      [... calculate the movement of entityB in order to know if A has collided with B ...]
      if([... entityA and entityB have collided ...])
         entityA.onCollision(entityB);
    }
}

Cependant, notez que vous aurez probablement besoin d’une dépendance du monde tôt ou tard, c’est-à-dire chaque fois que vous avez besoin de fonctionnalités du Monde: vous voulez savoir où se trouve l’ennemi le plus proche? Vous voulez savoir à quelle distance se trouve le prochain rebord? C'est la dépendance.

API-Beast
la source
4
+1 La dépendance circulaire n'est pas vraiment un problème ici. À ce stade, il n'y a aucune raison de s'inquiéter de cela. Si le jeu prend de l'ampleur et que le code mûrit, il sera probablement judicieux de refactoriser de toute façon les classes Player et World des sous-classes, de disposer d'un système à base de composants approprié, de classes de gestion des entrées, peut-être d'un rendu, etc. un début, pas de problème.
Laurent Couvidou
4
-1, ce n'est certainement pas la seule raison pour ne pas introduire de dépendances circulaires. En ne les introduisant pas, vous facilitez l’extension et la modification de votre système.
jcora
4
@Bane Vous ne pouvez rien coder sans cette colle. La différence réside dans la quantité d'indirection que vous ajoutez. Si vous avez les classes Jeu -> Monde -> Entité ou si vous avez les classes Jeu -> Monde, SoundManager, InputManager, PhysicsEngine, ComponentManager. Cela rend les choses moins lisibles à cause de la surcharge (syntaxique) et de la complexité implicite qui en découle. Et à un moment donné, vous aurez besoin des composants pour interagir les uns avec les autres. Et c'est le point où une classe de colle rend les choses plus faciles que tout ce qui est divisé en plusieurs classes.
API-Beast
3
Non, vous déplacez les poteaux. Bien sûr, quelque chose doit appeler render(World). Le débat porte sur le point de savoir si tout le code doit être rangé dans une classe ou si le code doit être divisé en unités logiques et fonctionnelles, qui sont ensuite plus faciles à gérer, à étendre et à gérer. BTW, bonne chance à réutiliser les gestionnaires de composants, les moteurs physiques et les gestionnaires d’intrants, tous intelligemment indifférenciés et complètement couplés.
jcora
1
@Bane Il existe d'autres moyens de diviser les choses en blocs logiques que l'introduction de nouvelles classes, en passant. Vous pouvez également ajouter de nouvelles fonctions ou diviser vos fichiers en plusieurs sections séparées par des blocs de commentaires. Garder les choses simples ne signifie pas que le code sera un gâchis.
API-Beast
13

Votre conception actuelle semble aller à l’encontre du premier principe de la conception SOLID .

Ce premier principe, appelé "principe de responsabilité unique", est généralement un bon guide à suivre pour ne pas créer d'objets monolithiques à tout faire qui feront toujours mal à votre conception.

Concrètement, votre Worldobjet est responsable à la fois de la mise à jour et du maintien de l’état du jeu, ainsi que de son dessin.

Que se passe-t-il si votre code de rendu change / doit changer? Pourquoi devriez-vous avoir à mettre à jour les deux classes qui n'ont en réalité rien à voir avec le rendu? Comme Liosan l’a déjà dit, vous devriez avoir un Renderer.


Maintenant, pour répondre à votre question actuelle ...

Il y a plusieurs façons de le faire, et ce n'est qu'un moyen de découpler:

  1. Le monde ne sait pas ce qu'est un joueur.
    • Il a cependant une liste de Objects dans laquelle se trouve le joueur, mais cela ne dépend pas de la classe du joueur (utilisez l'héritage pour y parvenir).
  2. Le joueur est mis à jour par certains InputManager.
  3. Le monde gère la détection des mouvements et des collisions, applique les modifications physiques appropriées et envoie des mises à jour aux objets.
    • Par exemple, si un objet A et un objet B entrent en collision, le monde les informera et ils pourront le gérer eux-mêmes.
    • Le monde gérerait toujours la physique (si votre conception est comme ça).
    • Ensuite, les deux objets pourraient voir si la collision les intéresse ou non. Par exemple, si l'objet A était le joueur et que l'objet B était une pointe, le joueur pouvait alors s'endommager.
    • Cela peut être résolu d'autres manières, cependant.
  4. Le Rendererdessine tous les objets.
jcora
la source
Vous dites que le monde ne sait pas ce qu'est un joueur, mais qu'il gère la détection de collision pouvant nécessiter de connaître les propriétés du joueur, s'il s'agit de l'un des objets en collision.
Markus von Broady
Héritage, le monde doit être conscient de certains types d'objets, qui peuvent être décrits de manière générale. Le problème n’est pas que le monde a juste une référence au joueur, mais qu’il pourrait en dépendre en tant que classe (c’est-à-dire utiliser des champs comme ceux healthque seule cette instance Playera).
jcora
Ah, vous voulez dire que le monde n’a aucune référence au joueur, il a juste un tableau d’objets implémentant l’interface ICollidable, avec le joueur si nécessaire.
Markus von Broady
2
+1 bonne réponse. Mais: "s'il vous plaît, ignorez toutes les personnes qui disent qu'une bonne conception logicielle n'est pas importante". Commun. Personne n'a dit ça.
Laurent Couvidou
2
Édité! De toute façon, cela semblait inutile ...
jcora
1

Le joueur devrait demander au monde des choses comme la détection de collision. Le moyen d'éviter la dépendance circulaire est de ne pas laisser le monde avoir une dépendance sur Player. Le Monde a besoin de savoir où il se dessine lui-même: vous voulez probablement que ce résumé soit plus éloigné, peut-être avec une référence à un objet Camera qui peut à son tour contenir une référence à une entité à suivre.

Ce que vous voulez éviter en termes de références circulaires n’est pas tant de garder des références les unes aux autres, mais plutôt de se référer explicitement les uns aux autres dans le code.

Tom Johnson
la source
1

Chaque fois que deux types d'objets différents peuvent se demander. Ils dépendront les uns des autres car ils doivent avoir une référence à l'autre pour appeler ses méthodes.

Vous pouvez éviter les dépendances circulaires en demandant au monde de demander au joueur, mais ce dernier ne peut pas demander au monde, ou inversement. De cette façon, le monde fait référence aux joueurs mais les joueurs n'ont pas besoin de référence au monde. Ou vice versa. Mais cela ne résoudra pas le problème, car le Monde aurait besoin de demander aux joueurs s'ils ont quelque chose à demander et de le leur dire lors du prochain appel ...

Donc, vous ne pouvez pas vraiment contourner ce "problème" et je pense qu'il n'y a pas besoin de s'inquiéter à ce sujet. Gardez la conception simple et bête aussi longtemps que vous le pouvez.

Calmarius
la source
0

En supprimant les détails sur le joueur et le monde, vous avez un cas simple de ne pas vouloir introduire une dépendance circulaire entre deux objets (ce qui, selon votre langue, n'a peut-être même pas d'importance, voir le lien dans le commentaire de Fuhrmanator). Il existe au moins deux solutions structurelles très simples qui s'appliqueraient à ce problème et à des problèmes similaires:

1) Introduisez le motif singleton dans votre classe mondiale . Cela permettra au joueur (et à tout autre objet) de trouver facilement l'objet du monde sans recherches coûteuses ou liens tenus en permanence. L'essentiel de ce modèle est simplement que la classe a une référence statique à la seule instance de cette classe, qui est définie sur l'instanciation de l'objet et effacée lors de sa suppression.

En fonction de votre langage de développement et de la complexité souhaitée, vous pouvez facilement l'implémenter en tant que super-classe ou interface et le réutiliser pour de nombreuses classes majeures dont vous ne vous attendez pas à avoir plusieurs dans votre projet.

2) Si la langue dans laquelle vous développez le prend en charge (beaucoup le font), utilisez une référence faible . C’est une référence qui n’affecte pas les tâches telles que la récupération de place. Il est utile exactement dans ces cas, veillez simplement à ne pas présumer que l'objet que vous référencez toujours existe toujours.

Dans votre cas particulier, votre (vos) joueur (s) pourrait avoir une faible référence au monde. L'avantage de cela (comme avec le singleton) est que vous n'avez pas besoin de rechercher l'objet monde d'une manière ou d'une autre, ni d'avoir une référence permanente qui gênera les processus affectés par les références circulaires telles que le garbage collection.

FlintZA
la source
0

Comme les autres l'ont dit, je pense que vous Worldfaites une chose de trop: essayer à la fois de contenir le jeu Map(qui devrait être une entité distincte) et d' être Rendereren même temps.

Créez donc un nouvel objet (appelé GameMapéventuellement) et stockez-y les données de niveau cartographique. Écrivez-y des fonctions qui interagissent avec la carte actuelle.

Ensuite, vous avez également besoin d'un Rendererobjet. Vous pouvez faire de cet Rendererobjet la chose qui contient GameMap et Player(ainsi que Enemies), et les dessine également.

bobobobo
la source
-6

Vous pouvez éviter les dépendances circulaires en n’ajoutant pas les variables en tant que membres. Utilisez une fonction statique CurrentWorld () pour le lecteur ou quelque chose comme ça. N'inventez pas déjà une interface différente de celle implémentée dans World, c'est totalement inutile.

Il est également possible de détruire la référence avant / tout en détruisant l’objet joueur pour arrêter efficacement les problèmes causés par les références circulaires.

serpent5
la source
1
Je suis d'accord. OOP est trop surestimé. Les tutoriels et la formation passent rapidement à OO après avoir appris les bases du flux de contrôle. Les programmes OO sont généralement plus lents que le code de procédure, car il y a une bureaucratie entre vos objets et que vous avez beaucoup d'accès par pointeur, ce qui provoque une surcharge de mémoire cache. Votre jeu fonctionne mais très lentement. Les jeux réels, très rapides et riches en fonctionnalités, qui utilisent des baies globales simples et des fonctions optimisées et optimisées à la main pour éviter tout manque de mémoire cache. Ce qui peut décupler les performances.
Calmarius