
Ce que vous obtiendrez de cette page: Des stratégies efficaces pour architecturer le code d'un projet en croissance, afin qu'il évolue de manière ordonnée et avec 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 changements que vous apportez, de décomposer les choses en éléments plus petits pour les clarifier, puis de tout remettre ensemble.
L'article est de Mikael Kalms, CTO du studio de jeux suédois Fall Damage. Mikael a plus de 20 ans d'expérience dans les domaines du développement et de la publication de jeux. Après tout ce temps, il est toujours vivement intéressé par la manière d'architecturer le code afin que les projets puissent croître de manière sûre et efficace.

Examinons quelques exemples de code d'un jeu de style Pong très basique que mon équipe a réalisé pour ma Unite Berlin talk. Comme vous pouvez le voir sur l'image ci-dessus, il y a deux palettes et quatre murs – en haut et en bas, à gauche et à droite – une logique de jeu et l'interface utilisateur du score. Il y a un script simple sur la palette ainsi que pour les murs.
Cet exemple s'appuie sur quelques principes clés :
Ces principes fonctionnent pour un projet très simple comme celui-ci, mais nous devrons changer la structure si nous voulons que cela grandisse. Alors, quelles sont les stratégies que nous pouvons utiliser pour organiser le code ?

Tout d'abord, clarifions la confusion sur les différences entre les instances, les Prefabs et les ScriptableObjects. Ci-dessus se trouve le composant Paddle sur le GameObject Paddle du joueur 1, vu dans l'Inspecteur :
Nous pouvons voir qu'il y a trois paramètres dessus. Cependant, rien dans cette vue ne me donne une indication de ce que le code sous-jacent attend de moi.
Est-ce que cela a du sens pour moi de changer l'Input Axis sur la palette de gauche en le changeant sur l'instance, ou devrais-je le faire 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 Movement Speed Scale ? Est-ce quelque chose que je devrais changer sur l'instance ou sur le Prefab ?
Regardons le code qui représente le composant Paddle.

Si nous nous arrêtons et réfléchissons un peu, nous réaliserons que les différents paramètres sont utilisés de différentes manières dans notre programme. Nous devrions changer le InputAxisName individuellement pour chaque joueur : Le facteur d'échelle de vitesse de mouvement et la position d'échelle doivent être partagés par les deux joueurs. Voici une stratégie qui peut vous guider sur quand utiliser des instances, des Prefabs et des ScriptableObjects :
Voyez comment nous utilisons les ScriptableObjects avec notre composant Paddle dans l'exemple de code suivant.

Puisque nous avons déplacé ces paramètres vers un ScriptableObject de type PaddleData, nous avons juste une référence à ce 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 l'intention derrière les différents paramètres plus facilement.

Si c'était un jeu en développement réel, vous verriez les MonoBehaviors individuels devenir de plus en plus grands. 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 appliqué correctement, vous devriez être en mesure de donner des réponses courtes aux questions, "que fait une classe particulière ?" ainsi que "que ne fait-elle pas ?" Cela facilite la compréhension de ce que font les classes individuelles pour chaque développeur de votre équipe. C'est un principe applicable aux bases de code, quelle que soit leur taille. Regardons un exemple simple comme montré dans l'image ci-dessus.
Cela montre le code pour une balle. Cela ne semble pas être grand-chose, mais à y regarder de plus près, nous voyons que la balle a une vitesse qui est utilisée à la fois par le designer pour définir le vecteur de vitesse initial de la balle, et par la simulation physique maison pour suivre quelle est la vitesse actuelle de la balle.
Nous réutilisons la même variable pour deux objectifs légèrement différents. Dès que la balle commence à bouger, 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 fond de l'appel de retour OnTriggerEnter() se trouve une opération Destroy(). C'est là que la logique de déclenchement supprime son propre GameObject. Dans de grandes bases de code, il est rare de permettre aux entités de se supprimer elles-mêmes ; la tendance est plutôt de faire en sorte que les propriétaires suppriment les choses qu'ils possèdent.
Il y a ici une opportunité de diviser les choses en parties plus petites. Il existe plusieurs types de responsabilités dans ces classes : logique de jeu, gestion des entrées, simulations physiques, présentations et plus encore.
Voici quelques techniques pour créer ces petits éléments :
Je pense que pour de nombreux jeux, il vaut la peine de sortir autant de code que possible des MonoBehaviors. Une façon de le faire est d'utiliser des ScriptableObjects, et il existe déjà d'excellentes ressources sur cette méthode.

Déplacer les MonoBehaviors vers des classes C# régulières est une autre méthode à envisager, mais quels sont les avantages de cela ?
Les classes C# régulières ont de meilleures facilités linguistiques que les propres objets de Unity pour décomposer le code en petits morceaux composables. Et, le code C# régulier peut être partagé avec des bases de code .NET natives 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 souhaitez diviser la logique par type de responsabilité. Revenons à notre exemple de balle : nous avons déplacé la simulation physique simple vers une classe C# appelée BallSimulation. Le seul travail qu'il doit faire est l'intégration physique et réagir chaque fois que la balle touche quelque chose.
Cependant, est-ce logique pour une simulation de balle de prendre des décisions en fonction de ce qu'elle touche réellement ? Non, ça, c'est de la logique de jeu. Ce que nous obtenons, c'est que la balle a une portion logique qui contrôle la simulation d'une certaine manière, et le résultat de cette simulation est renvoyé au MonoBehavior.
Si nous regardons la version réorganisée ci-dessus, un changement significatif que nous voyons est que l'opération Destroy() n'est plus enfouie à de nombreux niveaux. Il ne reste que quelques domaines de responsabilité clairs dans le MonoBehavior à ce stade.
Il y a plus de choses que nous pouvons faire à cela. 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 de la balle ne possède pas vraiment l'emplacement de la balle ; elle exécute un tick de simulation basé sur l'emplacement d'une balle qui est fournie, puis renvoie le résultat.

Si nous utilisons des interfaces, alors peut-être que nous pouvons partager une partie de ce MonoBehavior de balle avec la simulation, juste les parties dont elle a besoin (voir l'image ci-dessus).
Regardons à nouveau 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. Nous ne remettons pas l'objet Ball entier, seulement l'aspect LocalPositionAdapter de celui-ci.
BallLogic doit également informer Ball quand il est temps de 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. Cela nous donne un design soigné : il y a beaucoup de logique de base, mais chaque classe a un but défini de manière étroite.
En appliquant ces principes, vous pourrez conserver une bonne organisation de votre projet, si vous êtes seul à travailler dessus.

Examinons les solutions d'architecture logicielle pour des projets légèrement plus grands. Si nous prenons l'exemple du jeu de balle, une fois que nous commençons à introduire des classes plus spécifiques dans le code – BallLogic, BallSimulation, etc. – alors nous devrions être en mesure de construire une hiérarchie :
Les MonoBehaviours doivent connaître tout le reste car ils encapsulent toute cette autre logique, mais les pièces de simulation du jeu n'ont pas nécessairement besoin de savoir comment la logique fonctionne. Ils se contentent d'exécuter une simulation. Parfois, la logique envoie des signaux à la simulation, et la simulation réagit en conséquence.
Il est bénéfique de gérer l'entrée dans un endroit séparé et autonome. C'est là que les événements en entrée sont générés, puis transmis à la logique. Quoi qu'il arrive ensuite, cela dépend de la simulation.
Cela fonctionne bien pour l'entrée et la simulation. Cependant, vous allez probablement rencontrer des problèmes avec tout ce qui a trait à la présentation, par exemple, la logique qui génère des effets spéciaux, la mise à jour de vos compteurs de score, etc.
La présentation doit savoir ce qui se passe dans d'autres systèmes, mais elle n'a pas besoin d'avoir un accès complet à tous ces systèmes. Essayez, si possible, de séparer la logique de la présentation. Essayez d'arriver à un point où vous pouvez exécuter votre base de code en deux modes : logique uniquement et logique plus présentation.
Parfois, vous devrez connecter la logique et la présentation afin que la présentation 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. De cette façon, vous obtiendrez une frontière naturelle entre les deux parties qui réduira la complexité globale du jeu que vous construisez.
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.
Ce qui est bien avec une méthode statique, c'est que, si vous supposez qu'elle ne touche à aucune variable globale, alors vous pouvez identifier la portée de ce que la méthode affecte potentiellement juste en regardant ce qui est passé en tant qu'arguments lors de l'appel de la méthode. Vous n'avez pas besoin de regarder l'implémentation de la méthode du tout.
Cette approche touche au 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. Essayez cette approche ; vous pourriez constater que vous obtenez moins de bogues par rapport à lorsque vous faites de la programmation orientée objet classique.
Vous pouvez également découpler les objets en insérant une logique de liaison entre eux. 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 ? Ils devront communiquer entre eux, d'une manière ou d'une autre.
Vous pouvez créer un objet tampon dont le seul but est de fournir une zone de stockage où la logique peut écrire des choses et la présentation peut lire des choses. Ou, vous pourriez mettre une file d'attente entre eux, de sorte que le système logique puisse mettre des choses dans la file d'attente et la présentation lira ce qui vient de la file d'attente.
Une bonne façon de découpler la logique de la présentation à mesure que votre jeu grandit est d'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. Un bon point de départ si vous souhaitez découpler les systèmes est d'utiliser UnityEvents - ou d'écrire le vôtre ; vous pouvez alors avoir des bus séparés pour des fins distinctes.
Arrêtez d'utiliser LoadSceneMode.Single et utilisez plutôt LoadSceneMode.Additive.
Utilisez des déchargements explicites lorsque vous souhaitez décharger une scène - tôt ou tard, vous devrez garder quelques objets en vie pendant une transition de scène.
Arrêtez également d'utiliser 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. Mettez vos objets à longue durée de vie dans une scène spéciale à longue durée de vie à la place.
Autre conseil qui m'a été utile avec chaque jeu sur lequel j'ai travaillé : prévoyez une fermeture propre et sous contrôle.
Rendez votre application capable de libérer pratiquement toutes les ressources avant que l'application ne se ferme. Si possible, aucune variable globale ne devrait encore être assignée et aucun GameObject ne devrait être marqué avec DontDestroyOnLoad.
Lorsque vous avez un ordre particulier pour la façon dont vous éteignez les choses, il vous sera plus facile de repérer les erreurs et de trouver des 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. Si vous avez un arrêt propre, il est moins probable que l'éditeur ou tout type de script en mode édition montre des comportements étranges après avoir exécuté votre jeu dans l'éditeur.
Vous pouvez le faire en utilisant un système de contrôle de version tel que 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, divisez les fichiers de scène en plusieurs scènes plus petites, mais sachez que cela pourrait nécessiter des outils supplémentaires.
Si vous êtes bientôt une équipe de, disons, 10 personnes ou plus, vous devrez faire un peu de travail sur l'automatisation des processus.
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 écrire 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. Cela n'a pas de sens partout, mais cela tend à rendre votre code accessible à d'autres programmeurs plus tard.
Les tests ne concernent pas seulement le test du code. vous devez aussi tester votre contenu. Si vous avez des créateurs de contenu dans votre équipe, vous serez tous mieux lotis s'ils ont un moyen standardisé de valider rapidement le contenu qu'ils créent.
La logique de test - comme valider un Prefab ou valider certaines données qu'ils ont saisies via un éditeur personnalisé - devrait être facilement accessible aux créateurs de contenu. S'ils peuvent simplement cliquer sur un bouton dans l'éditeur et obtenir une validation rapide, ils apprendront bientôt à apprécier que cela leur fait gagner du temps.
La prochaine étape après cela est de configurer le Unity Test Runner afin que vous obteniez des retests automatiques des éléments sur une base régulière. Configurez-le de façon à l'intégrer au système de compilation, afin qu'il puisse exécuter l'ensemble de vos tests. Une bonne pratique consiste à configurer des notifications, afin que lorsque un problème se produit, vos coéquipiers reçoivent une notification Slack ou par email.
Les playthroughs automatisés impliquent de créer une IA qui peut jouer à votre jeu et ensuite enregistrer les erreurs. En termes simples, toute erreur que votre IA trouve est une erreur de moins que vous devez passer du temps à trouver !
Dans notre cas, nous avons configuré environ 10 clients de jeu sur la même machine, avec les paramètres de détail les plus bas, et les avons laissés tous fonctionner. 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. Cela signifiait que lorsque nous avons réellement testé le jeu nous-mêmes et avec d'autres joueurs, nous pouvions nous concentrer sur le fait que le jeu était amusant, où se trouvaient les glitches visuels, et ainsi de suite.