Architettura del gioco / domanda di progettazione: creazione di un motore efficiente evitando le istanze globali (gioco C ++)


28

Avevo una domanda sull'architettura di gioco: qual è il modo migliore per far comunicare componenti diversi tra loro?

Mi scuso davvero se questa domanda è già stata posta un milione di volte, ma non riesco a trovare nulla con esattamente il tipo di informazioni che sto cercando.

Ho cercato di creare un gioco da zero (C ++ se è importante) e ho osservato alcuni software di gioco open source per l'ispirazione (Super Maryo Chronicles, OpenTTD e altri). Ho notato che molti di questi design di giochi utilizzano istanze globali e / o singoli in tutto il luogo (per cose come code di rendering, gestori di entità, gestori di video e così via). Sto cercando di evitare istanze globali e singoli e costruire un motore che sia accoppiato il più liberamente possibile, ma sto colpendo alcuni ostacoli che devo alla mia inesperienza nel design efficace. (Parte della motivazione per questo progetto è di affrontare questo :))

Ho costruito un progetto in cui ho un GameCoreoggetto principale che ha membri analoghi alle istanze globali che vedo in altri progetti (ad esempio, ha un gestore di input, un gestore di video, un GameStageoggetto che controlla tutte le entità e il gioco per qualunque fase sia attualmente caricata, ecc.). Il problema è che, poiché tutto è centralizzato GameCorenell'oggetto, non ho un modo semplice per comunicare componenti diversi tra loro.

Guardando Super Maryo Chronicles, ad esempio, ogni volta che un componente del gioco deve comunicare con un altro componente (ovvero, un oggetto nemico vuole aggiungersi alla coda di rendering per essere disegnato nella fase di rendering), parla solo con istanza globale.

Per quanto mi riguarda, devo fare in modo che i miei oggetti di gioco restituiscano le informazioni rilevanti GameCoreall'oggetto, in modo che l' GameCoreoggetto possa passare tali informazioni agli altri componenti del sistema che ne hanno bisogno (ovvero: per la situazione sopra, ogni oggetto nemico passerebbe le informazioni di rendering GameStageall'oggetto, che le raccoglierebbe tutte e le restituirebbe GameCore, che a sua volta le passerebbe al gestore video per il rendering). Sembra un design davvero orribile così com'è, e stavo cercando di pensare a una soluzione a questo. Le mie opinioni su possibili progetti:

  1. Istanze globali (progettazione di Super Maryo Chronicles, OpenTTD, ecc.)
  2. Avere l' GameCoreoggetto agire come un intermediario attraverso il quale tutti gli oggetti comunicano (disegno attuale descritto sopra)
  3. Fornisci puntatori ai componenti a tutti gli altri componenti con cui dovranno parlare (ad esempio, nell'esempio di Maryo sopra, la classe nemica avrebbe un puntatore all'oggetto video con cui deve parlare)
  4. Suddividere il gioco in sottosistemi - Ad esempio, avere oggetti manager GameCorenell'oggetto che gestiscono la comunicazione tra oggetti nel loro sottosistema
  5. (Altre opzioni? ....)

Immagino che l'opzione 4 sopra sia la soluzione migliore, ma ho qualche problema a progettarla ... forse perché ho pensato in termini di design che ho visto usare i globuli. Mi sembra di prendere lo stesso problema esistente nel mio progetto attuale e di replicarlo in ciascun sottosistema, solo su scala ridotta. Ad esempio, l' GameStageoggetto sopra descritto è in qualche modo un tentativo in questo, ma l' GameCoreoggetto è ancora coinvolto nel processo.

Qualcuno può offrire qualche consiglio di progettazione qui?

Grazie!


1
Capisco il tuo istinto che i singoli non sono un grande design. Nella mia esperienza, sono stati il ​​modo più semplice per gestire la comunicazione tra sistemi
Emmett Butler

4
Aggiunta come commento poiché non so se sia una buona pratica. Ho un GameManager centrale che è composto da sottosistemi come InputSystem, GraphicsSystem, ecc. Ogni sottosistema prende il GameManager come parametro nel costruttore e memorizza il riferimento a un membro privato della classe. A quel punto, posso fare riferimento a qualsiasi altro sistema accedendo tramite il riferimento GameManager.
Inisheer,

Ho cambiato i tag perché questa domanda riguarda il codice, non il design del gioco.
Klaim

questa discussione è un po 'vecchia, ma ho esattamente lo stesso problema. Uso OGRE e cerco di usare il modo migliore, a mio avviso l'opzione 4 è l'approccio migliore. Ho costruito qualcosa come Advanced Ogre Framework, ma questo non è molto modulare. Penso di aver bisogno di una gestione dell'input del sottosistema che ottiene solo i colpi di tastiera e i movimenti del mouse. Quello che non capisco è, come posso creare un gestore di "comunicazione" tra i sottosistemi?
Dominik 2000,

1
Ciao @ Dominik2000, questo è un sito di domande e risposte, non un forum. Se hai una domanda, dovresti pubblicare una domanda reale e non una risposta a una esistente. Vedi le domande frequenti per maggiori dettagli.
Josh,

Risposte:


19

Qualcosa che utilizziamo nei nostri giochi per organizzare i nostri dati globali è il modello di progettazione di ServiceLocator . Il vantaggio di questo modello rispetto al modello Singleton è che l'implementazione dei dati globali può cambiare durante il runtime dell'applicazione. Inoltre, anche i tuoi oggetti globali possono essere modificati durante il runtime. Un altro vantaggio è che è più semplice gestire l'ordine di inizializzazione dei tuoi oggetti globali, che è molto importante soprattutto in C ++.

ad es. (codice C # che può essere facilmente tradotto in C ++ o Java)

Diciamo che hai un'interfaccia di backend di rendering che ha alcune operazioni comuni per il rendering di cose.

public interface IRenderBackend
{
    void Draw();
}

E che hai l'implementazione di backend di rendering predefinita

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

In alcuni progetti sembra legittimo poter accedere al backend di rendering a livello globale. Nel modello Singleton ciò significa che ogni implementazione di IRenderBackend deve essere implementata come istanza globale univoca. Ma l'utilizzo del modello ServiceLocator non richiede questo.

Ecco come:

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

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

Per poter accedere al tuo oggetto globale devi prima inizializzarlo.

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

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

Giusto per dimostrare come le implementazioni possono variare durante il runtime, supponiamo che il tuo gioco abbia un minigioco in cui il rendering è isometrico e implementi un IsometricRenderBackend .

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

Quando si passa dallo stato corrente allo stato del minigioco, è sufficiente modificare il backend di rendering globale fornito dal localizzatore di servizi.

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

Un altro vantaggio è che puoi usare anche servizi null. Per esempio, se abbiamo avuto un ISoundManager servizio e l'utente ha voluto disattivare l'audio, potremmo semplicemente implementare un NullSoundManager che non fa nulla quando i suoi metodi sono chiamati, in modo impostando il ServiceLocator 's oggetto servizio a un NullSoundManager oggetto potremmo realizzare questo risultato con quasi nessuna quantità di lavoro.

Riassumendo, a volte può essere impossibile eliminare i dati globali, ma ciò non significa che non sia possibile organizzarli correttamente e orientati agli oggetti.


Ho già esaminato questo aspetto, ma in realtà non l'ho implementato in nessuno dei miei progetti. Questa volta, ho intenzione di farlo. Grazie :)
Awesomania,

3
@Erevis Quindi, in sostanza, stai descrivendo il riferimento globale all'oggetto polimorfico. A sua volta, si tratta solo di una doppia indiretta (puntatore -> interfaccia -> implementazione). In C ++ può essere implementato facilmente come std::unique_ptr<ISomeService>.
Shadows In Rain

1
È possibile modificare la strategia di inizializzazione per "inizializzare al primo accesso" ed evitare di dover allocare una sequenza di codice esterno e inviare servizi al localizzatore. Puoi aggiungere un elenco "dipende da" ai servizi in modo che quando uno viene inizializzato imposterà automaticamente altri servizi di cui ha bisogno e non pregare che qualcuno si ricordi di farlo in main.cpp. Una buona risposta con flessibilità per future modifiche.
Patrick Hughes,

4

Esistono molti modi per progettare un motore di gioco e tutto si riduce alle preferenze.

Per togliere le basi, alcuni sviluppatori preferiscono progettarlo come una piramide in cui esiste una classe di core superiore spesso definita come una classe kernel, core o framework che crea, possiede e inizializza una serie di sottosistemi come come audio, grafica, rete, fisica, intelligenza artificiale e gestione di attività, entità e risorse. In genere, questi sottosistemi sono esposti a voi da questa classe di framework e di solito passate questa classe di framework alle vostre classi come argomento del costruttore, ove appropriato.

Credo che tu sia sulla buona strada pensando all'opzione n. 4.

Tieni presente quando si tratta della comunicazione stessa, che non deve sempre implicare una chiamata di funzione diretta stessa. Esistono molti modi indiretti in cui la comunicazione può avvenire, sia attraverso un metodo indiretto che usando Signal and Slotso usando Messages.

A volte nei giochi, è importante consentire che le azioni avvengano in modo asincrono per mantenere il nostro loop di gioco in movimento il più velocemente possibile in modo che i frame rate siano fluidi a occhio nudo. Ai giocatori non piacciono le scene lente e increspate e quindi dobbiamo trovare il modo di far fluire le cose per loro ma mantenere la logica fluida ma sotto controllo e anche ordinata. Mentre le operazioni asincrone hanno il loro posto, non sono neanche la risposta per ogni operazione.

Sappi solo che avrai un mix di comunicazioni sincrone e asincrone. Scegli ciò che è appropriato, ma sappi che dovrai supportare entrambi gli stili tra i tuoi sottosistemi. Progettare il supporto per entrambi ti servirà bene in futuro.


1

Devi solo assicurarti che non ci siano dipendenze inverse o cicliche. Ad esempio, se si dispone di una classe Coree questa Coreha una Levele Levelha un elenco di Entity, l'albero della dipendenza dovrebbe apparire come:

Core --> Level --> Entity

Quindi, dato questo albero di dipendenza iniziale, non dovresti mai Entitydipendere da Levelo Coree Levelnon dovresti mai dipendere Core. Se uno Levelo entrambi Entitydevono avere accesso ai dati più in alto nella struttura delle dipendenze, devono essere passati come parametro come riferimento.

Considera il seguente codice (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;
}

Usando questa tecnica, puoi vedere che ognuno Entityha accesso al Levele il Levelha accesso al Core. Si noti che ognuno Entitymemorizza un riferimento allo stesso Level, sprecando memoria. Dopo aver notato questo, dovresti chiederti se ognuno ha Entitydavvero bisogno dell'accesso al Level.

Nella mia esperienza, c'è A) Una soluzione davvero ovvia per evitare dipendenze inverse, oppure B) Non c'è modo possibile per evitare istanze e singoli punti globali.


Mi sto perdendo qualcosa? Dici "non dovresti mai far dipendere l'Entità dal Livello" ma poi descrivi che è ctor come "Entità (Livello e livelloIn)". Capisco che la dipendenza è passata da ref ma è ancora una dipendenza.
Adam Naylor,

@AdamNaylor Il punto è che a volte hai davvero bisogno di dipendenze inverse e puoi evitare i globi passando passaggi. In generale, tuttavia, è meglio evitare del tutto queste dipendenze, e non è sempre chiaro come farlo.
Mele,

0

Quindi, in sostanza, vuoi evitare lo stato mutabile globale ? Puoi renderlo locale, immutabile o per niente uno stato. Gli ultimi sono i più efficienti e flessibili, imo. È noto come nascondere iplementation.

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

extern ISomeComponent & g_SomeComponent; // will be defined somewhere else;

0

La domanda sembra in realtà riguardare come ridurre l'accoppiamento senza sacrificare le prestazioni. Tutti gli oggetti (servizi) globali di solito formano una sorta di contesto mutabile durante il runtime del gioco. In questo senso, il modello di localizzazione del servizio disperde diverse parti del contesto in diverse parti dell'applicazione, che possono essere o meno ciò che si desidera. Un altro approccio del mondo reale sarebbe quello di dichiarare una struttura come questa:

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

E passalo in giro come un puntatore grezzo non proprietario sEnvironment*. Qui i puntatori puntano alle interfacce, quindi l'accoppiamento è ridotto in modo simile rispetto al localizzatore di servizio. Tuttavia, tutti i servizi si trovano in un unico posto (che potrebbe essere o non essere buono). Questo è solo un altro approccio.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.