Organisation de la scène

Cet article traite de sujets liés à l’organisation efficace du contenu des scènes. Quels nœuds utiliser ? Où doit-on les placer ? Comment devraient-ils interagir ?

Comment établir des relations efficacement

Lorsque les utilisateurs de Godot commencent à créer leurs propres scènes, ils se heurtent souvent au problème suivant :

Ils créent leur première scène et la remplissent de contenu avant que le sentiment rampant qu’ils ont besoin de la diviser en morceaux réutilisables ne les hante. Ils sauvegardent des branches de leur scène dans leur propre scène. Cependant, ils constatent alors que les références dures sur lesquelles ils pouvaient compter auparavant ne sont plus possibles. La réutilisation de la scène à plusieurs endroits crée des problèmes car les chemins de nœuds ne trouvent pas leurs cibles. Les connexions de signal établies dans l’éditeur sont rompues.

Pour résoudre ces problèmes, il faut instancier les sous-scènes sans qu’elles ne nécessitent de détails sur leur environnement. Il faut pouvoir être sûr que la sous-scène se créera d’elle-même sans être pointilleuse sur la façon dont on l’utilise.

L’une des choses les plus importantes à prendre en compte dans la POO est le maintien de classes ciblées et à but unique avec un couplage lâche <https://en.wikipedia.org/wiki/Loose_coupling>`_ à d’autres parties du code de base. La taille des objets reste ainsi réduite (pour des raisons de maintenabilité) et leur ré-utilisabilité est améliorée, de sorte qu’il n’est pas nécessaire de réécrire la logique compléte.

Ces meilleures pratiques OOP ont plusieurs ramifications pour les meilleures pratiques en matière de structure de scène et d’utilisation de script.

Si c’est possible, il faut concevoir des scènes sans dépendance. C’est-à-dire qu’il faut créer des scènes qui gardent tout ce dont elles ont besoin à l’intérieur d’elles-mêmes.

Si une scène doit interagir avec un contexte externe, les développeurs expérimentés recommandent l’utilisation de Dependency Injection. Cette technique implique qu’une API de haut niveau fournisse les dépendances de l’API de bas niveau. Pourquoi faire cela ? Parce que les classes qui dépendent de leur environnement externe peuvent déclencher par inadvertance des bogues et des comportements inattendus.

Pour ce faire, il faut exposer les données, puis s’appuyer sur un contexte parent pour les initialiser :

  1. Connecter à un signal. Extrêmement sûr, mais ne doit être utilisé que pour « répondre » au comportement, pas pour le démarrer. Notez que les noms de signaux sont généralement des verbes au passé comme « entered », « skill_activated », ou « 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. Appeler une méthode. Utilisé pour démarrer le comportement.

    # 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. Initialiser une propriété FuncRef. Plus sûr qu’une méthode car la propriété de la méthode n’est pas nécessaire. Utilisé pour démarrer le comportement.

    # 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. Initialiser un nœud ou une autre référence d’objet.

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

Ces options cachent la source des accès au nœud enfant. Cela permet à l’enfant d’être couplé de façon lâche à son environnement. On peut le réutiliser dans un autre contexte sans aucune modification supplémentaire de son API.

Note

Bien que les exemples ci-dessus illustrent les relations parent-enfant, les mêmes principes s’appliquent à toutes les relations objet. Les nœuds qui sont des frères et sœurs ne devraient connaître que leur hiérarchie pendant qu’un ancêtre assure la médiation de leurs communications et de leurs références.

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

Les mêmes principes s’appliquent également aux objets non nœuds qui dépendent d’autres objets. Quel que soit l’objet qui possède réellement les objets, il doit gérer les relations entre eux.

Avertissement

Il faut privilégier la conservation des données en interne (interne à une scène), placer une dépendance sur un contexte externe, même faiblement couplé, signifie que le nœud s’attendra à ce que quelque chose soit vrai dans son environnement. Les philosophies de conception du projet devraient empêcher que cela ne se produise. Sinon, les responsabilités inhérentes au code obligeront les développeurs à utiliser de la documentation pour suivre les relations entre objets à une échelle microscopique ; c’est ce qu’on appelle l’enfer du développement. L’écriture d’un code qui s’appuie sur une documentation externe pour l’utiliser en toute sécurité est sujette à des erreurs par défaut.

Pour éviter de créer et de maintenir une telle documentation, on convertit le noeud dépendant ( » enfant  » ci-dessus) en un script outil qui implémente _get_configuration_warning(). Retourner une chaîne non vide à partir de celle-ci fera en sorte que le dock Scène génère une icône d’avertissement avec la chaîne sous forme d’info-bulle par le nœud. C’est la même icône qui apparaît pour les nœuds tels que le nœud Area2D quand il n’a pas d’enfant :ref:`CollisionShape2D <class_CollisionShape2D <Nœuds définis. L’éditeur documente ensuite lui-même la scène à l’aide du code du script. Aucune duplication de contenu via la documentation n’est nécessaire.

Une interface graphique comme celle-ci peut mieux informer les utilisateurs d’informations critiques sur un nœud. A-t-il des dépendances externes ? Ces dépendances ont-elles été satisfaites ? D’autres programmeurs, et en particulier les designers et les écrivains, auront besoin d’instructions claires dans les messages leur indiquant ce qu’ils doivent faire pour le configurer.

Alors, pourquoi tout ce switcharoo complexe fonctionne-t-il ? Parce que les scènes fonctionnent mieux quand elles fonctionnent seules. Si elle est incapable de travailler seule, travailler avec d’autres de façon anonyme (avec un minimum de dépendances dures, c.-à-d. un couplage lâche). Si les changements inévitables apportés à une classe l’amènent à interagir avec d’autres scènes d’une manière imprévue, alors des choses s’effondrent. Le changement d’une classe peut avoir des effets néfastes sur les autres classes.

Les scripts et les scènes, en tant qu’extensions des classes du moteur, doivent respecter tous les principes OOP. En voici quelques exemples…

Choix d’une structure d’arborescence de nœud

Ainsi, un développeur commence à travailler sur un jeu seulement pour s’arrêter aux vastes possibilités qui s’offrent à lui. Ils savent peut-être ce qu’ils veulent faire, quels systèmes ils veulent avoir, mais les mettre ? Eh bien, c’est toujours à eux de décider comment faire leur jeu. On peut construire des arbres de nœuds de multiples façons. Mais, pour ceux qui ne sont pas sûrs, ce guide peut leur donner un exemple d’une structure décente pour commencer.

Un jeu devrait toujours avoir une sorte de « point d’entrée » ; quelque part, le développeur peut suivre où les choses commencent afin de pouvoir suivre la logique comme elle continue ailleurs. Cet endroit sert également de vue d’ensemble de toutes les autres données et logiques du programme. Pour les applications traditionnelles, ce serait la fonction « main ». Dans ce cas, il s’agirait d’un nœud principal.

  • Nœud « Main » (main.gd)

Le script main.gd servirait alors de contrôleur principal de son jeu.

Puis certains ont leur « Monde » (en 2D ou 3D). Ce peut être un enfant de Main. En outre, certains auront besoin d’une interface graphique primaire pour leur jeu qui gère les différents menus et widgets dont le projet a besoin.

  • Nœud « Main » (main.gd)
    • Node2D/Spatial « World » (game_world.gd)
    • Contrôle « GUI » (gui.gd)

Lors d’un changement de niveau, on peut alors échanger les enfants du nœud « Monde ». Changing scenes manually donnant aux utilisateurs un contrôle total sur la façon dont les transitions de leur monde de jeu s’effectuent.

L’étape suivante consiste à déterminer quels sont les systèmes de gameplay nécessaires à la réalisation de son projet. Si on a un système qui…

  1. fait le suivi de toutes ses données à l’interne
  2. devrait être accessible à l’échelle mondiale
  3. devrait exister de manière isolée

… alors on devrait créer un autoload “singleton » nœud.

Note

Pour les petits jeux, une alternative plus simple avec moins de contrôle serait d’avoir un singleton « Game » qui appelle simplement le SceneTree.change_scene() méthode pour remplacer le contenu du scénario principal. Cette structure maintient plus ou moins le « Monde » comme nœud principal du jeu.

Toute interface graphique devrait aussi être un singleton, être une partie transitoire du « Monde », ou être ajoutée manuellement comme un enfant direct de la racine. Sinon, les nœuds de l’interface graphique se supprimeraient également d’eux-mêmes pendant les transitions de scène.

Si l’on possède des systèmes qui modifient les données d’autres systèmes, il faut les définir comme leurs propres scripts ou scènes plutôt que comme des autochargements. Pour plus d’informations sur les raisons, veuillez consulter la documentation “Autoloads vs. Internal Nodes”.

Chaque sous-système d’un jeu doit avoir sa propre section dans l’arbre des scènes. On ne devrait utiliser les relations parent-enfant que dans les cas où les nœuds sont effectivement des éléments de leurs parents. Le fait de retirer le parent signifie-t-il raisonnablement que l’on devrait également retirer les enfants ? Si ce n’est pas le cas, il devrait avoir sa propre place dans hiérarchie en tant que frère, sœur ou autre parent.

Note

Dans certains cas, on a besoin de ces nœuds séparés pour également se positionner les uns par rapport aux autres. On peut utiliser les nœuds RemoteTransform / RemoteTransform2D à cette fin. Ils permettront à un nœud cible d’hériter conditionnellement des éléments de transformation sélectionnés du nœud Remote*. Pour assigner le target` NodePath, utilisez l’une des options suivantes :

  1. Une tierce partie fiable, probablement un nœud parent, pour assurer la médiation de l’assignation.
  2. Un groupe, pour facilement obtenir une référence au nœud désiré (en supposant qu’il n’y aura jamais qu’une seule des cibles).

Quand faut-il le faire ? C’est à eux de décider. Le dilemme se pose lorsqu’il faut micro-gérer quand un nœud doit se déplacer dans l’arbre des scènes pour se préserver. Par exemple….

  • Ajouter un nœud « player » à une « room ».

  • Besoin de changer de pièce, il faut donc supprimer la pièce courante.

  • Avant de pouvoir supprimer la salle, il faut conserver et/ou déplacer le joueur.

    La mémoire est-elle une préoccupation ?

    • Si ce n’est pas le cas, il suffit de créer les deux salles, de déplacer le lecteur et d’effacer l’ancienne. Pas de problème.

    Si c’est le cas, il faudra….

    • Déplacez le joueur ailleurs dans l’arbre.
    • Supprimer la salle.
    • Instanciez et ajoutez la nouvelle pièce.
    • Ré-ajouter le joueur.

Le problème est que le joueur est ici un « cas spécial », un cas où les développeurs doivent savoir qu’ils doivent gérer le joueur de cette façon pour le projet. En tant que telle, la seule façon de partager cette information de façon fiable en équipe est de la documenter. Il est cependant dangereux de conserver les détails de la mise en œuvre dans la documentation. C’est une charge de maintenance, qui pèse sur la lisibilité du code et gonfle inutilement le contenu intellectuel d’un projet.

Dans un jeu plus complexe avec des ressources plus importantes, il peut être préférable de simplement garder le joueur ailleurs dans l’Arbre de scène. Cela implique…

  1. Plus de cohérence.
  2. Pas de « cas spéciaux » qui doivent être documentés et conservés quelque part.
  3. Il n’y a aucun risque d’erreur parce que ces détails ne sont pas pris en compte.

En revanche, si l’on a besoin d’avoir un nœud enfant qui n’hérite pas de la transformation de son parent, on a les options suivantes :

  1. La solution déclarative : placez un Node entre eux. En tant que nœuds sans transformation, les nœuds ne transmettront pas ces informations à leurs enfants.
  2. La solution impérative : Utilisez le setter set_as_toplevel pour le nœud CanvasItem ou Spatial. Cela fera que le nœud ignorera sa transformation héritée.

Note

Si vous construisez un jeu en réseau, gardez à l’esprit quels nœuds et systèmes de jeu sont pertinents pour tous les joueurs par rapport à ceux qui ne le sont que pour le serveur faisant autorité. Par exemple, les utilisateurs n’ont pas tous besoin d’avoir une copie de la logique « PlayerController » de chaque joueur. Au lieu de cela, ils n’ont besoin que des leurs. En tant que tel, le fait de les garder dans une branche séparée du « monde » peut aider à simplifier la gestion des connexions de jeu et d’autres choses du même genre.

La clé de l’organisation d’une scène est de considérer le SceneTree en termes relationnels plutôt qu’en termes spatiaux. Les nœuds doivent-ils être dépendants de l’existence de leurs parents ? Si ce n’est pas le cas, ils peuvent prospérer tous seuls ailleurs. Si c’est le cas, il va de soi qu’ils devraient être les enfants de ce parent (et probablement faire partie de la scène de ce parent s’ils ne le sont pas déjà).

Cela signifie-t-il que les nœuds eux-mêmes sont des composants ? Pas du tout. Les arbres de nœuds de Godot forment une relation d’agrégation, pas une relation de composition. Mais bien que l’on ait toujours la flexibilité de déplacer les nœuds, il est préférable que de tels déplacements ne soient pas nécessaires par défaut.