Lorsque votre code Verse ne se comporte pas comme prévu, il est parfois difficile d'identifier l'origine des erreurs. Par exemple, les erreurs suivantes peuvent se produire :
Erreurs d'exécution.
Code s'exécutant dans le mauvais ordre.
Processus beaucoup trop lents.
Chacun de ces éléments peut entraîner un comportement inattendu de votre code et créer des problèmes dans votre expérience. L'acte de diagnostiquer les problèmes dans votre code s'appelle le débogage, et il existe plusieurs solutions différentes que vous pouvez utiliser pour corriger et optimiser votre code.
Erreurs d'exécution de Verse
Votre code Verse est analysé à la fois lorsque vous le rédigez sur le serveur de langage et lorsque vous le compilez via l'éditeur ou Visual Studio Code. Cependant, cette analyse sémantique ne permet pas à elle seule de répondre à tous les problèmes que vous pouvez rencontrer. Lors de l'exécution de votre code, des erreurs d’exécution peuvent survenir. Celles-ci risquent d'entraîner l'arrêt de l'exécution du code Verse restant et rendre votre expérience injouable.
À titre d’exemple, supposons que vous disposiez d'un code Verse exécutant les opérations suivantes :
# Has the suspends specifier, so can be called in a loop expression.
SuspendsFunction()<suspends>:void={}
# Calls SuspendFunction forever without breaking or returning,
# causing a runtime error due to an infinite loop.
CausesInfiniteLoop()<suspends>:void=
loop:
SuspendsFunction()En théorie, la fonction CausesInfiniteLoop() ne génère aucune erreur dans le compilateur Verse et votre programme devrait se compiler correctement. Cependant, si vous appelez CausesInfiniteLoop() à l'exécution, une boucle infinie s’exécute, ce qui déclenche une erreur d'exécution.
Pour inspecter les erreurs d'exécution qui se sont produites au cours de votre expérience, accédez au portail de service de contenu. Vous y trouverez une liste de tous vos projets, publiés et non publiés. Pour chaque projet, vous avez accès à un onglet Verse qui répertorie les catégories d'erreurs d’exécution survenues dans un projet. Vous pouvez également vérifier la pile d'appels Verse dans laquelle cette erreur a été signalée, ce qui vous donne des informations supplémentaires sur l'origine des incidents. Les rapports d'erreur sont conservés pendant 30 jours.
Notez qu'il s'agit d'une nouvelle fonctionnalité qui est en cours de développement et que son fonctionnement peut changer dans les futures versions d'UEFN et de Verse.
Profiler un code lent
Si votre code s'exécute plus lentement que prévu, vous pouvez le tester à l'aide de l'expression profile. L'expression `profile` vous indique le temps nécessaire pour exécuter un extrait de code particulier, ce qui vous aide à identifier les blocs de code lents et à les optimiser. Par exemple, supposons que vous souhaitiez déterminer si une matrice contient un numéro particulier et renvoyer l'index où celui-ci apparaît. Pour cela, vous pouvez itérer la matrice et vérifier si le numéro correspond à celui que vous recherchiez.
# An array of test numbers.
TestNumbers:[]int = array{1,2,3,4,5}
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
# Find if the number exists in the TestNumbers array by iterating
# through each element and checking if it matches.
for:
Index -> Number:TestNumbers
Ce code est toutefois inefficace, car il doit vérifier chaque numéro de la matrice pour trouver une correspondance. Cela entraîne une complexité temporelle inefficace, car même s'il trouve l'élément, le code continue de vérifier le reste de la liste. Au lieu de cela, vous pouvez utiliser la fonction Find[] pour vérifier si la matrice contient le numéro que vous recherchez et le renvoyer. Étant donné que Find[] renvoie immédiatement l'élément lorsqu'elle le trouve, son exécution est plus rapide si l’élément se trouve plus tôt dans la liste. Si vous utilisez une expression profile pour tester les deux extraits de code, vous constaterez que, dans ce cas, le code utilisant la fonction Find[] s’exécute plus lentement.
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
# Find if the number exists in the TestNumbers array by iterating
# through each element and checking if it matches.
profile("Finding a number by checking each array element"):
for:
Index -> Number:TestNumbers
Number = 4
do:
Ces petites différences dans le temps d'exécution s'amplifient à mesure que vous devez itérer d'autres éléments. Chaque expression que vous exécutez lors de l'itération d'une longue liste accroît la complexité temporelle, en particulier lorsque vos matrices comportent des centaines, voire des milliers d'éléments. À mesure que vous élargissez vos expériences à un nombre croissant de joueurs, utilisez l'expression profile pour identifier et résoudre les principaux points de ralentissement.
Enregistreurs et journalisation des sorties
Par défaut, lorsque vous appelez Print() dans le code Verse pour afficher un message, ce message est écrit dans un journal d’affichage dédié. Les messages affichés apparaissent sur l'écran du jeu, dans le journal du jeu et dans le journal de sortie de l'UEFN.
Lorsque vous affichez un message à l'aide de la fonction Print(), ce message est consigné dans le journal de sortie, dans l'onglet Journal du jeu et sur l'écran du jeu.
Il est toutefois possible que vous ne souhaitiez pas que ces messages s'affichent à l'écran dans le jeu. Vous souhaitez peut-être recourir à des messages pour suivre les événements qui se produisent, par exemple lorsqu'un événement se déclenche ou qu'un certain laps de temps s'est écoulé, ou bien encore pour signaler la présence d'un problème dans votre code. L'affichage de multiples messages pendant le jeu peut s'avérer gênant, surtout si ces messages ne fournissent pas d'informations pertinentes au joueur.
Pour résoudre ce problème, vous pouvez utiliser un enregistreur. Un enregistreur est une classe spéciale qui permet d'afficher des messages directement dans le journal de sortie et dans l'onglet Journal sans les afficher à l'écran.
Enregistreurs
Pour créer un enregistreur, vous devez d’abord créer un canal de journal. Chaque enregistreur imprime des messages dans le journal de sortie, mais il peut être difficile de discerner à quel enregistreur appartient tel ou tel message. Les canaux de journalisation ajoutent le nom du canal de journal au début du message, ce qui permet d'identifier facilement l'enregistreur ayant envoyé le message. Les canaux de journaux sont déclarés au niveau du module, tandis que les enregistreurs sont déclarés à l'intérieur des classes ou des fonctions. Voici un exemple de déclaration d'un canal de journalisation au niveau du module, puis de déclaration et d'appel d'un enregistreur à l'intérieur d'un appareil Verse.
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# A log channel for the debugging_tester class.
# Log channels declared at module scope can be used by any class.
debugging_tester_log := class(log_channel){}
# A Verse-authored creative device that can be placed in a level
debugging_tester := class(creative_device):
Lorsque vous affichez un message à l'aide de la fonction Print() d'un enregistreur, ce message est consigné dans le journal de sortie et dans l'onglet Journal du jeu.
Niveaux de journalisation
En plus des canaux, vous pouvez également spécifier le niveau de journalisation par défaut auquel le journal s'affiche. Il existe cinq niveaux, chacun disposant de ses propres propriétés :
| Niveau de journalisation | Afficher sur | Propriétés spéciales |
|---|---|---|
Débogage | Journal du jeu | S/O |
Détaillé | Journal du jeu | S/O |
Normal | Journal du jeu, journal de sortie | S/O |
Avertissement | Journal du jeu, journal de sortie | Le texte est affiché en couleur jaune |
Erreur | Journal du jeu, journal de sortie | Le texte est affiché en couleur rouge |
Lorsque vous créez un enregistreur, celui-ci est défini par défaut sur le niveau de journalisation normal. Vous pouvez modifier le niveau d'un enregistreur à sa création ou spécifier un niveau de journalisation à afficher lors de l'appel de Print().
# A logger local to the debugging_tester class. By default, this prints
# to log_level.Normal.
Logger:log = log{Channel := debugging_tester_log}
# A logger with log_level.Debug as the default log channel.
DebugLogger:log = log{Channel := debugging_tester_log, DefaultLevel := log_level.Debug}
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
Dans l'exemple ci-dessus, l’enregistreur utilise par défaut le canal de journal normal, tandis que DebugLogger utilise par défaut le canal de journal Débogage. Tout enregistreur peut afficher du contenu à n'importe quel niveau de journalisation en spécifiant log_level lors de l'appel de Print().
Résultats de l'utilisation d'un enregistreur pour afficher à différents niveaux de journalisation. Notez que log_level.Debug et log_level.Verbose n'affichent aucun contenu dans le journal du jeu, mais uniquement dans le journal de sortie de l'UEFN.
Afficher la pile d'appels
La pile d'appels suit la liste des appels de fonction qui ont conduit à l’étendue actuelle. Il s'agit d'un ensemble d'instructions empilé que votre code utilise pour savoir où il doit revenir une fois l'exécution de la routine actuelle terminée. Vous pouvez afficher la pile d'appels à partir de n'importe quel enregistreur à l'aide de la fonction PrintCallStack(). Prenons le code d'exemple suivant :
# A logger local to the debugging_tester class. By default, this prints
# to log_level.Normal.
Logger:log = log{Channel := debugging_tester_log}
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
# Move into the first function, and print the call stack after a few levels.
LevelOne()
Le code de la fonction OnBegin() ci-dessus appelle LevelOne() pour passer à la première fonction. LevelOne() appelle ensuite LevelTwo(), qui appelle LevelThree(), laquelle appelle à son tour Logger.PrintCallStack() pour afficher la pile d'appels actuelle. L'appel le plus récent étant placé en haut de la pile, LevelThree() est affiché en premier. LevelTwo(), LevelOne() et OnBegin() sont appelées ensuite dans cet ordre.
Si un problème se produit dans le code, l'impression de la pile d'appels permet d'identifier les appels à l'origine de l'erreur. Vous êtes donc en mesure de visualiser plus facilement la structure de votre code lors de son exécution et d'isoler les traces de pile individuelles dans les projets riches en code.
Visualiser les données de jeu avec l'API Debug Draw
Une autre façon de déboguer différentes fonctionnalités de vos expériences consiste à utiliser l'API Debug Draw. Cette API peut créer des formes de débogage pour visualiser les données du jeu. Voici quelques exemples :
La ligne de mire d'un garde.
La distance sur laquelle un déplaceur d'accessoire déplace un objet.
La distance d'atténuation d'un lecteur audio.
Vous pouvez utiliser ces formes de débogage pour affiner votre expérience sans exposer ces données dans une expérience publiée. Pour plus d'informations, consultez la rubrique L'API Debug Draw dans Verse.
Optimisation et synchronisation avec concurrence
La concurrence est au cœur du langage de programmation Verse et est un puissant outil permettant d'améliorer vos expériences. L'outil Concurrence permet notamment à un appareil Verse d'exécuter plusieurs opérations à la fois. Il est ainsi possible d'écrire du code plus flexible et compact, et d'utiliser moins d'appareils dans votre niveau. Excellent outil d'optimisation, Concurrence utilise du code asynchrone pour gérer plusieurs tâches à la fois, ce qui permet d'accélérer efficacement l'exécution de vos programmes et de résoudre les problèmes liés à la synchronisation.
Créer des contextes asynchrones avec l'expression Spawn
L'expression spawn lance une expression asynchrone à partir de n'importe quel contexte tout en permettant aux expressions suivantes de s'exécuter immédiatement. Cela permet d'exécuter plusieurs tâches en même temps, à partir du même appareil, sans avoir besoin de créer de nouveaux fichiers Verse pour chacune d'elles. Supposons par exemple qu'une partie de votre code surveille les PV de chaque joueur toutes les secondes. Si le niveau de PV d'un joueur tombe en dessous d'un certain seuil, vous souhaitez soigner le joueur en lui octroyant une petite quantité de PV. Vous souhaitez ensuite exécuter du code qui gère une autre tâche. Un appareil qui implémente ce code pourrait ressembler à ceci :
# A Verse-authored creative device that can be placed in a level
healing_device := class(creative_device):
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
AllPlayers:[]agent = GetPlayspace().GetPlayers()
# Every second, check each player. If the player has less than half health,
# heal them by a small amount.
Cependant, étant donné que cette boucle s'exécute indéfiniment et ne s'interrompt jamais, tout code successif n'est jamais exécuté. Il s'agit d'une conception restrictive, car cet appareil est bloqué et n'exécute que l'expression de boucle. Pour permettre à l'appareil d'effectuer plusieurs tâches à la fois et d'exécuter du code simultanément, vous pouvez déplacer le code loop dans une fonction asynchrone et le générer pendant l’exécution de la fonction OnBegin().
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
# Use the spawn expression to run HealMonitor() asynchronously.
spawn{HealMonitor()}
# The code after this executes immediately.
Print("This code keeps going while the spawned expression executes")
HealMonitor(Players:[]agent)<suspends>:void=
Il s'agit d'une amélioration, car l'appareil peut désormais exécuter un autre code pendant l'exécution de la fonction HealMonitor(). Cependant, la fonction doit toujours parcourir en boucle chaque joueur, et d'éventuels problèmes de synchronisation risquent de survenir à mesure que le nombre de joueurs augmente dans l'expérience. Par exemple, que faire pour récompenser le score de chaque joueur en fonction de ses PV ou pour vérifier si un joueur détient un objet ? Ajouter une logique supplémentaire par joueur dans l’expression for ajoute à la complexité temporelle de cette fonction. En outre, avec un nombre suffisant de joueurs, un joueur pourrait ne pas être soigné à temps s’il subit des dégâts en raison de problèmes de synchronisation.
Au lieu de parcourir en boucle chaque joueur et de les vérifier individuellement, vous pouvez optimiser ce code en générant une instance de la fonction pour chaque joueur. En d'autres termes, une seule fonction peut surveiller un seul joueur afin que votre code n'ait pas à surveiller chaque joueur avant d'arrêter la boucle sur celui devant être soigné. L'utilisation d'expressions de concurrence telles que spawn à votre avantage peut rendre votre code plus efficace et flexible, et permet au code de base restant de gérer d'autres tâches.
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
AllPlayers := GetPlayspace().GetPlayers()
# Spawn an instance of the HealMonitor() function for each player.
for:
Player:AllPlayers
do:
# Use the spawn expression to run HealMonitorPerPlayer() asynchronously.
L’utilisation de l’expression spawn à l’intérieur d’une expression loop peut entraîner des comportements inattendus si elle n’est pas correctement gérée. Par exemple, étant donné que HealMonitorPerPlayer() ne se termine jamais, ce code continue de générer une quantité infinie de fonctions asynchrones jusqu'à ce qu'une erreur d’exécution se produise.
# Spawn an instance of the HealMonitor() function for each player, looping forever.
# This will cause a runtime error as the number of asynchronous functions infinitely increases.
loop:
for:
Player:AllPlayers
do:
spawn{HealMonitorPerPlayer(Player)}
Sleep(0.0)Contrôler la synchronisation avec des événements
Il peut s'avérer difficile de synchroniser correctement chaque partie de votre code, en particulier dans les expériences multijoueurs de grande envergure dans lesquelles de nombreux scripts s'exécutent simultanément. Différentes parties de votre code peuvent s'appuyer sur d'autres fonctions ou scripts s'exécutant dans un ordre défini, ce qui peut générer des problèmes de synchronisation entre eux si vous n'appliquez pas des contrôles stricts. Prenons par exemple la fonction suivante, qui déclenche un compte à rebours pendant un certain temps, puis attribue un score au joueur qui lui est transmis si ses PV sont supérieurs au seuil défini.
CountdownScore(Player:agent)<suspends>:void=
# Wait for some amount of time, then award each player whose HP is above the threshold some score.
Sleep(CountdownTime)
if:
Character := Player.GetFortCharacter[]
PlayerHP := Character.GetHealth()
PlayerHP >= HPThreshold
then:
ScoreManager.Activate(Player)Étant donné que cette fonction possède le modificateur <suspends>, vous pouvez exécuter une instance de celle-ci de manière asynchrone par joueur en utilisant spawn(). Cependant, vous devez garantir que tout autre code qui s'appuie sur cette fonction s'exécutera toujours une fois celle-ci terminée. Que faire si vous souhaitez afficher chaque joueur qui a marqué après la fin de CountdownScore() ? Vous pourriez réaliser cela dans OnBegin() en appelant Sleep() pour attendre la même durée que l’exécution de CountdownScore(), mais cela risque de créer des problèmes de synchronisation pendant l’exécution du jeu et ajoute une variable supplémentaire à mettre à jour à chaque modification de votre code. À la place, vous pouvez créer des événements personnalisés et y faire un appel de Await() pour contrôler précisément l’ordre d’exécution des événements dans votre code.
# Custom event to signal when the countdown finishes.
CountdownCompleteEvent:event() = event(){}
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
AllPlayers := GetPlayspace().GetPlayers()
# Spawn a CountdownScore function for each player
for:
Étant donné que ce code attend désormais que CountdownCompletedEvent() soit signalé, il vérifie le score de chaque joueur uniquement au terme de l'exécution de CountdownScore(). De nombreux appareils disposent d'événements intégrés sur lesquels vous pouvez appeler Await() afin de contrôler la synchronisation de votre code. En les utilisant avec vos propres événements personnalisés, vous pouvez créer des boucles de jeu complexes avec plusieurs parties mobiles. À titre d'exemple, le modèle de démarrage Verse utilise plusieurs événements personnalisés pour contrôler le mouvement des personnages, mettre à jour l'interface et gérer la boucle de jeu globale d'un plateau à l'autre.
Gérer plusieurs expressions avec Sync, Race et Rush
Les fonctions sync, race et rush permettent toutes d’exécuter plusieurs expressions asynchrones simultanément, tout en réalisant des actions différentes selon la manière dont l’exécution de ces expressions se termine. En exploitant chacune d'elles, vous pouvez contrôler strictement la durée de vie de chacune de vos expressions asynchrones, ce qui produit un code plus dynamique capable de gérer diverses situations.
Prenons par exemple l'expression rush. Cette expression exécute plusieurs expressions asynchrones simultanément, mais renvoie uniquement la valeur de l'expression qui se termine en premier. Supposons que vous ayez un mini-jeu dans lequel les équipes doivent accomplir une tâche, la première équipe à terminer le jeu obtenant un bonus lui permettant d'interférer avec les autres joueurs. Vous pouvez écrire une logique de synchronisation complexe pour déterminer le moment où chaque équipe termine la tâche, ou utiliser l'expression rush. Étant donné que cette expression renvoie la valeur de la première expression asynchrone à terminer, elle renvoie l'équipe gagnante, tout en permettant au code qui gère les autres équipes de continuer à fonctionner.
WinningTeam := rush:
# All three async functions start at the same time.
RushToFinish(TeamOne)
RushToFinish(TeamTwo)
RushToFinish(TeamThree)
# The next expression is called immediately when any of the async functions complete.
GrantPowerup(WinnerTeam)L'expression race suit les mêmes règles, hormis le fait que lorsqu'une expression asynchrone se termine, les autres expressions sont annulées. Cela vous permet de contrôler strictement la durée de vie de plusieurs expressions asynchrones à la fois, et vous pouvez même combiner cela avec l'expression sleep() pour limiter le temps pendant lequel vous souhaitez que l'expression s'exécute. Revenez à l’exemple de l’expression rush, sauf que cette fois vous souhaitez que le mini-jeu se termine immédiatement lorsqu’une équipe gagne. Vous souhaitez également ajouter un chronomètre pour que le mini-jeu ne dure pas indéfiniment. L’expression race permet de faire ces deux choses, sans qu’il soit nécessaire d'utiliser des événements ou d'autres outils de concurrence pour savoir quand annuler les expressions qui perdent la course.
WinningTeam := race:
# All four async functions start at the same time.
RaceToFinish(TeamOne)
RaceToFinish(TeamTwo)
RaceToFinish(TeamThree)
Sleep(TimeLimit)
# The next expression is called immediately when any of the async functions complete. Any other async functions are canceled.
GrantPowerup(WinnerTeam)Enfin, l'expression sync permet d'attendre la fin de l'exécution de plusieurs expressions, garantissant ainsi que chacune d'elles se termine avant de continuer. Étant donné que l’expression sync renvoie un tuple contenant les résultats de chacune des expressions asynchrones, vous pouvez terminer l’exécution de toutes vos expressions et évaluer les données de chacune d’elles individuellement. Revenons à l'exemple du mini-jeu. Supposons que vous souhaitiez maintenant accorder des bonus à chaque équipe en fonction de leurs performances dans le mini-jeu. C'est là qu'intervient l'expression sync.
TeamResults := sync:
# All three async functions start at the same time.
WaitForFinish(TeamOne)
WaitForFinish(TeamTwo)
WaitForFinish(TeamThree)
# The next expression is called only when all of the async expressions complete.
GrantPowerups(TeamResults)Si vous souhaitez exécuter une expression asynchrone sur plusieurs éléments de matrice, vous pouvez utiliser la fonction pratique ArraySync() pour garantir qu'ils sont tous synchronisés.
Chacune de ces expressions de concurrence est un outil puissant en soi ; en apprenant à les combiner et à les utiliser ensemble, vous pouvez rédiger un code capable de gérer n'importe quelle situation. Consultez cet exemple tiré du modèle Circuit de course avec persistance Verse, qui combine plusieurs expressions de concurrence pour non seulement jouer une introduction pour chaque joueur avant la course, mais aussi l'annuler si le joueur quitte le jeu pendant l'introduction. Dans cet exemple, nous vous expliquons comment utiliser la concurrence de plusieurs manières et créer un code résilient qui réagit de manière dynamique à différents événements.
# Wait for the player's intro start and display their info.
# Cancel the wait if they leave.
WaitForPlayerIntro(Player:agent, StartOrder:int)<suspends>:void=
var IntroCounter:int = 0
race:
# Waiting for this player to finish the race and then record the finish.
loop:
sync: