Cuando las cosas no funcionan como esperas en tu código de Verse, a veces es difícil entender qué ha salido mal. Por ejemplo, puedes encontrarte con:
Errores en tiempo de ejecución.
Código que se ejecuta en el orden incorrecto.
Procesos que tardan más de lo debido.
Todo lo anterior puede hacer que tu código se comporte de formas inesperadas y crear problemas de experiencia. El acto de diagnosticar problemas en el código se denomina depurar y existen varias soluciones que puedes utilizar para corregir y optimizar tu código.
Errores en tiempo de ejecución de Verse
Tu código de Verse se analiza tanto cuando lo escribes en el servidor de lenguaje como cuando lo compilas desde el editor o Visual Studio Code. Sin embargo, este análisis semántico por sí solo no puede detectar todos los posibles problemas que puedes encontrar. Cuando el código se ejecuta en tiempo de ejecución, puedes activar errores en tiempo de ejecución. Esto hará que todo el resto del código de Verse deje de ejecutarse, lo que puede hacer que tu experiencia no se pueda reproducir.
A modo de ejemplo, supón que tienes código de Verse que hace lo siguiente:
# 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 función CausesInfiniteLoop() no provocaría ningún error en el compilador de Verse y tu programa se compilaría correctamente. Sin embargo, si llamas a CausesInfiniteLoop() en tiempo de ejecución, ejecutará un bucle infinito y, por tanto, activará un error en tiempo de ejecución.
Para inspeccionar los errores en tiempo de ejecución que se han producido en tu experiencia, ve al portal de servicios de contenido. Allí puedes ver una lista de todos tus proyectos, tanto publicados como no publicados. Para cada proyecto, tienes acceso a una pestaña de Verse que enumera las categorías de errores en tiempo de ejecución que se han producido en un proyecto. También puedes consultar la pila de llamadas de Verse donde se notificó ese error para ver más detalles sobre lo que pudo haber salido mal. Los informes de errores se almacenan hasta durante 30 días.
Ten en cuenta que se trata de una nueva característica que se encuentra en una fase de desarrollo temprana. Su funcionamiento puede cambiar en futuras versiones de UEFN y Verse.
Cómo probar código lento con la expresión `profile`
Si tu código se ejecuta más lento de lo esperado, puedes probarlo utilizando la expresión profile. La expresión `profile` te indica cuánto tarda en ejecutarse un fragmento de código concreto y puede ayudarte a identificar bloques de código lentos y optimizarlos. Por ejemplo, supongamos que deseas averiguar si una matriz contiene un número concreto y devolver el índice donde aparece. Puedes hacerlo recorriendo la matriz y comprobando si el número coincide con el que estabas buscando.
# 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
Sin embargo, este código es ineficaz, ya que necesita comprobar cada número de la matriz en busca de una coincidencia. Esto da lugar a una complejidad temporal ineficaz, ya que aunque encuentre el elemento, seguirá comprobando el resto de la lista. En su lugar, puedes utilizar la función Find[] para comprobar si la matriz contiene el número que estás buscando y devolverlo. Como Find[] devuelve inmediatamente cuando encuentra el elemento, se ejecutará más rápido cuanto antes esté el elemento en la lista. Si utilizas una expresión profile para probar ambos fragmentos de código, verás que en este caso el código que utiliza la función Find[] reduce el tiempo de ejecución.
# 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:
Estas pequeñas diferencias en el tiempo de ejecución se magnifican cuantos más elementos tengas por recorrer. Cada expresión que ejecutas mientras iteras por una lista grande añade complejidad temporal, especialmente cuando tus matrices crecen hasta cientos o incluso miles de elementos. A medida que amplíes tus experiencias cada vez a más jugadores, utiliza la expresión profile para encontrar y abordar las zonas clave de ralentización.
Registradores y registro de salida
De forma predeterminada, cuando llamas a Print() en el código de Verse para imprimir un mensaje, ese mensaje se escribe en un registro Print dedicado. Los mensajes impresos aparecen en la pantalla durante la partida, en el registro de la partida y en el registro de salida en UEFN.
Cuando imprimes un mensaje con la función Print(), se escribe en el registro de salida, en la pestaña Registro de la partida y en la pantalla del juego.
Sin embargo, hay muchas ocasiones en las que puede que no quieras que aparezcan mensajes en la pantalla durante la partida. Puede que quieras utilizar mensajes para rastrear cuándo suceden cosas, como cuando se activa un evento o ha pasado una determinada cantidad de tiempo, o para señalar cuando algo va mal en tu código. Muchos mensajes durante el juego pueden distraer, sobre todo si no proporcionan información relevante para el jugador.
Para solucionarlo, puedes utilizar un registrador. Un registrador es una clase especial que te permite imprimir mensajes directamente en las pestañas Registro de salida y Registro sin mostrarlos en pantalla.
Registradores
Para compilar un registrador, primero tienes que crear un canal de registro. Cada registrador imprime mensajes en el registro de salida, pero puede ser difícil distinguir qué mensaje procede de cada registrador. Los canales de registro añaden el nombre del canal de registro al inicio del mensaje, lo que facilita ver qué registrador envió el mensaje. Los canales de registro se declaran en el ámbito del módulo, mientras que los registradores se declaran dentro de clases o funciones. A continuación se muestra un ejemplo de declaración de un canal de registro en el ámbito del módulo, y luego una declaración y una llamada a un registrador dentro de 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):
Cuando imprimes un mensaje utilizando la función Print() de un registrador, ese mensaje se escribe en el registro de salida y en la pestaña Registro de la partida.
Niveles de registro
Además de los canales, también puedes especificar un nivel de registro predeterminado en el que se imprime el registrador. Hay cinco niveles, cada uno con sus propias propiedades:
| Nivel de registro | Imprimir en | Propiedades especiales |
|---|---|---|
Depuración | Registro de la partida | N/A |
Detallado | Registro de la partida | N/A |
Normal | Registro de la partida, Registro de salida | N/A |
Advertencia | Registro de la partida, Registro de salida | El color del texto es amarillo |
Error | Registro de la partida, Registro de salida | El color del texto es rojo |
Cuando creas un registrador, este se establece de forma predeterminada en el nivel de registro Normal. Puedes cambiar el nivel de un registrador cuando lo creas o especificar un nivel de registro para imprimir al llamar a 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=
En el ejemplo anterior, Logger toma por defecto el canal de registro Normal, mientras que DebugLogger toma por defecto el canal de registro Debug. Cualquier registrador puede imprimir en cualquier nivel de registro especificando log_level al llamar a Print().
Resultados de utilizar un registrador para imprimir en diferentes niveles de registro. Ten en cuenta que log_level.Debug y log_level.Verbose no se imprimen en el registro de la partida, solo en el registro de salida de UEFN.
Cómo imprimir la pila de llamadas
La pila de llamadas realiza un seguimiento de la lista de llamadas a funciones que han conducido al ámbito actual. Es como un conjunto de instrucciones apiladas que tu código utiliza para saber adónde debe volver una vez que termine de ejecutarse la rutina actual. Puedes imprimir la pila de llamadas desde cualquier registrador utilizando la función PrintCallStack(). Por ejemplo, toma el siguiente código:
# 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()
El código de OnBegin() anterior llama a LevelOne() para pasar a la primera función. A continuación, LevelOne() llama a LevelTwo(), que llama a LevelThree(), que llama a Logger.PrintCallStack() para imprimir la pila de llamadas actual. La llamada más reciente estará en la parte superior de la pila, por lo que LevelThree() se imprimirá primero. Seguidamente, LevelTwo(), LevelOne() y OnBegin(), en ese orden.
Cuando algo va mal en tu código, imprimir la pila de llamadas es útil para saber exactamente qué llamadas han conducido a ese punto. Esto facilita la visualización de la estructura de tu código mientras se ejecuta y permite aislar rastros de pila individuales en proyectos con mucho código.
Cómo visualizar los datos del juego con el dibujado de depuración
Otra forma de depurar diferentes funciones de tus experiencias es mediante la API de dibujado de depuración. Esta API puede crear formas de depuración para visualizar los datos del juego. Algunos ejemplos son:
La línea de visión de un guardia.
La distancia que un colocador de elementos moverá un objeto.
La distancia de atenuación de un reproductor de sonido.
Puedes utilizar estas formas de depuración para afinar tu experiencia sin exponer estos datos en una experiencia publicada. Para obtener más información, consulta Dibujado de depuración en Verse.
Optimización y sincronización con simultaneidad
La simultaneidad es el núcleo del lenguaje de programación Verse y es una potente herramienta para mejorar tus experiencias. Con la simultaneidad, puedes hacer que un dispositivo de Verse ejecute varias operaciones a la vez. Esto permite escribir código más flexible y compacto, y ahorrar en el número de dispositivos utilizados en el nivel. La simultaneidad es una herramienta estupenda para la optimización, y encontrar formas de utilizar código asíncrono para gestionar varias tareas a la vez es una manera fantástica de acelerar la ejecución de tus programas y abordar los problemas relacionados con la sincronización.
Cómo crear contextos asíncronos con Spawn
La expresión spawn inicia una expresión asíncrona desde cualquier contexto y permite que las siguientes expresiones se ejecuten inmediatamente. Esto permite ejecutar varias tareas al mismo tiempo, desde el mismo dispositivo, sin necesidad de crear nuevos archivos de Verse para cada una. Por ejemplo, imagina una situación en la que tienes algún código que supervisa la salud de cada jugador cada segundo. Si la salud de un jugador desciende por debajo de un determinado número, debes curarlo un poco. A continuación, desea ejecutar algún código después que se encargue de otra tarea. Un dispositivo que implemente este código podría ser algo como esto:
# 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.
Sin embargo, como este bucle se ejecuta eternamente y nunca se interrumpe, el código que le siga no se ejecutará nunca. Este es un diseño limitante, ya que este dispositivo está atascado solo ejecutando la expresión `loop`. Para permitir que el dispositivo haga varias cosas a la vez y ejecute código simultáneamente, puedes mover el código loop a una función asíncrona y 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=
Se trata de una mejora, ya que el dispositivo ahora puede ejecutar otro código mientras se ejecuta la función HealMonitor(). Sin embargo, la función sigue teniendo que recorrer en bucle cada jugador, y podrían producirse posibles problemas de sincronización cuantos más jugadores haya en la experiencia. Por ejemplo, ¿qué pasaría si quisieras otorgar a cada jugador una puntuación en función de sus PS o comprobar si lleva un objeto en la mano? Añadir una lógica adicional por jugador en la expresión for aumenta la complejidad temporal de esta función, y con un número suficiente de jugadores, es posible que un jugador no se cure a tiempo si recibe daño debido a problemas de sincronización.
En lugar de recorrer en bucle cada jugador y comprobarlo individualmente, puedes optimizar aún más este código generando una instancia de la función por jugador. Esto significa que una sola función puede controlar a un solo jugador, asegurando que tu código no tenga que comprobar a todos y cada uno de los jugadores antes de volver al que necesita curarse. Utilizar expresiones de simultaneidad como spawn a tu favor puede hacer que tu código sea más eficiente y flexible, y libera el resto de tu base de código para manejar otras tareas.
# 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.
Utilizar la expresión spawn dentro de una expresión loop puede provocar un comportamiento no deseado si se maneja incorrectamente. Por ejemplo, como HealMonitorPerPlayer() nunca termina, este código seguirá generando una cantidad infinita de funciones asíncronas hasta que se produzca un error en tiempo de ejecución.
# 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)Cómo controlar la sincronización con eventos
Conseguir que cada parte de tu código se sincronice correctamente puede ser difícil, sobre todo en grandes experiencias multijugador con muchas secuencias de comandos ejecutándose a la vez. Diferentes partes de tu código pueden depender de otras funciones o secuencias de comandos que se ejecuten en un orden establecido y esto puede crear problemas de sincronización entre ellas sin controles estrictos. Por ejemplo, piensa en la siguiente función que cuenta hacia atrás durante un tiempo y luego otorga al jugador pasado una puntuación si sus PS superan el umbral.
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)Como esta función tiene el modificador <suspends>, puedes ejecutar una instancia de ella de forma asíncrona por jugador utilizando spawn(). Sin embargo, tienes que garantizar que cualquier otro código que dependa de esta función se ejecutará siempre después de que se complete. ¿Qué pasa si quieres imprimir cada jugador que marcó después de que termine CountdownScore()? Podrías hacerlo en OnBegin() llamando a Sleep() para esperar la misma cantidad de tiempo que tarda en ejecutarse CountdownScore(), pero esto podría crear problemas de tiempo cuando tu juego se está ejecutando e introduce una nueva variable que tienes que actualizar constantemente si quieras hacer cambios en el código. En su lugar, puedes crear eventos personalizados y llamar a Await() en ellos para controlar estrictamente el orden de los eventos en tu código.
# 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:
Como este código ahora espera a que se señale CountdownCompletedEvent(), se garantiza que comprobará la puntuación de cada jugador solo después de que CountdownScore() termine de ejecutarse. Muchos dispositivos tienen eventos incorporados en los que puedes llamar a Await() para controlar la sincronización de tu código. Si los utilizas con tus propios eventos personalizados, puedes crear bucles de juego complejos con varias partes móviles. A modo de ejemplo, la plantilla inicial de Verse utiliza varios eventos personalizados para controlar el movimiento del personaje, actualizar la IU y gestionar el bucle general del juego paso a paso.
Cómo gestionar varias expresiones con Sync, Race y Rush
sync, race y rush permiten ejecutar varias expresiones asíncronas a la vez y, al mismo tiempo, realizar diferentes funciones cuando esas terminan de ejecutarse. Al aprovechar cada una de ellas, puedes controlar estrictamente la duración de cada una de tus expresiones asíncronas, lo que da como resultado un código más dinámico que puede manejar múltiples situaciones diferentes.
Por ejemplo, tomemos la expresión rush. Esta expresión ejecuta varias expresiones asíncronas simultáneamente, pero solo devuelve el valor de la expresión que termina primero. Supongamos que tienes un minijuego en el que los equipos tienen que completar alguna tarea y el equipo que termine primero recibe un potenciador que le permite interferir con los otros jugadores mientras ellos terminan. Podrías escribir una lógica de sincronización complicada para rastrear cuándo completa cada equipo la tarea, o podrías utilizar la expresión rush. Como la expresión devuelve el valor de la primera expresión asíncrona para finalizar, devolverá el equipo ganador y permitirá que el código que controla a los otros equipos siga ejecutándose.
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)La expresión race sigue las mismas reglas, excepto que cuando una expresión asíncrona se completa, las demás expresiones se cancelan. Esto te permite controlar estrictamente el tiempo de vida de varias expresiones asíncronas a la vez, e incluso puedes combinarlo con la expresión sleep() para limitar la cantidad de tiempo que quieres que se ejecute la expresión. Retomemos el ejemplo de rush, excepto que esta vez quieres que el minijuego termine inmediatamente cuando un equipo gana. También debes añadir un cronómetro para que el minijuego no se prolongue eternamente. La expresión race te permite hacer ambas cosas sin necesidad de utilizar eventos u otras herramientas de simultaneidad para saber cuándo cancelar las expresiones que pierden la carrera.
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)Por último, la expresión sync te permite esperar a que terminen de ejecutarse varias expresiones, lo que garantiza que cada una de ellas finalice antes de continuar. Como la expresión sync devuelve una tupla que contiene los resultados de cada una de las expresiones asíncronas, puedes terminar de ejecutar todas tus expresiones y evaluar los datos de cada una de ellas individualmente. Volviendo al ejemplo del minijuego, supongamos que quieres conceder potenciadores a cada equipo en función de sus resultados en el minijuego. Aquí es donde entra en juego la expresión 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 quieres ejecutar una expresión asíncrona en varios elementos de una matriz, puedes utilizar la práctica función ArraySync() para garantizar que todos se sincronicen.
Cada una de estas expresiones de simultaneidad es una potente herramienta en sí misma, y si aprendes a combinarlas y utilizarlas juntas, podrás escribir código para hacer frente a cualquier situación. Considera este ejemplo de la plantilla Juego de carreras con persistencia de Verse, que combina varias expresiones de simultaneidad para no solo reproducir una introducción para cada una, antes de la carrera, sino también cancelarla si el jugador se va durante la introducción. Este ejemplo destaca cómo puedes utilizar la simultaneidad de múltiples formas y compilar código resistente que reaccione dinámicamente a diferentes eventos.
# 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: