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:

Crean la primer escena y la llenan con contenido antes de que aparezca la sensación de que deberían dividirla en partes reusables. Luego guardan las ramas de su escena en sus propias escenas. Sin embargo, notan que las referencias rígidas de las que dependían, ya no son posibles de usar. Reutilizar la escena en muchos lugares genera problemas porque los paths de los nodos no llegan a su destino y las señales creadas en el editor se rompen.

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.

Una de las mayores cosas a considerar en POO es mantener clases enfocadas, de propósito simple, con vínculos no estrictos (loose coupling ) respecto a otras partes del proyecto. Esto mantiene pequeño el tamaño de los objetos (para facilitar el mantenimiento) y mejora la reusabilidad de modo que sea innecesario reescribir lógica no necesaria.

Estas buenas prácticas POO tienen muchas ramificaciones para buenas prácticas en estructura de escenas y uso de scripts.

** 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. Conectarse a una señal. Es extremadamente seguro, pero solo debe ser utilizado como «respuesta» a un comportamiento, nunca para iniciarlo. Notemos que los nombres de las señales normalmente están en tiempo pasado, como por ejemplo «entered», «skill_activate» o «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.
    

Estas opciones ocultan la fuente de accesos del nodo hijo. Esto a su vez mantiene al nodo hijo ** acoplado libremente ** a su entorno. Esto permitiría reutilizarlo en otro contexto sin ningún cambio adicional en su 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 su jerarquía, mientras que sus ancestros mediarán sus comuniaciones 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: ref:_get_configuration_warning() <class_Node_method__get_configuration_warning>. Regresando una cadena no vacía de ello que hará a la ventana 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: :ref:`Area2D <class_Area2D> cuando no tiene nodos descendientes CollisionShape2D definidos. El editor entonces autodocumenta la escena atravé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.

¿Entonces, por qué todo esto funciona? Porque las escenas operan mejor cuando trabajan solas. Si no son capaces de funcionar solas al menos deberían intentar trabajar con otras de manera anónima (con mínimas dependencias, bajo acoplamiento). Si los inevitables cambios hechos a una clase resultan que interactúen con otras escenas en modos no previstos, entonces las cosas se romperán. Un cambio a una clase puede resultar en efectos dañinos a otras clases.

Tanto scripts y escenas, como clases de extensión del motor, deben apegarse a todos los principios POO. Ejemplos incluyen…

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.

Un juego debe tener siempre un «punto de entrada», en alguna parte el desarrollador puede saber definitivamente dónde comienzan las cosas, así se puede seguir la lógica mientras contiúa en alguna otra parte. Este lugar también sirve punto de vista panorámico del resto de los datos y lógica del programa. Para aplicaciones tradicionales, esto sería la función «main». En este caso, podría ser el nodo Main.

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

Un GUI puede necesitar también un singleton, sean partes transitorias de «World», o agregadas manualmente como hijas del raíz. De otro modo, los nodos GUI pueden también borrarse a si mismos durante escenas de transición.

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

¿Cuándo debería hacer esto? Bueno, eso depende de de tí decidirlo. El dilema surge cuando se debe realizar un control fino si un nodo debe moverse en el árbol de escenas para preservarse. Por ejemplo…

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

El problema es que en este caso, el jugador es un «caso especial», uno donde los desarrolladores deben saber que habrá que utilizar de este modo al jugador en el proyecto. El único modo de compartir información de manera confiable en el equipo, es documentarla. Aunque mantener detalles de implementación en la documentación es peligroso. Es una carga extra de mantenimiento, afecta la facilidad de lectura del código y engorda el contenido intelectual del proyecto de manera innecesaria.

En un juego más complejo, con recursos más grandes, puede ser mejor simplemente dejar al jugador en alguna otra parte del árbol de escenas. Esto implica…

  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.

La clave para la organización de las escenas es considerar al Arbol de Escenas en términos relacionales en lugar de términos especiales. ¿Deben los nodos depender de la existencia de su nodo padre? Si no, entonces pueden arreglárselas solos en alguna otra parte. Caso contrario, deben ser hijos de ese padre (y probablemente deberían ser parte de la escena del padre si no lo son actualmente).

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