Question d'architecture / conception de jeu - construire un moteur efficace tout en évitant les instances globales (jeu C ++)

28

J'avais une question sur l'architecture du jeu: quelle est la meilleure façon de faire communiquer les différents composants entre eux?

Je m'excuse vraiment si cette question a déjà été posée un million de fois, mais je ne trouve rien avec exactement le type d'informations que je recherche.

J'ai essayé de créer un jeu à partir de zéro (C ++ si cela importe) et j'ai observé certains logiciels de jeu open source pour l'inspiration (Super Maryo Chronicles, OpenTTD et autres). Je remarque que beaucoup de ces conceptions de jeux utilisent des instances globales et / ou des singletons partout (pour des choses comme les files d'attente de rendu, les gestionnaires d'entités, les gestionnaires vidéo, etc.). J'essaie d'éviter les instances globales et les singletons et de construire un moteur aussi peu couplé que possible, mais je rencontre certains obstacles dus à mon inexpérience dans la conception efficace. (Une partie de la motivation de ce projet est de résoudre ce problème :))

J'ai construit un design où j'ai un GameCoreobjet principal qui a des membres qui sont analogues aux instances globales que je vois dans d'autres projets (c'est-à-dire qu'il a un gestionnaire d'entrée, un gestionnaire vidéo, un GameStageobjet qui contrôle toutes les entités et le jeu) quelle que soit l'étape actuellement chargée, etc.). Le problème est que, puisque tout est centralisé dans l' GameCoreobjet, je n'ai pas de moyen facile pour différents composants de communiquer entre eux.

En regardant Super Maryo Chronicles, par exemple, chaque fois qu'un composant du jeu doit communiquer avec un autre composant (c'est-à-dire qu'un objet ennemi veut s'ajouter à la file d'attente de rendu à dessiner dans la phase de rendu), il parle simplement au instance globale.

Pour moi, je dois demander à mes objets de jeu de transmettre les informations pertinentes à l' GameCoreobjet, afin que l' GameCoreobjet puisse transmettre ces informations aux autres composants du système qui en ont besoin (par exemple: pour la situation ci-dessus, chaque objet ennemi transmettraient leurs informations de rendu à l' GameStageobjet, qui les collecterait toutes et les retransmettrait GameCore, qui à leur tour les transmettraient au gestionnaire vidéo pour le rendu). Cela ressemble à un design vraiment horrible tel quel, et j'essayais de penser à une résolution. Mes réflexions sur les designs possibles:

  1. Instances globales (conception de Super Maryo Chronicles, OpenTTD, etc.)
  2. Faire en sorte que l' GameCoreobjet agisse comme un intermédiaire par lequel tous les objets communiquent (conception actuelle décrite ci-dessus)
  3. Donnez des pointeurs de composants à tous les autres composants avec lesquels ils devront parler (c.-à-d., Dans l'exemple Maryo ci-dessus, la classe ennemie aurait un pointeur sur l'objet vidéo avec lequel elle doit parler)
  4. Diviser le jeu en sous-systèmes - Par exemple, avoir des objets de gestionnaire dans l' GameCoreobjet qui gèrent la communication entre les objets de leur sous-système
  5. (Autres options? ....)

J'imagine que l'option 4 ci-dessus est la meilleure solution, mais j'ai du mal à la concevoir ... peut-être parce que j'ai pensé en termes de conceptions que j'ai vues utiliser des globales. J'ai l'impression de prendre le même problème qui existe dans ma conception actuelle et de le reproduire dans chaque sous-système, juste à une échelle plus petite. Par exemple, l' GameStageobjet décrit ci-dessus est en quelque sorte une tentative, mais l' GameCoreobjet est toujours impliqué dans le processus.

Quelqu'un peut-il offrir des conseils de conception ici?

Merci!

Awesomania
la source
1
Je comprends votre instinct que les singletons ne sont pas un super design. D'après mon expérience, ils ont été le moyen le plus simple de gérer la communication entre les systèmes
Emmett Butler
4
Ajout en tant que commentaire car je ne sais pas si c'est une meilleure pratique. J'ai un GameManager central qui est composé de sous-systèmes tels que InputSystem, GraphicsSystem, etc. Chaque sous-système prend le GameManager comme paramètre dans le constructeur et stocke la référence à un membre privé de la classe. À ce stade, je peux me référer à tout autre système en y accédant via la référence GameManager.
Inisheer
J'ai changé les balises car cette question concerne le code, pas la conception de jeux.
Klaim
ce fil est un peu vieux, mais j'ai exactement le même problème. J'utilise OGRE et j'essaie d'utiliser la meilleure façon, à mon avis, l'option # 4 est la meilleure approche. J'ai construit quelque chose comme Advanced Ogre Framework, mais ce n'est pas très modulaire. Je pense que j'ai besoin d'une gestion d'entrée de sous-système qui n'obtient que les touches du clavier et les mouvements de la souris. Ce que je ne comprends pas, c'est comment créer un tel gestionnaire de "communication" entre les sous-systèmes?
Dominik2000
1
Salut @ Dominik2000, c'est un site de questions / réponses, pas un forum. Si vous avez une question, vous devez publier une question réelle et non une réponse à une question existante. Voir la FAQ pour plus de détails.
Josh

Réponses:

19

Quelque chose que nous utilisons dans nos jeux pour organiser nos données globales est le modèle de conception ServiceLocator . L'avantage de ce modèle par rapport au modèle Singleton est que l'implémentation de vos données globales peut changer pendant l'exécution de l'application. De plus, vos objets globaux peuvent également être modifiés pendant l'exécution. Un autre avantage est qu'il est plus facile de gérer l'ordre d'initialisation de vos objets globaux, ce qui est très important notamment en C ++.

par exemple (code C # qui peut être facilement traduit en C ++ ou Java)

Disons que vous avez une interface de rendu qui a des opérations communes pour le rendu des trucs.

public interface IRenderBackend
{
    void Draw();
}

Et que vous avez l'implémentation du backend de rendu par défaut

public class DefaultRenderBackend : IRenderBackend
{
    public void Draw()
    {
        //do default rendering stuff.
    }
}

Dans certaines conceptions, il semble légitime de pouvoir accéder au backend de rendu globalement. Dans le modèle Singleton , cela signifie que chaque implémentation IRenderBackend doit être implémentée en tant qu'instance globale unique. Mais l' utilisation du modèle ServiceLocator ne nécessite pas cela.

Voici comment:

public class ServiceLocator<T>
{
    private static T currGlobalInstance;

    public static T Service
    {
        get { return currGlobalInstance; }
        set { currGlobalInstance = value; }
    }
}

Pour pouvoir accéder à votre objet global, vous devez d'abord l'initialiser.

//somewhere during program initialization
ServiceLocator<IRenderBackend>.Service = new DefaultRenderBackend();

//somewhere else in the code
IRenderBackend currentRenderBackend = ServiceLocator<IRenderBackend>.Service;

Juste pour montrer comment les implémentations peuvent varier pendant l'exécution, disons que votre jeu a un mini-jeu où le rendu est isométrique et vous implémentez un IsometricRenderBackend .

public class IsometricRenderBackend : IRenderBackend
{
    void draw()
    {
        //do rendering using an isometric view
    }
}

Lorsque vous passez de l'état actuel à l'état de mini-jeu, il vous suffit de modifier le backend de rendu global fourni par le localisateur de services.

ServiceLocator<IRenderBackend>.Service = new IsometricRenderBackend();

Un autre avantage est que vous pouvez également utiliser des services nuls. Par exemple, si nous avions un service ISoundManager et que l'utilisateur voulait désactiver le son, nous pourrions simplement implémenter un NullSoundManager qui ne fait rien lorsque ses méthodes sont appelées, donc en définissant l' objet de service ServiceLocator sur un objet NullSoundManager que nous pourrions atteindre ce résultat avec peu de travail.

Pour résumer, il peut parfois être impossible d'éliminer les données globales, mais cela ne signifie pas que vous ne pouvez pas les organiser correctement et de manière orientée objet.

vdaras
la source
J'ai déjà étudié cela, mais je ne l'ai pas mis en œuvre dans aucune de mes conceptions. Cette fois, je prévois. Merci :)
Awesomania
3
@Erevis Donc, fondamentalement, vous décrivez une référence globale à un objet polymorphe. À son tour, ce n'est qu'une double indirection (pointeur -> interface -> implémentation). En C ++, il peut être facilement implémenté en tant que std::unique_ptr<ISomeService>.
Shadows In Rain
1
Vous pouvez modifier la stratégie d'initialisation pour "initialiser au premier accès" et éviter d'avoir à allouer une séquence de code externe et à envoyer des services au localisateur. Vous pouvez ajouter une liste "dépend de" aux services de sorte que lorsque l'un est initialisé, il configure automatiquement les autres services dont il a besoin et ne prie pas pour que quelqu'un se souvienne de le faire dans main.cpp. Une bonne réponse avec flexibilité pour de futurs ajustements.
Patrick Hughes,
4

Il existe de nombreuses façons de concevoir un moteur de jeu et tout se résume vraiment à vos préférences.

Pour éliminer les bases, certains développeurs préfèrent la concevoir comme une pyramide où il existe une classe de noyau supérieure souvent appelée noyau, classe de noyau ou classe de structure qui crée, possède et initialise une série de sous-systèmes tels que comme l'audio, les graphiques, le réseau, la physique, l'IA et la gestion des tâches, des entités et des ressources. Généralement, ces sous-systèmes vous sont exposés par cette classe d'infrastructure et vous transmettez généralement cette classe d'infrastructure à vos propres classes en tant qu'argument constructeur, le cas échéant.

Je pense que vous êtes sur la bonne voie avec votre réflexion sur l'option # 4.

Gardez à l'esprit quand il s'agit de la communication elle-même, cela ne doit pas toujours impliquer un appel direct à une fonction. Il existe de nombreuses façons indirectes de communiquer, que ce soit par une méthode indirecte utilisant Signal and Slotsou en utilisant Messages.

Parfois, dans les jeux, il est important de permettre aux actions de se produire de manière asynchrone pour que notre boucle de jeu se déplace aussi rapidement que possible afin que les fréquences d'images soient fluides à l'œil nu. Les joueurs n'aiment pas les scènes lentes et saccadées et nous devons donc trouver des moyens de faire bouger les choses pour eux, mais garder la logique qui coule mais en échec et ordonné aussi. Bien que les opérations asynchrones aient leur place, elles ne sont pas non plus la réponse pour chaque opération.

Sachez simplement que vous aurez un mélange de communications synchrones et asynchrones. Choisissez ce qui est approprié, mais sachez que vous devrez prendre en charge les deux styles parmi vos sous-systèmes. Concevoir un support pour les deux vous sera utile dans le futur.

Naros
la source
1

Vous devez juste vous assurer qu'il n'y a pas de dépendances inverses ou cycliques. Par exemple, si vous avez une classe Core, et celle-ci Corea un Level, et Levela une liste de Entity, alors l'arborescence des dépendances devrait ressembler à:

Core --> Level --> Entity

Donc, étant donné cet arbre de dépendance initial, vous ne devriez jamais Entitydépendre de Levelou Coreet Levelne devriez jamais dépendre de Core. Si l'un Levelou l' autre Entitydoit avoir accès à des données plus haut dans l'arborescence des dépendances, il doit être transmis en tant que paramètre par référence.

Considérez le code suivant (C ++):

class Core;
class Entity;
class Level;

class Level
{
    public:
        Level(Core& coreIn) : core(coreIn) {}

        Core& core;
}

class Entity
{
    public:
        Entity(Level& levelIn) : level(levelIn) {}

        Level& level;
}

En utilisant cette technique, vous pouvez voir que chacun Entitya accès au Level, et le Levela accès au Core. Notez que chacun Entitystocke une référence à la même Level, ce qui gaspille de la mémoire. Après avoir remarqué cela, vous devriez vous demander si chacun a Entityvraiment besoin d'accéder au Level.

D'après mon expérience, il existe soit A) une solution vraiment évidente pour éviter les dépendances inverses, soit B) il n'y a aucun moyen possible d'éviter les instances globales et les singletons.

Pommes
la source
Suis-je en train de manquer quelque chose? Vous mentionnez «vous ne devriez jamais faire dépendre l'entité du niveau» mais vous décrivez ensuite son ctor comme «entité (niveau et niveauIn)». Je comprends que la dépendance est passée par ref mais c'est toujours une dépendance.
Adam Naylor
@AdamNaylor Le fait est que parfois vous avez vraiment besoin de dépendances inverses, et vous pouvez éviter les globaux en passant des références. En général, cependant, il est préférable d'éviter complètement ces dépendances, et il n'est pas toujours clair comment procéder.
Pommes
0

Donc, fondamentalement, vous voulez éviter un état mutable global ? Vous pouvez en faire un état local, immuable ou pas du tout. Ce dernier est le plus efficace et le plus flexible, imo. Il est connu sous le nom de masquage par mise en œuvre.

class ISomeComponent // abstract base class
{
    //...
};

extern ISomeComponent & g_SomeComponent; // will be defined somewhere else;
Ombres sous la pluie
la source
0

La question semble en fait être de savoir comment réduire le couplage sans sacrifier les performances. Tous les objets globaux (services) forment généralement une sorte de contexte qui est modifiable pendant l'exécution du jeu. En ce sens, le modèle de localisateur de service disperse différentes parties du contexte dans différentes parties de l'application, qui peuvent ou non être ce que vous voulez. Une autre approche du monde réel serait de déclarer une structure comme celle-ci:

struct sEnvironment
{
    owning<iAudio*> m_Audio;
    owning<iRenderer*> m_Renderer;
    owning<iGameLevel*> m_GameLevel;
    ...
}

Et passez-le comme un pointeur brut non propriétaire sEnvironment*. Ici, les pointeurs pointent vers des interfaces, de sorte que le couplage est réduit de manière similaire par rapport au localisateur de services. Cependant, tous les services sont au même endroit (ce qui pourrait ou non être bon). Ceci est juste une autre approche.

Sergey K.
la source