Szenenorganisation

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

Wie man effektiv Beziehungen aufbaut

Wenn Godot-Nutzer anfangen 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 schließlich Teile ihrer Szene in separate Szenen zu speichern, da sich das nagende Gefühl, dass sie die Dinge aufteilen sollten, zu häufen beginnt. 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 ihre Ziele und Signalverbindungen nicht finden, die in der Editorunterbrechung hergestellt wurden.

Um diese Probleme zu beheben, müssen die Unterszenen instanziiert werden, ohne dass Details zu ihrer Umgebung erforderlich sind. Man muss darauf vertrauen können, dass sich die Unterszene selbst erstellt, ohne wählerisch zu sein, wie man sie verwendet.

Eines der wichtigsten Dinge, die bei OOP berücksichtigt werden müssen, ist die Beibehaltung fokussierter Klassen für einzelne Zwecke mit loser Kopplung an andere Teile der Codebasis. Dies hält die Größe von Objekten klein (aus Gründen der Wartbarkeit) und verbessert deren Wiederverwendbarkeit.

Diese empfohlenen OOP Vorgehensweisen haben mehrere Implikationen für empfohlene Vorgehensweisen in der Szenenstruktur und der Skriptverwendung.

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

Wenn eine Szene mit einem externen Kontext interagieren muss, empfehlen erfahrene Entwickler die Verwendung von Dependency Injection. Diese Technik beinhaltet, dass eine High-Level-API die Abhängigkeiten der Low-Level-API bereitstellt. 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. Verbinden mit einem Signal. Äußerst sicher, sollte aber nur verwendet werden, um auf ein Verhalten zu "reagieren", nicht um es zu starten. Beachten Sie, dass Signalnamen in der Regel Verben in der Vergangenheitsform sind, z. B. "betreten", "Fertigkeit_aktiviert" oder "Gegenstand_gesammelt".

    # Parent
    $Child.connect("signal_name", object_with_method, "method_on_the_object")
    
    # Child
    emit_signal("signal_name") # Triggers parent-defined behavior.
    
    // Parent
    GetNode("Child").Connect("SignalName", ObjectWithMethod, "MethodOnTheObject");
    
    // Child
    EmitSignal("SignalName"); // Triggers parent-defined behavior.
    
  2. Ruft eine Methode auf. Wird zum Starten des Verhaltens verwendet.

    # 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).
    
    // Parent
    GetNode("Child").Set("MethodName", "Do");
    
    // Child
    Call(MethodName); // Call parent-defined method (which child must own).
    
  3. Initialisieren einer FuncRef Eigenschaft. Sicherer als eine Methode, da der Besitz der Methode unnötig ist. Wird verwendet, um das Verhalten zu starten.

    # Parent
    $Child.func_property = funcref(object_with_method, "method_on_the_object")
    
    # Child
    func_property.call_func() # Call parent-defined method (can come from anywhere).
    
    // Parent
    GetNode("Child").Set("FuncProperty", GD.FuncRef(ObjectWithMethod, "MethodOnTheObject"));
    
    // Child
    FuncProperty.CallFunc(); // 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.
    
    // Parent
    GetNode("Child").Set("Target", this);
    
    // Child
    GD.Print(Target); // Use parent-defined node.
    
  5. NodePath initialisieren.

    # Parent
    $Child.target_path = ".."
    
    # Child
    get_node(target_path) # Use parent-defined NodePath.
    
    // Parent
    GetNode("Child").Set("TargetPath", NodePath(".."));
    
    // Child
    GetNode(TargetPath); // Use parent-defined NodePath.
    

Diese Optionen verbergen die Zugriffspunkte vor dem untergeordneten Node. Dadurch bleibt der untergeordnete Node wiederum an seine Umgebung locker verbunden. Man kann es in einem anderen Kontext wiederverwenden, ohne zusätzliche Änderungen an seiner API vorzunehmen.

Bemerkung

Obwohl die obigen Beispiele Eltern-Kind-Beziehungen illustrieren, gelten die gleichen Prinzipien für alle Objektbeziehungen. Nodes, die Geschwister sind, sollten sich ihrer Hierarchie nur bewusst sein, während ein Vorfahre 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)
// Parent
GetNode<Left>("Left").Target = GetNode("Right/Receiver");

public class Left : Node
{
    public Node Target = null;

    public void Execute()
    {
        // Do something with 'Target'.
    }
}

public class Right : Node
{
    public Node Receiver = null;

    public Right()
    {
        Receiver = ResourceLoader.Load<Script>("Receiver.cs").New();
        AddChild(Receiver);
    }
}

Dieselben Prinzipien gelten auch für Nicht-Node-Objekte, die Abhängigkeiten von anderen Objekten beibehalten. Welches Objekt 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 das Node etwas in seiner Umgebung erwartet, das wahr ist. Die Designphilosophien des Projekts sollten dies verhindern. Wenn nicht, werden die inhärenten Verbindlichkeiten des Codes die Entwickler dazu zwingen, die Dokumentation zu benutzen, um die Beziehungen zwischen den Objekten auf einer mikroskopischen Skala im Auge zu behalten; dies ist auch als Entwicklungshölle bekannt. Code zu schreiben, der auf externe Dokumentation angewiesen ist, damit man ihn sicher verwenden kann, ist standardmäßig fehleranfällig.

Um die Erstellung und Pflege einer solchen Dokumentation zu vermeiden, konvertiert man das abhängige Node ("Kind" oben) in ein Werkzeugskript, das _get_configuration_warning() implementiert. Die Rückgabe einer nicht leeren Zeichenkette bewirkt, dass das Scene-Dock ein Warnsymbol mit der Zeichenkette als Tooltip neben des Nodes erzeugt. Dies ist das gleiche Symbol, das für Nodes wie das Node Area2D erscheint, wenn es keine untergeordneten Nodes CollisionShape2D definiert hat. Der Editor dokumentiert die Szene dann selbst durch den Skriptcode. Es ist keine inhaltliche Duplizierung über die Dokumentation notwendig.

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

Warum also dieses ganze komplexe Schaltkarussell? Nun, weil Szenen am besten funktionieren, wenn sie alleine arbeiten. Wenn sie nicht alleine arbeiten können, dann ist die anonyme Zusammenarbeit mit anderen (mit minimalen harten Abhängigkeiten, d.h. loser Kopplung) die nächstbeste Lösung. Es ist unvermeidlich, dass Änderungen an einer Klasse vorgenommen werden müssen, und wenn diese Änderungen dazu führen, dass sie mit anderen Szenen auf unvorhergesehene Weise interagiert, dann fangen die Dinge an, zusammenzubrechen. Der Sinn dieser ganzen Indirektion ist es, zu vermeiden, dass die Änderung einer Klasse sich negativ auf andere Klassen auswirkt.

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

Auswahl einer Node Tree-Struktur

So, a developer starts work on a game only to stop at the vast possibilities before them. They might know what they want to do, what systems they want to have, but where to put them all? Well, how one goes about making their game is always up to them. One can construct node trees in countless ways. But, for those who are unsure, this helpful guide can give them a sample of a decent structure to start with.

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 weitergeht. Dieser Ort dient auch als Vogelperspektive für alle anderen Daten und Logik im Programm. Bei traditionellen Anwendungen wäre dies die "main"-Funktion. In diesem Fall wäre es ein Main-Node.

  • Node "Main" (main.gd)

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

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

  • Node "Main" (main.gd)
    • Node2D/Spatial "World" (game_world.gd)

    • Control "GUI" (gui.gd)

Beim Ändern von Ebenen kann man dann die Kinder des Nodes "Welt" austauschen. Szenen manuell ändern 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 verfolgt

  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 "Game"-Singleton zu haben, das einfach die Methode SceneTree.change_scene() aufruft, um den Inhalt der Hauptszene auszutauschen. Diese Struktur behält mehr oder weniger die "Welt" als Hauptspielknoten.

Jede GUI müsste ebenfalls ein Singleton sein, ein vorübergehender Teil der "Welt" sein oder manuell als direktes Kind der Wurzel hinzugefügt werden. Andernfalls würden sich die GUI-Knoten bei Szenenübergängen auch selbst löschen.

If one has systems that modify other systems' data, one should define those as their own scripts or scenes rather than autoloads. For more information on the reasons, please see the Autoloads versus regular nodes documentation.

Jedes Subsystem innerhalb eines Spiels sollte seinen eigenen Abschnitt innerhalb des SceneTree haben. Man sollte Eltern-Kind-Beziehungen nur in den Fällen verwenden, in denen die Nodes tatsächlich Elemente ihrer Eltern sind. Bedeutet das Entfernen des Elternteils sinnvollerweise, dass man auch die Kinder entfernen sollte? Wenn nicht, dann sollte es seinen eigenen Platz in der Hierarchie als Geschwister oder eine andere Beziehung haben.

Bemerkung

In manchen Fällen benötigt man diese getrennten Nodes, um sich auch relativ zueinander zu positionieren. Zu diesem Zweck kann man die RemoteTransform / RemoteTransform2D-Nodes verwenden. Sie ermöglichen es einem Zielnode, ausgewählte Transformationselemente vom Remote*-Node bedingt zu erben. Um das target NodePath zuzuweisen, verwenden Sie eine der folgenden Möglichkeiten:

  1. Ein zuverlässiger Drittanbieter, wahrscheinlich ein übergeordneter Node, der die Zuweisung vermittelt.

  2. Eine Gruppe um einfach einen Verweis 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 sich beim bewegen eines Nodes um den SceneTree zum Selbsterhalt mit Kleinigkeiten aufhalten muss. Beispielsweise...

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

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

  • Bevor der Raum gelöscht werden kann, muss der Spieler erhalten oder bewegt werden.

    Ist der Hauptspeicher ein Problem?

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

    Falls ja, muss man...

    • Bewegen Sie den Spieler an eine andere Stelle im Baum.

    • Lösche diesen Raum.

    • Instanziieren und neuen Raum hinzufügen.

    • Füge den Spieler wieder hinzu.

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 als Team zuverlässig weiterzugeben darin, sie zu dokumentieren. Es ist jedoch gefährlich, Implementierungsdetails in der Dokumentation zu behalten. Dies ist eine Wartungslast, die die Lesbarkeit des Codes beeinträchtigt und den intellektuellen Inhalt eines Projekts unnötig aufbläht.

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

  1. Höhere Beständigkeit.

  2. Keine "Spezialfälle" die hier weder dokumentiert noch sonst wo unterhalten werden dürfen.

  3. Keine Möglichkeit für das Auftreten von Fehlern, da diese Details dafür nicht begründet werden können.

Falls man aber dennoch jemals ein Unterobjekt benötigen würde welches nicht den Transform des Eltern-Node übernehmen sollte, hat man dazu folgende Möglichkeiten:

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

  2. Die imperative Lösung: Verwenden Sie den Setter set_as_toplevel für das Node CanvasItem oder Spatial. Dies bewirkt, dass das Node seine geerbte Transformation ignoriert.

Bemerkung

Wenn Sie ein vernetztes Spiel erstellen, sollten Sie bedenken, welche Nodes und Spielsysteme für alle Spieler relevant sind und welche nur für den maßgeblichen 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 der "Welt" getrennten Zweig aufbewahren, können Sie die Verwaltung von Spielverbindungen und Ähnlichem vereinfachen.

Der Schlüssel zur Organisation der Szene liegt darin, den SceneTree in relationalen Begriffen und nicht in räumlichen Begriffen zu betrachten. Sind die Nodes von der Existenz ihrer Eltern 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 Kinder dieses Elternteils sein sollten (und wahrscheinlich Teil der Szene dieses Elternteils, wenn sie es nicht schon sind).

Bedeutet dies, dass Nodes selbst Komponenten sind? Nein, überhaupt nicht. Godot's Szenenbäume bilden eine ansammelnde Beziehung, nicht aber in etwa eine Zusammensetzung. Doch während man immer noch die Flexibilität hat Nodes herumzubewegen, ist es einfach immer noch das beste wenn diese Versetzungen nicht zwingend notwendig werden.