Spielarchitektur / Designfrage - Aufbau einer effizienten Engine unter Vermeidung globaler Instanzen (C ++ - Spiel)

28

Ich hatte eine Frage zur Spielarchitektur: Wie lassen sich verschiedene Komponenten am besten miteinander kommunizieren?

Ich entschuldige mich wirklich, wenn diese Frage bereits eine Million Mal gestellt wurde, aber ich kann mit genau den Informationen, nach denen ich suche, nichts finden.

Ich habe versucht, ein Spiel von Grund auf neu aufzubauen (C ++, wenn es darauf ankommt) und habe einige Open-Source-Spielesoftware als Inspiration betrachtet (Super Maryo Chronicles, OpenTTD und andere). Ich stelle fest, dass in vielen dieser Spiele-Designs überall globale Instanzen und / oder Singletons verwendet werden (z. B. für Render-Warteschlangen, Entity-Manager, Videomanager usw.). Ich versuche, globale Instanzen und Singletons zu vermeiden und eine Engine zu bauen, die so locker wie möglich gekoppelt ist, aber ich stoße auf einige Hindernisse, die meiner Unerfahrenheit im effektiven Design geschuldet sind. (Ein Teil der Motivation für dieses Projekt ist es, dies anzusprechen :))

Ich habe ein Design erstellt, in dem ich ein GameCoreHauptobjekt habe, dessen Mitglieder den globalen Instanzen entsprechen, die ich in anderen Projekten sehe (dh, es hat einen Eingabemanager, einen Videomanager, ein GameStageObjekt, das alle Entitäten und das Spiel steuert für welche Bühne gerade geladen ist, etc). Das Problem ist, dass es GameCorefür verschiedene Komponenten nicht einfach ist, miteinander zu kommunizieren , da sich alles im Objekt befindet.

Wenn beispielsweise eine Komponente des Spiels mit einer anderen Komponente kommunizieren muss (dh ein feindliches Objekt möchte sich selbst in die Render-Warteschlange einfügen, die in der Render-Phase gezeichnet werden soll), spricht Super Maryo Chronicles nur mit dem globale Instanz.

Für mich muss ich meine Spielobjekte relevante Informationen an das GameCoreObjekt zurückgeben lassen, damit das GameCoreObjekt diese Informationen an die anderen Komponenten des Systems weitergeben kann, die sie benötigen (dh für die obige Situation jedes feindliche Objekt) ihre Renderinformationen an das GameStageObjekt zurückgeben, das sie alle sammelt und an das Objekt zurückgibt GameCore, das sie wiederum an den Videomanager zum Rendern weitergibt). Das fühlt sich an wie ein wirklich schreckliches Design, und ich habe versucht, eine Lösung dafür zu finden. Meine Gedanken zu möglichen Designs:

  1. Globale Instanzen (Design von Super Maryo Chronicles, OpenTTD usw.)
  2. Das GameCoreObjekt als Vermittler fungieren zu lassen, über den alle Objekte kommunizieren (aktuelles Design wie oben beschrieben)
  3. Geben Sie den Komponenten Zeiger auf alle anderen Komponenten, mit denen sie sprechen müssen (dh im obigen Maryo-Beispiel hätte die feindliche Klasse einen Zeiger auf das Videoobjekt, mit dem sie sprechen muss).
  4. Unterteilen Sie das Spiel in Subsysteme. Verfügen Sie beispielsweise über Managerobjekte in dem GameCoreObjekt, die die Kommunikation zwischen Objekten in ihrem Subsystem verwalten
  5. (Andere Optionen? ....)

Ich stelle mir die obige Option 4 als die beste Lösung vor, aber ich habe einige Probleme beim Entwerfen ... vielleicht, weil ich über die Entwürfe nachgedacht habe, die Globale verwenden. Anscheinend nehme ich das gleiche Problem, das in meinem aktuellen Design besteht, und repliziere es in jedem Subsystem, nur in kleinerem Maßstab. Beispielsweise ist das GameStageoben beschriebene Objekt ein Versuch, aber das GameCoreObjekt ist immer noch in den Prozess involviert.

Kann hier jemand einen Konstruktionsratschlag geben?

Vielen Dank!

Awesomania
quelle
1
Ich verstehe Ihren Instinkt, dass Singletons kein großartiges Design sind. Nach meiner Erfahrung war dies die einfachste Möglichkeit, die Kommunikation zwischen Systemen zu verwalten
Emmett Butler,
4
Als Kommentar hinzufügen, da ich nicht weiß, ob es sich um eine bewährte Methode handelt. Ich habe einen zentralen GameManager, der aus Subsystemen wie InputSystem, GraphicsSystem usw. besteht. Jedes Subsystem verwendet den GameManager als Parameter im Konstruktor und speichert den Verweis auf ein privates Klassenmitglied. Zu diesem Zeitpunkt kann ich auf jedes andere System verweisen, indem ich über die GameManager-Referenz darauf zugreife.
Inisheer
Ich habe die Tags geändert, da es sich bei dieser Frage um Code und nicht um Spieldesign handelt.
Klaim
Dieser Thread ist ein bisschen alt, aber ich habe genau das gleiche Problem. Ich benutze OGRE und versuche, den besten Weg zu finden. Meiner Meinung nach ist Option 4 der beste Ansatz. Ich habe so etwas wie das Advanced Ogre Framework erstellt, aber dieses ist nicht sehr modular. Ich glaube, ich brauche ein Subsystem zur Eingabe, das nur die Tastaturtreffer und Mausbewegungen bekommt. Was ich nicht herausfinde ist, wie kann ich einen solchen "Kommunikations" -Manager zwischen den Subsystemen erstellen?
Dominik2000
1
Hi @ Dominik2000, dies ist eine Q & A Seite, kein Forum. Wenn Sie eine Frage haben, sollten Sie eine aktuelle Frage posten und keine Antwort auf eine bestehende. Weitere Informationen finden Sie in der FAQ .
Josh

Antworten:

19

In unseren Spielen verwenden wir zur Organisation unserer globalen Daten das ServiceLocator- Entwurfsmuster. Der Vorteil dieses Musters gegenüber dem Singleton- Muster besteht darin, dass sich die Implementierung Ihrer globalen Daten während der Laufzeit der Anwendung ändern kann. Außerdem können Ihre globalen Objekte auch zur Laufzeit geändert werden. Ein weiterer Vorteil ist, dass die Reihenfolge der Initialisierung Ihrer globalen Objekte einfacher verwaltet werden kann, was insbesondere in C ++ sehr wichtig ist.

zB (C # -Code, der leicht in C ++ oder Java übersetzt werden kann)

Nehmen wir an, Sie haben eine Rendering-Backend-Oberfläche, die einige gängige Operationen zum Rendern von Inhalten enthält.

public interface IRenderBackend
{
    void Draw();
}

Und dass Sie die Standard-Render-Backend-Implementierung haben

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

In einigen Designs scheint es legitim zu sein, global auf das Render-Backend zugreifen zu können. Im Singleton- Muster bedeutet dies, dass jede IRenderBackend- Implementierung als eindeutige globale Instanz implementiert werden sollte. Für die Verwendung des ServiceLocator- Musters ist dies jedoch nicht erforderlich.

Hier ist wie:

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

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

Um auf Ihr globales Objekt zugreifen zu können, müssen Sie es zuerst initialisieren.

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

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

Angenommen, Ihr Spiel verfügt über ein Minispiel, in dem das Rendern isometrisch ist, und Sie implementieren ein IsometricRenderBackend , um zu veranschaulichen, wie die Implementierungen während der Laufzeit variieren können .

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

Wenn Sie vom aktuellen Status in den Minispielstatus wechseln, müssen Sie nur das vom Service Locator bereitgestellte globale Render-Backend ändern.

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

Ein weiterer Vorteil ist, dass Sie auch Null-Services verwenden können. Wenn wir zum Beispiel einen ISoundManager- Dienst hatten und der Benutzer den Sound ausschalten wollte, konnten wir einfach einen NullSoundManager implementieren, der beim Aufrufen seiner Methoden nichts tut, indem wir das Service -Objekt des ServiceLocators auf ein NullSoundManager- Objekt setzen, das wir erreichen konnten dieses ergebnis mit kaum arbeitsaufwand.

Zusammenfassend lässt sich sagen, dass es manchmal unmöglich ist, globale Daten zu löschen, dies bedeutet jedoch nicht, dass Sie sie nicht ordnungsgemäß und objektorientiert organisieren können.

vdaras
quelle
Ich habe das schon einmal untersucht, es aber in keinem meiner Entwürfe umgesetzt. Diesmal plane ich es. Vielen Dank :)
Awesomania
3
@Erevis Im Grunde beschreiben Sie also einen globalen Verweis auf ein polymorphes Objekt. Dies ist wiederum nur eine doppelte Indirektion (Zeiger -> Schnittstelle -> Implementierung). In C ++ kann es einfach als implementiert werden std::unique_ptr<ISomeService>.
Schatten im Regen
1
Sie könnten die Initialisierungsstrategie in "Initialisierung beim ersten Zugriff" ändern und vermeiden, dass dem Locator einige externe Codesequenzen zugewiesen und Push-Dienste zugewiesen werden müssen. Sie können den Diensten eine "Abhängigkeiten von" -Liste hinzufügen, damit sie beim Initialisieren automatisch andere Dienste einrichten, die sie benötigen, und nicht darum beten, dass sich jemand daran erinnert, dies in main.cpp zu tun. Eine gute Antwort mit Flexibilität für zukünftige Optimierungen.
Patrick Hughes
4

Es gibt viele Möglichkeiten, eine Spiel-Engine zu entwerfen, und es kommt wirklich auf die Vorlieben an.

Um die Grundlagen aus dem Weg zu räumen, bevorzugen einige Entwickler das Entwerfen einer Pyramide, bei der es eine Top-Core-Klasse gibt, die oft als Kernel-, Core- oder Framework-Klasse bezeichnet wird und die eine Reihe von Subsystemen erstellt, besitzt und initialisiert, z B. Audio-, Grafik-, Netzwerk-, Physik-, KI- und Aufgaben-, Entitäts- und Ressourcenverwaltung. Im Allgemeinen werden diese Subsysteme von dieser Framework-Klasse für Sie verfügbar gemacht, und in der Regel übergeben Sie diese Framework-Klasse gegebenenfalls als Konstruktorargument an Ihre eigenen Klassen.

Ich glaube, Sie sind auf dem richtigen Weg, wenn Sie über Option 4 nachdenken.

Denken Sie daran, wenn es um die Kommunikation selbst geht, muss dies nicht immer einen direkten Funktionsaufruf selbst implizieren. Es gibt viele indirekte Wege, wie Kommunikation stattfinden kann, sei es durch eine indirekte Methode unter Verwendung Signal and Slotsoder unter Verwendung Messages.

Manchmal ist es in Spielen wichtig, dass Aktionen asynchron ablaufen, damit sich unsere Spieleschleife so schnell wie möglich bewegt, damit die Bildraten für das bloße Auge flüssig sind. Die Spieler mögen keine langsamen und abgehackten Szenen und deshalb müssen wir Wege finden, die Dinge für sie fließend zu halten, aber die Logik fließend zu halten, aber in Schach zu halten und auch zu ordnen. Während asynchrone Operationen ihren Platz haben, sind sie auch nicht die Antwort für jede Operation.

Sie müssen nur wissen, dass Sie eine Mischung aus synchroner und asynchroner Kommunikation haben. Wählen Sie die geeignete Option aus, aber Sie müssen wissen, dass beide Stile in Ihren Subsystemen unterstützt werden müssen. Das Entwerfen von Unterstützung für beide wird Ihnen auch in Zukunft von Nutzen sein.

Naros
quelle
1

Sie müssen nur sicherstellen, dass es keine umgekehrten oder zyklischen Abhängigkeiten gibt. Wenn Sie beispielsweise eine Klasse Corehaben und diese Coreeine Levelund die Leveleine Liste von hat Entity, sollte der Abhängigkeitsbaum wie folgt aussehen:

Core --> Level --> Entity

In Anbetracht dieses anfänglichen Abhängigkeitsbaums sollten Sie sich also niemals Entityauf Leveloder verlassen Coreund Levelsollten sich niemals darauf verlassen Core. Wenn ein Leveloder EntityNotwendigkeit , den Zugang zu Daten haben , die in der Abhängigkeitsstruktur höher sind, sollte es als Parameter als Referenz übergeben werden.

Betrachten Sie den folgenden Code (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;
}

Mit dieser Technik können Sie sehen, dass jeder EntityZugriff auf Levelund der LevelZugriff auf hat Core. Beachten Sie, dass jeder Entityeinen Verweis auf denselben LevelSpeicher speichert , wodurch Speicher verschwendet wird. Wenn Sie dies bemerken, sollten Sie sich fragen, ob jeder Entitywirklich Zugriff auf das Internet benötigt oder nicht Level.

Nach meiner Erfahrung gibt es entweder A) eine wirklich offensichtliche Lösung, um umgekehrte Abhängigkeiten zu vermeiden, oder B) es gibt keine Möglichkeit, globale Instanzen und Singletons zu vermeiden.

Äpfel
quelle
Vermisse ich etwas? Sie erwähnen, dass "Entity sollte niemals von Level abhängen", dann beschreiben Sie es als "Entity (Level & LevelIn)". Ich verstehe, dass die Abhängigkeit von ref übergeben wird, aber es ist immer noch eine Abhängigkeit.
Adam Naylor
@AdamNaylor Der Punkt ist, dass Sie manchmal wirklich umgekehrte Abhängigkeiten benötigen und Sie Globale vermeiden können, indem Sie Referenzen übergeben. Im Allgemeinen ist es jedoch am besten, diese Abhängigkeiten vollständig zu vermeiden, und es ist nicht immer klar, wie dies zu tun ist.
Äpfel
0

Sie möchten also grundsätzlich einen globalen veränderlichen Zustand vermeiden ? Sie können es entweder lokal, unveränderlich oder gar nicht zu einem Bundesstaat machen. Letzteres ist am effizientesten und flexibelsten, imo. Es ist bekannt als Iplementation Hiding.

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

extern ISomeComponent & g_SomeComponent; // will be defined somewhere else;
Schatten im Regen
quelle
0

Die Frage scheint tatsächlich zu sein, wie die Kopplung verringert werden kann, ohne die Leistung zu beeinträchtigen. Alle globalen Objekte (Dienste) bilden normalerweise eine Art Kontext, der während der Laufzeit des Spiels geändert werden kann. In diesem Sinne verteilt das Dienstlokalisierungsmuster verschiedene Teile des Kontexts auf verschiedene Teile der Anwendung, die möglicherweise Ihren Wünschen entsprechen oder auch nicht. Ein anderer realer Ansatz wäre, eine Struktur wie die folgende zu deklarieren:

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

Und geben Sie es als nicht-besitzenden rohen Zeiger weiter sEnvironment*. Hier verweisen Zeiger auf Schnittstellen, sodass die Kopplung im Vergleich zum Service-Locator reduziert ist. Alle Dienste befinden sich jedoch an einem Ort (was gut oder schlecht sein kann). Dies ist nur ein weiterer Ansatz.

Sergey K.
quelle