Wie vermeide ich zirkuläre Abhängigkeiten zwischen Player und World?

60

Ich arbeite an einem 2D-Spiel, in dem Sie sich nach oben, unten, links und rechts bewegen können. Ich habe im Wesentlichen zwei Spiellogikobjekte:

  • Spieler: Hat eine Position relativ zur Welt
  • Welt: Zeichnet die Karte und den Spieler

Bisher hängt die Welt vom Spieler ab (dh sie hat einen Verweis darauf), und ihre Position muss ermittelt werden, wo der Spielercharakter gezeichnet werden soll und welcher Teil der Karte gezeichnet werden soll.

Jetzt möchte ich die Kollisionserkennung hinzufügen, damit sich der Spieler nicht durch Wände bewegen kann.

Der einfachste Weg, den ich mir vorstellen kann, ist, den Spieler die Welt fragen zu lassen, ob die beabsichtigte Bewegung möglich ist. Aber das würde eine zirkuläre Abhängigkeit zwischen Spieler und Welt hervorrufen (dh jeder hat einen Bezug zum anderen), die es wert ist, vermieden zu werden. Der einzige Weg, den ich mir ausgedacht habe, ist, die Welt den Spieler bewegen zu lassen , aber ich finde das etwas unintuitiv.

Was ist meine beste Option? Oder lohnt es sich nicht, eine zirkuläre Abhängigkeit zu vermeiden?

futlib
quelle
4
Warum halten Sie eine zirkuläre Abhängigkeit für eine schlechte Sache? stackoverflow.com/questions/1897537/…
Fuhrmanator
@Fuhrmanator Ich denke nicht, dass sie im Allgemeinen eine schlechte Sache sind, aber ich müsste die Dinge in meinem Code etwas komplexer gestalten, um sie einzuführen.
Futlib
Ich habe einen Artikel über unsere kleine Diskussion geschrieben, aber nichts Neues: yannbane.com/2012/11/… ...
jcora 18.11.12

Antworten:

61

Die Welt sollte sich nicht selbst zeichnen. Der Renderer sollte die Welt zeichnen. Der Spieler sollte sich nicht selbst zeichnen. Der Renderer sollte den Player relativ zur Welt zeichnen.

Der Spieler sollte die Welt nach der Kollisionserkennung fragen. oder vielleicht sollten Kollisionen von einer separaten Klasse behandelt werden, die die Kollisionserkennung nicht nur gegen die statische Welt, sondern auch gegen andere Akteure prüft.

Ich denke, die Welt sollte sich des Spielers wahrscheinlich überhaupt nicht bewusst sein. Es sollte ein einfaches Primitiv sein, kein Gott-Objekt. Der Player muss wahrscheinlich einige World-Methoden aufrufen, möglicherweise indirekt (Kollisionserkennung oder Überprüfung auf interaktive Objekte usw.).

Liosan
quelle
25
@ snake5 - Es gibt einen Unterschied zwischen "kann" und "sollte". Alles kann alles zeichnen - aber wenn Sie Code ändern müssen, der sich mit dem Zeichnen befasst, ist es viel einfacher, zur Klasse "Renderer" zu wechseln, als nach dem "Alles" zu suchen, das gezeichnet wird. "Besessenheit von Kompartimentierung" ist ein anderes Wort für "Zusammenhalt".
Nate
16
@ Mr.Beast, nein, ist er nicht. Er plädiert für gutes Design. Es macht keinen Sinn, alles in einem Fehler einer Klasse zu packen.
JCORA
23
Ich hätte nicht gedacht, dass es eine solche Reaktion auslösen würde :) Ich habe der Antwort nichts hinzuzufügen, aber ich kann erklären, warum ich sie gegeben habe - weil ich denke, dass es einfacher ist. Nicht 'richtig' oder 'richtig'. Ich wollte nicht, dass es so klingt. Für mich ist es einfacher, weil eine Aufteilung, wenn ich mich mit Klassen mit zu vielen Verantwortlichkeiten befasse, schneller ist als das Erzwingen der Lesbarkeit des vorhandenen Codes. Ich mag Code in Chunks, die ich verstehen kann, und als Reaktion auf Probleme wie das bei @futlib auftretende Refactor.
Liosan
12
@ snake5 Wenn ich sage, dass das Hinzufügen weiterer Klassen den Aufwand für den Programmierer erhöht, ist das meiner Erfahrung nach oftmals völlig falsch. Meiner Meinung nach sind 10x100 Linienklassen mit informativen Namen und klar definierten Verantwortlichkeiten für den Programmierer leichter zu lesen und mit weniger Aufwand verbunden als eine einzelne 1000 Liniengottklasse .
Martin
7
Als Hinweis darauf, was was zeichnet, ist eine RendererArt von notwendig, aber das bedeutet nicht, dass die Logik für das Rendern der Renderereinzelnen Elemente von der verarbeitet wird. Jedes zu zeichnende Element sollte wahrscheinlich von einer gemeinsamen Schnittstelle wie z IDrawableoder IRenderable(oder eine entsprechende Benutzeroberfläche in der von Ihnen verwendeten Sprache). Die Welt könnte die sein Renderer, nehme ich an, aber das scheint, als würde es seine Verantwortung überschreiten, besonders wenn es bereits eine IRenderableSelbst war.
zzzzBov
35

So geht eine typische Rendering-Engine mit diesen Dingen um:

Es gibt einen grundsätzlichen Unterschied zwischen dem Ort, an dem sich ein Objekt im Raum befindet, und dem Ort, an dem das Objekt gezeichnet wird.

  1. Ein Objekt zeichnen

    In der Regel verfügen Sie über eine Renderer- Klasse, die dies ausführt . Es nimmt einfach ein Objekt (Modell) und zeichnet auf dem Bildschirm. Es kann Methoden wie drawSprite (Sprite), drawLine (..), drawModel (Model) enthalten, was auch immer Sie benötigen. Es ist ein Renderer, also soll er all diese Dinge tun. Es wird auch jede API verwendet, die Sie darunter haben, sodass Sie beispielsweise einen Renderer haben können, der OpenGL verwendet, und einen, der DirectX verwendet. Wenn Sie Ihr Spiel auf eine andere Plattform portieren möchten, schreiben Sie einfach einen neuen Renderer und verwenden diesen. So einfach ist das.

  2. Ein Objekt verschieben

    Jedes Objekt ist an etwas gebunden, das wir gerne als SceneNode bezeichnen . Sie erreichen dies durch Komposition. Ein SceneNode enthält ein Objekt. Das ist es. Was ist ein SceneNode? Es ist eine einfache Klasse, die alle Transformationen (Position, Drehung, Skalierung) eines Objekts (normalerweise relativ zu einem anderen SceneNode) zusammen mit dem tatsächlichen Objekt enthält.

  3. Objekte verwalten

    Wie werden SceneNodes verwaltet? Durch einen SceneManager . Diese Klasse erstellt und verfolgt jeden SceneNode in Ihrer Szene. Sie können ihn nach einem bestimmten SceneNode (normalerweise durch einen String-Namen wie "Player" oder "Table" gekennzeichnet) oder einer Liste aller Knoten fragen.

  4. Die Welt zeichnen

    Dies sollte mittlerweile ziemlich offensichtlich sein. Gehen Sie einfach durch jeden SceneNode in der Szene und lassen Sie ihn vom Renderer an der richtigen Stelle zeichnen. Sie können es an der richtigen Stelle zeichnen, indem Sie den Renderer die Transformationen eines Objekts speichern lassen, bevor Sie es rendern.

  5. Kollisionserkennung

    Das ist nicht immer trivial. Normalerweise können Sie in der Szene abfragen, welches Objekt sich an einem bestimmten Punkt im Raum befindet oder welche Objekte ein Strahl schneidet. Auf diese Weise können Sie einen Strahl von Ihrem Player in Bewegungsrichtung erstellen und den Szenenmanager fragen, welches Objekt der erste Strahl schneidet. Sie können dann den Spieler an die neue Position bewegen, ihn um einen kleineren Betrag bewegen (um ihn neben das kollidierende Objekt zu bringen) oder ihn überhaupt nicht bewegen. Stellen Sie sicher, dass diese Abfragen von separaten Klassen verarbeitet werden. Sie sollten den SceneManager nach einer Liste von SceneNodes fragen, aber es ist eine andere Aufgabe, zu bestimmen, ob dieser SceneNode einen Punkt im Raum abdeckt oder sich mit einem Strahl schneidet. Beachten Sie, dass der SceneManager nur Knoten erstellt und speichert.

Also, was ist der Spieler und was ist die Welt?

Der Player kann eine Klasse sein, die einen SceneNode enthält, der wiederum das zu rendernde Modell enthält. Sie bewegen den Player, indem Sie die Position des Szenenknotens ändern. Die Welt ist einfach eine Instanz des SceneManager. Es enthält alle Objekte (über SceneNodes). Sie behandeln die Kollisionserkennung, indem Sie den aktuellen Status der Szene abfragen.

Dies ist keineswegs eine vollständige oder genaue Beschreibung dessen, was in den meisten Motoren vor sich geht, sondern soll Ihnen helfen, die Grundlagen zu verstehen und warum es wichtig ist, die von SOLID unterstrichenen OOP-Prinzipien zu respektieren . Geben Sie sich nicht der Idee hin, dass es zu schwierig ist, Ihren Code zu restrukturieren, oder dass es Ihnen nicht wirklich hilft. Sie werden in Zukunft viel mehr gewinnen, wenn Sie Ihren Code sorgfältig entwerfen.

Rootlocus
quelle
+1 - Ich habe festgestellt, dass ich meine Spielsysteme in etwa so aufgebaut habe und finde, dass sie sehr flexibel sind.
Cypher
+1, tolle Antwort. Konkreter und auf den Punkt als meine eigene.
JCORA
+1, ich habe so viel aus dieser Antwort gelernt und es hatte sogar ein inspirierendes Ende. Thanks @rootlocus
joslinm
16

Warum solltest du das vermeiden wollen? Zirkuläre Abhängigkeiten sollten vermieden werden, wenn Sie eine wiederverwendbare Klasse erstellen möchten. Aber der Player ist keine Klasse, die überhaupt wiederverwendbar sein muss. Möchten Sie den Player jemals ohne Welt verwenden? Wahrscheinlich nicht.

Denken Sie daran, dass Klassen nichts anderes als Funktionssammlungen sind. Die Frage ist nur, wie man die Funktionalität teilt. Tun Sie, was immer Sie tun müssen. Wenn Sie eine zirkuläre Dekadenz brauchen, dann sei es so. (Gleiches gilt übrigens für alle OOP-Funktionen. Codieren Sie die Dinge so, dass sie einem Zweck dienen, und folgen Sie den Paradigmen nicht einfach blind.)

Bearbeiten
Okay, um die Frage zu beantworten: Sie können vermeiden, dass der Player die Welt für Kollisionsprüfungen kennen muss, indem Sie Rückrufe verwenden:

World::checkForCollisions()
{
  [...]
  foreach(entityA in entityList)
    foreach(entityB in entityList)
      if([... entityA and entityB have collided ...])
         entityA.onCollision(entityB);
}

Player::onCollision(other)
{
  [... react on the collision ...]
}

Die Art der Physik, die Sie in der Frage beschrieben haben, kann von der Welt gehandhabt werden, wenn Sie die Geschwindigkeit der Entitäten exponieren:

World::calculatePhysics()
{ 
  foreach(entityA in entityList)
    foreach(entityB in entityList)
    {
      [... move entityA according to its velocity as far as possible ...]
      if([... entityA has collided with the world ...])
         entityA.onWorldCollision();
      [... calculate the movement of entityB in order to know if A has collided with B ...]
      if([... entityA and entityB have collided ...])
         entityA.onCollision(entityB);
    }
}

Beachten Sie jedoch, dass Sie wahrscheinlich früher oder später eine Abhängigkeit von der Welt benötigen, dh wann immer Sie die Funktionalität der Welt benötigen: Sie möchten wissen, wo sich der nächste Feind befindet? Sie möchten wissen, wie weit der nächste Sims entfernt ist? Abhängigkeit ist es.

API-Biest
quelle
4
+ 1-Kreisabhängigkeit ist hier kein wirkliches Problem. In dieser Phase gibt es keinen Grund, sich darüber Sorgen zu machen. Wenn das Spiel wächst und der Code reift, wird es wahrscheinlich eine gute Idee sein, diese Spieler- und Weltklassen sowieso in Unterklassen umzugestalten, ein geeignetes komponentenbasiertes System zu haben, Klassen für die Eingabebearbeitung, vielleicht ein Rendered usw. Aber für Ein Anfang, kein Problem.
Laurent Couvidou
4
-1, das ist definitiv nicht der einzige Grund, keine zirkulären Abhängigkeiten einzuführen. Wenn Sie sie nicht einführen, können Sie Ihr System einfacher erweitern und ändern.
JCORA
4
@Bane Ohne diesen Kleber kann man nichts codieren. Der Unterschied ist nur, wie viel Indirektion Sie hinzufügen. Wenn Sie die Klassen Spiel -> Welt -> Entität haben oder wenn Sie die Klassen Spiel -> Welt haben, SoundManager, InputManager, PhysicsEngine, ComponentManager. Es macht die Dinge aufgrund des (syntaktischen) Aufwands und der damit verbundenen Komplexität weniger lesbar. Und irgendwann müssen die Komponenten miteinander interagieren. Und das ist der Punkt, an dem eine Klebeklasse die Dinge einfacher macht als alles, was auf mehrere Klassen aufgeteilt ist.
API-Beast
3
Nein, Sie bewegen die Torpfosten. Natürlich muss etwas anrufen render(World). Die Debatte dreht sich darum, ob der gesamte Code innerhalb einer Klasse vollgestopft werden soll oder ob Code in logische und funktionale Einheiten unterteilt werden soll, die dann einfacher zu warten, zu erweitern und zu verwalten sind. Übrigens, viel Glück beim Wiederverwenden dieser Komponentenmanager, Physik-Engines und Input-Manager, die alle geschickt undifferenziert und vollständig gekoppelt sind.
JCORA
1
@Bane Es gibt andere Möglichkeiten, Dinge in logische Blöcke zu unterteilen, als neue Klassen einzuführen, übrigens. Sie können genauso gut neue Funktionen hinzufügen oder Ihre Dateien in mehrere Abschnitte unterteilen, die durch Kommentarblöcke getrennt sind. Es einfach zu halten bedeutet nicht, dass der Code ein Chaos sein wird.
API-Beast
13

Ihr aktuelles Design scheint gegen das erste Prinzip des SOLID-Designs zu verstoßen .

Dieses erste Prinzip, das als "Prinzip der einmaligen Verantwortung" bezeichnet wird, ist im Allgemeinen eine gute Richtlinie, um keine monolithischen, alles tuenden Objekte zu erstellen, die Ihrem Design immer schaden.

Zum Konkretisieren ist Ihr WorldObjekt sowohl dafür verantwortlich, den Spielstatus zu aktualisieren und beizubehalten als auch alles zu zeichnen.

Was ist, wenn sich Ihr Rendering-Code ändert / ändern muss? Warum sollten Sie beide Klassen aktualisieren müssen, die eigentlich nichts mit Rendern zu tun haben? Wie Liosan bereits gesagt hat, solltest du eine haben Renderer.


Nun, um Ihre eigentliche Frage zu beantworten ...

Es gibt viele Möglichkeiten, dies zu tun, und dies ist nur eine Möglichkeit, sich zu entkoppeln:

  1. Die Welt weiß nicht, was ein Spieler ist.
    • Es gibt zwar eine Liste mit Objects, in der sich der Player befindet, dies hängt jedoch nicht von der Player-Klasse ab (verwenden Sie die Vererbung, um dies zu erreichen).
  2. Der Player wird von einigen aktualisiert InputManager.
  3. Die Welt kümmert sich um die Erkennung von Bewegungen und Kollisionen, wendet die richtigen physischen Änderungen an und sendet Aktualisierungen an Objekte.
    • Wenn zum Beispiel Objekt A und Objekt B kollidieren, wird die Welt sie informieren und dann könnten sie selbst damit umgehen.
    • Die Welt würde immer noch mit Physik umgehen (wenn Ihr Design so ist).
    • Dann könnten beide Objekte sehen, ob die Kollision sie interessiert oder nicht. Wenn zum Beispiel Objekt A der Spieler war und Objekt B ein Spike, dann könnte der Spieler sich selbst Schaden zufügen.
    • Dies kann jedoch auch auf andere Weise gelöst werden.
  4. Der Rendererzeichnet alle Objekte.
jcora
quelle
Sie sagen, die Welt weiß nicht, was ein Spieler ist, aber sie erkennt Kollisionen, die möglicherweise die Eigenschaften des Spielers kennen müssen, wenn es sich um eines der kollidierenden Objekte handelt.
Markus von Broady
Vererbung, die Welt muss sich einer Art von Objekten bewusst sein, die allgemein beschrieben werden können. Das Problem besteht nicht darin, dass die Welt nur einen Verweis auf den Spieler hat, sondern dass es von ihm als Klasse abhängen könnte (dh Felder verwenden, wie healthsie nur diese Instanz von Playerhat).
JCORA
Ah, du meinst, die Welt hat keinen Bezug zum Player, sie hat nur eine Reihe von Objekten, die die ICollidable-Schnittstelle implementieren, zusammen mit dem Player, falls erforderlich.
Markus von Broady
2
+1 Gute Antwort. Aber: "Ignorieren Sie bitte alle Leute, die sagen, dass gutes Software-Design nicht wichtig ist". Verbreitet. Niemand hat das gesagt.
Laurent Couvidou
2
Bearbeitet! Es schien irgendwie unnötig ...
JCORA
1

Der Spieler sollte die Welt nach Dingen wie Kollisionserkennung fragen. Der Weg, um die zirkuläre Abhängigkeit zu vermeiden, besteht darin, dass die Welt keine Abhängigkeit vom Spieler hat. Die Welt muss wissen, wo sie selbst zeichnet: Möglicherweise möchten Sie, dass das Objekt weiter entfernt ist, möglicherweise mit einem Verweis auf ein Kameraobjekt, das wiederum einen Verweis auf eine zu verfolgende Entität enthalten kann.

Was Sie in Bezug auf Zirkelverweise vermeiden möchten, ist nicht so sehr, dass Sie sich gegenseitig verweisen, sondern dass Sie sich im Code explizit aufeinander beziehen.

Tom Johnson
quelle
1

Wann immer zwei verschiedene Arten von Objekten sich gegenseitig fragen können. Sie sind voneinander abhängig, da sie einen Verweis auf den anderen enthalten müssen, um dessen Methoden aufzurufen.

Sie können eine zirkuläre Abhängigkeit vermeiden, indem Sie die Welt den Spieler fragen lassen, aber der Spieler kann die Welt nicht fragen oder umgekehrt. Auf diese Weise hat die Welt Referenzen zu den Spielern, aber die Spieler brauchen keine Referenzen zu der Welt. Oder umgekehrt. Aber das wird das Problem nicht lösen, denn die Welt müsste die Spieler fragen, ob sie etwas zu fragen haben, und sie beim nächsten Anruf informieren ...

Sie können dieses "Problem" also nicht wirklich umgehen, und ich glaube, das ist kein Grund zur Sorge. Halte das Design so einfach wie möglich.

Calmarius
quelle
0

Wenn Sie die Details zu Spieler und Welt streichen, möchten Sie in einem einfachen Fall keine zirkuläre Abhängigkeit zwischen zwei Objekten einführen (was je nach Ihrer Sprache möglicherweise nicht einmal von Bedeutung ist, siehe den Link im Kommentar von Fuhrmanator). Es gibt mindestens zwei sehr einfache strukturelle Lösungen, die für dieses und ähnliche Probleme gelten würden:

1) Fügen Sie das Singleton- Muster in Ihre Weltklasse ein . Auf diese Weise kann der Spieler (und jedes andere Objekt) das Weltobjekt leicht finden, ohne dass teure Suchvorgänge oder permanente Verknüpfungen erforderlich sind. Der Kern dieses Musters besteht lediglich darin, dass die Klasse einen statischen Verweis auf die einzige Instanz dieser Klasse hat, die bei der Instanziierung des Objekts festgelegt und beim Löschen gelöscht wird.

Abhängig von Ihrer Entwicklungssprache und der gewünschten Komplexität können Sie diese problemlos als Oberklasse oder Schnittstelle implementieren und für viele Hauptklassen wiederverwenden, von denen Sie nicht erwarten, dass sie mehr als eine in Ihrem Projekt haben.

2) Wenn die Sprache, in der Sie entwickeln, dies unterstützt (viele unterstützen dies), verwenden Sie eine schwache Referenz . Dies ist eine Referenz, die sich nicht auf Dinge wie die Speicherbereinigung auswirkt. In genau diesen Fällen ist es hilfreich, nur darauf zu achten, dass keine Annahmen darüber getroffen werden, ob das Objekt, auf das Sie schwach verweisen, noch vorhanden ist.

In Ihrem speziellen Fall könnten Ihre Spieler einen schwachen Bezug zur Welt haben. Der Vorteil davon (wie beim Singleton) ist, dass Sie nicht in jedem Frame nach dem Weltobjekt suchen müssen oder über einen permanenten Verweis verfügen müssen, der Prozesse behindert, die von zirkulären Verweisen wie Garbage Collection betroffen sind.

FlintZA
quelle
0

Wie die anderen gesagt haben, denke ich, dass Sie Worldeine Sache zu viel tun: Es wird versucht, sowohl das Spiel zu enthalten Map(das eine eigenständige Einheit sein sollte) als auch gleichzeitig ein Rendererzu sein.

Erstellen Sie also ein neues Objekt (das GameMapmöglicherweise aufgerufen wird) und speichern Sie die Daten auf Kartenebene darin. Schreiben Sie Funktionen, die mit der aktuellen Karte interagieren.

Dann brauchen Sie auch ein RendererObjekt. Sie könnten dieses machen RendererObjekt das Ding , das beide enthält GameMap und Player(sowie Enemies) und ziehen sie auch.

Bobobobo
quelle
-6

Sie können zirkuläre Abhängigkeiten vermeiden, indem Sie die Variablen nicht als Member hinzufügen. Verwenden Sie eine statische CurrentWorld () - Funktion für den Player oder ähnliches. Erfinden Sie keine andere Schnittstelle als die, die bereits in World implementiert ist. Dies ist jedoch völlig unnötig.

Es ist auch möglich, die Referenz vor / während der Zerstörung des Player-Objekts zu zerstören, um die durch Zirkelverweise verursachten Probleme effektiv zu stoppen.

Schlange5
quelle
1
Ich bin bei dir. OOP ist zu überbewertet. Tutorials und Schulungen springen schnell zu OO, nachdem Sie die grundlegenden Funktionen des Kontrollflusses erlernt haben. OO-Programme sind im Allgemeinen langsamer als prozeduraler Code, da es zwischen Ihren Objekten Bürokratie gibt und Sie viele Zeigerzugriffe haben, was zu einer Unmenge von Cache-Fehlern führt. Dein Spiel funktioniert aber sehr langsam. Die realen, sehr schnellen und funktionsreichen Spiele mit einfachen globalen Arrays und handoptimierten, fein abgestimmten Funktionen für alles, um Cache-Ausfälle zu vermeiden. Dies kann zu einer Verzehnfachung der Leistung führen.
Calmarius