Dernière mise à jour : mai 2019. Durée de lecture : 10 min.

Comment concevoir le code au fur et à mesure que votre projet prend de l'ampleur

Ce que vous trouverez sur cette page : 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. 

Mikael Kalms, directeur technique du studio suédois Fall Damage, est l'auteur de cet article. Mikael a plus de 20 ans d'expérience dans les domaines du développement et de la publication de jeux. Il est néanmoins toujours aussi intéressé à concevoir du code de façon que les projets puissent se développer efficacement et en toute sécurité.

Comment concevoir le code au fur et à mesure que votre projet prend de l'ampleur

De la simplicité à la complexité

Étudions quelques exemples de code tirés d'un jeu de type Pong très basique, que mon équipe a créé pour mon intervention à l'Unite Berlin.  Comme l'illustre l'image ci-dessus, le jeu est composé de deux barres (paddles) et de quatre murs (walls), en haut, en bas, à gauche et à droite, d'une logique de jeu et d'une IU 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 contenant 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 ?

Comment concevoir le code au fur et à mesure que votre projet prend de l'ampleur_Paramètres des composants

Instances, prefabs et objets programmables

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.

Comment concevoir le code au fur et à mesure que votre projet prend de l'ampleur_Paramètres dans le code

Paramètres dans un exemple de code simple

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 l’élément InputAxisName pour chaque joueur, alors que les valeurs MovementSpeedScaleFactor et PositionScale doivent être partagées par les deux. 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. 

Comment concevoir le code au fur et à mesure que votre projet prend de l'ampleur_À l'aide d'objets programmables

Utiliser des objets programmables

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.

Comment concevoir le code au fur et à mesure que votre projet prend de l'ampleur_principe de la responsabilité unique

Scinder les MonoBehaviors volumineux

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. Appliqué correctement, il doit vous permettre de répondre facilement aux questions « que fait telle classe ? » ou « que ne fait-elle pas ? ». Chaque développeur de votre équipe pourra comprendre aisément ce que fait chaque classe. 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 uniquement constituée 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 résider au sein des MonoBehaviors, des objets programmables ou des classes C# brutes. 
  • Les MonoBehaviors ou les objets programmables peuvent être utilisés pour exposer les paramètres dans l'Inspector.
  • Les gestionnaires d'événement moteur et la gestion du cycle de vie d'un GameObject doivent résider 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. 

Comment concevoir le code au fur et à mesure que votre projet prend de l'ampleur_passer des Monobehaviors aux classes C#

Déplacer les MonoBehaviors vers les classes C# standard

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, l'Éditeur ne comprend plus les objets et ne peut plus les afficher en natif dans l'Inspector, etc.

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. 

Comment concevoir le code au fur et à mesure que votre projet prend de l'ampleur_À l'aide d'interfaces

Utilisation d'interfaces

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 à y travailler. 

Comment concevoir le code au fur et à mesure que votre projet prend de l'ampleur_architecture logicielle

Architecture logicielle

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.

Logique et présentation

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.

Classes de données uniquement et classes d'aide

Il est parfois bon d'avoir une classe qui ne contient que des données, sans y incorporer la logique et les opérations qui peuvent être effectuées avec ces données dans la même classe. 

Il peut aussi être intéressant de créer des classes qui ne possèdent aucune donnée, mais contiennent des fonctions dont le but est de manipuler les objets qu'on leur remet.

Méthodes statiques

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.

Découpler vos 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.

Charger des scènes

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.

Une fermeture propre et contrôlée

Autre conseil qui m'a été utile pour chaque jeu sur lequel j'ai travaillé : mettez en œuvre une fermeture propre et contrôlée.

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.

Réduire les problèmes de fusion des fichiers de scène

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.

Automatisation des processus pour les tests de code

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 voudrez effectuer toutes les tâches uniques et délicates, et automatiser le plus possible celles qui sont 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.

Automatisation des processus pour les tests de contenu

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.

Créer des parties automatisées

Mettre en place des parties automatisées implique de créer une IA capable de jouer à votre jeu, puis de consigner les erreurs. En bref, chaque erreur décelée par votre IA est une erreur de moins à trouver ! 

Dans notre cas, nous avons configuré environ 10 clients de jeu sur la même machine, avec un niveau de détail réduit au minimum, puis nous les avons laissés s'exécuter. Nous avons attendu qu'ils plantent, puis avons étudié les rapports d'erreurs. Dès qu'un des clients plantait, cela nous faisait gagner du temps car nous n'avions pas à jouer nous-mêmes, ni à faire jouer quelqu'un d'autre, pour trouver les bugs. Ainsi, lorsque nous avons nous-mêmes joué au jeu avec d'autres, nous avons pu nous concentrer sur son aspect ludique, sur la recherche des bugs graphiques, etc.

Vous avez aimé ce contenu ?

Ce site utilise des cookies dans le but de vous offrir la meilleure expérience possible. Consultez notre politique de cookies pour en savoir plus.

Compris