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 efficaces

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, pour finir par sauvegarder des branches de leur scène dans des scènes séparées au fur et à mesure que s'accumule le sentiment tenace qu'ils doivent séparer les choses. Cependant, ils constatent alors que les références dures, sur lesquelles ils pouvaient compter auparavant, ne sont plus fonctionnelles. La réutilisation de la scène à plusieurs endroits crée des problèmes car les chemins de nœuds ne trouvent plus leurs cibles et 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étail 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 à 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.

Ces bonnes pratiques de la POO ont plusieurs implications sur les bonnes pratiques en matière de structure des scènes et d'utilisation de script.

Il faut, quand cela est possible, 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 d'Injection de Dépendances. Cette technique nécessite qu'une API de haut niveau fournisse les dépendances à 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 Chemin de Nœud (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 commun 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 le propriétaire réel des objets dépendants, il doit gérer les relations entre eux.

Avertissement

Il faut privilégier la conservation des données en interne (interne à une scène) puisque placer une dépendance à contexte externe, même faiblement couplé, signifie que le nœud s'attendra à ce que quelque chose arrive dans son environnement. Les philosophies de conception du projet devraient empêcher que cela ne se produise. Sinon, les contraintes inhérentes au code forceront les développeurs à utiliser 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 nécessite une documentation externe pour pouvoir être utilisé en toute sécurité est par définition sujette à des erreurs.

Pour éviter de créer et de maintenir une telle documentation, on convertit le nœud dépendant ("enfant" ci-dessus) en un script outil (tool) qui implémente _get_configuration_warning(). En renvoyant une chaîne non vide, le système génère une icône d'avertissement près du nœud et affiche la chaîne sous forme d'info-bulle. C'est la même icône qui apparaît pour les nœuds tels qu' Area2D quand il n'a pas d'enfant CollisionShape2D. Le développeur se documente sur la scène grâce au code du script lui-même. Aucune duplication de contenu dans la documentation n'est nécessaire.

Une intégration à l'interface graphique comme celle-ci permet de mieux renseigner les utilisateurs sur les informations essentielles concernant un nœud. A-t-il des dépendances externes ? Ces dépendances ont-elles été satisfaites ? D'autres développeurs, en particulier les concepteurs et les rédacteurs, auront besoin d'instructions claires par des messages leur indiquant ce qu'ils doivent faire pour configurer ce nœud.

Alors, pourquoi tout ce schmilblick complexe fonctionne-t-il ? Parce que les scènes fonctionnent mieux lorsqu'elles fonctionnent seules. Si une scène est incapable de travailler seule, elle peut travailler avec d'autres de façon anonyme (avec un minimum de dépendances dures, c.-à-d. un couplage lâche) est la meilleure chose suivante. Inévitablement, il peut être nécessaire d'apporter des changements à une classe l'amènent à interagir avec d'autres scènes d'une manière imprévue, alors tout s'écroule. L'objectif de cette approche indirecte est d'éviter de se retrouver dans une situation où le changement d'une classe a des effets négatifs sur les autres classes.

Les scripts et les scènes, en tant qu'extensions de classe du moteur, doivent respecter tous les principes de la POO. En voici quelques exemples...

Choisir une arborescence de nœuds

Ainsi, un développeur qui commence à travailler sur un jeu n'aura pour limite que les vastes possibilités qui s'offrent à lui. On peut savoir ce que l'on veut faire, quelles mécaniques l'on souhaite, mais les mettre ? Eh bien c'est à chacun de décider comment faire son 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 donner un exemple d'une structure suffisante pour commencer.

Un jeu devrait toujours avoir une sorte de "point d'entrée" ; un endroit où le développeur peut repérer de façon précise où les événements commencent afin de pouvoir suivre la logique qui se prolonge ailleurs. Cet endroit sert également de vue d'ensemble pour toutes les autres données et logiques du programme. Pour les applications traditionnelles, ce serait la fonction "main". Ici, il s'agirait d'un nœud Main.

  • Node "Main" (main.gd)

Le script main.gd agira alors comme le contrôleur principal du jeu.

Ensuite, on a le "Monde" du jeu (en 2D ou 3D). Il peut s'agir d'un enfant de Main. En outre, certains auront besoin pour leur jeu d'une interface graphique primaire gérant les différents menus et widgets requis par le projet.

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

Lors d'un changement de niveau, on peut alors échanger les enfants du nœud "World". Changer manuellement de scène donne aux utilisateurs un contrôle total sur la façon dont les transitions entre les mondes du jeu se déroulent.

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. suit l'ensemble de ses données en interne
  2. doit être accessible de façon globale
  3. doit exister de manière isolée

Il convient alors de créer un nœud autoload 'singleton'.

Note

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

Toute interface graphique doit également être un singleton, faire partie du monde "World" de manière temporaire ou être ajoutée manuellement en tant qu'enfant direct de la racine. Dans le cas contraire, les nœuds de l'interface graphique se supprimeraient également lors des transitions de scènes.

Si l'on dispose de 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 chargements automatiques. Pour plus d'informations, veuillez consulter la documentation 'Chargements automatiques contre nœuds normaux'.

Chaque sous-système d'un jeu doit avoir sa propre section dans l'arbre de scène. On ne devrait utiliser les relations parent-enfant que dans les cas où les nœuds sont effectivement des composantes de leurs parents. Le retrait du parent signifie-t-il que le retrait des enfants doit également être opéré ? 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, il faut que ces nœuds séparés se positionnent aussi les uns par rapport aux autres. Pour cela, on peut utiliser les nœuds RemoteTransform / RemoteTransform2D. Ils permettront à un nœud cible d'hériter sous conditions des éléments de transformation sélectionnés du nœud Remote*. Pour assigner la cible NodePath, utilisez l'une des options suivantes :

  1. Une tierce partie fiable, probablement un nœud parent, pour assurer la coordination de la réaffectation.
  2. Un groupe, pour obtenir facilement 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 ? Eh bien, c'est subjectif. 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 "Joueur" à une "Salle".

  • Besoin de changer de salle, il faut donc supprimer la salle courante.

  • Avant de pouvoir supprimer la salle, il faut sauvegarder 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 joueur et d'effacer l'ancienne. Pas de problème.

    Si c'est le cas, il faudra...

    • Déplacer le joueur ailleurs dans l'arbre de scène.
    • Supprimer la salle.
    • Instancier et ajouter la nouvelle salle.
    • Replacer le joueur.

Le problème ici est que le joueur est un "cas spécial", un cas où les développeurs ont besoin de savoir qu'ils doivent manipuler le joueur de cette façon pour le projet. Dans ce contexte, la seule façon de partager ces informations de manière fiable en équipe est de la documenter. Cependant, il est dangereux de conserver les détails de l'implémentation 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 assets plus volumineux, il peut être préférable de simplement garder le joueur ailleurs dans l'arbre de scène. Cela implique :

  1. Plus de constance.
  2. Pas de "cas spéciaux" qui doivent être documentés et maintenus quelque part.
  3. Aucune possibilité d'erreur car ces particularités ne sont pas prises en compte.

En revanche, si l'on a besoin d'avoir un nœud enfant qui n'hérite pas des transformations de son parent, on dispose des options suivantes :

  1. La solution déclarative : placer un Node entre eux. En tant que nœuds sans transformation, les nœuds Node ne transmettront pas ces paramètres à leurs enfants.
  2. La solution impérative : Utiliser le setter set_as_toplevel pour les nœuds CanvasItem ou Spatial. Cela permettra au nœud d'ignorer 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 joueurs n'ont pas besoin d'avoir une copie de la logique "PlayerController" de chaque joueur. Ils n'ont besoin que de la leur. Ainsi, le fait de les garder dans une branche distincte du monde "World" peut contribuer à simplifier la gestion des connexions de jeu et similaires.

La clé de l'organisation d'une scène est de considérer l'arbre de scène 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 si ce n'est pas déjà le cas).

Cela signifie-t-il que les nœuds sont eux-mêmes des composants ? Pas tout-à-fait. Les arbres de nœuds de Godot forment une relation d'agrégation, et non une relation de composition. Mais bien qu'on a toujours la possibilité de déplacer les nœuds, il est toujours préférable que ces déplacements soient évités par défaut.