Retour au Blog
Game Development14 mars 202612 min de lecture

Comment je gère 123+ scripts dans Godot sans perdre le contrôle

DR

Doug Ribeiro

12 min de lecture

La plupart des tutoriels Godot vous montrent comment construire un petit jeu. Très peu vous montrent ce qui se passe quand le projet grossit : quand vous avez plus qu'une poignée de scènes, quand vos scripts commencent à se référencer mutuellement de manières que vous n'aviez pas prévues, quand un changement dans un système casse quelque chose dans un système que vous aviez oublié être connecté. Je développe un RPG tactique dans Godot 4 avec C# depuis un certain temps, et le projet a grandi. Les chiffres que j'utilise ici sont un instantané pris en février 2026 : 123+ scripts, 334+ tests répartis entre les suites unitaires, d'intégration et de performance, et 28 shaders. Cet article traite des décisions architecturales qui ont gardé le projet gérable à cette échelle.

Je veux être direct sur le contexte : je suis un développeur solo. Les patterns que j'utilise sont optimisés pour une personne qui doit comprendre l'ensemble du codebase et le maintenir sur des mois de développement intermittent. Certains de ces patterns seraient différents dans un contexte d'équipe.

Structure de dossiers : refléter le domaine, pas le moteur

La structure de projet Godot par défaut met tout dans un répertoire plat ou légèrement organisé. Pour les petits projets, c'est suffisant. Pour un projet à l'échelle, vous avez besoin d'une structure de dossiers qui rend la navigation intuitive : où vous pouvez trouver n'importe quel script sans chercher parce que son emplacement découle de ce qu'il fait.

Mon organisation de premier niveau reflète les domaines du jeu, pas les catégories techniques de Godot. Je n'ai pas de dossier appelé Scripts au premier niveau : j'ai des dossiers appelés Combat, Units, Grid, Skills, AI, UI, Campaign, Audio et Shaders. Dans chaque dossier de domaine, j'ai une sous-organisation par type technique : models, services, events, components. Mais l'axe principal d'organisation est le domaine du jeu.

  • src/Combat/. Calcul de dégâts, résolution d'attaque, application d'effets de statut, journal de combat
  • src/Units/. Données d'unité, systèmes de stats, machines à états d'unité, factory d'unités
  • src/Grid/. Représentation de la grille, pathfinding, calcul de portée de mouvement, requêtes de tuiles
  • src/Skills/. Définitions de compétences en Resources, pipeline d'exécution de compétences, logique de ciblage
  • src/AI/. Arbres de décision ennemis, évaluation de menace, sélection d'action
  • src/UI/. Tous les composants d'interface, organisés par écran ou panneau
  • src/Campaign/. Séquençage des batailles, état de campagne, sauvegarde/chargement
  • assets/resources/. Tous les fichiers Resource .tres, organisés en miroir de src/
  • tests/. Miroir exact de la structure src/ pour une découverte facile des tests
Entrée du jeu / autoloadsGameManager, EventBus, AudioManager. Singletons globaux initialisés une… 1 Systèmes de baseCombat, Grid, Units, AI. Namespaces de domaine avec des classes de logi… 2 Noeuds de scèneBattleScene, UnitNode, TileNode. Wrappers Godot minces qui délèguent au… 3 Tests (miroir de src/)xUnit pour la logique pure, GdUnit4 pour les tests liés au moteur. 334+… 4

Le miroir du répertoire tests/ par rapport à src/ est intentionnel. Quand j'ouvre CombatCalculator.cs, je peux immédiatement naviguer vers tests/Combat/CombatCalculatorTests.cs sans réfléchir à où le fichier de test pourrait être. La prévisibilité de la structure réduit la charge cognitive lors du passage entre implémentation et tests.

Autoload vs dépendance : savoir quand utiliser chacun

Le système Autoload de Godot est effectivement un système de singletons globaux. Vous enregistrez une scène ou un script comme Autoload et il est accessible de n'importe où dans le jeu sans aucune gestion de référence explicite. C'est puissant et dangereux à parts égales.

Ma règle est que les Autoloads sont pour les préoccupations au niveau application qui couvrent véritablement tout le cycle de vie du jeu : le gestionnaire audio, le système de sauvegarde, le gestionnaire d'entrées, le bus d'événements. Ce sont des choses que chaque système pourrait légitimement avoir besoin de référencer, et les rendre globalement accessibles est approprié parce qu'elles représentent une infrastructure transversale, pas de la logique de domaine.

Les services spécifiques au domaine ne sont pas des Autoloads. Le CombatResolver n'a pas besoin d'être globalement accessible : seuls les systèmes impliqués dans le combat ont besoin d'une référence à celui-ci. J'injecte les références à ces services via la composition de scènes : un noeud BattleScene détient des références à CombatResolver, MovementController, TurnManager et SkillExecutor, et passe ces références aux noeuds enfants qui en ont besoin. Ça garde les dépendances explicites et testables.

csharp
// BattleScene.cs - Composition root for battle context
public partial class BattleScene : Node
{
    [Export] private BattleConfiguration _config;

    // Services constructed and owned here
    private CombatResolver _combatResolver;
    private MovementController _movementController;
    private TurnManager _turnManager;
    private SkillExecutor _skillExecutor;

    public override void _Ready()
    {
        // Build the service graph
        var gridQuery = new GridQueryService(GetNode<GridMap>("GridLayer"));
        var pathfinder = new AStarPathfinder(gridQuery);
        var movementRange = new MovementRangeCalculator(gridQuery);

        _movementController = new MovementController(pathfinder, movementRange, gridQuery);
        _combatResolver = new CombatResolver(_config.CombatRules);
        _skillExecutor = new SkillExecutor(_combatResolver, gridQuery);
        _turnManager = new TurnManager();

        // Inject into child nodes that need them
        GetNode<UnitCommandController>("UnitCommandController")
            .Initialize(_movementController, _skillExecutor, _turnManager);
        GetNode<EnemyAIController>("EnemyAIController")
            .Initialize(_movementController, _skillExecutor, _turnManager, gridQuery);
    }
}

Le pattern Signal Bus

Le système de signaux intégré de Godot connecte les signaux d'un émetteur spécifique à un récepteur spécifique. Ça fonctionne bien pour les relations parent-enfant directes, mais ça crée un couplage fort quand deux systèmes qui n'ont aucune raison de se connaître ont besoin de communiquer, ce qui arrive fréquemment dans un jeu avec beaucoup de systèmes interagissant.

Le pattern Signal Bus résout ça avec un unique Autoload qui agit comme courtier de messages. Les systèmes émettent des événements vers le bus ; d'autres systèmes s'abonnent aux événements sur le bus. Aucun système n'a de référence directe à l'autre. C'est effectivement un bus d'événements ou un système pub/sub : un terrain familier pour quiconque a travaillé avec les services Angular ou Redux.

csharp
// GameEvents.cs - Autoload signal bus
public partial class GameEvents : Node
{
    public static GameEvents Instance { get; private set; }

    // Combat events
    [Signal] public delegate void UnitDamagedEventHandler(Unit target, DamageResult result);
    [Signal] public delegate void UnitDefeatedEventHandler(Unit unit);
    [Signal] public delegate void StatusEffectAppliedEventHandler(Unit target, StatusEffect effect);
    [Signal] public delegate void StatusEffectRemovedEventHandler(Unit target, StatusEffect effect);

    // Movement events
    [Signal] public delegate void UnitMovedEventHandler(Unit unit, Vector2I fromCell, Vector2I toCell);
    [Signal] public delegate void UnitTeleportedEventHandler(Unit unit, Vector2I toCell);

    // Turn events
    [Signal] public delegate void TurnStartedEventHandler(Unit activeUnit);
    [Signal] public delegate void TurnEndedEventHandler(Unit unit);
    [Signal] public delegate void BattleEndedEventHandler(BattleOutcome outcome);

    public override void _Ready()
    {
        Instance = this;
    }
}

// Usage - emitting
GameEvents.Instance.EmitSignal(GameEvents.SignalName.UnitDamaged, unit, damageResult);

// Usage - subscribing
GameEvents.Instance.UnitDamaged += OnUnitDamaged;

Une contrainte dont dépend l'exemple ci-dessus : les paramètres de signaux C# dans Godot doivent être compatibles Variant. Les types intégrés, les structs Godot comme Vector2I et tout ce qui dérive de GodotObject ou Resource passent sans problème. Une simple classe C# ne passe pas, et le générateur de source rejette carrément la déclaration [Signal]. Les types de payload comme DamageResult ou StatusEffect dérivent soit de GodotObject soit de Resource, soit vous les aplatissez en primitives avant d'émettre.

Le Signal Bus a un avantage significatif pour les tests : dans les tests, je peux me connecter au bus pour vérifier qu'un système a émis l'événement attendu, sans avoir besoin d'assembler un arbre de scènes complet. Je peux aussi injecter un double de test du bus pour vérifier que des signaux spécifiques ont été émis avec des paramètres spécifiques.

Configuration basée sur les Resources : le pattern de jeu data-driven

La décision architecturale la plus impactante que j'ai prise tôt dans le projet était de m'engager dans la configuration basée sur les Resources pour tout le contenu du jeu. Chaque capacité, chaque template d'unité, chaque type de terrain, chaque objet et chaque configuration de rencontre est une Resource Godot : un objet de données sérialisable stocké comme fichier .tres et éditable dans l'inspecteur Godot.

Ça veut dire que je ne code presque jamais les données du jeu en dur dans les scripts. Les stats de base d'une unité guerrière ne sont pas définies dans une classe C# : elles sont définies dans un fichier UnitDefinition.tres. Les dégâts d'une boule de feu, sa portée, sa zone d'effet et ses effets visuels sont définis dans un fichier AbilityDefinition.tres. Quand je veux équilibrer le jeu, j'ouvre le fichier resource dans l'inspecteur et change les valeurs. Quand je veux ajouter une nouvelle capacité, je duplique une resource AbilityDefinition existante et modifie les propriétés. Aucun changement de code nécessaire.

csharp
// AbilityDefinition.cs - A Resource subclass for ability configuration
[GlobalClass]
public partial class AbilityDefinition : Resource
{
    [Export] public string AbilityId { get; set; } = string.Empty;
    [Export] public string DisplayName { get; set; } = string.Empty;
    [Export(PropertyHint.MultilineText)] public string Description { get; set; } = string.Empty;

    [ExportGroup("Cost")]
    [Export] public int ActionPointCost { get; set; } = 1;
    [Export] public int ManaCost { get; set; } = 0;

    [ExportGroup("Targeting")]
    [Export] public TargetingType TargetingType { get; set; } = TargetingType.SingleEnemy;
    [Export] public int Range { get; set; } = 1;
    [Export] public int AreaOfEffect { get; set; } = 0;

    [ExportGroup("Effects")]
    [Export] public AbilityEffect[] Effects { get; set; } = Array.Empty<AbilityEffect>();

    [ExportGroup("Visual")]
    [Export] public PackedScene AnimationScene { get; set; }
    [Export] public AudioStream SoundEffect { get; set; }
}

Composition de scènes vs héritage : une leçon durement apprise

Au début du projet, je m'appuyais fortement sur l'héritage de scènes : Godot permet de créer une scène de base et d'en hériter pour créer des variations. J'avais une scène BaseUnit, puis WarriorUnit, MageUnit et ArcherUnit en héritaient. Ça semblait propre au début. C'est rapidement devenu un problème de maintenance.

Le problème avec les hiérarchies de scènes basées sur l'héritage est que les changements à la scène de base se propagent à tous les enfants de manières pas toujours prévisibles. Ajouter un nouveau noeud à la scène de base restructure la hiérarchie de chaque scène héritante. Quand les scènes héritantes ont des propriétés surchargées, ces surcharges peuvent entrer en conflit avec les changements de la scène de base de manière subtile. Déboguer un comportement qui existe dans cinq scènes héritantes mais pas dans la scène de base est pénible.

J'ai refactorisé vers la composition : tous les types d'unités utilisent la même scène Unit de base, et leur comportement spécifique est défini entièrement par leur resource UnitDefinition et par des noeuds composants ajoutés à l'arbre de scènes pour des capacités spécifiques. Une unité qui peut voler a un FlyingMovementComponent attaché. Une unité qui a un déclencheur de capacité passive a un PassiveAbilityComponent attaché. La scène de base n'est pas sous-classée : elle est configurée.

C'est le même principe que le design basé sur les composants dans les moteurs de jeu comme Unity, et c'est le bon principe. La composition plutôt que l'héritage n'est pas juste un bon conseil pour le logiciel orienté objet : c'est particulièrement important dans le développement de jeux basé sur les graphes de scènes.

Namespaces C# dans Godot : ne sautez pas cette étape

Les projets Godot C# n'imposent pas de namespaces par défaut. Au début du projet, j'écrivais des classes sans namespaces, et quand j'ai atteint 30-40 scripts, j'avais des problèmes de collision de noms. Une classe Unit, une classe UnitManager, une classe Unit resource : sans namespaces, elles nécessitaient soit des noms longs maladroits, soit elles entraient en collision.

J'ai tout refactorisé pour utiliser des namespaces qui reflètent la structure des dossiers. Toutes les classes dans src/Combat/ sont dans le namespace TacticalRPG.Combat. Toutes les classes dans src/Grid/ sont dans TacticalRPG.Grid. C'est une pratique C# standard mais ça nécessite une mise en place intentionnelle dans Godot parce que le moteur ne l'impose pas.

Un bémol : les namespaces s'arrêtent au compilateur C#. Godot enregistre un type [GlobalClass] sous son nom de classe court, pas sous son nom avec namespace, donc les classes Resource globales ont quand même besoin de noms uniques dans tout le projet.

Garder 334+ tests sous contrôle

334+ tests répartis entre les suites unitaires, d'intégration et de performance, ça semble beaucoup pour un projet de jeu, et c'est peu commun dans l'espace du jeu indépendant. Mais au niveau de complexité d'un RPG tactique, c'est réellement nécessaire. Voici comment je les organise et les maintiens sans que la suite de tests devienne un fardeau.

  • Les tests sont organisés par le domaine qu'ils testent, dans une arborescence tests/ qui reflète src/, pas dans un seul dossier plat. Trouver les tests d'un système spécifique est immédiat.
  • Nommage des classes de test : [NomDuSystème]Tests.cs. Nommage des méthodes : [NomDeMéthode]_[Scénario]_[RésultatAttendu]. Ça rend les messages d'échec de test auto-documentés.
  • Les tests sont étiquetés par catégorie : Unit, Integration, Performance. Je relance la catégorie Unit depuis le test runner de Rider pendant que je travaille, les tests Integration avant de commiter, et les tests Performance périodiquement.
  • Les fixtures et factories de test partagées sont dans un répertoire TestUtilities/ que les tests unitaires et d'intégration peuvent utiliser. Un GridMapFactory qui construit de petites grilles de test en est un exemple typique.
  • Je ne teste jamais le comportement des noeuds Godot dans les tests unitaires. Ceux-ci couvrent uniquement la logique C# pure, sous xUnit, sans processus moteur. Le comportement des noeuds est testé via des tests d'intégration GdUnit4 qui instancient une scène minimale. Ça garde la suite unitaire rapide.

La considération de performance dont personne ne parle

C# dans Godot 4 utilise le runtime .NET, ce qui signifie que le ramasse-miettes est une réalité que vous devez gérer pour la logique de jeu sensible à la performance. Dans un RPG tactique, les opérations les plus sensibles au GC sont le pathfinding (qui alloue des collections intermédiaires) et le pipeline de résolution de combat (qui crée beaucoup d'objets à courte durée de vie pendant une seule résolution).

Mon approche : profiler avant d'optimiser, et utiliser le pooling d'objets uniquement là où le profilage montre que c'est nécessaire. Le profileur intégré de Godot ne profile pas les scripts C#, donc trouver les points chauds de pression GC demande de l'outillage .NET ; la documentation Godot pointe vers JetBrains Rider et dotTrace. Pour le pathfinding spécifiquement, je pré-alloue les ensembles ouverts et fermés et les vide entre les appels plutôt que d'allouer de nouvelles collections à chaque requête de pathfinding. Pour les objets de résultat de dégâts qui sont créés et jetés rapidement, j'utilise un pool simple.

La structure de tours du RPG tactique aide ici : la plupart des calculs complexes se produisent pendant les moments de transition (quand un tour commence, quand une action se résout), pas à chaque frame. Je ne fais pas de pathfinding pour 50 unités 60 fois par seconde : je le fais une fois quand un joueur sélectionne une unité, et une fois par unité ennemie pendant le tour de l'IA. Le budget de performance est généreux, et la pression GC est gérable avec une attention modeste.

L'architecture est l'ensemble des décisions qui deviennent coûteuses à changer. Dans un projet de jeu, les décisions coûteuses sont celles qui touchent votre modèle de données et votre système d'événements : ayez-les justes tôt, et le reste n'est qu'itération.

Leçons pour d'autres développeurs solo qui font évoluer un projet Godot

  • Mettez en place les namespaces dès le premier jour. Les ajouter rétroactivement est fastidieux et source d'erreurs.
  • Utilisez des Resources typées pour tout le contenu du jeu dès le départ. L'intégration avec l'inspecteur vaut le coût de mise en place initial.
  • Établissez le Signal Bus avant d'avoir plus de cinq ou six systèmes. L'ajouter plus tard nécessite de toucher beaucoup de scripts existants.
  • Écrivez des tests pour la logique déterministe dès le départ. Ajouter des tests à du code existant non testé est significativement plus difficile que d'écrire des tests en parallèle du nouveau code.
  • Préférez la composition à l'héritage dans le design de scènes. Vous refactoriserez de toute façon : mieux vaut commencer avec le bon pattern.
  • Les dossiers organisés par domaine battent les dossiers par catégorie technique à toute échelle non triviale.
  • Rendez la liste d'Autoloads aussi courte que possible. Chaque Autoload est une dépendance globale qui rend les tests plus difficiles.

Gérer un grand projet Godot en solo est un problème de conception de systèmes autant qu'un problème de game design. Les patterns que j'ai décrits ne sont pas exotiques : ce sont des pratiques standard d'ingénierie logicielle appliquées à un contexte de développement de jeux. Venant d'un background professionnel en logiciel, j'avais un avantage significatif à connaître ces patterns. Si vous venez d'un background en développement de jeux et que les concepts d'ingénierie logicielle sont nouveaux, investir du temps à comprendre la gestion des dépendances, le design événementiel et la configuration data-driven produira des dividendes qui se composent sur toute la durée de vie de votre projet.

Références

GodotC#Game DevelopmentArchitecture