Comment les objets de jeu doivent-ils se connaître?

18

J'ai du mal à trouver un moyen d'organiser les objets du jeu pour qu'ils soient polymorphes mais en même temps pas polymorphes.

Voici un exemple: en supposant que nous voulons tous nos objets pour update()et draw(). Pour ce faire, nous devons définir une classe de base GameObjectqui a ces deux méthodes pures virtuelles et laisse le polymorphisme entrer en jeu:

class World {
private:
    std::vector<GameObject*> objects;
public:
    // ...
    update() {
        for (auto& o : objects) o->update();
        for (auto& o : objects) o->draw(window);
    }
};

La méthode de mise à jour est censée prendre soin de tout état que l'objet de classe spécifique doit mettre à jour. Le fait est que chaque objet doit connaître le monde qui les entoure. Par exemple:

  • Une mine doit savoir si quelqu'un entre en collision avec elle
  • Un soldat doit savoir si le soldat d'une autre équipe est à proximité
  • Un zombie devrait savoir où se trouve le cerveau le plus proche, dans un rayon,

Pour les interactions passives (comme la première), je pensais que la détection de collision pouvait déléguer quoi faire dans des cas spécifiques de collisions à l'objet lui-même avec un on_collide(GameObject*).

La plupart des autres informations (comme les deux autres exemples) pourraient simplement être interrogées par le monde du jeu transmis à la updateméthode. Maintenant, le monde ne distingue pas les objets en fonction de leur type (il stocke tous les objets dans un seul conteneur polymorphe), donc ce qu'il renverra avec un idéal world.entities_in(center, radius)est un conteneur de GameObject*. Mais bien sûr, le soldat ne veut pas attaquer d'autres soldats de son équipe et un zombie ne fait pas cas d'autres zombies. Nous devons donc distinguer le comportement. Une solution pourrait être la suivante:

void TeamASoldier::update(const World& world) {
    auto list = world.entities_in(position, eye_sight);
    for (const auto& e : list)
        if (auto enemy = dynamic_cast<TeamBSoldier*>(e))
            // shoot towards enemy
}

void Zombie::update(const World& world) {
    auto list = world.entities_in(position, eye_sight);
    for (const auto& e : list)
        if (auto enemy = dynamic_cast<Human*>(e))
            // go and eat brain
}

mais bien sûr, le nombre dynamic_cast<>par image peut être horriblement élevé, et nous savons tous à quel point la lenteur dynamic_castpeut être. Le même problème s'applique également au on_collide(GameObject*)délégué dont nous avons discuté précédemment.

Alors, quel est le moyen idéal d'organiser le code afin que les objets puissent être conscients d'autres objets et pouvoir les ignorer ou entreprendre des actions en fonction de leur type?

Chaussure
la source
1
Je pense que vous cherchez une implémentation personnalisée C ++ RTTI polyvalente. Néanmoins, votre question ne semble pas concerner uniquement les mécanismes RTTI judicieux. Les choses que vous demandez sont requises par presque tous les middleware que le jeu utilisera (système d'animation, physique pour n'en nommer que quelques-uns). Selon la liste des requêtes prises en charge, vous pouvez vous frayer un chemin autour de RTTI en utilisant des ID et des index dans les tableaux, ou vous finirez par concevoir un protocole complet pour prendre en charge des alternatives moins chères à dynamic_cast et type_info.
teodron
Je déconseille d'utiliser le système de type pour la logique du jeu. Par exemple, au lieu de dépendre du résultat de dynamic_cast<Human*>, implémentez quelque chose comme a bool GameObject::IsHuman(), qui retourne falsepar défaut mais est remplacé pour revenir truedans la Humanclasse.
congusbongus
un extra: vous n'envoyez presque jamais une tonne d'objets à l'autre entité qui pourrait être intéressée par eux. C'est une optimisation évidente que vous devrez vraiment considérer.
teodron
@congusbongus L'utilisation d'une vtable et de IsAremplacements personnalisés s'est avérée être légèrement meilleure que la diffusion dynamique dans la pratique pour moi. La meilleure chose à faire est que l'utilisateur ait, dans la mesure du possible, des listes de données triées au lieu d'itérer aveuglément sur l'ensemble du pool d'entités.
teodron
4
@Jefffrey: idéalement, vous n'écrivez pas de code spécifique au type. Vous écrivez du code spécifique à l' interface («interface» au sens général). Votre logique pour un TeamASoldieret TeamBSoldierest vraiment identique - tiré sur n'importe qui dans l'autre équipe. Tout ce dont elle a besoin pour d'autres entités est une GetTeam()méthode à son plus spécifique et, par l'exemple de congusbongus, qui peut être encore plus abstraite en une IsEnemyOf(this)sorte d'interface. Le code n'a pas besoin de se soucier des classifications taxonomiques des soldats, des zombies, des joueurs, etc. Concentrez-vous sur l'interaction, pas sur les types.
Sean Middleditch

Réponses:

11

Au lieu de mettre en œuvre la prise de décision de chaque entité en soi, vous pouvez également opter pour le modèle de contrôleur. Vous auriez des classes de contrôleurs centraux qui connaissent tous les objets (qui leur importent) et contrôlent leur comportement.

Un MovementController gérerait le mouvement de tous les objets qui peuvent se déplacer (faire la recherche d'itinéraire, mettre à jour les positions en fonction des vecteurs de mouvement actuels).

Un MineBehaviorController vérifierait toutes les mines et tous les soldats et ordonnerait à une mine d'exploser lorsqu'un soldat s'approche trop.

Un ZombieBehaviorController vérifierait tous les zombies et les soldats à proximité, choisirait la meilleure cible pour chaque zombie et lui ordonnerait de s'y déplacer et de l'attaquer (le mouvement lui-même est géré par le MovementController).

Un SoldierBehaviorController analyserait toute la situation et proposerait ensuite des instructions tactiques pour tous les soldats (vous vous déplacez là-bas, vous tirez dessus, vous guérissez ce type ...). L'exécution réelle de ces commandes de niveau supérieur serait également gérée par des contrôleurs de niveau inférieur. Lorsque vous y mettez des efforts, vous pouvez rendre l'IA capable de prendre des décisions coopératives assez intelligentes.

Philipp
la source
1
Il s'agit probablement aussi du "système" qui gère la logique de certains types de composants dans une architecture Entité-Composant.
teodron
Cela ressemble à une solution de style C. Les composants sont regroupés en std::maps et les entités ne sont que des ID et nous devons ensuite créer une sorte de système de type (peut-être avec un composant tag, car le moteur de rendu devra savoir quoi dessiner); et si nous ne voulons pas le faire, nous aurons besoin d'un composant de dessin: mais il a besoin du composant de position pour savoir où se dessiner, donc nous créons des dépendances entre les composants que nous résolvons avec un système de messagerie super complexe. Est-ce bien ce que vous proposez?
Chaussure
1
@Jefffrey "Cela ressemble à une solution de style C" - même si cela serait vrai, pourquoi serait-ce nécessairement une mauvaise chose? Les autres préoccupations peuvent être valables, mais il existe des solutions pour elles. Malheureusement, un commentaire est trop court pour aborder chacun d'eux correctement.
Philipp
1
@Jefffrey Utiliser l'approche où les composants eux-mêmes n'ont pas de logique et les "systèmes" sont responsables de gérer toute la logique ne crée pas de dépendances entre les composants et ne nécessite pas de système de messagerie super complexe (au moins, pas aussi complexe) . Voir par exemple: gamadu.com/artemis/tutorial.html
1

Tout d'abord, essayez d'implémenter des fonctionnalités afin que les objets restent indépendants les uns des autres, dans la mesure du possible. Surtout, vous voulez le faire pour le multi-threading. Dans votre premier exemple de code, l'ensemble de tous les objets pourrait être divisé en ensembles correspondant au nombre de cœurs de processeur et être mis à jour très efficacement.

Mais comme vous l'avez dit, une interaction avec d'autres objets est nécessaire pour certaines fonctionnalités. Cela signifie que l'état de tous les objets doit être synchronisé à certains points. En d'autres termes, votre application doit attendre que toutes les tâches parallèles se terminent en premier, puis appliquer des calculs impliquant une interaction. Il est bon de réduire le nombre de ces points de synchronisation, car ils impliquent toujours que certains threads doivent attendre que d'autres se terminent.

Par conséquent, je suggère de mettre en mémoire tampon ces informations sur les objets qui sont nécessaires à l'intérieur d'autres objets. Avec un tel tampon global, vous pouvez mettre à jour tous vos objets indépendamment les uns des autres, mais uniquement en fonction d'eux-mêmes et du tampon global, qui est à la fois plus rapide et plus facile à entretenir. À un pas de temps fixe, par exemple après chaque image, mettez à jour le tampon avec l'état actuel des objets.

Donc, ce que vous faites une fois par image, 1. mettez en mémoire tampon l’état actuel des objets, 2. mettez à jour tous les objets en fonction d’eux-mêmes et du tampon, 3. dessinez vos objets et recommencez avec le renouvellement du tampon.

danijar
la source
1

Utilisez un système basé sur les composants, dans lequel vous avez un GameObject barebones qui contient 1 ou plusieurs composants qui définissent leur comportement.

Par exemple, si un objet est censé se déplacer à gauche et à droite tout le temps (une plate-forme), vous pouvez créer un tel composant et le joindre à un GameObject.

Supposons maintenant qu'un objet de jeu soit censé tourner lentement tout le temps, vous pouvez créer un composant distinct qui fait exactement cela et le fixer au GameObject.

Et si vous vouliez avoir une plate-forme mobile qui tournait également, dans une hiérarchie de classe traditionnelle qui devient difficile à faire sans dupliquer le code.

La beauté de ce système, c'est qu'au lieu d'avoir une classe Rotatable ou MovingPlatform, vous attachez ces deux composants au GameObject et vous avez maintenant une MovingPlatform qui tourne automatiquement.

Tous les composants ont une propriété, "requiresUpdate" qui, bien que vraie, GameObject appellera la méthode "update" sur ledit composant. Par exemple, supposons que vous ayez un composant déplaçable, ce composant en survolant la souris (s'il se trouvait sur le GameObject) peut définir "requiresUpdate" sur true, puis sur mouse-up le définir sur false. Lui permettant de suivre la souris uniquement lorsque la souris est en panne.

L'un des développeurs de Tony Hawk Pro Skater a écrit de facto dessus, et cela vaut la peine d'être lu: http://cowboyprogramming.com/2007/01/05/evolve-your-heirachy/

onedayitwillmake
la source
1

Privilégiez la composition à l'héritage.

Mon conseil le plus fort en dehors de cela serait: Ne vous laissez pas entraîner dans la mentalité de "Je veux que cela soit suprêmement flexible". La flexibilité est excellente, mais rappelez-vous qu'à un certain niveau, dans tout système fini tel qu'un jeu, il y a des parties atomiques qui sont utilisées pour construire le tout. D'une manière ou d'une autre, votre traitement repose sur ces types atomiques prédéfinis. En d'autres termes, la prise en charge de "tout" type de données (si cela était possible) ne vous aiderait pas à long terme, si vous n'avez pas de code pour les traiter. Fondamentalement, tout le code doit analyser / traiter les données en fonction de spécifications connues ... ce qui signifie un ensemble prédéfini de types. Quelle est la taille de cet ensemble? Dépend de vous.

Cet article offre un aperçu du principe de la composition sur l'héritage dans le développement de jeux via une architecture à composants d'entité robuste et performante.

En créant des entités à partir de sous-ensembles (différents) de certains sur-ensembles de composants prédéfinis, vous offrez à vos IA des façons concrètes et fragmentaires de comprendre le monde et les acteurs qui les entourent, en lisant les états de ces composants.

Ingénieur
la source
1

Personnellement, je recommande de garder la fonction draw hors de la classe Object elle-même. Je recommande même de garder l'emplacement / les coordonnées des objets hors de l'objet lui-même.

Cette méthode draw () va traiter de l'API de rendu de bas niveau d'OpenGL, OpenGL ES, Direct3D, votre couche d'encapsulation sur ces API ou une API de moteurs. Il se peut que vous deviez échanger entre (Si vous vouliez prendre en charge OpenGL + OpenGL ES + Direct3D par exemple.

Ce GameObject devrait simplement contenir les informations de base sur son apparence visuelle, comme un maillage ou peut-être un ensemble plus grand, y compris des entrées de shader, l'état d'animation, etc.

Vous voudrez également un pipeline graphique flexible. Que se passe-t-il si vous souhaitez commander des objets en fonction de leur distance à la caméra. Ou leur type de matériau. Que se passe-t-il si vous souhaitez dessiner un objet «sélectionné» d'une couleur différente. Que se passe-t-il si au lieu de restituer aussi soo que vous appelez une fonction de dessin sur un objet, il la place plutôt dans une liste de commandes d'actions à effectuer pour le rendu (peut être nécessaire pour le thread). Vous pouvez faire ce genre de chose avec l'autre système mais c'est un PITA.

Ce que je recommande, au lieu de dessiner directement, vous liez tous les objets que vous souhaitez à une autre structure de données. Cette liaison n'a vraiment besoin que d'une référence à l'emplacement des objets et aux informations de rendu.

Vos niveaux / morceaux / zones / cartes / hubs / wholeworld / quoi que ce soit reçoivent un index spatial, cela contient les objets et les renvoie en fonction de requêtes de coordonnées et pourrait être une simple liste ou quelque chose comme un Octree. Cela pourrait également être un wrapper pour quelque chose implémenté par un moteur physique tiers en tant que scène physique. Il vous permet de faire des choses comme "Interroger tous les objets qui sont dans la vue de la caméra avec une zone supplémentaire autour d'eux", ou pour des jeux plus simples où vous pouvez simplement tout rendre pour récupérer la liste entière.

Les index spatiaux ne doivent pas contenir les informations de positionnement réelles. Ils fonctionnent en stockant des objets dans des structures arborescentes par rapport à l'emplacement d'autres objets. Ils peuvent être considérés comme une sorte de cache avec perte permettant une recherche rapide d'un objet en fonction de sa position. Il n'est pas vraiment nécessaire de dupliquer vos coordonnées X, Y, Z réelles. Cela dit, vous pouvez si vous le souhaitez

En fait, vos objets de jeu n'ont même pas besoin de contenir leurs propres informations de localisation. Par exemple, un objet qui n'a pas été placé dans un niveau ne doit pas avoir de coordonnées x, y, z, cela n'a aucun sens. Vous pouvez le contenir dans l'index spécial. Si vous devez rechercher les coordonnées de l'objet en fonction de sa référence réelle, vous souhaiterez avoir une liaison entre l'objet et le graphique de la scène (les graphiques de la scène sont destinés au retour d'objets basés sur les coordonnées mais sont lents à renvoyer les coordonnées basées sur les objets) .

Lorsque vous ajoutez un objet à un niveau. Il fera ce qui suit:

1) Créer une structure de localisation:

 class Location { 
     float x, y, z; // Or a special Coordinates class, or a vec3 or whatever.
     SpacialIndex& spacialIndex; // Note this could be the area/level/map/whatever here
 };

Cela pourrait également être une référence à un objet dans un moteur physique tiers. Ou il peut s'agir de coordonnées décalées avec une référence à un autre emplacement (pour une caméra de suivi ou un objet ou un exemple attaché). Avec le polymorphisme, cela peut être selon qu'il s'agit d'un objet statique ou dynamique. En gardant une référence à l'index spatial ici lorsque les coordonnées sont mises à jour, l'index spatial peut aussi l'être.

Si vous êtes préoccupé par l'allocation dynamique de mémoire, utilisez un pool de mémoire.

2) Une liaison / liaison entre votre objet, son emplacement et le graphique de la scène.

typedef std::pair<Object, Location> SpacialBinding.

3) La liaison est ajoutée à l'index spatial à l'intérieur du niveau au point approprié.

Lorsque vous vous préparez à rendre.

1) Obtenez la caméra (ce ne sera qu'un autre objet, sauf que son emplacement suivra le personnage du joueur et que votre moteur de rendu y aura une référence spéciale, en fait c'est tout ce dont il a vraiment besoin).

2) Obtenez le SpacialBinding de la caméra.

3) Obtenez l'index spatial de la liaison.

4) Recherchez les objets qui sont (éventuellement) visibles par la caméra.

5A) Vous devez faire traiter les informations visuelles. Textures téléchargées sur le GPU et ainsi de suite. Il serait préférable de le faire à l'avance (comme au niveau de la charge), mais peut-être de le faire lors de l'exécution (pour un monde ouvert, vous pouvez charger des choses lorsque vous approchez d'un morceau, mais cela devrait toujours être fait à l'avance).

5B) Créez éventuellement un arbre de rendu mis en cache, si vous souhaitez trier la profondeur / le matériau ou suivre les objets à proximité, ils pourraient être visibles ultérieurement. Sinon, vous pouvez simplement interroger l'index spatial à chaque fois que cela dépendra de vos exigences de jeu / performances.

Votre moteur de rendu aura probablement besoin d'un objet RenderBinding qui fera le lien entre l'objet, les coordonnées

class RenderBinding {
    Object& object;
    RenderInformation& renderInfo;
    Location& location // This could just be a coordinates class.
}

Ensuite, lorsque vous effectuez le rendu, exécutez simplement la liste.

J'ai utilisé des références ci-dessus, mais il peut s'agir de pointeurs intelligents, de pointeurs bruts, de poignées d'objets, etc.

ÉDITER:

class Game {
    weak_ptr<Camera> camera;
    Level level1;

    void init() {
        Camera camera(75.0_deg, 1.025_ratio, 1000_meters);
        auto template_player = loadObject("Player.json")
        auto player = level1.addObject(move(player), Position(1.0, 2.0, 3.0));
        level1.addObject(move(camera), getRelativePosition(player));

        auto template_bad_guy = loadObject("BadGuy.json")
        level1.addObject(template_bad_guy, {10, 10, 20});
        level1.addObject(template_bad_guy, {10, 30, 20});
        level1.addObject(move(template_bad_guy), {50, 30, 20});
    }

    void render() {
        camera->getFrustrum();
        auto level = camera->getLocation()->getLevel();
        auto object = level.getVisible(camera);
        for(object : objects) {
            render(objects);
        }
    }

    void render(Object& object) {
        auto ri = object.getRenderInfo();
        renderVBO(ri.getVBO());
    }

    Object loadObject(string file) {
        Object object;
        // Load file from disk and set the properties
        // Upload mesh data, textures to GPU. Load shaders whatever.
        object.setHitPoints(// values from file);
        object.setRenderInfo(// data from 3D api);
    }
}

class Level {
    Octree octree;
    vector<ObjectPtr> objects;
    // NOTE: If your level is mesh based there might also be a BSP here. Or a hightmap for an openworld
    // There could also be a physics scene here.
    ObjectPtr addObject(Object&& object, Position& pos) {
        Location location(pos, level, object);
        objects.emplace_back(object);
        object->setLocation(location)
        return octree.addObject(location);
    }
    vector<Object> getVisible(Camera& camera) {
        auto f = camera.getFtrustrum();
        return octree.getObjectsInFrustrum(f);
    }
    void updatePosition(LocationPtr l) {
        octree->updatePosition(l);
    }
}

class Octree {
    OctreeNode root_node;
    ObjectPtr add(Location&& object) {
        return root_node.add(location);
    }
    vector<ObjectPtr> getObjectsInRadius(const vec3& position, const float& radius) { // pass to root_node };
    vector<ObjectPtr> getObjectsinFrustrum(const FrustrumShape frustrum;) {//...}
    void updatePosition(LocationPtr* l) {
        // Walk up from l.octree_node until you reach the new place
        // Check if objects are colliding
        // l.object.CollidedWith(other)
    }
}

class Object {
    Location location;
    RenderInfo render_info;
    Properties object_props;
    Position getPosition() { return getLocation().position; }
    Location getLocation() { return location; }
    void collidedWith(ObjectPtr other) {
        // if other.isPickup() && object.needs(other.pickupType()) pick it up, play sound whatever
    }
}

class Location {
    Position position;
    LevelPtr level;
    ObjectPtr object;
    OctreeNote octree_node;
    setPosition(Position position) {
        position = position;
        level.updatePosition(this);
    }
}

class Position {
    vec3 coordinates;
    vec3 rotation;
}

class RenderInfo {
    AnimationState anim;
}
class RenderInfo_OpenGL : public RenderInfo {
    GLuint vbo_object;
    GLuint texture_object;
    GLuint shader_object;
}

class Camera: public Object {
    Degrees fov;
    Ratio aspect;
    Meters draw_distance;
    Frustrum getFrustrum() {
        // Use above to make a skewed frustum box
    }
}

Quant à rendre les choses «conscientes» les unes des autres. C'est la détection de collision. Il serait probablement mis en œuvre dans l'Octree. Vous devrez fournir un rappel dans votre objet principal. Ce truc est mieux géré par un moteur physique approprié tel que Bullet. Dans ce cas, remplacez simplement Octree par PhysicsScene et Position par un lien vers quelque chose comme CollisionMesh.getPosition ().

David C. Bishop
la source
Wow, ça a l'air très bien. Je pense que j'ai saisi l'idée de base, mais sans plus d'exemple, je ne peux pas vraiment avoir une vue extérieure de cela. Avez-vous d'autres références ou exemples vivants à ce sujet? (Je vais continuer à lire cette réponse pendant un certain temps en attendant).
Chaussure
Je n'ai pas vraiment d'exemples, c'est juste ce que j'ai l'intention de faire quand j'aurai le temps. J'ajouterai quelques autres classes globales et voir si cela aide, il y a ceci et cela . c'est plus sur les classes d'objets que sur la façon dont elles sont liées ou sur le rendu. Comme je ne l'ai pas implémenté moi-même, il peut y avoir des pièges, des bits qui nécessitent d'être travaillés ou des performances, mais je pense que la structure globale est correcte.
David C. Bishop