Quando le cose non funzionano come previsto nel codice Verse, a volte è difficile capire cosa sia andato storto. Ad esempio, puoi riscontrare:
Errori di runtime.
Codice eseguito nell'ordine sbagliato.
Processi che richiedono più tempo del dovuto.
Ognuno di questi può causare comportamenti imprevisti del codice e creare problemi nell'esperienza d'uso. L'atto di diagnosticare i problemi nel codice è chiamato debug; ci sono diverse soluzioni che puoi provare per correggere e ottimizzare il codice.
Errori di runtime di Verse
Il codice Verse viene analizzato sia mentre lo scrivi nel server del linguaggio sia quando lo compili dall'editor o da Visual Studio Code. Tuttavia, questa analisi semantica da sola non è in grado di individuare tutti i possibili problemi che si possono incontrare. Quando il codice viene eseguito in runtime, è possibile che vengano attivati gli errori di runtime. Questi causeranno l'interruzione dell'esecuzione di tutto il codice Verse successivo, il che potrebbe rendere non riproducibile la tua esperienza.
Ad esempio, supponiamo di avere del codice Verse che ha eseguito quanto segue:
# 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()La funzione CausesInfiniteLoop() non dovrebbe causare errori nel compilatore Verse e il programma dovrebbe essere compilato correttamente. Tuttavia, se chiami CausesInfiniteLoop() in fase di runtime, verrà eseguito un loop infinito e quindi si attiverà un errore di runtime.
Per controllare gli errori di runtime che si sono verificati nella tua esperienza, vai al Portale Content Service. Qui puoi vedere un elenco di tutti i tuoi progetti, sia pubblicati che non. Per ogni progetto, hai accesso a una scheda Verse che elenca le categorie di errori di runtime verificati in un progetto. Puoi anche controllare lo stack di chiamate Verse in cui è stato segnalato l'errore che fornisce maggiori dettagli su cosa potrebbe essere andato storto. Le segnalazioni di errore vengono archiviate per un massimo di 30 giorni.
Tieni presente che si tratta di una nuova funzionalità in fase di sviluppo e il suo funzionamento potrebbe cambiare nelle versioni future di UEFN e Verse.
Profilazione del codice lento
Se il codice viene eseguito più lentamente del previsto, puoi testarlo utilizzando l'espressione profile. L'espressione profile indica il tempo necessario per l'esecuzione di una particolare parte di codice e può aiutarti a identificare i blocchi di codice lenti per ottimizzarli. Per esempio, supponiamo tu voglia verificare se un array contiene un certo numero e restituire l'indice in cui appare. Puoi farlo iterando l'array e controllando se il numero corrisponde a quello che stavi cercando.
# 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
Tuttavia, questo codice è inefficiente poiché deve controllare ogni numero dell'array per trovare una corrispondenza. Ciò si traduce in una complessità temporale inefficiente, poiché anche se trova l'elemento, continua a controllare il resto dell'elenco. Invece, puoi utilizzare la funzione Find[] per verificare se l'array contiene il numero che stai cercando e restituirlo. Poiché Find[] esegue immediatamente la restituzione quando trova l'elemento, è più veloce se l'elemento cercato si trova all'inizio della lista. Se utilizzi un'espressione profile per testare entrambe le parti di codice, noterai che in questo caso il codice con utilizzo di funzione Find[] comporta un tempo di esecuzione inferiore.
# 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:
Queste piccole differenze nel tempo di esecuzione si amplificano con l'aumentare del numero di elementi da iterare. Ogni espressione eseguita durante l'iterazione di un ampio elenco aumenta la complessità del tempo, soprattutto quando i tuoi array crescono fino a raggiungere centinaia o addirittura migliaia di elementi. Man mano che scali le esperienze a un numero sempre maggiore di giocatori, utilizza l'espressione profile per trovare e affrontare le aree chiave di rallentamento.
Logger e registrazione output
Per impostazione predefinita, quando chiami Print() nel codice Verse per stampare un messaggio, quel messaggio viene scritto in un registro di Print dedicato. I messaggi stampati vengono visualizzati sullo schermo nel gioco, nel registro di gioco e nel registro di output in UEFN.
Quando stampi un messaggio utilizzando la funzione Print(), il messaggio viene scritto nel registro di output, nella scheda Registro nel gioco e nella schermata del gioco.
Tuttavia, molte volte potresti non volere che i messaggi vengano visualizzati sullo schermo durante il gioco. Puoi utilizzare i messaggi per tenere traccia degli eventi che accadono, ad esempio quando si verifica un evento, o è trascorso un determinato periodo di tempo, o per segnalare quando qualcosa va storto nel codice. Più messaggi durante il gameplay possono distrarre, soprattutto se non forniscono informazioni rilevanti per il giocatore.
Per risolvere questo problema, puoi utilizzare un logger. Un logger è una classe speciale che permette di stampare i messaggi direttamente nel registro di output e nella scheda registro senza visualizzarli sullo schermo.
Logger
Per creare un logger, devi prima creare un canale di registro. Ogni logger stampa i messaggi nel registro di output, ma può essere difficile distinguere quale messaggio proviene da quale logger. I canali di registro aggiungono il nome del canale di registro all'inizio del messaggio, rendendo più facile vedere quale logger ha inviato il messaggio. I canali di registro sono dichiarati nell'ambito del modulo, mentre i logger sono dichiarati all'interno di classi o funzioni. Di seguito è riportato un esempio di dichiarazione di un canale di registro nell'ambito del modulo, poi un esempio di dichiarazione e chiamata di un logger all'interno di un dispositivo 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):
Quando stampi un messaggio utilizzando la funzione Print() di un logger, quel messaggio viene scritto nel registro di output e nella scheda Registro nel gioco.
Livelli di registro
Oltre ai canali, puoi anche specificare un livello di registro predefinito su cui il logger esegue la stampa. Ci sono cinque livelli, ognuno con le proprie proprietà:
| Livello di registro | Stampa su | Proprietà speciali |
|---|---|---|
Debug | Registro in gioco | N/D |
Prolisso | Registro in gioco | N/D |
Normale | Registro in gioco, Registro di output | N/D |
Avviso | Registro in gioco, Registro di output | Il colore del testo è giallo |
Errore | Registro in gioco, Registro di output | Il colore del testo è rosso |
Quando crei un logger, per impostazione predefinita viene impostato il livello di registro Normal. Puoi modificare il livello di un logger quando lo crei oppure puoi specificare un livello di registro su cui stampare quando chiami 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=
Nell'esempio precedente, Logger utilizza per impostazione predefinita il canale di registro Normal, mentre DebugLogger utilizza il canale di registro Debug per impostazione predefinita. Qualsiasi logger può stampare su qualsiasi livello di registro specificando log_level quando chiama Print().
Risultati dell'utilizzo di un logger per la stampa su diversi livelli di registro. Tieni presente che log_level.Debug e log_level.Verbose non vengono stampati nel registro di gioco, ma solo nel registro di output UEFN.
Stampa dello stack di chiamate
Lo stack di chiamate tiene traccia dell'elenco delle chiamate di funzione che hanno portato all'ambito corrente. È come un insieme impilato di istruzioni che il codice utilizza per sapere dove deve tornare una volta terminata l'esecuzione della routine corrente. Puoi stampare lo stack di chiamate da qualsiasi logger utilizzando la funzione PrintCallStack(). Ad esempio, prendi il seguente codice:
# 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()
Il codice in OnBegin() in alto richiama LevelOne() per passare alla prima funzione. Quindi LevelOne() chiama LevelTwo(), che chiama a sua volta LevelThree(), che chiama infine Logger.PrintCallStack() per stampare lo stack di chiamate corrente. La chiamata più recente sarà in cima allo stack, quindi LevelThree() verrà stampato per primo. Poi si procede con LevelTwo(), LevelOne() e OnBegin(), in quest'ordine.
Quando qualcosa non funziona nel codice, la stampa dello stack di chiamata è utile per sapere esattamente quali chiamate hanno causato il problema. In questo modo è più facile vedere la struttura del codice durante l'esecuzione, inoltre è possibile isolare le singole tracce dello stack nei progetti ad alta densità di codice.
Visualizzazione dei dati di gioco con il disegno di debug
Un altro modo per eseguire il debug di diverse funzionalità delle tue esperienze è utilizzare l'API Disegno di debug. Questa API può creare forme di debug per visualizzare i dati di gioco. Alcuni esempi includono:
La linea di visuale di una guardia.
La distanza alla quale un traslatore di oggetti sposterà un oggetto.
La distanza di attenuazione di un riproduttore audio.
Puoi utilizzare queste forme di debug per ottimizzare la tua esperienza senza esporre questi dati in un'esperienza pubblicata. Per maggiori informazioni, consulta Disegno di debug in Verse.
Ottimizzazione e tempistiche con l'uso della concorrenza
La concorrenza è il cuore del linguaggio di programmazione Verse ed è un potente strumento per migliorare le esperienze. Con la concorrenza, puoi fare in modo che un dispositivo Verse esegua più operazioni contemporaneamente. Ciò rende possibile scrivere un codice più flessibile e compatto e risparmiare sul numero di dispositivi utilizzati nel livello. La concorrenza è un ottimo strumento per l'ottimizzazione e trovare modi per utilizzare il codice asincrono per gestire più attività contemporaneamente è un ottimo modo per accelerare l'esecuzione dei programmi e affrontare i problemi relativi alla temporizzazione.
Creazione di contesti asincroni con generazione
L'espressione spawn avvia un'espressione asincrona da qualsiasi contesto permettendo l'esecuzione immediata delle espressioni seguenti. In questo modo è possibile eseguire più attività contemporaneamente, dallo stesso dispositivo, senza dover creare nuovi file Verse per ciascuna. Ad esempio, considera uno scenario in cui hai del codice che monitora la salute di ogni giocatore ogni secondo. Se la salute di un giocatore scende al di sotto di un certo numero, devi curarlo di una piccola quantità. Successivamente eseguirai del codice che gestisce un'altra attività. Un dispositivo che implementa questo codice potrebbe essere simile al seguente:
# 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.
Tuttavia, poiché questo loop viene eseguito all'infinito e non si interrompe mai, il codice che lo segue non verrà mai eseguito. Questo è un progetto limitante poiché questo dispositivo è bloccato eseguendo solo l'espressione loop. Per consentire al dispositivo di svolgere più operazioni contemporaneamente, ed eseguire codice simultaneamente, puoi spostare il codice loop in una funzione asincrona e generarla durante 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=
Si tratta di un miglioramento, in quanto il dispositivo può eseguire altro codice mentre è in esecuzione la funzione HealMonitor(). Tuttavia, la funzione deve ancora eseguire il loop di ogni giocatore e possibili problemi di temporizzazione potrebbero verificarsi con più giocatori presenti nell'esperienza. Ad esempio, cosa accadrebbe se volessi assegnare a ogni giocatore un punteggio in base ai suoi HP o controllare se è in possesso di un oggetto? L'aggiunta di una logica aggiuntiva per ogni giocatore nell'espressione for aumenta la complessità temporale di questa funzione e, con un numero sufficiente di giocatori, un giocatore potrebbe non guarire in tempo se subisce danni a causa di problemi di temporizzazione.
Invece di scorrere ogni giocatore e di controllarli individualmente, puoi ottimizzare ulteriormente il codice generando un'istanza della funzione per ogni giocatore. Ciò significa che una singola funzione può monitorare un singolo giocatore, assicurando che il codice non debba controllare ogni singolo giocatore prima di tornare a quello che ha bisogno di essere curato. L'utilizzo di espressioni di concorrenza come spawn a proprio vantaggio può rendere il codice più efficiente e flessibile e liberare il resto del codice base per gestire altre task.
# 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'utilizzo dell'espressione spawn all'interno di un'espressione loop può causare comportamenti indesiderati se gestita in modo improprio. Ad esempio, poiché HealMonitorPerPlayer() non termina mai, questo codice continuerà a generare una quantità infinita di funzioni asincrone fino a quando non si verifica un errore di runtime.
# 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)Controllo della temporizzazione con gli eventi
Sincronizzare correttamente ogni parte del codice può essere difficile, soprattutto in esperienze multigiocatore di grandi dimensioni con molti script in esecuzione contemporaneamente. Parti diverse del codice possono fare affidamento su altre funzioni o script eseguiti in un ordine prestabilito, e ciò può creare problemi di temporizzazione tra loro se non ci sono controlli severi. Ad esempio, considera questa funzione: fa partire un conto alla rovescia e poi assegna un punteggio al giocatore passato come parametro, se i suoi HP superano una certa soglia.
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)Poiché questa funzione ha il modificatore <suspends>, puoi eseguirne un'istanza in modo asincrono per giocatore utilizzando spawn(). Tuttavia, devi garantire che qualsiasi altro codice che si basa su questa funzione venga sempre eseguito dopo il suo completamento. E se volessi stampare ogni giocatore che ha segnato dopo il termine di CountdownScore()? Puoi farlo in OnBegin() chiamando Sleep() per attendere lo stesso tempo impiegato da CountdownScore() per l'esecuzione, ma ciò potrebbe creare problemi di temporizzazione quando il gioco è in esecuzione e introduce una nuova variabile, che devi aggiornare costantemente se desideri apportare modifiche al tuo codice. Invece, puoi creare eventi personalizzati e chiamare Await() su di essi per controllare rigorosamente l'ordine degli eventi nel codice.
# 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:
Dato che il codice aspetta ora il segnale da CountdownCompletedEvent(), il controllo dei punteggi dei giocatori avverrà solo una volta conclusa l'esecuzione di CountdownScore(). Molti dispositivi hanno eventi integrati che puoi chiamare Await() per controllare la temporizzazione del codice e, sfruttandoli con i tuoi eventi personalizzati, puoi creare loop di gioco complessi con diverse parti mobili. Ad esempio, il modello Verse Starter utilizza diversi eventi personalizzati per il controllo del movimento dei personaggi, aggiornare la UI e gestire l'intero loop di gioco da un tabellone all'altro.
Gestione di più espressioni con Sync, Race e Rush
I sync, race e rush ti permettono di eseguire più espressioni asincrone contemporaneamente, mentre esegui funzioni diverse al termine dell'esecuzione di tali espressioni. Sfruttando ciascuno di questi elementi, puoi controllare rigorosamente la durata in vita di ciascuna delle tue espressioni asincrone, risultando in un codice più dinamico in grado di gestire più situazioni diverse.
Ad esempio, prendi l'espressione rush. Questa espressione esegue più espressioni asincrone contemporaneamente, ma restituisce solo il valore dell'espressione che termina per prima. Supponiamo di avere un minigioco in cui i team devono completare un compito, in cui il team che finisce per primo riceve un potenziamento che gli permette di interferire con gli altri giocatori mentre finiscono. Puoi scrivere una logica di temporizzazione complicata per tenere traccia di quando ogni team completa il task oppure puoi utilizzare l'espressione rush. Poiché l'espressione restituisce il valore della prima espressione asincrona da terminare, restituirà il team vincitore, permettendo al codice che gestisce gli altri team di continuare a essere eseguito.
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'espressione race segue le stesse regole, tranne per il fatto che quando un'espressione asincrona viene completata, le altre vengono annullate. Ciò ti permette di controllare rigorosamente la durata di più espressioni asincrone contemporaneamente e puoi anche combinarla con l'espressione sleep() per limitare il tempo di esecuzione dell'espressione. Considera l'esempio rush, tranne per il fatto che questa volta vuoi che il minigioco termini immediatamente quando un team vince. Vuoi anche aggiungere un timer in modo che il minigioco non continui all'infinito. L'espressione race consente di eseguire entrambe le operazioni, senza dover utilizzare eventi o altri strumenti di concorrenza per sapere quando annullare le espressioni che perdono la gara.
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)Infine, l'espressione sync ti permette di aspettare fino al termine dell'esecuzione di più espressioni, assicurandoti che ognuna di esse venga completata prima di procedere. Poiché l'espressione sync restituisce una tupla con i risultati di ciascuna delle espressioni asincrone, puoi completarne l'esecuzione ed esaminare i dati di ognuna singolarmente. Tornando all'esempio del minigioco, supponiamo invece che tu voglia assegnare potenziamenti a ogni team in base a come si è comportato nel minigioco. È qui che entra in gioco l'espressione 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)Se vuoi eseguire un'espressione asincrona su più elementi dell'array, puoi utilizzare la pratica funzione ArraySync() per garantire che siano tutti sincronizzati.
Ognuna di queste espressioni di concorrenza è di per sé uno strumento potente e, imparando a combinarle e a utilizzarle insieme, puoi scrivere codice per gestire qualsiasi situazione. Considera questo esempio del modello di Gara a circuito con Persistenza in Verse, che combina più espressioni di concorrenza non solo per riprodurre un'introduzione per ogni giocatore prima della gara, ma anche per annullarla se il giocatore abbandona durante l'introduzione. Questo esempio evidenzia come puoi utilizzare la concorrenza in diversi modi e compilare codice resiliente che reagisce dinamicamente a eventi diversi.
# 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: