Wie sollten sich Spielobjekte gegenseitig bewusst sein?

18

Ich finde es schwierig, Spielobjekte so zu organisieren, dass sie polymorph, aber nicht polymorph sind.

Hier ist ein Beispiel: Angenommen, wir wollen alle unsere Objekte zu update()und draw(). Dazu müssen wir eine Basisklasse definieren GameObject, die diese beiden virtuellen reinen Methoden hat und den Polymorphismus einsetzt:

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

Die Aktualisierungsmethode muss sich um den Status kümmern, den das jeweilige Klassenobjekt für die Aktualisierung benötigt. Tatsache ist, dass jedes Objekt über die Welt um es herum Bescheid wissen muss. Beispielsweise:

  • Eine Mine muss wissen, ob jemand damit kollidiert
  • Ein Soldat sollte wissen, ob der Soldat eines anderen Teams in der Nähe ist
  • Ein Zombie sollte wissen, wo sich das nächste Gehirn innerhalb eines Radius befindet

Für passive Interaktionen (wie die erste) habe ich gedacht, dass die Kollisionserkennung in bestimmten Fällen von Kollisionen das Objekt selbst mit einem delegieren kann on_collide(GameObject*).

Die meisten anderen Informationen (wie auch die beiden anderen Beispiele) konnten nur von der Spielwelt abgefragt werden, die an die updateMethode übergeben wurde. Jetzt unterscheidet die Welt Objekte nicht mehr nach ihrem Typ (sie speichert alle Objekte in einem einzigen polymorphen Container). Was also mit einem Ideal zurückkommt, world.entities_in(center, radius)ist ein Container von GameObject*. Aber natürlich will der Soldat keine anderen Soldaten aus seinem Team angreifen und ein Zombie macht sich keine Gedanken über andere Zombies. Wir müssen also das Verhalten unterscheiden. Eine Lösung könnte sein:

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
}

aber natürlich kann die anzahl dynamic_cast<>pro bild furchtbar hoch sein, und wir alle wissen, wie langsam es sein dynamic_castkann. Dasselbe Problem betrifft auch den on_collide(GameObject*)zuvor besprochenen Delegierten.

Was ist der ideale Weg, um den Code so zu organisieren, dass Objekte andere Objekte erkennen und sie ignorieren oder Aktionen basierend auf ihrem Typ ausführen können?

Schuh
quelle
1
Ich denke, Sie suchen nach einer vielseitigen benutzerdefinierten C ++ - RTTI-Implementierung. Ihre Frage scheint sich jedoch nicht nur um vernünftige RTTI-Mechanismen zu handeln. Die Dinge, nach denen Sie fragen, werden von fast jeder Middleware benötigt, die das Spiel verwenden wird (Animationssystem, Physik, um nur einige zu nennen). Abhängig von der Liste der unterstützten Abfragen können Sie sich mit IDs und Indizes in Arrays in RTTI zurechtfinden, oder Sie entwerfen ein vollwertiges Protokoll, das billigere Alternativen zu dynamic_cast und type_info unterstützt.
Teodron
Ich würde davon abraten, das Typensystem für die Spielelogik zu verwenden. dynamic_cast<Human*>Implementieren Sie beispielsweise, anstatt vom Ergebnis von abhängig zu sein , so etwas wie a bool GameObject::IsHuman(), das falsestandardmäßig zurückgegeben wird, aber truein der HumanKlasse überschrieben wird, um zurückzukehren .
Congusbongus
ein Extra: Sie senden fast nie eine Tonne von Objekten an andere Entitäten, die an ihnen interessiert sein könnten. Das ist eine offensichtliche Optimierung, die Sie wirklich in Betracht ziehen müssen.
Teodron
@congusbongus Die Verwendung einer vtable und von benutzerdefinierten IsAOverrides erwies sich in der Praxis für mich als nur unwesentlich besser als dynamisches Casting. Das Beste, was Sie tun können, ist, dass der Benutzer nach Möglichkeit Datenlisten sortiert, anstatt blind über den gesamten Entitätspool zu iterieren.
Teodron
4
@Jefffrey: Idealerweise schreiben Sie keinen typspezifischen Code. Sie schreiben schnittstellenspezifischen Code ("Schnittstelle" im allgemeinen Sinne). Ihre Logik für ein TeamASoldierundTeamBSoldier ist wirklich identisch - auf jeden im anderen Team geschossen. Alles, was es von anderen Entitäten benötigt, ist eine GetTeam()Methode in ihrer spezifischsten Form, die sich nach dem Beispiel von congusbongus noch weiter in eine IsEnemyOf(this)Art Schnittstelle abstrahieren lässt . Der Code muss sich nicht um taxonomische Klassifikationen von Soldaten, Zombies, Spielern usw. kümmern. Konzentrieren Sie sich auf Interaktion, nicht auf Typen.
Sean Middleditch

Antworten:

11

Anstatt die Entscheidungsfindung jeder Entität für sich zu implementieren, können Sie alternativ das Controller-Pattern wählen. Sie würden zentrale Steuerungsklassen haben, die alle Objekte (die für sie von Bedeutung sind) kennen und deren Verhalten steuern.

Ein MovementController übernimmt die Bewegung aller Objekte, die sich bewegen können (Routensuche durchführen, Positionen anhand aktueller Bewegungsvektoren aktualisieren).

Ein MineBehaviorController überprüft alle Minen und alle Soldaten und befiehlt einer Mine, zu explodieren, wenn ein Soldat zu nahe kommt.

Ein ZombieBehaviorController überprüft alle Zombies und die Soldaten in ihrer Nähe, wählt das beste Ziel für jeden Zombie aus und befiehlt ihm, sich dorthin zu bewegen und ihn anzugreifen (der Zug selbst wird vom MovementController ausgeführt).

Ein SoldierBehaviorController würde die gesamte Situation analysieren und dann taktische Anweisungen für alle Soldaten ausarbeiten (Sie ziehen dorthin, Sie schießen darauf, Sie heilen diesen Kerl ...). Die eigentliche Ausführung dieser übergeordneten Befehle würde auch von untergeordneten Steuerungen übernommen. Wenn Sie sich anstrengen, können Sie die KI in die Lage versetzen, recht kluge kooperative Entscheidungen zu treffen.

Philipp
quelle
1
Wahrscheinlich ist dies auch als "System" bekannt, das die Logik für bestimmte Komponententypen in einer Entity-Component-Architektur verwaltet.
Teodron
Das klingt nach einer Lösung im C-Stil. Komponenten werden in std::maps gruppiert und Entitäten sind nur IDs. Dann müssen wir eine Art Typsystem erstellen (möglicherweise mit einer Tag-Komponente, da der Renderer wissen muss, was zu zeichnen ist). Und wenn wir das nicht wollen, brauchen wir eine Zeichnungskomponente. Die Positionskomponente muss jedoch wissen, wo sie gezeichnet werden soll. Daher erstellen wir Abhängigkeiten zwischen Komponenten, die wir mit einem superkomplexen Messagingsystem lösen. Schlagen Sie das vor?
Schuh
1
@Jefffrey "Das klingt nach einer Lösung im C-Stil" - auch wenn das stimmt, warum sollte es dann unbedingt eine schlechte Sache sein? Die anderen Bedenken mögen zutreffen, aber es gibt Lösungen für sie. Leider ist ein Kommentar zu kurz, um jeden von ihnen richtig anzusprechen.
Philipp
1
@Jefffrey Bei Verwendung des Ansatzes, bei dem Komponenten selbst keine Logik haben und die "Systeme" für die Verarbeitung der gesamten Logik verantwortlich sind, entstehen keine Abhängigkeiten zwischen Komponenten, und es wird kein überkomplexes Messagingsystem benötigt (zumindest nicht annähernd so komplex). . Siehe zum Beispiel: gamadu.com/artemis/tutorial.html
1

Versuchen Sie zunächst, Features so zu implementieren, dass Objekte möglichst unabhängig voneinander bleiben. Dies möchten Sie vor allem für Multi-Threading tun. In Ihrem ersten Codebeispiel könnte die Menge aller Objekte in Mengen unterteilt werden, die der Anzahl der CPU-Kerne entsprechen, und sehr effizient aktualisiert werden.

Wie Sie bereits sagten, ist für einige Funktionen eine Interaktion mit anderen Objekten erforderlich. Das bedeutet, dass der Zustand aller Objekte an einigen Punkten synchronisiert werden muss. Mit anderen Worten, Ihre Anwendung muss warten, bis alle parallelen Aufgaben abgeschlossen sind, und dann Berechnungen anwenden, die eine Interaktion beinhalten. Es empfiehlt sich, die Anzahl dieser Synchronisationspunkte zu verringern, da dies immer bedeutet, dass einige Threads warten müssen, bis andere beendet sind.

Daher empfehle ich, die Informationen zu den Objekten, die in anderen Objekten benötigt werden, zu puffern. Mit einem solchen globalen Puffer können Sie alle Ihre Objekte unabhängig voneinander aktualisieren, jedoch nur abhängig von sich selbst und dem globalen Puffer, der sowohl schneller als auch einfacher zu warten ist. Aktualisieren Sie zu einem festgelegten Zeitpunkt, beispielsweise nach jedem Frame, den Puffer mit dem aktuellen Objektstatus.

Einmal pro Frame müssen Sie also 1. den aktuellen Objektstatus global puffern, 2. alle Objekte basierend auf sich selbst und dem Puffer aktualisieren, 3. Ihre Objekte zeichnen und dann den Puffer erneut laden.

danijar
quelle
1

Verwenden Sie ein komponentenbasiertes System, in dem Sie ein Barebones-GameObject haben, das mindestens eine Komponente enthält, die deren Verhalten definiert.

Angenommen, ein Objekt soll sich die ganze Zeit nach links und rechts bewegen (eine Plattform), dann könnten Sie eine solche Komponente erstellen und an ein GameObject anhängen.

Angenommen, ein Spielobjekt soll sich die ganze Zeit langsam drehen. Sie könnten eine separate Komponente erstellen, die genau das tut, und sie an das GameObject anhängen.

Was wäre, wenn Sie eine sich bewegende Plattform haben möchten, die sich ebenfalls dreht, in einer traditionellen Klassen-Hierarchie, die ohne das Duplizieren von Code nur schwer zu realisieren ist.

Das Schöne an diesem System ist, dass Sie anstelle einer drehbaren oder einer MovingPlatform-Klasse beide Komponenten an das GameObject anhängen und jetzt eine MovingPlatform haben, die sich automatisch dreht.

Alle Komponenten haben die Eigenschaft 'requireUpdate', die zwar wahr ist, das GameObject jedoch die 'update'-Methode für diese Komponente aufruft. Angenommen, Sie haben eine Draggable-Komponente. Diese Komponente kann bei gedrückter Maustaste (wenn sie sich über dem GameObject befand) "requireUpdate" auf "true" und bei gedrückter Maustaste auf "false" setzen. Es darf nur der Maus folgen, wenn die Maus gedrückt ist.

Einer der Tony Hawk Pro Skater-Entwickler hat das Defacto, um es zu schreiben, und es lohnt sich zu lesen: http://cowboyprogramming.com/2007/01/05/evolve-your-heirachy/

einTagwirdnunmachen
quelle
1

Bevorzugen Sie die Komposition gegenüber der Vererbung.

Abgesehen davon wäre mein stärkster Rat: Lassen Sie sich nicht in die Denkweise von "Ich möchte, dass dies äußerst flexibel ist" hineinziehen. Flexibilität ist großartig, aber denken Sie daran, dass es in einem endlichen System wie einem Spiel auf einer bestimmten Ebene atomare Teile gibt, die zum Aufbau des Ganzen verwendet werden. So oder so hängt Ihre Verarbeitung von diesen vordefinierten atomaren Typen ab. Mit anderen Worten, das Catering für "alle" Datentypen (wenn das möglich wäre) würde Ihnen auf lange Sicht nicht helfen, wenn Sie keinen Code zur Verarbeitung haben. Grundsätzlich muss jeder Code Daten analysieren / verarbeiten, die auf bekannten Spezifikationen basieren ... was eine vordefinierte Menge von Typen bedeutet. Wie groß ist das Set? Wie du willst.

Dieser Beitrag bietet über eine robuste und performante Entity-Component-Architektur einen Einblick in das Prinzip von Composition over Inheritance bei der Spieleentwicklung.

Indem Sie Entitäten aus (unterschiedlichen) Teilmengen einer Obermenge vordefinierter Komponenten aufbauen, bieten Sie Ihren AIs konkrete, schrittweise Möglichkeiten, die Welt und die Akteure in ihrer Umgebung zu verstehen, indem Sie die Zustände der Komponenten dieser Akteure lesen.

Ingenieur
quelle
1

Persönlich empfehle ich, die Zeichenfunktion außerhalb der Object-Klasse selbst zu halten. Ich empfehle sogar, die Position / Koordinaten des Objekts außerhalb des Objekts selbst zu halten.

Diese draw () -Methode wird sich mit der Low-Level-Rendering-API von OpenGL, OpenGL ES, Direct3D, Ihrem Wrapping-Layer auf diesen APIs oder einer Engine-API befassen. Es kann sein, dass Sie zwischen diesen Einstellungen wechseln müssen (wenn Sie beispielsweise OpenGL + OpenGL ES + Direct3D unterstützen möchten).

Dieses GameObject sollte nur die grundlegenden Informationen über das Erscheinungsbild enthalten, z. B. ein Mesh oder ein größeres Bundle, einschließlich Shader-Eingaben, Animationsstatus usw.

Außerdem möchten Sie eine flexible Grafik-Pipeline. Was passiert, wenn Sie Objekte anhand ihres Abstands zur Kamera bestellen möchten? Oder ihre Materialart. Was passiert, wenn Sie ein 'ausgewähltes' Objekt in einer anderen Farbe zeichnen möchten? Was ist, wenn Sie beim Aufrufen einer Zeichenfunktion für ein Objekt nicht das Rendern durchführen, sondern es in eine Befehlsliste mit Aktionen einfügen, die das Rendern ausführen soll (möglicherweise für das Threading erforderlich)? Sie können so etwas mit dem anderen System machen, aber es ist eine PITA.

Ich empfehle, anstatt direkt zu zeichnen, alle gewünschten Objekte an eine andere Datenstruktur zu binden. Diese Bindung muss nur wirklich einen Verweis auf die Position des Objekts und die Rendering-Informationen enthalten.

Ihre Ebenen / Blöcke / Bereiche / Karten / Hubs / ganze Welt / was auch immer einen räumlichen Index erhalten, dieser enthält die Objekte und gibt sie basierend auf Koordinatenabfragen zurück und könnte eine einfache Liste oder so etwas wie ein Octree sein. Es könnte auch ein Wrapper für etwas sein, das von einer Physik-Engine eines Drittanbieters als Physikszene implementiert wird. Es ermöglicht Ihnen, Dinge wie "Alle Objekte abfragen, die sich in der Ansicht der Kamera befinden, mit einem zusätzlichen Bereich um sie herum" oder für einfachere Spiele, bei denen Sie einfach alles rendern können, greifen Sie auf die gesamte Liste zu.

Raumindizes müssen nicht die tatsächlichen Positionsinformationen enthalten. Sie speichern Objekte in Baumstrukturen in Bezug auf die Position anderer Objekte. Sie können jedoch als eine Art verlustbehafteter Cache angesehen werden, der ein schnelles Nachschlagen eines Objekts anhand seiner Position ermöglicht. Es besteht keine wirkliche Notwendigkeit, Ihre tatsächlichen X-, Y- und Z-Koordinaten zu duplizieren. Davon abgesehen könntest du, wenn du behalten wolltest

Tatsächlich müssen Ihre Spielobjekte nicht einmal ihre eigenen Standortinformationen enthalten. Zum Beispiel sollte ein Objekt, das nicht in eine Ebene gelegt wurde, keine x-, y-, z-Koordinaten haben, was keinen Sinn ergibt. Sie können das in dem speziellen Index enthalten. Wenn Sie die Koordinaten des Objekts basierend auf seiner tatsächlichen Referenz nachschlagen müssen, müssen Sie eine Bindung zwischen dem Objekt und dem Szenendiagramm herstellen (Szenendiagramme dienen zum Zurückgeben von Objekten basierend auf Koordinaten, sind jedoch beim Zurückgeben von Koordinaten basierend auf Objekten langsam). .

Wenn Sie einem Level ein Objekt hinzufügen. Es wird folgendes tun:

1) Legen Sie eine Lokationsstruktur an:

 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
 };

Dies kann auch ein Verweis auf ein Objekt in einer Physik-Engine eines Drittanbieters sein. Oder es könnte eine Versatzkoordinate mit einem Verweis auf einen anderen Ort sein (für eine Verfolgungskamera oder ein angefügtes Objekt oder ein Beispiel). Beim Polymorphismus kann dies davon abhängen, ob es sich um ein statisches oder ein dynamisches Objekt handelt. Wenn Sie hier beim Aktualisieren der Koordinaten einen Verweis auf den Raumindex behalten, kann dies auch der Raumindex sein.

Wenn Sie sich Sorgen über die dynamische Speicherzuweisung machen, verwenden Sie einen Speicherpool.

2) Eine Bindung / Verknüpfung zwischen Ihrem Objekt, seiner Position und dem Szenengraphen.

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

3) Die Bindung wird an der entsprechenden Stelle zum räumlichen Index innerhalb der Ebene hinzugefügt.

Wenn Sie sich auf das Rendern vorbereiten.

1) Holen Sie sich die Kamera (Es wird nur ein weiteres Objekt sein, außer dass der Ort den Charakter des Spielers verfolgt und Ihr Renderer einen speziellen Verweis darauf hat, das ist alles, was er wirklich benötigt).

2) Holen Sie sich die SpacialBinding der Kamera.

3) Ermitteln Sie den Raumindex aus der Bindung.

4) Fragen Sie die Objekte ab, die (möglicherweise) für die Kamera sichtbar sind.

5A) Sie müssen die visuellen Informationen verarbeiten lassen. Auf die GPU hochgeladene Texturen und so weiter. Dies geschieht am besten im Voraus (z. B. beim Laden auf einer Ebene), kann jedoch möglicherweise zur Laufzeit erfolgen (in einer offenen Welt können Sie Dinge laden, wenn Sie sich einem Block nähern, dies sollte jedoch noch im Voraus erfolgen).

5B) Erstellen Sie optional einen zwischengespeicherten Render-Baum, wenn Sie Objekte in der Nähe sortieren oder nachverfolgen möchten, die möglicherweise zu einem späteren Zeitpunkt sichtbar sind. Andernfalls können Sie den räumlichen Index jedes Mal abfragen, wenn dies von Ihren Spiel- / Leistungsanforderungen abhängt.

Ihr Renderer benötigt wahrscheinlich ein RenderBinding-Objekt, das die Koordinaten zwischen dem Objekt und den Koordinaten verknüpft

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

Wenn Sie dann rendern, führen Sie einfach die Liste aus.

Ich habe oben Referenzen verwendet, aber sie können intelligente Zeiger, unformatierte Zeiger, Objekthandles usw. sein.

BEARBEITEN:

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
    }
}

Was die gegenseitige Bewusstwerdung betrifft. Das ist Kollisionserkennung. Es würde wahrscheinlich im Octree implementiert werden. Sie müssten in Ihrem Hauptobjekt einen Rückruf bereitstellen. Dieses Zeug wird am besten von einer richtigen Physik-Engine wie Bullet gehandhabt. In diesem Fall ersetzen Sie Octree einfach durch PhysicsScene und Position durch einen Link zu CollisionMesh.getPosition ().

David C. Bishop
quelle
Wow, das sieht sehr gut aus. Ich glaube, ich habe die Grundidee begriffen, aber ohne weiteres Beispiel kann ich mir das nicht so recht vorstellen. Haben Sie weitere Referenzen oder Live-Beispiele dazu? (Ich werde diese Antwort in der Zwischenzeit noch eine Weile lesen.)
Schuh
Ich habe keine wirklichen Beispiele, es ist nur das, was ich vorhabe, wenn ich Zeit habe. Ich werde noch ein paar der Gesamtklassen hinzufügen und sehen, ob das hilft. Es gibt dies und das . Es geht mehr um Objektklassen als um ihre Beziehung oder das Rendern. Da ich es selbst nicht implementiert habe, kann es sein, dass es Fallstricke gibt, Teile, die bearbeitet werden müssen, oder Performance-Dinge, aber ich denke, die Gesamtstruktur ist in Ordnung.
David C. Bishop