Ce que vous obtiendrez de cette page: Découvrez des stratégies efficaces pour concevoir le code d'un projet en pleine expansion, afin qu'il évolue correctement et génère moins de problèmes. Au fur et à mesure que votre projet se développe, vous devrez modifier et épurer sa conception plusieurs fois. Il est toujours bon de prendre du recul par rapport aux modifications que vous effectuez, de scinder les choses plus petits en éléments afin de les remettre en ordre, puis de les assembler à nouveau.
Examinons quelques exemples de code d'un jeu très basique de style Pong que mon équipe a créé pour ma conférence Unite Berlin. Comme vous pouvez le voir sur l'image ci-dessus, il y a deux palettes et quatre murs – en haut et en bas, ainsi qu'à gauche et à droite – une logique de jeu et l'interface utilisateur de score. Un script simple s'applique aux barres et aux murs.
Cet exemple s'appuie sur quelques principes clés :
- Un élément = un prefab
- La logique personnalisée d'un élément = un MonoBehavior
- une application = une scène qui contient les prefabs interconnectés
Ces principes fonctionnent pour les projets très simples comme celui-ci, mais pas pour les plus complexes. Alors, quelles stratégies appliquer pour structurer le code ?
Commençons par clarifier les différences entre les instances, les prefabs et les objets programmables. Voici le composant Paddle du GameObject Paddle du joueur 1, tel qu'il apparaît dans l'Inspector :
Nous pouvons voir qu'il comporte trois paramètres. Mais rien dans cette fenêtre ne m'indique ce que le code sous-jacent attend de moi.
Dois-je modifier l'élément Input Axis de la barre gauche dans l'instance ou dans le prefab ? Je présume que cet axe d'entrée est différent pour chaque joueur et qu'il devrait donc être modifié dans l'instance. Qu'en est-il de la vitesse de déplacement ? Dois-je la changer dans l'instance ou dans le prefab ?
Jetons un coup d'œil au code du composant Paddle.
Si on y réfléchit, on se rend compte que les différents paramètres sont utilisés de différentes façons par le programme. Nous devons changer le InputAxisName individuellement pour chaque joueur : MovementSpeedScaleFactor et PositionScale doivent être partagés par les deux joueurs. Voici une stratégie qui peut vous aider à savoir comment choisir entre instance, prefab ou objet programmable :
- Vous avez besoin de quelque chose une seule fois ? Créez un prefab, puis instanciez-le.
- Vous avez besoin de quelque chose plusieurs fois, éventuellement avec des modifications spécifiques à une instance ? Créez un prefab, instanciez-le et passez outre certains paramètres.
- Vous voulez être sûr d'utiliser les mêmes paramètres sur plusieurs instances ? Créez plutôt un objet programmable et des données sources à partir de là.
L'exemple de code suivant illustre comment nous utilisons les objets programmables avec notre composant Paddle.
Comme nous avons déplacé ces paramètres vers un objet programmable de type PaddleData, nous avons juste une référence à cet élément PaddleData dans notre composant Paddle. Nous nous retrouvons avec deux éléments dans l'Inspector : un PaddleData et deux instances Paddle. Vous pouvez toujours changer le nom de l'axe et le paquet de paramètres partagés vers lequel chaque barre pointe. La nouvelle structure vous permet de voir plus facilement l'intention derrière les différents paramètres.
Si ce jeu était vraiment en développement, les MonoBehaviors individuels ne feraient que croître. Nous allons voir comment les scinder en utilisant ce qu'on appelle le principe de responsabilité unique, qui stipule que chaque classe doit gérer une seule chose. Si cela est appliqué correctement, vous devriez être capable de donner des réponses courtes aux questions : « Que fait une classe particulière ? » ainsi que "qu'est-ce que ça ne fait pas?" Cela permet à chaque développeur de votre équipe de comprendre facilement ce que font les classes individuelles. C'est un principe applicable aux bases de code, quelle que soit leur taille. Prenons un exemple simple.
L'image ci-dessus montre le code d'une balle. Il ne ressemble pas à grand-chose à première vue, mais en l'étudiant d'un peu plus près, on voit que la balle a une vélocité utilisée à la fois par le concepteur pour configurer le vecteur de vitesse initial, et par la simulation physique maison, pour suivre la vitesse actuelle de la balle.
Nous réutilisons la même variable à deux fins légèrement différentes. Dès que la balle est lancée, l'information sur la vitesse initiale est perdue.
La simulation physique maison n'est pas constituée uniquement du mouvement dans FixedUpdate() ; elle englobe aussi la réaction quand la balle touche un mur.
Au cœur du rappel OnTriggerEnter() se trouve une opération Destroy(). C'est là que la logique de déclenchement supprime son propre GameObject. Dans les bases de code plus vastes, il est rare que les entités soient autorisées à se supprimer. C'est une tâche qui revient aux propriétaires.
Nous avons ici l'occasion de scinder les choses en petits éléments. Il existe différents types de responsabilités dans ces logiques de jeu-classe, dans le traitement des données d'entrée, les simulations physiques, les présentations, etc.
Voici quelques techniques pour créer ces petits éléments :
- La logique de jeu générale, le traitement des données d'entrée, la simulation physique et la présentation peuvent se trouver au sein des MonoBehaviors, des objets programmables ou des classes C# brutes.
- Les MonoBehaviors ou les objets programmables peuvent être utilisés pour révéler des paramètres dans l'Inspector.
- Les gestionnaires d'événement moteur et la gestion du cycle de vie d'un GameObject doivent se trouver dans les MonoBehaviors.
Je pense que pour de nombreux jeux, cela vaut la peine de tirer le plus de code possible des MonoBehaviors. Une façon de faire consiste à utiliser les objets programmables et il existe de nombreuses ressources très utiles concernant cette méthode.
Le déplacement des MonoBehaviors vers les classes C# standard constitue une autre méthode. Mais quel est l'intérêt ?
Les classes C# standard ont de meilleures capacités que les objets Unity pour scinder le code petits éléments composables. Sans oublier que le code C# peut être partagé avec les bases de code natives .NET en dehors de Unity.
En revanche, si vous utilisez des classes C# standard, alors l'Éditeur ne comprendra plus les objets et ne pourra plus les afficher en natif dans l'Inspector, par exemple.
Avec cette méthode, vous scindez la logique selon le type de responsabilité. Revenons à notre exemple de balle : nous avons déplacé la simulation physique simple vers une classe C# appelée BallSimulation. Sa seule tâche est de procéder à une intégration physique et de réagir quand la balle touche quelque chose.
Mais est-ce vraiment logique qu'une simulation de balle prenne des décisions en fonction de ce qu'elle touche ? Non, ça, c'est de la logique de jeu. Nous avons donc une balle avec une partie logique qui contrôle la simulation de certaines façons. Le résultat de cette simulation est reporté dans le MonoBehavior.
Si on regarde la version réorganisée ci-dessus, on constate un changement significatif : l'opération Destroy() n'est plus enfouie sous des couches de code. Il ne reste à ce stade que quelques zones de responsabilité vierges dans le MonoBehavior.
Nous pouvons encore aller plus loin. Si on regarde la logique de mise à jour de la position dans FixedUpdate(), on constate que le code doit envoyer une position avant d'en renvoyer une nouvelle. La simulation ne possède pas vraiment l'emplacement de la balle, mais exécute une simulation en fonction de l'emplacement fourni, avant de renvoyer le résultat.
Si nous utilisons des interfaces, nous pouvons peut-être partager une portion du MonoBehavior de la balle avec la simulation, juste les parties nécessaires (voir l'image ci-dessus) :
Penchons-nous à nouveau sur le code. La classe Ball implémente une interface simple. La classe LocalPositionAdapter permet de transmettre une référence de l'objet Ball à une autre classe. On ne transmet pas tout l'objet Ball, juste sa caractéristique LocalPositionAdapter.
BallLogic doit aussi informer Ball quand il faut détruire le GameObject. Plutôt que de renvoyer une marque, Ball peut fournir un délégué à BallLogic. C'est ce que fait la dernière ligne marquée dans la version réorganisée. On obtient un code propre, avec une grande quantité de logique standard, mais où chaque classe a son propre objectif bien précis.
En appliquant ces principes, vous pourrez conserver une bonne organisation de votre projet, si vous êtes seul à travailler dessus.
Intéressons-nous à quelques solutions d'architecture logicielle pour les projets un peu plus complexes. Si on reprend l'exemple de la balle, quand on commence à introduire plus de classes spécifiques dans le code (BallLogic, BallSimulation, etc.), on doit alors pouvoir construire une hiérarchie :
Les MonoBehaviours doivent tout savoir sur les autres éléments, car ce sont eux qui enveloppent cette logique, mais les éléments de simulation du jeu n'ont pas forcément besoin de savoir comment fonctionne la logique. Ils se contentent d'exécuter une simulation. Parfois, la logique envoie des signaux à la simulation, qui réagit en conséquence.
Il est intéressant de gérer les entrées dans un emplacement distinct et autonome. C'est là que les événements en entrée sont générés, puis transmis à la logique. Ce qui se produit ensuite dépend de la simulation.
Cette méthode fonctionne bien pour les entrées et les simulations. Néanmoins, vous rencontrerez sans doute des problèmes pour tout ce qui relève de la présentation, comme la logique qui génère des effets spéciaux, la mise à jour des compteurs de score, etc.
La présentation doit savoir ce qui se passe dans les autres systèmes, sans nécessiter un accès total à ceux-ci. Essayez, si possible, de séparer la logique de la présentation. Vous devriez pouvoir exécuter votre base de code dans deux modes : logique uniquement et logique plus présentation.
Parfois, vous aurez besoin de relier logique et présentation, pour que cette dernière soit mise à jour au bon moment. Mais l'objectif reste de fournir à la présentation uniquement ce dont elle a besoin pour un affichage correct, rien de plus. Ainsi, vous fixerez une frontière naturelle entre les deux parties, ce qui réduira la complexité globale du jeu.
Parfois, il est bon d'avoir une classe qui ne contient que des données, sans y incorporer la logique et les opérations qui peuvent être réalisées avec ces données.
Il peut aussi être intéressant de créer des classes qui ne possèdent aucune donnée, mais contiennent des fonctions dont l'objectif est de manipuler les objets qu'on leur remet.
L'intérêt d'une méthode statique est que, si on part du principe qu'elle ne touche aucune des variables globales, vous pouvez alors facilement identifier sa portée potentielle en regardant simplement ce qui sert d'arguments lorsque la méthode est appelée. Vous n'avez pas besoin de vous intéresser à son implémentation.
Cette approche aborde le domaine de la programmation fonctionnelle. Le composant principal est le suivant : vous envoyez quelque chose à une fonction, qui renvoie un résultat ou modifie peut-être l'un des paramètres de sortie. En essayant cette approche, il est possible que vous obteniez moins d'erreurs qu'avec une programmation classique orientée objets.
Vous pouvez aussi découpler les objets en insérant entre eux une logique de liaison. Reprenons notre exemple de jeu type Pong : comment la logique de la balle et la présentation du score communiquent-elles ? la première informe-t-elle la seconde quand il se passe quelque chose au niveau de la balle ? La logique du score s'enquiert-elle de la logique de la balle ? D'une façon ou d'une autre, elles doivent communiquer.
Vous pouvez créer un objet tampon dont l'unique but est de fournir une zone de stockage dans laquelle la logique peut écrire des informations, que la présentation peut lire. Ou vous pouvez insérer une file d'attente entre elles, pour que le système de logique ajoute des éléments à la file, que la présentation lira ensuite.
Quand votre jeu prend de l'ampleur, une bonne technique pour découpler la logique de la présentation consiste à utiliser un bus de messages. Le principe de base de cette méthode est que le destinataire et l'expéditeur ne savent rien l'un de l'autre, mais connaissent l'existence du système/bus de messages. Une présentation de score doit donc être informée par le système de messages de tout événement modifiant le score. La logique du jeu transmettra donc à celui-ci les événements qui impliquent un changement de points pour un joueur. Si vous souhaitez découpler des systèmes, vous pouvez commencer par utiliser UnityEvents ou rédiger votre propre événement avec des bus distincts pour chaque objectif.
N'utilisez plus LoadSceneMode.Single et préférez-lui LoadSceneMode.Additive.
Utilisez des fonctions explicites quand vous voulez décharger une scène, car tôt ou tard, vous devrez bien conserver quelques objets actifs lors des transitions de scènes.
Oubliez aussi DontDestroyOnLoad, qui vous enlève tout contrôle sur le cycle de vie d'un objet. Si vous effectuez des chargements avec LoadSceneMode.Additive, vous n'avez plus besoin d'utiliser DontDestroyOnLoad. Intégrez plutôt vos objets avec un long cycle de vie dans une scène spéciale à longue durée de vie.
Autre conseil qui m'a été utile avec chaque jeu sur lequel j'ai travaillé : prévoyez une fermeture propre et sous contrôle.
Faites en sorte que votre application puisse libérer presque toutes les ressources avant de se fermer. Si possible, aucune variable globale ne doit plus être assignée et aucun GameObject ne doit être marqué avec DontDestroyOnLoad.
Lorsque vous instaurez un ordre précis pour la fermeture de l'application, il est alors plus facile d'identifier les erreurs et de trouver les fuites de ressources. Cela vous permettra aussi de laisser l'Éditeur Unity dans un état approprié si vous fermez Unity en mode Play, car dans ce cas, Unity n'effectue pas de rechargement complet du domaine. Avec une fermeture propre, il est peu probable que l'Éditeur ou toute sorte de programmation de scripts en mode Edit ait un comportement anormal par la suite.
Pour cela, utilisez un système de contrôle de version, comme Git, Perforce ou Plastic. Stockez toutes les ressources sous forme de texte et retirez les objets des fichiers de scène en les changeant en prefabs. Enfin, scindez les fichiers de scène en plusieurs petites scènes. Vous aurez sans doute besoin d'outils supplémentaires pour procéder.
Si votre équipe atteint, disons, 10 personnes, vous aurez alors besoin de mettre en place des processus d'automatisation.
En tant que programmeur créatif, vous devez effectuer toutes les tâches uniques et délicates en laissant un système automatique se charger du plus possible de corvées répétitives.
Commencez par rédiger des tests pour votre code. Si vous déplacez des éléments des MonoBehaviours dans des classes standard, il sera alors très simple d'utiliser un cadre de test unitaire, aussi bien pour la logique que pour la simulation. Ce n'est pas toujours possible, mais votre code sera ainsi plus accessible ultérieurement pour les autres programmeurs.
Le test n'est pas réservé au code, vous devez aussi tester votre contenu. Si votre équipe comprend des créateurs de contenu, il sera bénéfique pour tous qu'ils disposent d'une méthode standardisée pour valider rapidement le contenu qu'ils créent.
Tout comme ils valident un prefab ou des données saisies via un éditeur personnalisé, les créateurs de contenu devraient facilement pouvoir tester la logique. S'il leur suffit d'appuyer sur un bouton dans l'Éditeur pour effectuer une validation instantanée, ils comprendront très rapidement que cela leur fait gagner du temps.
L'étape suivante est de configurer Unity Test Runner pour répéter automatiquement les tests à intervalles réguliers. Configurez-le de façon à l'intégrer au système de compilation, afin qu'il puisse exécuter l'ensemble de vos tests. Je vous conseille de mettre en place des notifications pour que les membres de votre équipe soient prévenus via Slack ou par e-mail quand un problème survient.