Structurer son projet unity comme un pro avec des briques indépendantes

Structurer son projet comme un pro avec des briques indépendantes pour sauver la vie de votre projet sur le long terme

Structurer son projet unity, ça s’apprend. Si vous travaillez depuis plusieurs mois, voir plusieurs années sur votre jeu vidéo, il est probable que vous soyez dans cette situation.

Vous avez écrit beaucoup de code et créé beaucoup d’Assets. Avancer devient de plus en plus difficile car l’ajout de nouveaux éléments de gameplay risque de remettre en question le reste. Vous voudriez refaire certaines parties de votre jeu pour adapter votre gameplay existant mais vous n’osez pas de peur de tout péter.

Vous vous reconnaissez dans cette description ? Bonne nouvelle, lisez cet article jusqu’au bout pour apprendre comment régler ce problème. Si ce n’est pas (encore) le cas, lisez quand même l’article pour ne pas vous retrouver dans cette situation… 🙂

Un script de 800 lignes ? Pas de quoi se vanter !

Le truc, c’est que vous avez construit votre jeu à l’instinct et / ou en appliquant peut-être quelques règles d’architecture. Mais vous vous retrouvez rapidement avec des scripts assez complexes qui font beaucoup de choses pour répondre à vos besoins en matière de gameplay.

D’un côté, ça veut dire que vous avez réussi à étoffer votre jeu et c’est super chouette ! Mais d’un autre côté, vous vous retrouvez face à de gros blocs de code un peu moche et difficile à lire. N’ayez pas honte, c’est comme ça qu’on progresse. J’en ai moi-même écrit des milliers de ligne comme ça.

Le truc, c’est que ce genre de code « spaghetti » dans des scripts de plusieurs centaines de lignes (voir milliers), c’est absolument pas maintenable. Il est difficile de retrouver les éléments, et si vous avez envie de changer quelque chose, vous passerez plus de temps à essayer d’identifier les impacts possibles qu’à faire la modification…

C’est démoralisant, ya pas d’autre terme. C’est pour ça qu’il vous faut faire de l’architecture. Structurer son projet unity, c’est tout à fait possible si on s’y prend correctement.

L’approche empirique du gamedev

Cet état de fait, on ne va pas vous le reprocher. Mais comment on en arrive là même en ayant de bonnes pratiques et en faisant attention de « bien faire » les choses ? Eh bien c’est un problème d’anticipation. Vous n’avez pas anticipé l’évolution de vos comportements (ou pas assez).

Lorsque l’on écrit un script, c’est pour répondre à un besoin généralement assez précis : « je veux que quand j’appuie sur la touche espace, le personnage saute ». Mais on pense rarement à l’avenir de ce script :

  • Est-ce que je peux facilement le faire évoluer vers quelque chose de nouveau ?
  • Est-ce que je peux facilement remplacer ce comportement sans tout remettre en question ?
  • Est-ce que je peux tester ce comportement sans avoir besoin de toute une scène paramétrée ?

Et pourtant, votre gameplay va évoluer. Certaines parties vont disparaître au profit de nouvelles. Si vous n’anticipez pas l’évolution de vos scripts, vous allez mettre des rustines les unes sur les autres pour arriver au résultat que vous voulez.

C’est une approche empirique qui peut fonctionner sur de très courtes périodes comme des Game Jams. Mais sur un projet plus long, c’est une grossière erreur qui vous mènera à des difficultés inutiles.

Architecture « Data-driven »

Il n’y a pas de solution parfaite, je ne vais pas vous donner de formule magique. Désolé de vous ramener violemment sur terre… Mais l’une des façon de gérer cette problématique en amont dans votre projet est d’adopter une architecture centrée autour de la donnée. On parle d’architecture « Data-driven », car c’est la donnée qui va piloter l’architecture de votre jeu.

Par « donnée », il est question des informations qui vont être traitées par votre jeu pour proposer l’expérience au joueur. Cela peut être le nombre de PV du joueur, un compteur d’argent, un inventaire d’objets, etc. Toutes ces données sont au coeur de votre jeu et elle dirigent votre gameplay.

Avec cette philosophie, vous allez donc réfléchir à votre architecture de scripts non pas en pensant aux comportements dont vous avez besoin, mais en fonction des données que vous allez manipuler. Ce changement de paradigme n’est pas si anodin que ça.

Mauvais exemple d’architecture « spaghetti »

Prenons un exemple pour illustrer une architecture spaghetti. Vous avez un jeu dans lequel votre joueur peut se déplacer en 2D. Il a des points de vie qui sont affichés dans une UI. Des pièges sur la carte peuvent faire perdre des points de vie au joueur. Le joueur peut boire des potions pour regagner de la vie. Un écran de Game Over est affiché si le joueur n’a plus de points de vie.

On aurait donc un schéma comme ceci :

Exemple d'architecture Spaghetti

Imaginez que tous ces scripts sont ajoutés à des GameObjects sur une scène. Depuis l’inspecteur, on a relié le GameOverScreen et UI au Player et tous les Traps au Player également. C’est bon tout fonctionne, mais…

  • Le script Player pilote GameOverScreen. Si on veut le changer on fait comment ? On doit donc modifier Player en plus.
  • Le script Player pilote UI. Même combat.
  • Tout d’un coup, on décide d’ajouter des PNJ au jeu qui peuvent prendre des dégâts par les pièges. Il faut donc réécrire tout le système de pièges.

Sans réflexion en amont de la production pour structurer son projet unity, c’est généralement ce qu’on obtient. Même avec un jeu très simple, on peut rapidement arriver à un état où il est complexe de rajouter des briques de gameplay. Tellement complexe qu’il en devient impossible d’avancer. Pourtant, c’est un problème d’architecture et pas forcément de complexité de votre gameplay.

Structurer son projet Unity avec une architecture « data-driven »

Si on reprend le même exemple avec une approche « data-driven », on peut identifier que la donnée centrale de tous ces composants est la vie du joueur :

  • La UI affiche la vie du joueur
  • Le piège influe sur la vie du joueur
  • Le GameOverScreen s’affiche si la vie du joueur est à 0.
  • Le joueur ne peut plus bouger si ses PV sont à 0

Tous ces scripts fonctionnent grâce à une donnée centrale. Avec cette réflexion, on obtient donc un schéma légèrement différent :

Structurer son projet unity autour de la donnée

L’inversion de contrôle

Ce principe, c’est ce que l’on appelle l’inversion de contrôle. Ce n’est pas un script qui va se lier aux autres, on va lui fournir des dépendances. Et c’est tout ce dont il aura besoin pour faire son travail. Dans le cas de notre joueur, tout ce dont il a besoin pour fonctionner, c’est la dépendance « playerLife ».

Notez un truc absolument primordial avec cette approche. Si on enlève tout pour ne garder que le Player par exemple :

Il a toujours accès à la donnée dont il a besoin. Et UNIQUEMENT ça. Tout le reste a disparu, mais il peut toujours continuer de fonctionner ! Il n’est plus lié aux autres scripts. Et il en est de même pour les autres. Si on ne prend que l’InventoryManager par exemple, il peut fonctionner tout seul lui aussi :

Avec cette approche, tester des comportement prend tout d’un coup une autre tournure vous ne trouvez pas ? Vous pourrez vérifier votre interface sur une scène de test où… il n’y a que votre Canvas. Vous pourrez tester l’effet d’une potion… sans joueur.

La séparation est totale. Et on commence à avoir un outil super puissant entre les mains pour structurer son projet unity.

Les ScriptableObjects en tant que dépendances

Vous voyez maintenant l’utilité des cette philosophie. Mais ça reste assez théorique pour l’instant. Parlons maintenant de comment mettre en place cette structure dans votre projet. Nous allons utiliser la puissance des ScriptableObjects.

L’asset créé à partir du ScriptableObject sera notre « dépendance ». Dans cet Asset, on va venir stocker le nombre de points de vie du joueur.

[CreateAssetMenu(fileName ="PlayerLife", menuName ="Player Life Holder")]
public class PlayerLife : ScriptableObject
{
    public float Value;
}

Mais on ne va pas renseigner de valeur dans cet Asset. On ne cherche pas à enregistrer une valeur mais se servir des mécanismes des Assets de données pour faire « passe plat ». On aura par exemple les scripts suivants :

public class Player : MonoBehaviour
{
    [SerializeField]
    private PlayerLife playerLife;

    private void Update()
    {
        if(playerLife.Value > 0)
        {
            HandleMovement();
        }
    }

    private void HandleMovement()
    {
        // TODO
    }
}
public class UI : MonoBehaviour
{
    [SerializeField]
    private PlayerLife playerLife;

    [SerializeField]
    private Text lifeText;

    private void Update()
    {
        lifeText.text = playerLife.Value.ToString();
    }
}
public class Inventory : MonoBehaviour
{
    [SerializeField]
    private PlayerLife playerLife;

    public void DrinkPotion()
    {
        playerLife.Value += 50;
    }
}

Et le tour est joué. Chaque script prend via l’inspecteur le lien vers l’Asset de données et est totalement indépendant des autres scripts qui pourraient utiliser le même Asset. Vous pouvez maintenant tester votre UI sans avoir le joueur. Ou encore vérifier si une potion rend bien le bon nombre de PV sans avoir votre joueur sur la scène. De même, vous pouvez tester votre joueur sans avoir besoin de son inventaire !

Précautions et limitations

Comme expliqué plus haut, même si cette solution semble excellente, elle comporte des limitations et doit être utilisée avec précaution. Structurer son projet unity implique de faire des choix à un moment donné (et de s’y tenir).

Tout d’abord, pour utiliser les Assets de données de cette manière, il vous faudra initialiser les valeurs à un moment donnée dans un de vos scripts. Pour reprendre l’exemple du nombre de points de vie du joueur, cela devrait être fait au lancement d’une partie ou au chargement.

Le second point d’attention concerne la conservation des données. Les valeurs stockées dans un Asset de données ne seront pas enregistrées si vous quittez le jeu. Vous ne pouvez en aucun cas vous servir de cette technique pour enregistrer des valeurs in game.

Ensuite, si cette architecture donne une indépendance totale entre les scripts, sa principale faiblesse est… justement de rendre les scripts totalement indépendants. Il vous sera plus difficile d’identifier quel script va avoir un impact sur quel autre script puisqu’ils ne seront plus liés les uns avec les autres.

Enfin, comme toute votre architecture sera centrée autour de la donnée, modifier la façon dont est stockée ces informations dans vos ScriptableObjects va avoir un impacts énorme sur tous vos scripts l’utilisant. Une réflexion solide en amont permettant une évolutivité facile est indispensable.

Conclusion

Comme tous les outils avancés, cette approche n’est pas à mettre entre toutes les mains. Si vous débutez, utilisez la avec grande précaution car il est facile de s’y perdre. Si vous ne maitrisez pas ce que vous faites, vous allez vous retrouver dans une situation pire qu’avec du code spaghetti…

En revanche, avec un peu de bouteille et en posant bien à l’avance le squelette de communication de votre jeu via les différentes données centrales, les gains sont non négligeables :

  • Une flexibilité incroyable
  • Une facilité pour tester ses comportements
  • Une évolutivité inégalable
  • Un gain de temps assez fou

Pour aller plus loin, le même concept est également abordé dans cet article, sous une forme légèrement différente. Qui a dit qu’il n’y avait qu’une seule façon de structurer son projet unity ?

Maintenant, prenez une petite heure et réfléchissez à votre projet. Posez les données centrales et voyez comment vous pouvez adapter vos scripts à cette philosophie. Si vous vous en sentez le courage, tentez le coup d’adapter votre projet. Avec un backup avant, évidemment…

Faites passer cet article sur les réseaux et partagez votre expérience en commentaire.

Happy Gamedev !

  • Laurent dit :

    Très intéressant article, bravo et merci

  • >