Szenenorganisation

This article covers topics related to the effective organization of scene content. Which nodes should one use? Where should one place them? How should they interact?

How to build relationships effectively

When Godot users begin crafting their own scenes, they often run into the following problem:

They create their first scene and fill it with content only to eventually end up saving branches of their scene into separate scenes as the nagging feeling that they should split things up starts to accumulate. However, they then notice that the hard references they were able to rely on before are no longer possible. Re-using the scene in multiple places creates issues because the node paths do not find their targets and signal connections established in the editor break.

To fix these problems, one must instantiate the sub-scenes without them requiring details about their environment. One needs to be able to trust that the sub-scene will create itself without being picky about how one uses it.

One of the biggest things to consider in OOP is maintaining focused, singular-purpose classes with loose coupling to other parts of the codebase. This keeps the size of objects small (for maintainability) and improves their reusability.

These OOP best practices have several implications for best practices in scene structure and script usage.

If at all possible, one should design scenes to have no dependencies. That is, one should create scenes that keep everything they need within themselves.

If a scene must interact with an external context, experienced developers recommend the use of Dependency Injection. This technique involves having a high-level API provide the dependencies of the low-level API. Why do this? Because classes which rely on their external environment can inadvertently trigger bugs and unexpected behavior.

To do this, one must expose data and then rely on a parent context to initialize it:

  1. Connect to a signal. Extremely safe, but should be used only to "respond" to behavior, not start it. Note that signal names are usually past-tense verbs like "entered", "skill_activated", or "item_collected".

    # 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. Call a method. Used to start behavior.

    # 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. Initialize a FuncRef property. Safer than a method as ownership of the method is unnecessary. Used to start behavior.

    # 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. Initialize a Node or other Object reference.

    # 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. Initialize a NodePath.

    # 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.
    

These options hide the points of access from the child node. This in turn keeps the child loosely coupled to its environment. One can re-use it in another context without any extra changes to its API.

Bemerkung

Although the examples above illustrate parent-child relationships, the same principles apply towards all object relations. Nodes which are siblings should only be aware of their hierarchies while an ancestor mediates their communications and references.

# 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

One should favor keeping data in-house (internal to a scene) though as placing a dependency on an external context, even a loosely coupled one, still means that the node will expect something in its environment to be true. The project's design philosophies should prevent this from happening. If not, the code's inherent liabilities will force developers to use documentation to keep track of object relations on a microscopic scale; this is otherwise known as development hell. Writing code that relies on external documentation for one to use it safely is error-prone by default.

To avoid creating and maintaining such documentation, one converts the dependent node ("child" above) into a tool script that implements _get_configuration_warning(). Returning a non-empty string from it will make the Scene dock generate a warning icon with the string as a tooltip by the node. This is the same icon that appears for nodes such as the Area2D node when it has no child CollisionShape2D nodes defined. The editor then self-documents the scene through the script code. No content duplication via documentation is necessary.

A GUI like this can better inform project users of critical information about a Node. Does it have external dependencies? Have those dependencies been satisfied? Other programmers, and especially designers and writers, will need clear instructions in the messages telling them what to do to configure it.

So, why do all this complex switcharoo work? Well, because scenes operate best when they operate alone. If unable to work alone, then working with others anonymously (with minimal hard dependencies, i.e. loose coupling) is the next best thing. Inevitably, changes may need to be made to a class and if these changes cause it to interact with other scenes in unforeseen ways, then things will start to break down. The whole point of all this indirection is to avoid ending up in a situation where changing one class results in adversely effecting other classes.

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 a myriad of ways. But, for those who are unsure, this helpful guide can give them a sample of a decent structure to start with.

A game should always have a sort of "entry point"; somewhere the developer can definitively track where things begin so that they can follow the logic as it continues elsewhere. This place also serves as a bird's eye view of all of the other data and logic in the program. For traditional applications, this would be the "main" function. In this case, it would be a Main node.

  • Node "Main" (main.gd)

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

Then one has their actual in-game "World" (a 2D or 3D one). This can be a child of Main. In addition, one will need a primary GUI for their game that manages the various menus and widgets the project needs.

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

When changing levels, one can then swap out the children of the "World" node. Changing scenes manually gives users full control over how their game world transitions.

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

For smaller games, a simpler alternative with less control would be to have a "Game" singleton that simply calls the SceneTree.change_scene() method to swap out the main scene's content. This structure more or less keeps the "World" as the main game node.

Any GUI would need to also be a singleton; be a transitory part of the "World"; or be manually added as a direct child of the root. Otherwise, the GUI nodes would also delete themselves during scene transitions.

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 vs. Internal Nodes' documentation.

Each subsystem within one's game should have its own section within the SceneTree. One should use parent-child relationships only in cases where nodes are effectively elements of their parents. Does removing the parent reasonably mean that one should also remove the children? If not, then it should have its own place in the hierarchy as a sibling or some other relation.

Bemerkung

In some cases, one needs these separated nodes to also position themselves relative to each other. One can use the RemoteTransform / RemoteTransform2D nodes for this purpose. They will allow a target node to conditionally inherit selected transform elements from the Remote* node. To assign the target NodePath, use one of the following:

  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. The declarative solution: place a Node in between them. As nodes with no transform, Nodes will not pass along such information to their children.
  2. The imperative solution: Use the set_as_toplevel setter for the CanvasItem or Spatial node. This will make the node ignore its inherited transform.

Bemerkung

If building a networked game, keep in mind which nodes and gameplay systems are relevant to all players versus those just pertinent to the authoritative server. For example, users do not all need to have a copy of every players' "PlayerController" logic. Instead, they need only their own. As such, keeping these in a separate branch from the "world" can help simplify the management of game connections and the like.

The key to scene organization is to consider the SceneTree in relational terms rather than spatial terms. Are the nodes dependent on their parent's existance? If not, then they can thrive all by themselves somewhere else. If they are dependent, then it stands to reason that they should be children of that parent (and likely part of that parent's scene if they aren't already).

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.