Up to date

This page is up to date for Godot 4.2. If you still find outdated information, please open an issue.

Szenenorganisation

Dieser Artikel behandelt Themen im Zusammenhang mit der effektiven Organisation von Szeneninhalten. Welche Nodes sollte man verwenden? Wo sollte man sie platzieren? Wie sollten sie interagieren?

Wie man effektiv Beziehungen aufbaut

Wenn Godot-Anwender beginnen, ihre eigenen Szenen zu erstellen, stoßen sie häufig auf das folgende Problem:

Sie erstellen ihre erste Szene und füllen sie mit Inhalten, um dann irgendwann Teile ihrer Szene in separate Szenen zu speichern, da sich im Laufe der Zeit das Gefühl einstellt, dass sie die Dinge aufteilen sollten. Dann stellen sie jedoch fest, dass die direkten Verweise, auf die sie sich zuvor verlassen konnten, nicht mehr möglich sind. Die Wiederverwendung der Szene an mehreren Stellen führt zu Problemen, da die Node-Pfade nicht mehr ihre Ziele finden und Signalverbindungen, die im Editor erstellt wurde, abbrechen.

Um diese Probleme zu beheben, muss man die Unterszenen so instanziieren, dass sie keine Details zu ihrer Umgebung benötigen. Man muss darauf vertrauen können, dass sich die Unterszene selbst erstellt, ohne wählerisch damit zu sein, wie man sie verwendet.

Eines der wichtigsten Dinge, die bei OOP berücksichtigt werden müssen, ist die Beibehaltung konzentrierter Einzweck-Klassen mit loser Kopplung an andere Teile der Codebasis. Dies hält die Größe von Objekten klein (zwecks Wartbarkeit) und verbessert deren Wiederverwendbarkeit.

Diese bewährten OOP-Praktiken haben mehrere Konsequenzen für bewährte Praktiken in der Szenenstruktur und der Skriptverwendung.

Wenn irgend möglich, sollte man Szenen so gestalten, dass sie keine Abhängigkeiten aufweisen. Das heißt, man sollte Szenen schaffen, die alles, was sie brauchen, in sich tragen.

Muss eine Szene mit einem externen Kontext interagieren, empfehlen erfahrene Entwickler die Verwendung von Dependency Injection. Dieses Verfahren beschreibt die Bereitstellung einer High-Level-API für die Abhängigkeiten der Low-Level-API. Warum das? Weil Klassen, die sich auf ihre externe Umgebung verlassen, ungewollt Bugs und unerwartetes Verhalten auslösen können.

Dazu muss man Daten verfügbar machen und sich dann auf einen übergeordneten Kontext verlassen, um sie zu initialisieren:

  1. Mit einem Signal verbinden. Äußerst sicher, sollte aber nur verwendet werden, um auf ein Verhalten zu "reagieren", nicht, um es einzuleiten. Signalnamen bestehen in der Regel aus Verben in der Vergangenheitsform, z.B. "entered", "skill_activated" oder "item_collected".

    # Parent
    $Child.signal_name.connect(method_on_the_object)
    
    # Child
    signal_name.emit() # Triggers parent-defined behavior.
    
  2. Eine Methode aufrufen. Wird verwendet, um ein Verhalten einzuleiten.

    # Parent
    $Child.method_name = "do"
    
    # Child, assuming it has String property 'method_name' and method 'do'.
    call(method_name) # Call parent-defined method (which child must own).
    
  3. Initialisieren einer Callable-Property. Sicherer als eine Methode, da es nicht nötig ist, die Methode zu besitzen. Wird verwendet, um ein Verhalten einzuleiten.

    # Parent
    $Child.func_property = object_with_method.method_on_the_object
    
    # Child
    func_property.call() # Call parent-defined method (can come from anywhere).
    
  4. Initialisieren eines Nodes oder einer anderen Objektreferenz.

    # Parent
    $Child.target = self
    
    # Child
    print(target) # Use parent-defined node.
    
  5. Initialisieren eines NodePath.

    # Parent
    $Child.target_path = ".."
    
    # Child
    get_node(target_path) # Use parent-defined NodePath.
    

Diese Optionen verbergen die Zugriffspunkte vor dem Child-Node. Dadurch bleibt der Child-Node wiederum lose an seine Umgebung gekoppelt. Man kann ihn in einem anderen Kontext wiederverwenden, ohne zusätzliche Änderungen an seiner API vorzunehmen.

Bemerkung

Obwohl die obigen Beispiele Parent-Child-Beziehungen illustrieren, gelten die gleichen Prinzipien für alle Objektbeziehungen. Nodes, die benachbart sind, sollten sich nur ihrer Hierarchien bewusst sein, während ein übergeordneter Node ihre Kommunikation und Referenzen vermittelt.

# Parent
$Left.target = $Right.get_node("Receiver")

# Left
var target: Node
func execute():
    # Do something with 'target'.

# Right
func _init():
    var receiver = Receiver.new()
    add_child(receiver)

Dieselben Prinzipien gelten auch für Nicht-Node-Objekte, die Abhängigkeiten von anderen Objekten führen. Das Objekt, das die Objekte tatsächlich besitzt, sollte die Beziehungen zwischen ihnen verwalten.

Warnung

Man sollte es jedoch vorziehen, die Daten intern (innerhalb einer Szene) zu halten, da eine Abhängigkeit von einem externen Kontext, selbst einem lose gekoppelten, immer noch bedeutet, dass der Node eine bestimmte Information in seiner Umgebung erwartet. Die Designphilosophien des Projekts sollten dies verhindern. Ist das nicht der Fall, werden die inhärenten Verbindlichkeiten des Codes die Entwickler dazu zwingen, die Beziehungen zwischen Objekten haarklein über Dokumentation nachzuverfolgen. Dieses Phänomen wird als Development Hell bezeichnet. Code zu schreiben, der auf externe Dokumentation angewiesen ist, damit man ihn sicher verwenden kann, ist immer fehleranfällig.

Um die Erstellung und Pflege einer solchen Dokumentation zu vermeiden, konvertiert man den abhängigen Node ("Child" oben) in ein Tool-Skript, das _get_configuration_warnings() implementiert. Die Rückgabe eines nicht leeren PackedStringArray bewirkt, dass das Szenen-Dock ein Warnsymbol mit den Strings als Tooltip neben dem Node erzeugt. Dies ist das gleiche Symbol, das für Nodes wie dem Area2D-Node erscheint, wenn er keine Child-CollisionShape2D-Nodes definiert hat. Der Editor dokumentiert die Szene dann selbst durch den Skriptcode. Keine inhaltliche Duplizierung über die Dokumentation ist mehr notwendig.

Eine solche GUI kann Projektnutzer besser über wichtige Informationen zu einem Node informieren. Hat er externe Abhängigkeiten? Wurden diese Abhängigkeiten erfüllt? Andere Programmierer, und insbesondere Designer und Autoren, benötigen klare Anweisungen in den Meldungen, die ihnen sagen, was sie tun müssen, um ihn zu konfigurieren.

Warum also funktioniert dieses komplizierte Wechselspiel? Nun, weil Szenen am besten funktionieren, wenn sie allein arbeiten. Wenn sie nicht allein arbeiten können, dann ist die zweitbeste Lösung, mit anderen anonym zu arbeiten (mit minimalen harten Abhängigkeiten, d.h. loser Kopplung). Es kann unvermeidlich sein, dass Änderungen an einer Klasse vorgenommen werden müssen, und wenn diese Änderungen dazu führen, dass sie auf unvorhergesehene Weise mit anderen Szenen interagiert, dann werden die Dinge anfangen, zusammenzubrechen. Der Sinn all dieser Indirektion ist es, zu vermeiden, dass die Änderung einer Klasse sich negativ auf andere, von ihr abhängige Klassen auswirkt.

Skripte und Szenen als Erweiterungen von Engine-Klassen sollten allen OOP-Prinzipien folgen. Beispiele beinhalten...

Auswahl einer Node-Baum-Struktur

Ein Entwickler beginnt also mit der Arbeit an einem Spiel, um dann angesichts der riesigen Möglichkeiten, die sich ihm bieten, stecken zu bleiben. Sie wissen vielleicht, was sie tun wollen, welche Systeme sie haben wollen, aber wo soll das alles hin? Nun, wie man bei der Entwicklung seines Spiels vorgeht, bleibt immer jedem selbst überlassen. Man kann Node-Bäume auf unzählige Arten konstruieren. Aber für diejenigen, die sich nicht sicher sind, kann diese hilfreiche Anleitung ein Beispiel für eine sinnvolle Struktur geben, mit der man beginnen kann.

Ein Spiel sollte immer eine Art "Einstiegspunkt" haben; irgendwo kann der Entwickler definitiv nachvollziehen, wo die Dinge beginnen, damit er die Logik verfolgen kann, wenn sie an anderer Stelle fortgesetzt wird. Dieser Ort dient auch als grobe Übersicht über alle weiteren Daten und die Logik im Programm. Bei traditionellen Anwendungen wäre dies die "main"-Funktion. In unserem Fall wäre es ein Main-Node.

  • Node "Main" (main.gd)

Das main.gd-Skript würde dann als primärer Steuerblock des eigenen Spiels dienen.

Dann hat man seine eigentliche "Welt" im Spiel (eine 2D- oder 3D-Welt). Dies kann ein Child-Node von Main sein. Außerdem benötigt man für sein Spiel eine primäre GUI, von der die verschiedenen Menüs und Widgets des Projekts verwaltet werden.

  • Node "Main" (main.gd)
    • Node2D/Node3D "Welt" (game_world.gd)

    • Control "GUI" (gui.gd)

Beim Wechseln von Leveln kann man dann die Child-Nodes des "Welt"-Nodes austauschen. Das manuelle Ändern von Szenen gibt dem Benutzer die volle Kontrolle über die Übergänge seiner Spielwelt.

Der nächste Schritt besteht darin, zu überlegen, welche Gameplay-Systeme für ein Projekt erforderlich sind. Wenn man ein System hat, das ...

  1. alle seine Daten intern trackt

  2. global zugänglich sein sollte

  3. isoliert existieren sollte

... dann sollte man einen Autoload-'Singleton'-Node erstellen.

Bemerkung

Für kleinere Spiele wäre eine einfachere Alternative mit weniger Kontrolle ein Singleton namens "Game", das einfach die Methode SceneTree.change_scene_to_file() aufruft, um den Inhalt der Hauptszene auszutauschen. Diese Struktur behält mehr oder weniger "Welt" als Haupt-Spiel-Node.

Jede GUI müsste ebenfalls ein Singleton sein, ein vorübergehender Teil der "Welt" sein oder manuell als direkter Child-Node zum Root hinzugefügt werden. Andernfalls würden sich die GUI-Nodes bei Szenenübergängen auch selbst löschen.

Wenn man Systeme hat, die Daten anderer Systeme ändern, sollte man diese als eigene Skripte oder Szenen und nicht als Autoloads definieren. Weitere Informationen zu den Gründen finden Sie in der Dokumentation Autoloads im Vergleich zu Internal Nodes.

Jedes Subsystem innerhalb eines Spiels sollte seinen eigenen Abschnitt innerhalb des Szenenbaums haben. Man sollte Parent-Child-Beziehungen nur in Fällen verwenden, in denen die Nodes tatsächlich Elemente ihrer Parent-Nodes sind. Würde das Entfernen des Parent-Nodes bedeuten, dass man sinnvollerweise auch die Child-Nodes entfernen sollte? Wenn nicht, dann sollten sie ihren eigenen Platz in der Hierarchie als Nachbarn oder als eine andere Beziehung haben.

Bemerkung

In manchen Fällen muss man diese getrennten Nodes auch relativ zueinander positionieren. Zu diesem Zweck kann man die RemoteTransform3D / RemoteTransform2D-Nodes verwenden. Sie ermöglichen es einem Ziel-Node, ausgewählte Transformationselemente von dem Remote*-Node zu erben. Um den``Ziel``-NodePath zuzuweisen, verwenden Sie eine der folgenden Möglichkeiten:

  1. Ein zuverlässiger Dritter, wahrscheinlich ein Parent-Node, der die Zuweisung vermittelt.

  2. Eine Gruppe, um einfach eine Referenz auf den gewünschten Node zu ziehen (vorausgesetzt, es wird immer nur eines der Ziele geben).

Wann sollte man das tun? Das ist subjektiv. Das Dilemma entsteht, wenn man kleinteilig organisieren muss, wann ein Node sich durch den Szenenbaum bewegen muss, um sich selbst am Leben zu erhalten. Zum Beispiel...

  • Fügen Sie einen "Spieler"-Node zu einem "Raum" hinzu.

  • Sie wollen den Raum wechseln, also müssen Sie den aktuellen Raum löschen.

  • Bevor der Raum gelöscht werden kann, muss der Spieler gesichert und/oder bewegt werden.

    Gibt es Bedenken in Hinsicht auf Speicherplatz?

    • Falls nicht, kann man einfach die beiden Räume erstellen, den Spieler bewegen und den alten Raum löschen. Kein Problem.

    Falls doch, muss man...

    • Den Spieler an eine andere Stelle im Baum bewegen.

    • Den Raum löschen.

    • Den neuen Raum instanziieren und hinzufügen.

    • Den Spieler wieder hinzufügen.

Das Problem ist, dass der Spieler hier ein "Sonderfall" ist; Einer, bei dem die Entwickler wissen müssen, dass sie den Spieler für das Projekt so handhaben müssen. Daher besteht die einzige Möglichkeit, diese Informationen in einem Team zuverlässig weiterzugeben darin, sie zu dokumentieren. Es ist jedoch gefährlich, Implementierungsdetails in der Dokumentation zu verwalten. Dies ist eine Wartungslast, von der die Lesbarkeit des Codes beeinträchtigt wird und die den Wissensgehalt eines Projekts unnötig aufbläht.

In einem etwas komplexeren Spiel mit größeren Assets kann es durchaus eine gute Idee sein, den Spieler irgendwo anders im Szenenbaum aufzubewahren. Dies führt zu:

  1. Mehr Konsistenz.

  2. Keine "Spezialfälle", die weder dokumentiert noch irgendwo gepflegt werden müssen.

  3. Keine Möglichkeit für das Auftreten von Fehlern, da diese Details nicht berücksichtigt werden müssen.

Wenn man dagegen einen Child-Node haben möchte, der die Transformation des Parent-Nodes nicht erbt, hat man folgende Möglichkeiten:

  1. Die deklarative Lösung: Setzen Sie einen Node dazwischen. Als Node ohne Transformation geben Nodes solche Informationen nicht an ihre Child-Nodes weiter.

  2. Die imperative Lösung: Verwenden Sie die Property top_level für den CanvasItem- oder Node3D-Node. Dies führt dazu, dass der Node seine geerbte Transformation ignoriert.

Bemerkung

Wenn Sie ein Netzwerkspiel erstellen, sollten Sie darauf achten, welche Nodes und Spielsysteme für alle Spieler relevant sind und welche nur für den steuernden Server. Zum Beispiel müssen nicht alle Benutzer eine Kopie der "PlayerController"-Logik jedes Spielers haben. Stattdessen benötigen sie nur ihre eigene. Wenn Sie diese in einem von "Welt" getrennten Zweig aufbewahren, können Sie die Verwaltung von Spielverbindungen und Ähnlichem vereinfachen.

Der Schlüssel zur Organisation der Szene liegt darin, den Szenenbaum in relationalen Begriffen und nicht in räumlichen Begriffen zu betrachten. Sind die Nodes von der Existenz ihres Parent-Nodes abhängig? Wenn nicht, dann können sie ganz allein an anderer Stelle gedeihen. Wenn sie abhängig sind, dann ist es logisch, dass sie Child-Nodes dieses Parents sein sollten (und wahrscheinlich Teil der Szene dieses Parents, wenn sie es nicht schon sind).

Bedeutet dies, dass die Nodes selbst Komponenten sind? Ganz und gar nicht. Die Node-Bäume von Godot bilden eine Aggregationsbeziehung, keine Kompositionsbeziehung. Man hat zwar immer noch die Flexibilität, Nodes zu verschieben, aber es ist immer noch am besten, wenn solche Verschiebungen im Normalfall unnötig sind.