- Un plugin éditeur, ce n’est pas un simple ajout de fonctionnalité
Beaucoup pensent qu’un plugin pour l’éditeur Godot sert à enrichir le jeu en cours de conception : ajouter un contrôle UI, un outil d’exportation, ou un inspecteur personnalisé. En réalité, c’est l’inverse : un Editor Plugin agit sur l’éditeur lui-même, pas sur le runtime du jeu. Il permet d’intervenir dans la construction de l’arbre de scène, de détourner le processus d’importation, d’injecter dynamiquement des propriétés dans l’Inspector, ou même de redessiner le menu contextuel de la vue. En d’autres termes, on ne crée pas du contenu de jeu, on installe une nouvelle ligne de production, un poste de contrôle ou un terminal de supervision dans l’usine de création qu’est Godot.
Lors de mon premier Hello World, j’ai buté sur une confusion fondamentale : j’ai écrit un script tool en pensant qu’il s’agissait d’un script de jeu normal, avec un simple add_child() — mais rien ne s’affichait dans l’éditeur. J’ai ensuite cru qu’un plugin devait être empaqueté en .gdnlib, perdu du temps à compiler pour finalement comprendre que l’entrée la plus légère, la plus standard et la plus recommandée pour un Editor Plugin est une déclaration tool + une classe héritant de EditorPlugin, placée impérativement dans le dossier addons/ pour être détectée automatiquement. Ce choix de conception est clair : Godot ne veut pas que les plugins d’éditeur contaminent l’environnement d’exécution du projet, donc il impose un chargement isolé (uniquement au démarrage de l’éditeur), un contexte d’exécution sans SceneTree ni get_tree(), et des API restreintes (EditorInterface, EditorNode, etc.).
Ce tutoriel s’adresse aux développeurs venant d’Unity ou Unreal (surpris par la légèreté et l’aspect intrusif de Godot), aux débutants en GDScript qui n’ont jamais touché aux extensions d’éditeur (besoin de clarifier la hiérarchie entre tool, EditorPlugin et EditorProperty), et aux concepteurs techniques souhaitant valider rapidement une idée d’automatisation (par exemple « renommer en lot les nœuds » ou « surligner tous les boutons non connectés »). Le propos n’est pas abstrait : on déconstruit un Hello World réel et fonctionnel, de la structure des fichiers aux mécanismes d’enregistrement, en passant par l’accrochage UI et les hooks de cycle de vie, avec à chaque étape le « pourquoi cela doit être ainsi » et les pièges que la documentation ne mentionne pas.
- Structure des fichiers et mécanisme d'enregistrement : pourquoi
addons/xxx/etplugin.cfgsont‑ils obligatoires ?
2.1 Le répertoire obligatoire addons/
Au démarrage, l’éditeur Godot scanne les plugins selon un chemin fixe, codé en dur dans les sources du moteur :
// editor/editor_node.cpp (Godot 4.x)
Vector<String> plugin_paths;
plugin_paths.push_back("res://addons/");
plugin_paths.push_back("res://modules/"); // obsolète, seulement pour compatibilité
Cela signifie que où que vous placiez votre code (dans res://scripts/, res://src/, etc.), il ne sera jamais détecté s'il n'est pas sous res://addons/. De plus, chaque sous‑répertoire de addons/ doit correspondre à un unique plugin — on ne peut pas mettre dix plugins dans le même dossier, ni éparpiller un plugin dans plusieurs répertoires. C'est la base du rechargement à chaud, de l'activation/désactivation individuelle et du bac à sable des autorisations.
J'ai un jour essayé de placer un plugin dans res://plugins/ et de charger le script avec load() : _enter_tree() n'était jamais appelé. La raison est simple : l'instanciation, l'enregistrement et le cycle de vie de EditorPlugin sont gérés par un singleton interne EditorPluginList ; un load() externe ne donne qu'un objet GDScript ordinaire, incapable d'être lié au contexte éditeur.
2.2 plugin.cfg : la carte d'identité du plugin
Chaque répertoire addons/xxx/ doit contenir exactement un fichier plugin.cfg. Voici un exemple standard :
[plugin]
name="Mon Plugin Hello World"
description="Un plugin minimal qui ajoute un bouton à la barre d'outils"
author="Votre Nom"
version="1.0"
script="mon_plugin.gd"
Chaque ligne a son importance :
nameetdescription: affichés dans le panneau Projet → Plugins d'éditeur, unique identifiant pour l'activation/désactivation. Si le nom contient des espaces ou des caractères spéciaux (ex.Hello-World!), l'éditeur ignore silencieusement le plugin — premier piège que j'ai rencontré.authoretversion: métadonnées pour la distribution sur la place de marché. Localement, elles influencent les valeurs renvoyées parget_plugin_name()etget_version(), lues par certains gestionnaires de plugins.script: le champ le plus critique. Il spécifie le script principal du plugin, et doit être un chemin relatif au dossieraddons/xxx/. Ni un chemin absolu (res://addons/xxx/mon_plugin.gd), ni un chemin relatif syntaxiquement incorrect (./mon_plugin.gd) ne fonctionnent ; seul le nom simplemon_plugin.gdest accepté. En interne, Godot concatèneres://addons/xxx/mon_plugin.gdet tente de le charger. En cas d'erreur, le journal affiche simplementFailed to load plugin script: mon_plugin.gd, sans numéro de ligne ni contexte.
Conseil :
plugin.cfgdoit être encodé en UTF-8 sans BOM. Le Bloc-notes Windows enregistre par défaut avec BOM, ce qui provoque un échec d'analyse (et le plugin n'apparaît pas dans la liste). Utilisez VS Code ou Notepad++ avec l'option « UTF-8 sans BOM ».
2.3 Structure minimale du script principal mon_plugin.gd
Un script détecté par l'éditeur doit satisfaire trois conditions impératives :
- Première ligne :
@tool(outoolencore accepté mais déprécié). Cette directive indique à Godot qu'il doit être pré‑compilé dans le contexte de l'éditeur, pas seulement au moment de l'exécution. - Héritage de
EditorPluginobligatoire. - Méthode
get_plugin_name(): doit retourner exactement la même chaîne que le champnamedansplugin.cfg(y compris la casse et les espaces). Sinon, l'éditeur considère qu'il s'agit de deux plugins différents, ce qui peut brouiller l'état d'activation.
Voici le script minimal fonctionnel :
# res://addons/mon_plugin/mon_plugin.gd
@tool
class_name MonPlugin
extends EditorPlugin
func get_plugin_name() -> String:
return "Mon Plugin Hello World"
func get_feature_string() -> String:
return "mon_plugin"
func _enter_tree():
print("Plugin chargé dans l'éditeur")
func _exit_tree():
print("Plugin déchargé de l'éditeur")
La déclaration class_name n'est pas obligatoire, mais elle permet d'instancier le plugin depuis un autre script (rarement utile). Ce qui est indispensable, c'est la correspondance du retour de get_plugin_name().
- Injection UI concrète : ajouter un bouton dans la barre d'outils et le faire « vivre »
3.1 Pourquoi add_child() ne fonctionne‑t‑il pas ?
Beaucoup de débutants écrivent var btn = Button.new(); add_child(btn) et ne voient rien. Le problème : l'objet EditorPlugin n'est pas un nœud UI visible ; il n'a ni CanvasLayer ni contexte de rendu Control. add_child() invoque Object.add_child(), pas Control.add_child(), donc le nœud est ajouté à l'arbre des objets mais n'est jamais rendu.
L'interface de l'éditeur est modulaire : les zones visibles sont découpées en « conteneurs » d'édition prédéfinis (par exemple EDITOR_CONTAINERS.TOOLBAR, EDITOR_CONTAINERS.DOCK_SLOT_LEFT_UL, etc.). La méthode standard pour y ajouter un contrôle est add_control_to_container(), qui encapsule la recherche du conteneur, la liaison parent‑enfant et la synchronisation du cycle de vie.
3.2 Ajout d'un bouton en quatre étapes
Prenons l'exemple d'un bouton « Bonjour » dans la barre d'outils.
Étape 1 : déclarer une variable membre
Tout contrôle ajouté via add_control_to_container() doit être stocké comme variable membre et supprimé dans _exit_tree() avec remove_control_from_container().
@tool
class_name MonPlugin
extends EditorPlugin
var bouton_bonjour: Button
func _enter_tree():
bouton_bonjour = Button.new()
bouton_bonjour.text = "Bonjour"
bouton_bonjour.pressed.connect(_sur_bonjour)
add_control_to_container(EDITOR_CONTAINERS.TOOLBAR, bouton_bonjour)
func _exit_tree():
if bouton_bonjour and bouton_bonjour.is_inside_tree():
remove_control_from_container(EDITOR_CONTAINERS.TOOLBAR, bouton_bonjour)
bouton_bonjour = null
Étape 2 : comprendre le paramètre at_front
La signature est add_control_to_container(container_id, control, at_front = false). Beaucoup pensent que true signifie « mettre devant », mais en réalité at_front = true insère le contrôle à la fin des enfants du conteneur (visuellement le plus à droite) ; false l'insère au début (le plus à gauche). Pour que le bouton apparaisse à gauche (près du bouton « Jouer »), il faut passer false (valeur par défaut).
Étape 3 : limitations du contexte de liaison
La méthode connectée (_sur_bonjour) doit être une fonction membre de EditorPlugin, non static. Dans l'implémentation, self fait référence à l'instance du plugin, pas au bouton. On peut accéder à tous les membres de self, mais pas appeler get_parent() sur le bouton pour obtenir son conteneur parent (car ce dernier est interne à l'éditeur).
Étape 4 : une réponse visuelle immédiate
Au lieu d'écrire seulement print("Clic sur Bonjour"), ouvrons un PopupPanel :
func _sur_bonjour():
var popup = PopupPanel.new()
popup.size_flags_horizontal = Control.SIZE_EXPAND_FILL
popup.size_flags_vertical = Control.SIZE_EXPAND_FILL
popup.rect_min_size = Vector2(300, 150)
var label = Label.new()
label.text = "Bonjour depuis le plugin éditeur !\nCeci s'exécute uniquement dans le contexte de l'éditeur."
label.align = Label.ALIGN_CENTER
label.valign = Label.VALIGN_CENTER
popup.add_child(label)
get_editor_interface().get_editor_main_screen().add_child(popup)
popup.popup_centered()
Note : popup_centered() doit être appelée après add_child() ; autrement, elle lève une erreur Attempt to call popup_centered on a null instance (comportement connu de Godot 4.x).
3.3 Gestion dynamique de l'état du bouton
Un bouton professionnel ne doit pas rester toujours actif. Par exemple, si aucun nœud n'est sélectionné, désactiver le bouton ; si c'est un Sprite2D, changer le texte en « Bonjour Sprite ! ». Écoutons le signal selection_changed de EditorSelection :
func _enter_tree():
# ... création du bouton ...
get_editor_interface().get_selection().selection_changed.connect(_quand_selection_change)
_quand_selection_change() # initialisation
func _quand_selection_change():
var selectionnes = get_editor_interface().get_selection().get_selected_nodes()
if selectionnes.size() > 0:
var premier = selectionnes[0]
if premier is Sprite2D:
bouton_bonjour.text = "Bonjour Sprite !"
bouton_bonjour.disabled = false
else:
bouton_bonjour.text = "Bonjour"
bouton_bonjour.disabled = false
else:
bouton_bonjour.text = "Bonjour"
bouton_bonjour.disabled = true
Attention : get_selected_nodes() ne renvoie que les nœuds de l'arbre de scène, pas les ressources sélectionnées dans l'Inspecteur.
- Cycle de vie : six hooks clés au‑delà de
_enter_tree()et_exit_tree()
4.1 Pouruqoi _enter_tree() n'est‑il pas le bon endroit pour initialiser l'UI ?
Dans _enter_tree(), appeler get_editor_interface().get_editor_main_screen() peut renvoyer null, car l'interface n'est pas encore entièrement initialisée. Godot 4.x introduit des hooks plus précis, notamemnt make_visible(visible: bool) et hide().
make_visible(true) est appelé lorsque l'utilisateur active le plugin dans le panneau des plugins. À ce moment, les conteneurs UI (barre d'outils, docks) sont complètement prêts. La création UI doit donc être effectuée dans make_visible(true), pas dans _enter_tree().
func make_visible(visible: bool):
if visible:
bouton_bonjour = Button.new()
bouton_bonjour.text = "Bonjour"
bouton_bonjour.pressed.connect(_sur_bonjour)
add_control_to_container(EDITOR_CONTAINERS.TOOLBAR, bouton_bonjour)
else:
if bouton_bonjour and bouton_bonjour.is_inside_tree():
remove_control_from_container(EDITOR_CONTAINERS.TOOLBAR, bouton_bonjour)
bouton_bonjour = null
_enter_tree() ne devrait servir qu'à enregistrer les signaux globaux (comme EditorInterface.editor_changed).
4.2 Les six hooks de cycle de vie fondamentaux
| Méthode | Déclenchement | Utilisation typique | Raison d'être |
|---|---|---|---|
_enter_tree() |
Chargement du script et ajout à l'arbre | Abonnement à des signaux globaux, initialisation de variables non UI | Objet existe déjà mais UI pas prête ; seul moment pour lier certains signaux tôt |
make_visible(visible) |
Activation/désactivation par l'utilisateur | Création/destruction des contrôles UI | Conteneurs UI prêts ; get_editor_interface() sûr |
has_main_screen() |
Demande si le plugin fournit un écran principal (dock) | Retourner true pour ajouter un dock |
Détermine l'apparition dans Fenêtre → Disposition |
get_editor_main_screen() |
Après un retour true de has_main_screen() |
Retourner un Control comme racine du dock |
Point d'entrée unique pour un dock personnalisé |
_exit_tree() |
Fermeture de l'éditeur ou désinstallation du plugin | Nettoyage des références, déconnexion des signaux | Éviter les fuites mémoire, assurer une sortie propre |
handles(object) |
Demande si le plugin peut gérer un objet (ressource, nœud) | Retourner true pour intervenir dans l'Inspecteur |
Base de l'extension EditorProperty |
4.3 Piège fatal dans _exit_tree()
Ne pas déconnecter les signaux peut causer des crashes :
func _enter_tree():
get_editor_interface().get_selection().selection_changed.connect(_quand_selection_change)
func _exit_tree():
# ERREUR : la connexion persiste même après désactivation
Solution :
var _connexion_selection : SignalConnection
func _enter_tree():
var sel = get_editor_interface().get_selection()
_connexion_selection = sel.selection_changed.connect(_quand_selection_change)
func _exit_tree():
if _connexion_selection and _connexion_selection.is_connected():
get_editor_interface().get_selection().selection_changed.disconnect(_connexion_selection)
_connexion_selection = null
Astuce : _exit_tree() n'est pas garanti de s'exécuter avant le _exit_tree() des enfants. Si vous créez des Control avec leurs propres signaux, nettoyez‑les récursivement.
- Debug et dépannage : pourquoi mon plugin reste‑t‑il invisible ?
5.1 Cinq niveaux d'investigation
Quand le plugin n'apparaît pas dans le panneau Projet → Plugins d'éditeur, procédez ainsi :
- Chemin du fichier : Vérifiez
res://addons/mon_plugin/plugin.cfg. Attention à la casse (addonsen minuscules, pasAddons). - Format de
plugin.cfg: Assurez‑vous qu'il est en UTF‑8 sans BOM et que la syntaxe INI est valide. - Conditions du script principal :
@tool(outool) en première ligne,extends EditorPlugin,get_plugin_name()correspondant. - Journal de l'éditeur : Consultez le panneau Output (onglet Errors) pour des messages comme
Failed to load plugin script. - Désactivation globale : Vérifiez l'interrupteur « Tous les plugins désactivés » dans la liste des plugins.
5.2 Trois causes fréquentes d'UI absente
- Mauvais ID de conteneur : Dans Godot 4.2+,
EDITOR_CONTAINERS.TOOLBARa été renommé enEDITOR_CONTAINERS.MAIN_TOOLBAR. - Création trop précoce : Déplacez la création UI dans
make_visible(true). - Thème écrasant le style : Forcez par exemple
bouton_bonjour.theme_type_variation = "FlatButton".
5.3 Diagnostic des signaux : utilisez print_debug()
Remplacez print() par print_debug() qui s'affiche dans le débogueur avec la pile d'appels :
func _quand_selection_change():
print_debug("Sélection changée ! Compte : ", get_editor_interface().get_selection().get_selected_nodes().size())
- Du Hello World à un plugin production : cinq pratiques à adopter immédiatement
6.1 Verrouillage de version
Utilisez le champ version dans plugin.cfg et déclarez les dépendances :
[plugin]
# ...
dependencies=["mon_autre_plugin>=2.1", "encore_un<3.0"]
6.2 Isolation des ressources
Ne lisez jamais de fichiers en dehors de res://addons/votre_plugin/ :
# Sûr : utilisez ResourceLoader.load()
var config = ResourceLoader.load("res://addons/mon_plugin/config.tres")
6.3 Défense contre les erreurs
Utilisez des vérifications systématiques :
if selectionnes.size() > 0 and selectionnes[0] != null:
var premier = selectionnes[0]
if premier.has_method("get_size"):
premier.get_size()
6.4 Évitez les opérations lourdes dans les signaux à haute fréquence
Pour selection_changed (déclenché des dizaines de fois par seconde), utilisez call_deferred() ou un mécanisme de debounce.
6.5 Checklist de publication
- [ ]
namedansplugin.cfgégaleget_plugin_name()(copier‑coller) - [ ] Fichier
plugin.cfgen UTF‑8 sans BOM - [ ] Script principal :
@tool,extends EditorPlugin - [ ] Tous les
add_control_to_containerdansmake_visible(true), avecremove_control_from_containercorrespondant - [ ] Tous les signaux déconnectés dans
_exit_tree()oumake_visible(false) - [ ] Pas de fichier
*.importdans le dossier du plugin - [ ] Un fichier
README.mdprésent avec un titre en première ligne
- Conclusion personnelle : développer un plugin, c'est négocier avec l'éditeur
Écrire ce Hello World m'a fait comprendre que le développement de plugins Godot est une négociation constante. Vous n'ordonnez pas à l'éditeur, vous lui demandez de vous allouer un espace, un microphone, une tribune. Chaque add_control_to_container est une demande de place ; chaque connect est une réservation de temps de parole ; chaque _exit_tree est une restitution de privilèges. Cette philosophie rend le système à la fois puissant et discipliné. Ne considérez pas le Hello World comme une fin, mais comme la première poignée de main avec l'éditeur – pas trop molle, pas trop forte. Trouvez le dosage juste, et vous commencerez vraiment à personnaliser votre environnement de création.