La nouvelle technique pour structurer son projet Unity3D

La nouvelle technique pour structurer son projet et gagner en flexibilité dès maintenant

Il ne vous est jamais arrivé de vouloir modifier une fonctionnalité dans votre jeu mais d’abandonner parce que ça a trop d’impacts ? Pire : ne pas arriver à corriger un bug parce que cela a des répercussions à l’échelle de tout votre projet ?

Vous ne seriez pas la première personne à qui ça arrive, encore moins la dernière. Et pourtant, il est possible d’éviter tous ces tracas avec cette nouvelle technique de structuration.

Unity, une épée à double tranchant

La création de gameplay est grandement facilitée dans Unity. Vous avez tout un tas d’outils à votre disposition pour ça. Mais c’est à la fois une grande force et une grande faiblesse. Cela se traduit souvent par une approche itérative non structurée. Pour aller vite, le premier réflexe est de lier ses GameObjects les uns aux autres pour qu’ils puissent communiquer entre eux.

On rajoute des dépendances, on les charge à la volée, on stocke des références un peu de partout… Tout va bien jusqu’au jour où vous voulez changer un de vos composants pour modifier votre gameplay. Là, vous vous rendez compte que tous vos GameObjects et scripts sont tellement imbriqués les uns avec les autres qu’ils est très difficile de changer quoi que ce soit.

Vous vous retrouvez face à un dilemme cornélien :

  • Tout refaire pour que ça fonctionne (au risque de tout péter)
  • Ne pas effectuer la modification pour ne pas tout casser

Dans les deux cas, c’est terriblement frustrant. Dans le pire des cas ça peut vous amener à carrément abandonner votre projet, en mode ragequit à jeter le clavier contre votre écran.

Structurer son projet Unity3D pour éviter d'avoir le coeur brisé

Une architecture pour les gouverner tous

Le véritable problème qui se cache derrière cette situation, c’est que vous allez trouver plein de tutoriels en ligne sur comment faire ci ou ça… Mais vous ratez un élément fondamental : c’est la vision d’ensemble, et comment rassembler toutes ces petite parties de gameplay en un jeu cohérent.

Ce n’est pas quelque chose de naturel et beaucoup de créateurs passent à côté. Probablement parce que peu de gens parlent de ce sujet. La facilité de prise en main et d’utilisation d’Unity ne vous force pas à faire ce travail de structuration. Vous pouvez poser des éléments à l’arrache et les faire communiquer sans structuration particulière, ça fonctionnera. Au début en tout cas… Si vous suivez des tutoriels en ligne, vous allez apprendre à créer des briques de gameplay, mais pas à la lier les unes avec les autres pour créer quelque chose de cohérent.

C’est un fait, un jeu vidéo, c’est un logiciel au sens stricte du terme. Et comme tout logiciel, s’il n’est pas réfléchi, architecturé et structuré, il se transformera rapidement en un monstre difforme. On parle de “monolithe”. C’est un logiciel dont toutes les composantes sont tellement liées les unes avec les autres qu’il est quasiment impossible de changer quoi que ce soit. Vous ne voulez pas arriver à cette situation critique car c’est soit la mort de votre projet, soit l’obligation de tout refaire.

Les piliers

Alors comment faire pour ne pas se retrouver dans cette situation ? Il faut faire de l’architecture logicielle. Cela implique de vous poser la question de ce que vous allez mettre en place et comment chacun de ces éléments vont communiquer entre eux.

Une bonne architecture dans Unity  doit se reposer sur les trois piliers suivants :

Être modulaire :

  • Tous les composants doivent être indépendants les uns des autres
  • Un non développeur doit pouvoir assembler ces composants pour créer des comportements non prévus à la base *

Être éditable par tout le monde :

  • Possibilité de changer le comportement sans toucher au code *
  • Possibilité de tout modifié en mode Play

Être simple à déboguer :

  • Composants faciles à isoler pour les tester
  • Avoir tous les outils pour déboguer un composant
  • Ne jamais corriger un bug qu’on ne comprend pas

* même pour un développeur, ça a une très grande valeur

La technique secrète

Note : Il y a différentes approches possibles à l’architecture logicielle dans Unity. Cet article propose une approche mettant la donnée au centre de la réflexion, mais ce n’est pas une vérité absolue à appliquer coûte que coûte.

Cette architecture peut être utilisée pour des cas d’utilisation concrets :

  • Fichiers de configuration
  • Inventaires
  • Statistiques d’un ennemi
  • Collection de sons

Une utilisation classique pour résoudre ces problématiques est de mettre en place des Singletons. Ils ont des avantages, mais également des inconvénients :

Avantages :

  • Accès depuis n’importe où
  • État persistent
  • Facile à comprendre
  • Facile à planifier

Inconvénients :

  • Liens forts entre les éléments : fini la modularité
  • Supprime le concept de polymorphisme
  • Difficile à tester
  • Un cauchemar de dépendances
  • Une seule instance (non évolutif)

L’objectif global est de réduire le nombre de managers sur vos scènes. Ce sont des éléments qui sont trop fortement liés à vos briques de gameplay et inversement, vos briques de gameplay sont trop fortement liées à vos managers.

Pour briser ce couplage, vos composants doivent se reposer sur des dépendances. Afin de fonctionner, un script sait qu’il a besoin de telle ou de telle donnée, mais n’a pas besoin de savoir comment obtenir celles-ci. Il ne DOIT PAS aller chercher l’information dont il a besoin : exit les MonManager.Instance.MaVariable.

C’est ce que l’on appelle l’inversion de contrôle. Ce principe permet de rendre entièrement indépendants vos scripts. Vous avez alors une sorte de petite boite noire qui fait quelque chose de précis, à partir d’infos qu’on lui a fourni.

Ce qui nous amène au cœur du sujet : c’est quoi ce « on » et comment on fait ?

Les ScriptableObjects à la rescousse pour créer une architecture au top

Avec des ScriptableObjects, vous pouvez mettre en place une architecture qui respecte les 3 piliers vus plus haut.

Structurer son projet Unity3D par la donnée

Utilisation classique

Le principe fondamental derrière cette structuration est de mettre les données que vous manipulez au cœur de votre architecture. Dans Unity, pour stocker de la donnée, vous avez à disposition un outil bien utile : le ScriptableObject. Il permet de stocker des données dans votre projet et de les attacher à des composants comme n’importe quel Asset. Vous pouvez donc l’utiliser pour stocker des données propres à une ennemi par exemple :

Schéma de fonctionnement standard des ScriptableObjects

Votre ScriptableObject EnemyData définit la structure de vos données. L’asset “Goblin.asset” stocke les valeurs associés au type d’ennemi gobelin. Vous pourriez avoir un « Wolf.asset » contenant les stats d’un loup par exemple. Enfin vous liez cet Asset dans un une variable publique de votre script “Enemy” sur la scène. Votre script peut alors se baser sur les différentes statistiques de l’Asset pour effectuer des traitements en conséquence. Ce n’est plus le script qui porte les données. Votre script se charge uniquement de faire des choses précises à partir des données qu’on lui fournit : fini les variables publiques pour exposer les stats de votre ennemi, tout est maintenant dans un ScriptableObject.

Utilisation avancée

C’est bien, mais ce n’est pas parfait. Le problème avec cette approche est que ce n’est pas modulaire. Votre ScriptableObject contient les propriétés de votre ennemi et ça fonctionne très bien… jusqu’au jour où vous voulez ajouter un ennemi qui a une attaque à distance, ou une attaque magique, ou je ne sais quoi d’autre. Et là ça ne fonctionne plus puisque cela remet en question tous les niveaux de votre structure, de la donnée au script final… L’idée est d’aller encore plus loin et d’utiliser les ScriptableObjects pour stocker des variables simples.

Utilisation avancée des ScriptableObjects

Dès lors, le ScriptableObject ne porte plus de sens propre (EnemyData) et se transforme en un conteneur de données (je contiens une donnée d’un type particulier). Ce n’est plus la classe ScriptableObject qui définira l’utilité du contenu mais l’Asset en lui même, via son nom. Vous aurez donc un Asset qui donnera le nombre de HP, un asset qui donnera le AttackPower, et ainsi de suite.

Notez qu’avec cette méthode, vous aurez un nombre limité de définitions de ScriptableObjects, mais que vous pourrez utiliser dans de nombreux cas de figure, y compris des cas que vous n’aviez pas prévu à la base.

Besoin d’une nouvelle variable pour gérer l’attaque magique d’un nouveau monstre ? Vous n’avez qu’à créer un nouvel Asset stockant cette variable. Vous pourrez ensuite créer un script spécifique de monstre qui, en plus de tous les autres paramètres, prendra un Asset lui indiquant quelle est son attaque magique. Hop, modulaire, et on ne risque pas de casser pas le fonctionnement des autres scripts existants.

La véritable utilisation se fera au niveau est Assets de donnée (basés sur ces structures) ce qui vous offrira une grande modularité. De même du côté de vos Composants. Au lieu de demander un ScriptableObject “EnemyData” précis en dépendance, vos composants pourront demander qu’on leur fournisse les variables dont elles auront besoin.

L’inversion de contrôle est faite.

Mise en place

Voilà comment ça se passe en pratique. Il va donc vous falloir créer un ScriptableObject par type de donnée à stocker. Prenons l’exemple d’un float :

[CreateAssetMenu(...)]
public class FloatVariable : ScriptableObject
{
    public float Value;
}

Vous avez là votre structure pour contenir une variable de type float et créer autant de données de ce type que vous voulez. Vous pourrez stocker des nombres de points de vie, des vitesses, des modificateurs de dégâts… virtuellement tout ce que vous voulez !

Du côté de vos scripts, il vous faudra changer l’approche également. Au lieu d’avoir un seul ScriptableObject d’exposé, vous aurez différentes propriétés portant un sens précis :

public class Enemy : MonoBehaviour
{
  public FloatVariable MaxHP;
  public FloatVariable AttackPower;
}

De cette manière, vous définissez simplement quelles sont les dépendances de votre composant “Enemy”. Vous dites clairement qu’il a besoin de deux variables de type float. Le composant se fiche de savoir d’où ils viennent, il va se contenter de les utiliser. Votre composants est maintenant entièrement indépendant de tout le reste.

Mais cette modularité va encore plus loin que vous ne le pensez. En effet, une des particularités des ScriptableObjects est que leurs données sont persistantes. Elles sont enregistrées sur le disque, également en mode Play. Dans cet exemple, la vie du joueur est stockée dans un ScriptableObject :

Utilisation d'un ScriptableObjects comme dépendance

Le composant Player se contente de savoir qu’il faut qu’il utilise cette variable. Il va donc la mettre à jour quand il prend des dégâts par exemple mais il ne sait pas ce qu’il y a ailleurs et ça ne l’intéresse pas. Le script utilise sa dépendance. En revanche, de l’autre côté, il peut y avoir d’autres composants, eux aussi indépendants qui font quelque chose du nombre de points de vie du joueur :

Utilisation d'un ScriptableObject partagé en dépendance par plusieurs scripts

Comme les données d’un ScriptableObject sont enregistrées, vous pouvez vous servir de ce fonctionnement intrinsèque pour… partager des données entre composants ! C’est déroutant à première vue, moi aussi ça m’a perturbé. Mais ça fonctionne très bien. Un composant UI peut par exemple afficher la vie du joueur à l’écran, un autre changer le volume si la vie descend en dessous d’un certain seuil, etc.

Chacun de ces composants est indépendant et peut fonctionner dans leur coin juste en ayant connaissance de ses dépendances propres. Le joueur ne communique pas avec la UI et inversement, la UI ne communique pas avec le joueur : ils utilisent une dépendance commune.

C’est le côté “magique” des ScriptableObjects lorsqu’on les utilise de cette manière. Vous pouvez vous servir d’eux comme système de communication entre vos GameObjects.

Le mot de la fin

Utiliser une architecture orientée donnée basée sur des ScriptableObjects implique de penser autrement. Vous devez mettre les données au premier plan : quelles informations seront utilisées par qui. Ce n’est pas une façon de penser classique et ça peut être déroutant au départ.

Mais cette structuration vous ouvre les portes d’une modularité incroyable. Tester de nouvelles fonctionnalités devient extrêmement simple puisque vos composants ne sont plus liés les uns avec les autres. Envie de tester un nouveau script de gestion d’un monstre ? Désactivez l’ancien, ajoutez le nouveau et liez les dépendances dont il a besoin. Voilà, c’est fait. Vous n’avez même pas besoin de toucher aux autres scripts : zéro risque de tout péter !

De plus, cette modularité va grandement faciliter la création de vos comportements. Vous pourrez développer vos scripts dans un mode bac à sable complet : une scène sur laquelle il n’y a que votre GameObject et son script attaché. Toutes les dépendances seront dans des ScriptableObjects. Cela veut dire également que le debug sera facilité. Comme vos variables sont stockées dans des ScriptableObjects, il devient très simple de reproduire des situations particulières et débusquer les problèmes. Vous pouvez également utiliser les ScriptableObjects pour stocker des données fixes pour votre jeu.

Cette approche est vraiment intéressante et novatrice mais malheureusement encore très peu connue. C’est là que j’ai besoin de vous pour faire découvrir cette technique. Partagez cet article à un(e) ami(e) : sur Facebook, Twitter, par SMS, par téléphone. Parlez-en autour de vous à la pause café et propagez cette super technique !

  • Thomas dit :

    Bonjour,
    Je suis arrivé sur votre article en cherchant un moyen de casser les dépendances entre les scripts. Cette approche est très intéressante, mais je me demandais si niveau performance ça tenait la route ?
    Car les fichiers .asset sont stockés sur le disque, donc à chaque fois que l’on va vouloir lire ou modifier une variable contenu dans ce fichier, on va donc solliciter le disque dur au lieu de la RAM qui cette dernière est beaucoup plus rapide.
    C’est pour ça que je voulais savoir si cette méthode est viable sur le long terme ?

    • EspritUnity dit :

      Salut Thomas.

      Dans l’absolu, tu as raison : lire / écrire sur un disque est moins rapide que d’accéder à une donnée déjà en mémoire. L’objectif originel des ScriptableObjects était d’ailleurs de réduire l’emprunte mémoire (CF la doc). Mais en pratique, ça ne change pas grand chose. Les performances ne sont pas dégradées pour autant pour une utilisation pertinente de cette technique. C’est sûr que si tu as 5000 ScriptableObjects mis à jour en permanence, il y a des chances que ça se ressente. Mais vraiment, en pratique, si tu poses bien à l’avance qui communique avec quoi, tu as rarement beaucoup de données à mettre à jour en même temps. Donc ça reste une bonne pratique. C’est une technique qui est utilisée depuis un paquet d’années par de gros studios comme Schell Games. S’il y avait un défaut à ce niveau là, je pense qu’ils le sauraient depuis le temps (et seraient passés à autre chose).

      • Thomas dit :

        En effet, en regardant de plus prés l’utilisation des ScriptableObjects, leur fonctionnement parait plus rapide et plus complexe que je l’aurais imaginé. Je m’en servais surtout comme « base de données », mais il est vrai que leur utilisation peut être beaucoup plus poussé qu’un simple item porteur d’information.
        Merci beaucoup pour cet article, je ne connaissais pas votre site avant hier et il fait maintenant parti de mes favoris car il y a des choses intéressantes 🙂

        • Alex Frêne dit :

          Super, tu m’en vois ravis. 🙂
          Hésite pas à m’envoyer un mail s’il y a des sujets que tu aimerais que j’aborde ou si tu as des questions.

  • >