Organización de la escena

Este artículo cubre temas relacionados con la organización efectiva del contenido de la escena. ¿Qué nodos se deben utilizar? ¿Dónde hay que colocarlos? ¿Cómo deberían interactuar?

Cómo crear relaciones de manera eficiente

Cuando los usuarios de Godot comienzan a crear sus propias escenas, normalmente se encuentran con el siguiente problema:

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.

Para solucionar esos problemas, uno debe instanciar las sub escenas sin que estas requieran detalles de su entorno. Uno debe ser capaz de confiar de que la sub escena se creará a sí misma sin ser exigente respecto a quién la usa.

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.

Si es posible, las escenas deberían diseñarse para que no tengan dependencias. O lo que es lo mismo, que mantengan todo lo que necesitan dentro de sí mismas.

Si una escena debe interactuar en un contexto externo, los desarrolladores experimentados recomiendan el uso de Inyección de dependencias. Esta técnica implica que una API de alto nivel proporcione las dependencias de la API de bajo nivel. ¿Por qué hacer esto? Porque las clases que dependen de su entorno externo pueden desencadenar errores y comportamientos inesperados sin darse cuenta.

Para hacer esto, uno debe exponer datos y luego confiar en un contexto principal para inicializarlos:

  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. Llamar a un método. Usado para iniciar un comportamiento.

    # 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. Inicializar una propiedad FuncRef. Es más seguro que establecer un método como propiedad del método . Se utiliza para iniciar el comportamiento.

    # 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. Inicializa un Nodo u otra referencia de Objeto.

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

Nota

Aunque los ejemplos anteriores ilustran las relaciones padre-hijo, los mismos principios aplican a todas las relaciones entre objetos. Los nodos hermanos sólo deben percatarse de sus jerarquías, mientras que un ancestro mediará sus comunicaciones y referencias.

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

Los mismos principios también aplican a los objetos no-Nodos que mantengan dependencias de otros objetos. Cualquiera sea el objeto al que pertenezcan los objetos, deberá administrar las relaciones entre ellos.

Advertencia

Uno debería preferir mantener los datos internos a una escena, pues establecer dependencias en contextos externos, incluso levemente, significará que el Nodo esperará a que alguna condición en su contexto se cumpla. La filosofía de diseño del proyecto debe prevenir que esto pase. De lo contrario, las dependencias inherentes del código, forzará a los desarrolladores a usar documentación para seguir el paso de las relaciones entre objetos a escala microscópica; también conocido como «Infierno de desarrollo». Escribir código que dependa de documentación externa para su uso adecuado es propenso a errores por naturaleza.

para evitar crear y mantener tal documentación, uno convierte el nodo dependiente (arriba de «child») en una herramienta script que implementa _get_configuration_warning(). Regresando una cadena no vacía de ello que hará al panel de Escena generar un ícono de advertencia con la cadena como una información por el nodo. Éste es el mismo icono que aparece para nodos tal como el nodo Area2D cuando no tiene nodos descendientes CollisionShape2D definidos. El editor entonces auto-documenta la escena a través del código del script. No es necesaria la duplicación de contenido a través de la documentación.

Una GUI como esta puede informar mejor a los usuarios del proyecto sobre la existencia de información crítica sobre un Nodo. ¿Tiene dependencias externas? ¿se han satisfecho esas dependencias?. Otros programadores, y especialmente los diseñadores y escritores, necesitarán instrucciones claras en los mensajes que les indiquen qué hacer para configurarlo.

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.

Scripts and scenes, as extensions of engine classes, should abide by all OOP principles. Examples include…

Eligiendo una estructura de árbol de nodos

Un desarrollador comienza a trabajar en un juego, sólo para detenerse frente a las vastas posibilidades que se le presentan. Tal vez sepa qué quiere hacer, qué sistemas se usarán, ¿pero dónde ponerlos a todos?. Bueno, el modo en que alguien hace los juegos es asunto propio. Uno puede construir árboles de nodos de muchos modos. Pero para esos que no están seguros, esta guía puede darles un ejemplo de una estructura decente con la cual comenzar.

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.

  • Nodo «Main» (main.gd)

El script main.gd debería servir como controlador primario del juego.

Luego, uno tiene el «Mundo» (2D o 3D) del juego. Esto puede ser un hijo de Main. Adicionalmente, se puede necesitar una GUI primaria para el juego, que maneje los menues y widgets que necesita el proyecto.

  • Nodo «Main» (main.gd)
    • Node2D/Spatial «World» (game_world.gd)
    • Control «GUI» (gui.gd)

Cuando se cambian los niveles, entonces se pueden intercambiar los hijos del nodo «World». Cambiar escenas manualmente le da al usuario un mayor control sobre cómo suceden las transiciones del juego.

El siguiente paso es considerar qué sistemas de juego se requieren. Si uno tiene un sistema que…

  1. monitorea todos los datos internamente
  2. debería ser accesible globalmente
  3. debería existir de manera aislada

… entonces se puede crear un nodo autoload “singleton”.

Nota

Para juegos pequeños, una alternativa simple con menor control podría ser tener un singleton «Game» que simplemente llame al método SceneTree.change_scene() para intercambiar el contenido de la escena principal. Esta estructura mantiene a «World» como un nodo principal del juego.

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.

Si uno de los sistemas modifica datos de otro sistema, se los pueden definir en sus propios scripts en lugar de autoloads. Para más información sobre estas razones, por favor lee la documentación en “Autoloads vs. Nodos Internos”.

Cada subsistema dentro de tu juego debería tener su propia sección en el Árbol de Escenas. Uno debe usar la relación padre-hijo sólo en casos donde los nodos son efectivamente elementos de sus padres. ¿Remover un padre quiere decir también remover los hijos? Si la respuesta es no, entonces estos deben tener su propio lugar en la jerarquía como hermanos o alguna otra relación.

Nota

En algunos casos, es necesario que esos nodos separados se posicionen de manera relativa entre sí. Para este propósito se pueden utilizar los nodos RemoteTransform o RemoteTransform2D. Estos permiten que a un nodo target (objetivo) se le apliquen elementos del Transform del nodo remoto. Para asignar el target NodePath, usa uno de los siguientes:

  1. Un nodo externo confiable, como un nodo padre, para mediar en la asignación.
  2. Un grupo, para tomar fácilmente una referencia al nodo deseado (asumiendo que siempre será uno de los objetivos).

When should one do this? Well, this is subjective. The dilemma arises when one must micro-manage when a node must move around the SceneTree to preserve itself. For example…

  • Agregar un nodo «jugador» a un «escenario».

  • Se necesitan cambiar escenarios, entonces se debe eliminar el actual.

  • Antes de que el escenario sea borrado, hay que preservar o mover al jugador.

    ¿Importa la memoria?

    • Si no importa, se pueden crear dos escenarios, mover al jugador y borrar el escenario viejo. No hay problema.

    Si importa, habrá que…

    • Mover el jugador a algún lugar en el árbol de escenas.
    • Borrar el escenario.
    • Instanciar el escenario nuevo.
    • Agrega nuevamente el jugador.

The issue is that the player here is a «special case»; one where the developers must know that they need to handle the player this way for the project. As such, the only way to reliably share this information as a team is to document it. Keeping implementation details in documentation however is dangerous. It’s a maintenance burden, strains code readability, and bloats the intellectual content of a project unnecessarily.

In a more complex game with larger assets, it can be a better idea to simply keep the player somewhere else in the SceneTree entirely. This results in:

  1. Mayor consistencia.
  2. No hay «casos especiales» que deban ser documentados y mantenidos en algún lugar.
  3. No hay oportunidad de que sucedan esos errores porque algún detalle no se tuvo en cuenta.

En contraste, si se necesita que un hijo no herede el transform del padre, están las siguientes opciones:

  1. La solución declarativa: coloca un Node entre ellos. Como los Node no tienen transform, no pasarán esa información a sus hijos.
  2. La solución imperativa: Usar set_as_toplevel para el nodo CanvasItem o Spatial. Esto hará que el nodo ignore el transform heredado.

Nota

Si estás creando un juego en red, ten en mente qué nodos y sistemas de juego serán relevantes para todos los jugadores y cuáles sólo serán pertinentes para el servidor autoritativo. Por ejemplo, no todos los usuarios necesitan tener una copia de la lógica de cada «controlador de jugador». En cambio, sí necesitan el propio. De este modo, mantenerlos en una rama separada del «mundo» puede simplificar la administración de las conexiones del juego entre otras cosas.

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

¿Quiere decir esto que los nodos son componentes? Para nada, los árboles de nodos de Godot forman una relación de agregación, no una de composición. Y aunque exista la flexibilidad de mover nodos por el árbol, es mejor cuando esas acciones no son necesarias.