Un conte de ScriptableObjects

(Pas) frapper la tache sucrée réutilisable

ScriptableObjects est l’un des plus grands cadeaux du développement d’Unity. Ils occupent une intersection particulièrement utile entre les scripts et la manipulation visuelle des actifs dans le jeu. Dans un système où vous devez pouvoir définir untype de chose, puis créez des instances globales de ceuxdes chosesqui ne diffèrent que par leurs valeurs de champ, le ScriptableObject est une aubaine; cela vous permet de le faire directement dans l’éditeur Unity plutôt que via le code.

Mais les jeux sont complexes, et parfois, il ne suffit pas de changer les champs entre les instances. Peut-être que vous voulez que l’un se comporte différemment d’un autre. Quelle est la bonne approche alors? Devriez-vous simplement passer le comportement derrière les champs, comme une machine à états? Devriez-vous utiliser l’héritage? Ou est-ce que cette question est intrinsèquement viciée? la notion de comportement différent entre les instances du mêmechosetrahir le but entier de ScriptableObjects?

J’explore le développement de jeux vidéo indépendants depuis quelques années, tout en travaillant chez certaines des plus grandes entreprises de technologie. Je suis encore en train de publier mon premier jeu, mais en cours de route, je me suis plongé dans des désordres glorieux en essayant de trouver le juste équilibre entre la réutilisabilité et la simplicité dans mon code.

C’est l’un de ces désordres: l’histoire de la façon dont j’ai pris ScriptableObject, un outil de commodité censé faire gagner du temps, et l’a transformé en horrible, incompréhensible temps.

Mes motivations étaient pures. J’avais un principe de conception essentiel comme règle irréprochable et, ce faisant, j’ai été écrasé sous son poids. Peut-être que vous connaissez déjà mieux, auquel cas vous pouvez toujours profiter de cela à travers schadenfreude pure. Mais peut-être que vous commencez juste aussi. Et si ce récapitulatif peut empêcher un seul d’entre vous de perdre des semaines en descendant dans un trou de lapin similaire, il suffit de vous frayer un chemin comme je l’ai fait, eh bien, dic hic tibi proderit olim.

Le jeu

Je me suis mis à faire un jeu comme l’un de mes préférés: Final Fantasy: Tactics. FF: T était un RPG tactique dans lequel vous commencez avec une équipe de personnages, de simples écuyers et de chimistes avec rien d’autre que des poignards et des chemises sur le dos. Mais à la fin de votre aventure, ils deviennent des Ninjas, des Chevaliers, des Invocateurs, des Mystiques, etc., en fonction de la façon dont vous les spécialisez lorsque vous jouez.

Ou des chevaliers d’oignon, si vous êtes dans cela.

Pour mon jeu (à plusieurs reprises appelé Laser Tactics, Rogue Tactics et Kill Order), j’ai choisi la science-fiction comme un genre au lieu de la fantaisie pour me différencier de l’inspiration principale, et parce que je n’ai pas encore trouvé de TRPG de science-fiction. avec les mêmes fondamentaux que FF: T. J’ai travaillé sur tous mes documents de conception et feuilles de calcul qui énuméraient un arbre hiérarchique de 25 Jobs, avec quelques centaines de capacités que les personnages pouvaient apprendre. Il s’agissait simplement de mettre ces idées en code. Mais bon, je suis un programmeur expérimenté. Cela devrait être la partie facile, non?

Je pourrais honnêtement faire toute une série sur les erreurs que j’ai commises sur ce jeu, avec des sujets tels que le rétrécissement perpétuel de la portée et mes premières incursions dans la génération procédurale. Mais pour l’instant nous parlons de ScriptableObjects et de la conception de ces entités dans mon jeu. Dans cet exemple, cela signifie les caractères (contrôlés par le joueur et ennemi), les travaux et les capacités.

Ou des chevaliers d’oignon, si vous êtes dans cela.

Caractères, travaux et capacités

L’architecture de base ressemblait à ceci:

ChaquePersonnagea unEspèce, un ensemble de caractéristiques partagées. Ils ont aussi un courantEmploiet un enregistrement des Jobs qu’ils ont débloqués.

ChaqueEmploidéfinit un ensemble deCapacitésque les personnages peuvent apprendre.

Enfin, afin de différencier, disons, un personnage qui vient de débloquer leSoldatJob, par rapport à un soldat de niveau 10 qui maîtrise toutes les capacités du soldat, nous avons besoin d’une classe de données supplémentaire:JobProgress. Il suit le niveau d’un personnage dans un travail et les capacités qu’il a débloquées.

Le grand choix de design de la note ici est la décisionNE PASécrire une classe spécifique pour chaque métier et capacité. Je regardais environ 25 emplois avec 4-8 capacités chacun, pour un total de ~ 100-200 capacités.

À l’époque, l’écriture de 125 à 225 classes uniques semblait longue, sujette aux erreurs et mal conçue. Mais attendez, ces capacités vont toutes partager des blocs de construction communs, non? Ils vont tous avoir une combinaison de similaireEffetscomme endommager les ennemis, restaurer la santé, déplacer des personnages, accorder des faveurs / malaises, etc. Il y a aussi de nombreuses façons de cibler les capacités (auto, mêlée, à distance, faisceau, cône, explosion, etc.).

leRestrictionsLe déblocage de chaque Job ou Capacité peut également être décomposé en un ensemble de blocs de construction: le personnage est-il une espèce spécifique? At-il atteint le niveau X dans le job Y? A-t-il appris Z Capacité?

Le principe d’ingénierie que je décris estréutilisabilité; J’étais déterminé à définir les tâches et les capacités au sens large de ScriptableObjects, et à ne pas les implémenter individuellement. Au lieu de cela, je coderais les définitions de minusculeRestrictionsetEffetseux-mêmes, puis les combiner et les réutiliser selon les besoins. L’objectif était de réduire la duplication de code, et plus je créais de jobs et de capacités, plus ces gains se multiplieraient.

Voyons comment cela a fonctionné pour moi …

Restrictions

Dans le contexte des tâches et des capacités, une “restriction” définit une vérification qu’un personnage doit passer pour le déverrouiller. Regardons quelques exigences hypothétiques pour le travail deRecruteret son descendant,Soldat.

Recruter: Biologique uniquement (humain ou cyborg, pas de robot)

Soldat: Recrute le niveau 3 ou supérieur

Mais sans classes pour Human, Cyborg, Robot, Recruit, Soldier, etc., j’avais besoin des blocs à partir desquels ces chèques seraient construits.

Donc, ma définition de code deRestrictionétait: une classe qui effectue une vérification sur un personnage, appeléeIsMet (personnage), et retournevraisi le personnage répond à ses exigences.

Je définis deux sous-classes de restriction:EspèceRestrictionetJobRestriction, qui retourne vrai deIsMet (personnage)si le caractère appartient à l’espèce valide ou a atteint le niveau requis du travail spécifié, respectivement.

Lorsque le jeu a besoin de savoir si un personnage a débloqué un travail, il exécute toutes les restrictions du travail.Est rencontréfonction contre le personnage. S’ils passent tous, le Job est débloqué.

Les capacités ont aussi des restrictions, mais je vous épargnerai plus de diagrammes (je les ai cependant, DM moi pour les voir, ils sont géniaux). Le point principal ici est que toutes les restrictions de job / capacité ont la même entrée (un caractère) et la même sortie conceptuelle (qu’une chose soit déverrouillée). Ils ne nécessitent aucun autre contexte.

Ce système n’est pas terrible, mais peut-être plus volumineux que nécessaire selon l’ampleur du jeu. C’est quand je suis entré dans les effets, et comment cela intersecte avec les restrictions, que mes problèmes ont vraiment commencé.

Effets

Une capacité qui ne fait rien serait assez inutile, donc chaque capacité a 1 ou plusEffetstels que endommager, guérir ou déplacer des personnages.

Mais que se passe-t-il si les effets individuels d’une capacité doivent être limités à différents personnages? Prenons un exemple vu dans de nombreux jeux,Vol de vie. Cette capacité a deux effets:Cible (s) de dommage, etSoigner soi-même. Chaque effet doit sélectionner les caractères auxquels il s’applique, en fonction de la source et de la cible.

“Attends une minute,” pensai-je. “En sélectionnantest juste une façon différente de direRestreindren’est-ce pas? “Et le concept de Restriction dans ma base de code a également été étendu à cela.

Si vous vous demandez pourquoi les soldats peuvent voler la vie, c’était pour garder cet exemple “simple”. Vous voulez vous lancer dans des psions et des médiums en plus de tout cela? Je ne le pensais pas.

Looks grea- oh … attends … la restrictionbool Met (personnage)La méthode ne prend que Character comme un paramètre unique et ne fournit pas (ou jusqu’à ce point, besoin) aucune autre information. Comment un Effet pourrait-il dire par lui-même si le Personnage en question était leciblede la capacité, ou lela source? Clairement, ce type de restriction nécessite un contexte situationnel.

J’ai donc arrêté, pris cela comme un signe que ma précédente définition de “Restriction” ne correspondait pas à ce cas d’utilisation, et implémenté cette partie d’une autre manière.

Je plaisante, ce serait trop facile. “NON!” Je maudis la voix dans ma tête en marmonnant quelque chose à propos des canettes et des vers. “Je suis plus intelligent que ce problème! Je vais utiliser la mise en page! “

Donc, la classe de restriction simple est devenueRestriction & lt; T & gt;, etbool IsMet (Personnage)devenubool IsMet (T). Tout ce qui était une restriction jusqu’à ce point est devenu unRestriction & lt; Caractère & gt;, et maintenant les restrictions liées aux capacités sont devenuesRestriction & lt; ActionContext & gt;.

“Et vous savez quoi d’autre?” Je me suis opposé à mon meilleur jugement, “j’ai remarquéun autreplace je peux aussi utiliser des restrictions – de la manière dont les capacités choisissent les cibles! Certaines capacités ne peuvent affecter que des caractères biologiques, d’autres seulement des caractères mécaniques, et d’autres ne sélectionnent pas une seule cible à tous les niveaux.

Et à travers les vannes sont venus l’héritage multi-niveaux des sous-classes de restriction en fonction de la quantité et le type d’informations qu’ils voulaient, les fonctions de conversion, surchargéesEst rencontré(...)méthodes, propriétés qui n’étaient que des typologies d’autres propriétés, et pire encore:Editeurs Unity personnalisés. Le concept abstrait d’une « restriction » (qui, si vous y pensez, est à peu près aussi large que le concept d’unsidéclaration) est entré en vigueur chaque fois que je voulais appliquer le filtrage modulaire surn’importe quoidans le jeu.

Note: Je n’ai rien contre les éditeurs Unity personnalisés lorsqu’ils sont utilisés pour de bon. Leur utilisation pour permettre une mauvaise conception n’est pas bonne.

Voici ce que je finis avec pour implémenter seulement deux emplois et une capacité:

Rappelez-vous cette ligne bleue au milieu.

Si vous gardez un score, il y a sept classes, trois sous-classes etDouzeÉléments de l’éditeur. Pour trois concepts dans le jeu.

Et oui, beaucoup de ces classes seront réutilisées, certaines des dizaines de fois, et c’est très bien (bien que la liste des éléments de l’éditeur continue à augmenter de manière spectaculaire lorsque la plupart des capacités ont plusieurs effets et que la plupart des effets ont leurs propres restrictions). Mais en réalité, la plupart de ces pièces réutilisées ne duraient que quelques lignes … ne pouvaient-elles pas simplement être des fonctions de leurs classes de base? Cela est toujours conforme au principe de la réutilisabilité, il ne suffit pas de séparer inutilement le code en d’autres entités.

Maintenant, ce type de conception en soi n’est paspar naturemal (bien que ce ne soit pas sûrbien, À mon avis); en effet, pour certains développeurs dont l’esprit travaille de cette façon, avec une planificationpourraiten théorie, gagnez du temps ou des lignes de code.

Mais pour moi, cela signifiait l’implémentation de sous-classes entières de Restriction utilisées une seule fois. En plus de cela, il s’avère que lorsque vos entités sont divisées en un Web multicouche avec des dizaines de connexions et une structure impénétrable, cela annule un avantage que ScriptableObjects fournit également dans l’éditeur Unity, car il faut plus de temps pour le déballer dans votre cerveau. chaque fois que vous essayez de voir comment les pièces s’emboîtent.

Mieux encore, je n’ai PAS encore appris ma leçon lorsque j’ai commencé mon jeu actuel, SCUM (#SCUMgame), car je pensais toujours que diviser les éléments en minuscules blocs modulaires était toujours plus robuste. Voici une capture d’écran de mon éditeur Unity après avoir implémenté seulement huit commandes d’équipage (l’équivalent de SCUM) avec, vous l’avez deviné,EffetsetRestrictions:

Les éléments sélectionnés dans le tiers inférieur ne sont que des restrictions. Pensez à cela. Pour huit commandes maladroites, j’ai eu 23 restrictions. 23! Les restrictions n’étaient-elles pas censées réduire la complexité?

Si vous vous demandez pourquoi les soldats peuvent voler la vie, c’était pour garder cet exemple “simple”. Vous voulez vous lancer dans des psions et des médiums en plus de tout cela? Je ne le pensais pas.

Rappelez-vous cette ligne bleue au milieu.

Les éléments sélectionnés dans le tiers inférieur ne sont que des restrictions. Pensez à cela. Pour huit commandes maladroites, j’ai eu 23 restrictions.23! N’étaient pas des restrictions censéesréduirecomplexité?

Qu’est-ce qui aurait pu être

Dans le grand ensemble de choses, passer par cet exercice une seule fois, mais deux fois, m’a ouvert les yeux sur les limites d’utilisation et de restitution appropriées de ScriptableObjects. J’ai maintenant une bien meilleure appréciation de la meilleure façon dont ils sont utilisés, du moins pour moi:

  • Définir un type d’entité dans le jeu, qu’il soit tangible comme “Gun” ou “Room”, ou conceptuel comme “Job” ou “Riddle”.
  • En tant que conteneur pour un ensemble de champs connexes, tels que les statistiques globales de l’ennemi ou les préfabriqués, telles que les différentes versions couleur du même effet visuel, ou une liste des effets audio possibles à parcourir.

Alors, que devrais-je faire ici? Eh bien, c’est ce que je suis finalement parvenu à faire à SCUM, et le principal conseil que je donnerais à l’un ou l’autre d’entre vous qui est toujours avec moi:

Par défaut, laissez les détails sur le comportement d’une chose dans la définition de cette chose.

Code pour vos besoins actuels et futurs connus. Ne compliquez pas votre conception pour tenir compte des situations futures inconnues ou possibles. Tu n’en auras pas besoin.

Rappelez-vous la ligne bleue dans ce dernier diagramme? En suivant ce guide, tout ce qui est en dessous de cette ligne disparaît.

Est-ce que cela signifie que les SO sous-classés sont mauvais? Bien sûr que non. Si je reviens à Rogue Tactics, Job sera toujours un ScriptableObject. Je sous-classe simplement Job directement, et ces sous-classes seraient responsables de leurs différences de comportement. Je finirais avec ça:

Job et Ability deviennent abstraits ScriptableObjects, et acquièrent la méthode requiseIsUnlocked (personnage), qui effectue toutes les vérifications qui étaient auparavant dans les restrictions modulaires par tâche / par capacité. La capacité gagne aussiExécuter (ActionContext), qui effectue tout ce qui a été traité une fois en combinant des effets.

Cependant, cela ne signifie pas que chaque emploi nécessitesa propreclasse. Un cas récurrent est celui des jobs de second niveau qui sont débloqués une fois que vous avez atteint le niveau trois dans un job de démarrage. Les recrues deviennent des soldats. Les adeptes deviennent des psions. Les techniciens deviennent des mécaniciens. Les seules différences sont le job de départ requis et leurs capacités. Ai-je besoin de cours séparés pour Soldier, Psion et Mechanic? Bien sûr que non.

Et ceci sans extraire davantage cette exigence du job / niveau dans la classe de base du Job, car plus de Jobs nécessiteront cela en combinaison avec d’autres choses sur la ligne.

Cela me donne ce dernier diagramme (à l’exclusion de Character & amp; JobProgress, qui n’ont pas changé):

Nous avons maintenant sept classes et sept objets d’édition (Life Steal est les deux), et nous avons créé sept concepts de jeu au lieu de trois.

Vous pouvez imaginer des situations similaires avec Capacité. Il y a peut-être une douzaine de capacités dans le jeu qui endommagent un personnage ennemi et lui infligent une sorte de pénalité ou de maladie. ils peuvent tous être des instances d’unDamageAndAfflictsous-classe de la capacité. Etc.

Est-ce que tout ce travail ci-dessusvraimentCela ne vaut pas la peine de le faire quelques dizaines de fois? Absolument hors de question. Voici comment ScriptableObjectsdevraitêtre utilisé. Ils ne doivent pas nécessairement être des dizaines de parties minuscules. Chacun doit juste savoir quoisescensé faire.

En ce qui concerne ma question initiale – “et si vous voulez des instances d’un ScriptableObject àse comporterdifféremment? “, voici ma réponse ci-dessus: sous-classer un ScriptableObject avec un comportement différent est correct. Il suffit de ne pas être trop fine dans la division pour vous assurer que vous pouvezpeut êtreréutiliser certaines pièces plus tard, ou vous allez faire plus de désordre que vous enregistrez vous-même.

Il est vraiment aussi simple que cela, et je suis franchement gêné, il m’a fallu aussi longtemps qu’il a fait pour comprendre ces choses dans la pratique, même si je les connais en théorie depuis le collège.

Si, à tout moment, en lisant ceci, vous avez établi un parallèle entre ma conception et la vôtre, et que vous rencontrez des problèmes pour que les pièces soient bien adaptées, la réutilisation est excellente, mais c’est juste un principe. Certains jeux publiés en font bon usage, d’autres pas. Mais vous savez ce que tous ces jeux publiés ont en commun? Leurs développeurs (espérons-le) savent comment ils fonctionnent.

Jusqu’à la prochaine fois, DZ

A propos de l’auteur

Dane Zeke Liergaard est diplômé avec un B.S. en informatique, à l’Université du Wisconsin, Madison, en août 2011. Ce même mois, il s’est déplacé à Seattle avec tout ce qu’il possédait, y compris son chat, dans une Chevrolet Aveo 2004. Dane a travaillé pour Amazon, Inc. de 2011 à 2016, passant de l’infrastructure du service client / téléphonie à Amazon Smile en 2014.

En mai 2016, il a pris un emploi au bureau de Google à Venice Beach, en Californie, travaillant sur l’architecture des abonnements à YouTube. En septembre 2017, après environ 1,25 ans à Los Angeles (plus qu’assez), il a été transféré à Google Seattle. Au cours de cette transition, il a également changé de titre, passant de SWE (ingénieur logiciel) à DPE (développeur de programmes développeur). Pour une bonne explication de ce qu’est un DPE, consultez cet article du collègue de Dane @fhinkel:

Développeur de programmes pour développeurs – Dites quoi !?

Nous nous soucions des développeurs

Nous avons maintenant sept classes et sept objets d’édition (Life Steal est les deux), et nous avons créé sept concepts de jeu au lieu de trois.