27 diciembre 2024

Interpolación en Unity

La interpolación es un método matemático utilizado para encontrar un valor intermedio entre dos puntos puntos conocidos. Cuando esos dos puntos conocidos están unidos por una línea recta, decimos que estamos ante una interpolación lineal.

Aunque la definición matemática es un poco áspera, lo cierto es que nuestra vida diaria está poblada de interpolaciones:

  • Si mi coche automático tiene una velocidad máxima de 160 Km/h ¿A qué velocidad iremos cuando pisemos el acelerador a la mitad de su profundidad?
  • Si la autonomía de mi avión es de 300 Km, ¿Cuántos kilómetros más podremos recorrer cuando llevemos consumido un tercio del depósito?
  • Si mi nevera llena me dura 7 días y tengo la mitad de las baldas vacías ¿Cuándo me tocará hacer la compra?
Aunque los ejemplos anteriores parecen heterogéneos, en realidad no lo son. Todos se modelan mediante una función lineal, con una gráfica como la siguiente:

Una función lineal
Una función lineal

La diferencia estriba en qué representan los ejes en cada uno de los casos:
  • En el ejemplo del coche automático, el eje X es la profundidad de pisado del acelerador, mientras que el eje Y es la velocidad del coche.
  • En el caso del avión, el eje X son los litros de queroseno consumidos y el eje Y los kilómetros recorridos.
  • Para la nevera, el eje X son los días que van trascurriendo y el eje Y las baldas de la nevera que se van vaciando.
En todos los casos, la estructura de la gráfica (y de la función que representa) es la misma: una línea recta. Lo que diferenciará un caso u otro es la pendiente de la línea, es decir el ritmo de cambio del eje Y conforme avanzamos en el eje X.

Si en la vida real tenemos que hacer interpolaciones, te puede imaginar que en el desarrollo de juegos también. Al fin y al cabo, ¿Qué son los videojuegos sino una recreación de la vida real?

Pongamos un ejemplo similar a los anteriores. Supongamos que estoy desarrollando un simulador y quiero dar soporte de HOTAS.

Un mando HOTAS
Un mando HOTAS


Cuando nuestro jugador mueva la palanca de gases (la de la izquierda), el Input System de Unity nos devolverá un valor entre 0 y 1: 0 cuando la palanca esté situada en su posición mínima (la más cercana al cuerpo del jugador) y 1 cuando esté en su posición máxima (la más lejana al cuerpo del jugador). Con esa información, tendremos que hacer una interpolación para calcular la velocidad del vehículo, en todas las posiciones intermedias de la palanca, sabiendo que cuando esté en 0 el vehículo debe pararse y cuando esté en 1 debe ir a la velocidad máxima.

Si volvemos a nuestra gráfica, tendremos la misma estructura, sólo que en el eje X se representará la posición de la palanca (que sólo se moverá entre X=0 y X=1) y en el eje Y se representará la velocidad del vehículo de nuestro juego.

¿Cómo haremos ese cálculo? Hay varias posibilidades, dependiendo de los rangos que representemos en los ejes X e Y. Vamos a estudiarlas de menor a mayor complejidad.

Interpolación con rango entre 0 y 1 del eje X 


Es el caso más sencillo: el eje X varía entre 0 y 1, mientras que el eje Y varía entre un valor inicial (V1) y un valor final (V2).

Matemáticamente, el valor a interpolar (Y) se obtendría de la siguiente manera:

La fórmula de una función lineal
La fórmula de una función lineal


Podríamos escribir una función que implementase esa fórmula, pero sería reinventar la rueda, porque Unity ya ofrece un método que hace eso mismo: Mathf.Lerp().

Lerp() acepta tres parámetros:
  • Un valor inicial.
  • Un valor final.
  • Un valor entre 0 y 1.
Si el tercer parámetro fuera 1, la función devolvería el valor final, si fuera 0 devolvería el valor inicial y si estuviera entre 0 y 1 devolvería un valor intermedio obtenido con la línea que une los dos valores anteriores.

En nuestro ejemplo de la palanca de gases, le pasaríamos a Lerp(), la velocidad mínima del vehículo, su velocidad máxima y el valor, entre 0 y 1, que nos devolvería el Input System sobre la posición de la palanca de gases. El valor retornado por el método sería la nueva velocidad del vehículo. 

También puede interesarnos obtener la inversa de una interpolación.

Supón que la nave de nuestro juego cuenta con unos escudos y que estos han estado recibiendo los impactos de los proyectiles de nuestro enemigos. El escudo tiene un valor que representa su fuerza y a este valor le hemos estado restando una cantidad por cada uno de los impactos. Nos interesará coger el valor actual del escudo y representarlo en el panel de mandos de la cabina para que el jugador pueda saber a qué porcentaje tiene los escudos. Es decir, tenemos el valor máximo de los escudos (pongamos que 300), tenemos el valor mínimo de los escudos (lo normal es que sea 0), tenemos el valor actual de los escudos (supongamos que 150) y queremos saber el valor sobre 1 que representa ese valor actual respecto al total de los escudos, para poder presentar un porcentaje al jugador.

Si reorganizamos la fórmula anterior, veremos que la fórmula de función inversa es:

La fórmula de la función inversa
La fórmula de la función inversa


Si aplicamos los datos de nuestro ejemplo:
  • V2, como valor máximo de los escudos: 300
  • V1, como el valor mínimo de los escudos: 0
  • Y, como el valor actual de los escudos: 150
Nos sale que los escudos están al 0,5 en tanto por uno. Para sacar el porcentaje no tienes más que multiplicar por 100 el tanto por uno, así que te sale que el escudo está al 50% de su capacidad.

La fórmula de la función inversa, aplicada al ejemplo
La fórmula de la función inversa, aplicada al ejemplo


Por suerte, tampoco tenemos que implementar esta fórmula en un método propio. Unity ya lo ha hecho por nosotros a través del método Mathf.InverseLerp().

InverseLerp() acepta tres parámetros:
  • Un valor inicial, en nuestro ejemplo 0.
  • Un valor final, en nuestro caso 300.
  • El valor intermedio del que queremos obtener su interpolación inversa, en nuestro caso 150.
Con esos datos, InverseLerp() devolvería 0,5.

Hay que tener en cuenta que si el valor intermedio no lo es, es decir que está por debajo del valor inicial o por encima del final, entonces InverseLerp() devuelve 0 o 1, respectivamente.

Interpolación angular


Cuando operamos con ángulos, puede ser tentador utilizar Lerp(), pero debemos evitarlo. Los ángulos son un caso especial, ya que vuelven a empezar cuando llegan al máximo de 360º. Por ejemplo, un ángulo de 380º es lo mismo que un ángulo de 20º.

Para tener en cuenta esa peculiaridad, Unity ofrece el método Mathf.LerpAngle().

Este método acepta tres parámetros:
  • El ángulo inicial, en grados.
  • El ángulo final, en grados.
  • El valor intermedio entre 0 y 1.
Ten cuidado, porque el método no juega con ángulos de 0 a 360º, sino con un rango de -180º a 180º. Si visualizas tus 0º como un vector vertical, los 180º se alcanzarán cuando gires el vector hacia la derecha hasta que se dé media vuelta. Los -180º se alcanzarán girando a la izquierda. 

Para que te hagas una idea, una llamada a LerpAngle(0.0f, 190.0f, 1.0f) devolverá -170.0f, porque a los 180º interpretará que los 10 restantes se han obtenido girando 170º hacia la izquierda. Es decir, que LerpAngle() siempre te devolverá el giro más corto para rotar tu vector. Si lo que te interesase es el giro largo (en este caso girar los 190º hacia la derecha) tendrías que usar Lerp().

Interpolación lineal con valores del eje X más allá de 0 y 1


Por defecto, Lerp() sólo te dejará meter valores intermedios entre 0 y 1, pero puede interesarte obtner valores por encima o por debajo. En ese caso, Unity ofrece Mathf.LerpUnclamped().

Sus parámetros son los mismos que Lerp(), sólo que te deja meter valores intermedios por debajo de 0 y por encima de 1. El método se limita a prolongar la línea a ambos lados del rango [0,1] y a darte el el valor resultante.

Interpolación no lineal


Hasta ahora hemos supuesto que nuestro rango en el eje X se limitaba a 0 y 1. Todos los valores intermedios que le metíamos a Lerp() estaban limitados a ese rango. 

Sin embargo, puede haber situaciones en las que nos interese que el eje X sea otro rango distinto, por ejemplo porque queremos aplicar funciones lineales diferentes según avanzamos por el eje X. 

Imagina que tenemos una nave y queremos que su velocidad se vea afectada por el daño acumulado, ralentizando la nave conforme acumulemos más daños. Sin embargo, no queremos que la ralentización sea uniforme, sino que preferimos que, a partir de cierto umbral de daños, la ralentización se acelere. 

Tendríamos una gráfica con los daños en el eje X, la ralentización en el eje Y. De 0 al umbral del daños, la gráfica sería una línea que iría ascendiendo lentamente en un primer tramo, pero tendría un codo al llegar al umbral y, a partir de él, iniciaría un segundo tramos en el que ascendería de manera mucho más pronunciada. Ya no tendríamos una línea recta continua, sino una que se torcería en un punto determinado. Este tipo de funciones se denominan no lineales.

Hay varias maneras de implementar algo así. Podríamos normalizar el eje X del primer tramo usando el InverseLerp() con los valores inicial, final y actual del tramo en el eje X y utilizar el valor resultante para hacer un Lerp() de los valores mínimo y máximo del eje Y para ese tramo. El problema es que habría que repetir todas esas operaciones con el segundo tramo.

Podríamos simplificar un poco los cálculos usando la función remap() del paquete Mathematics de Unity. Ese método permite pasar un rango de origen (en el eje X), un rango de destino (en el ejeY) y un valor intermedio en el rango de origen, para obtener el valor equivalente en el rango de destino. Esto nos ahorraría tener que encadenar InverseLerp() y Lerp(), pero seguiría suponiendo aplicar el método remap() a cada uno de los tramos. Aparte de que nos obligaría a instalar el paquete Mathematics, a través del Package Manager.

Este método resulta insostenible en el caso de que nuestra gráfica tenga múltiples tramos y además, la transición entre un tramo y otro podría ser demasiado llamativa.

En esos casos, lo mejor es que recurramos a las AnimationCurve. Como su nombre indica, están pensadas para ser usadas en el ventana de animación, pero las podemos usar desde nuestro código. Las AnimationCurve nos permiten definir gráficamente nuestra función, con todos los tramos, curvas y rectas que queramos.

Por ejemplo, supongamos que queremos implementar un vehículo con una aceleración y una frenada suaves. Si fueran lineales sería poco natural. En vez de eso vamos a aplicar curvas. Para incluirlas en nuestro código podríamos hacer:

Inclusión de AnimationCurves en nuestro código
Inclusión de AnimationCurves en nuestro código

Los campos anteriores se verían desde el inspector de la siguiente manera:

AnimationCurves desde el inspector
AnimationCurves desde el inspector

Pinchando cualquiera de las AnimationCurve podremos editarla para darle forma. Por ejemplo, la de aceleración tiene este aspecto:

Curva de la aceleración
Curva de la aceleración

Si te fijas, la curva anterior está normalizada tanto en el eje X, como en el Y, ya que ambas se sitúan entre los valores 0 y 1. Sin embargo, eso no debería ser un problema con lo que que hemos aprendido hasta ahora. Por ejemplo, para calcular la velocidad durante la aceleración podemos hacer:

Muestreo de una AnimationCurve

Dado que la variable accelerationRadius define la distancia, desde el arranque, a la que el vehículo alcanza su velocidad máxima, sabemos que esa distancia se corresponde con el valor X=1 de la curva de aceleración. Por eso, para saber en qué punto de la curva de aceleración estamos, haremos un InverseLerp(), pasando como valor intermedio nuestra distancia al punto de arranque (línea 94).

El punto obtenido del InverseLerp(), se lo pasaremos al método Evaluate() de la AnimationCurve, para que nos devuelva el valor de la gráfica en ese punto (línea 93). Dado que el eje Y de la gráfica se corresponde con la velocidad, y que el punto Y=1 es el de velocidad máxima, nos basta con multiplicar el valor que nos devuelve la curva por la velocidad máxima.

Como verás, el uso de una AnimationCurve te libera de tener que aplicar cálculos diferentes por tramos y hace las transiciones mucho más naturales y suaves.

Conclusión


Las interpolaciones son muy útiles en el desarrollo juegos. Los casos más simples se pueden resolver con el uso de los métodos Lerp e InverseLerp, contando con LerpAngle cuando manipulemos ángulos. Para casos más sofisticados y complejos lo más cómodo será contar con AnimationCurves y exponerlas a través del inspector para poder editarlas de manera visual.

24 diciembre 2024

Testeo automatizado (TDD) en Godot - GdUnit4

El TDD es una metodología de desarrollo de software donde primero escribes las pruebas y luego el código necesario para que las pruebas pasen. Al tener que definir las pruebas primero, el TDD te obliga a plantearte qué funcionalidad quieres conseguir, antes de ponerte a tirar líneas de código. Tienes que diseñar qué entradas va a recibir tu prueba y qué salidas considerará como correctas. Esas entradas y salidas serán las de tu funcionalidad, y haberlas definido con antelación te permitirás implementar dicha funcionalidad de manera más limpia y encapsulada.

A muchos no les gusta esta metodología porque les resulta aburrido empezar por las pruebas y prefieren zambullirse en tirar líneas de código y probar luego manualmente. Suelen disculparse con que no pueden perder tiempo programando pruebas. El problema se lo encuentran cuando su proyecto empieza a ganar tamaño y complejidad e introducir nuevas funcionalidades se convierte en un infierno porque se rompen las funcionalidades anteriores. A menudo, esto pasa de manera inadvertida, porque resulta imposible probar manualmente toda la aplicación en cada cambio. Así que van acumulando errores ocultos en cada actualización. Para cuando descubren alguno de esos errores ya resulta muy complicado averiguar cuál de las actualizaciones lo provocó, lo que convierte en un calvario resolverlo.

Con TDD no pasa esto, porque las pruebas que diseñas para cada funcionalidad se añaden a la base de pruebas y pueden ejecutarse de nuevo automáticamente al incorporar nuevas funcionalidades. Cuando una nueva funcionalidad rompe algo de las anteriores, sus correspondientes pruebas fallarán, lo que servirá de piloto de alarma para señalar dónde está el problema e ir a tiro hecho a solucionarlo.

Los juegos son una aplicación más. TDD se le puede aplicar igual y por eso, todos los engines cuentan con frameworks de pruebas automatizadas. En este artículo nos centraremos en uno de los más populares en Godot Engine: GdUnit4.

GdUnit4 permite la automatización de pruebas usando tanto GdScript, como C#. Dado que yo uso este último para desarrollar en Godot, nos centraremos en él a lo largo de este artículo. Aun así, si eres usuario de GdScript te recomiendo que sigas leyendo porque muchos conceptos son similares en ese lenguaje. 

Instalación del plugin

Para instalarlo, tienes que ir a la pestaña AssetLib, de la parte superior del editor de Godot. Allí, debes introducir "gdunit4" en el buscador. Pincha en el resultado que te salga.

Búsqueda de GdUnit4 en el AssetLib

En la ventana emergente que te sale, pincha en "Download" y acepta la carpeta de instalación por defecto.

Hecho lo anterior, el plugin habrá quedado instalado en la carpeta de tu proyecto, pero estará deshabilitado. Para activarlo, tienes que ir a Project --> Project Settings... --> Plugins y activar la casilla Enabled.

Activación del plugin de GdUnit4
Activación del plugin de GdUnit4

Te recomiendo que reinicies Godot después de la activación del plugin, de otra manera puede que te salgan errores.

Configuración del entorno C#

La siguiente fase es configurar tu entorno C# para usar GdUnit4 sin problemas.

Para empezar, tienes que asegurarte de tener la versión 8.0 del .NET.

Luego, tienes que abrir tu archivo .csproj y asegurarte de hacer los siguientes cambios en la sección <PropertyGroup>:

  • Cambiar el TargetFramework a net8.0.
  • Añadir la etiqueta <LangVersion>11.0</LangVersion>
  • Añadir la etiqueta <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
Además, tienes que crear una sección <ItemGroup>, en caso de que no la tengas, y añadirle el siguiente contenido:

<ItemGroup>
        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
        <PackageReference Include="gdUnit4.api" Version="4.3.*" />
        <PackageReference Include="gdUnit4.test.adapter" Version="2.*" />
</ItemGroup>


Para que te hagas una idea del resultado, este es el contenido de mi fichero .csproj:

Contenido del fichero csproj adaptado a GdUnit4
Contenido del fichero csproj adaptado a GdUnit4

Llegados a este punto, si haces Rebuild de tu proyecto (bien desde el IDE o bien desde esquina superior derecha la pestaña MSBuild) y no te sale ningún error, significará que la configuración es correcta.

Configuración del IDE

Oficialmente, GdUnit4 puede ser usado desde Visual Studio, Visual Studio Code y Rider. La configuración necesaria varía en cada caso.

Uso Rider, así que centraré el ejemplo en ese IDE.

GdUnit4 espera encontrar una variable de entorno denominada GODOT_BIN con la ruta al ejecutable de Godot. Yo uso la herramienta GodotEnv de ChickenSoft para instalar y desinstalar las sucesivas versiones de Godot. Esto tiene la ventaja de que GodotEnv se encarga de mantener una variable de entorono llamada GODOT, con la ruta al ejecutable del editor. Así que lo que yo he hecho es crear una variable de entorno (de usuario, para no enredar con las de sistema) GODOT_BIN, que lo que hace es apuntar a la de GODOT. Se hace abriendo la ventana de Sistema del Windows, pulsando "Configuración avanzada del Sistema", pestaña "Opciones avanzadas", botón "Variables de entorno" y, en el apartado de "Variables de usuario" , botón "Nueva":

Configuración de la variable de entorno
Configuración de la variable de entorno

Si tu no usas GodotEnv, y careces de una variable previa con la ruta al ejecutable del editor, en el valor de la variable tendrás que poner esa ruta y acordarte de cambiarla cada vez que instales una nueva versión del editor.

Luego, en Rider, tendrás que asegurarte de tener el plugin de soporte a Godot activado (lo normal es que si está leyendo este artículo ya lo tengas) y de tener activado el soporte a los adaptadores de VSTest:

Configuración al soporte de los adaptadores de VSTest


En la captura anterior, he activado la casilla "Enable VSTest adapters support" y he incluido una línea con un asterisco en la lista "Projects with unit tests". Ojo a esto último que a mí se me pasó y el IDE no me identificaba los test como tales hasta que metí esta línea en la lista.

Para configurar la ejecución de los tests, debes crear un fichero .runsettings en la raíz de tu proyecto. A modo de ejemplo, el mío es el siguiente (copiado de la página de GdUnit4):

<?xml version="1.0" encoding="utf-8"?>

<RunSettings>

    <RunConfiguration>

        <MaxCpuCount>1</MaxCpuCount>

        <ResultsDirectory>./TestResults</ResultsDirectory>

        <TargetFrameworks>net7.0;net8.0</TargetFrameworks>

        <TestSessionTimeout>180000</TestSessionTimeout>

        <TreatNoTestsAsError>true</TreatNoTestsAsError>

    </RunConfiguration>


    <LoggerRunSettings>

        <Loggers>

            <Logger friendlyName="console" enabled="True">

                <Configuration>

                    <Verbosity>detailed</Verbosity>

                </Configuration>

            </Logger>

            <Logger friendlyName="html" enabled="True">

                <Configuration>

                    <LogFileName>test-result.html</LogFileName>

                </Configuration>

            </Logger>

            <Logger friendlyName="trx" enabled="True">

                <Configuration>

                    <LogFileName>test-result.trx</LogFileName>

                </Configuration>

            </Logger>

        </Loggers>

    </LoggerRunSettings>


    <GdUnit4>

        <!-- Additional Godot runtime parameters-->

        <Parameters></Parameters>

        <!-- Controls the Display name attribute of the TestCase. Allowed values are SimpleName and FullyQualifiedName.

             This likely determines how the test names are displayed in the test results.-->

        <DisplayName>FullyQualifiedName</DisplayName>

    </GdUnit4>

</RunSettings>

 

Para que Rider lea esa configuración, se lo tienes que decir en la configuración de su Test Runner:

Configuración de la ruta al .runsettings
Configuración de la ruta al .runsettings

Acabado todo lo anterior, tienes que reiniciar el Rider para que active la configuración.

Si quieres probar que la configuración es correcta, puedes crear una carpeta Tests en tu proyecto y, dentro de ella, un fichero de C# (lo puedes llamar ExampleTest.cs) con el siguiente contenido:

Contenido de Tests/ExampleTest.cs
Contenido de Tests/ExampleTest.cs

El ejemplo está pensado para que Success() sea una prueba exitosa y Failed() fracase.

Puedes ejecutar este test desde Rider o bien desde el editor de Godot.

Desde Rider, primero tienes que habilitar la pestaña de los tests pulsando View --> Tool Windows --> Tests. Desde la pestaña de tests puedes ejecutar un test en concreto o todos seguidos.

Pestaña para ejecutar tests en Rider
Pestaña para ejecutar tests en Rider

Para ejecutarlos desde Godot, tienes que ir a la pestaña de GdUnit.

Pestaña de ejecución de tests en Godot
Pestaña de ejecución de tests en Godot

Si la pestaña de Godot no mostrase los tests, podría ser que no estuviese buscando en la carpeta correcta. Para comprobarlo, debes pulsar en el botón de la herramientas de la esquina superior izquierda de la pestaña y asegurarte de que el parámetro "Test Lookup Folder" apunta a la carpeta donde tengas los tests.

Ruta a la carpeta con los tests
Ruta a la carpeta con los tests

Si aún así no te mostrase los test, te recomiendo que pruebes a reiniciar. Tanto Godot, como Rider, tienden a no detectar a veces los cambios y los nuevos tests. Cuando me pasa reinicio Godot o Rider (lo que me esté fallando) y entonces ya se detectan los cambios. Supongo que con el tiempo irán resolviendo esa problemática. En todo caso, en las pruebas que he ido haciendo, las que he hecho a través de Rider han funcionado muchísimo mejor que las que he realizado desde el editor de Godot mismo.

Prueba de un juego

Toda la configuración anterior ha sido ardua, pero lo bueno es que sólo hay que hacerla una vez. A partir de ahí se trata de ir creando tests y probándolos.

Hay múltiples cosas que probar en un juego. Las pruebas unitarias se centran en probar métodos y funciones concretas, yo voy a explicarte algo más amplio: las pruebas de integración. Estas prueban el juego al mismo nivel que lo haría un jugador, actuando sobre sus objetos y evaluando si la reacción del juego es la esperada. Para ello, es habitual crear niveles especiales de test en los que se concentran todas las funcionalidades para poder probarlas de una manera rápida.

Para entendernos, vamos a poner como ejemplo una prueba sencilla. Supongamos que tenemos un juego en el que hay un elemento (un agente inteligente) que tiene que desplazarse hasta la posición de un determinado marcador. Para comprobar el correcto funcionamiento del agente, lo colocaríamos en un extremo del escenario, al marcador en otro y esperaríamos uno segundos antes de evaluar la posición del agente. Si este se hubiera acercado lo suficiente al marcador, podríamos concluir que funciona correctamente.

El código Godot de este ejemplo lo puedes bajar de este commit concreto de uno de mis repositorios. Si lo abres en el editor de Godot, cargas la escena Tests/TestLevels/SimpleBehaviorTestLevel.tscn, la conviertes en la principal del juego (Project --> Project Settings...--> General --> Application - Run --> Main Scene) y ejecutas el juego, verás que el luego se comporta tal y como decíamos en el párrafo anterior: la mirilla roja se situará allá donde pinches el ratón y la bola verde se dirigirá a la posición de la mirilla. Por tanto, manualmente podemos comprobar que el juego se comporta como debe. Ahora vamos a comprobarlo de manera automatizada.

El juego que estamos probando
El juego que estamos probando

Lo primero es configurar el nivel con los elementos necesarios para facilitar la prueba. Estos elementos auxiliares no harán sino estorbar en los niveles a los que accedan los jugadores, y esa es la razón por la que generalmente se crean niveles específicos para los tests (por eso, este está en la carpeta Tests/TestLevels).

Nuestro nivel de pruebas tiene la siguiente estructura:

Estructura del nivel de pruebas
Estructura del nivel de pruebas

 Los elementos son los siguientes:

  • ClearCourtyard: Es la caja por la que se mueve nuestro agente. Un simple tilemap con el que he dibujado en gris oscuro las paredes, que no se pueden traspasar, y en gris claro el suelo, por el que nos desplazamos.
  • Target: Es una escena cuyo nodo principal es un Marker2D y por debajo tiene un Sprite2D con la imagen de la mirilla. El nodo principal tiene un script que escucha los eventos de Input y reacciona a la pulsación del botón izquierdo del ratón, situándose en la posición de la pantalla donde se haya hecho click.
  • SeekMovingAgent: Es el agente cuyo comportamiento queremos comprobar.
  • StartPosition1: Es la posición en la que queremos que se sitúe el agente al comenzar la prueba.
  • TargetPosition1: Es la posición en la que queremos que se sitúe el Target al comienzo de la prueba.
El código de nuestra prueba será uno o varios ficheros de C#, situados en la carpeta de tests. En este caso sólo tendremos un fichero, pero podemos tener varios si estamos probando diferentes funcionalidades. Cada fichero puede tener múltiples pruebas. Una prueba no es más que un método marcado con el atributo [TestCase]. La clase a la que pertenezca el método anterior debe estar marcada con el atributo [TestSuite] para que el test runner la tenga en cuenta al ejecutar la prueba. Nuestra prueba de ejemplo está en el fichero Tests/SimpleBehaviorTests.cs.

La primera mitad del fichero se dedica a los preparativos:


Como puedes ver en la línea 9, he guardado en una constante la ruta al nivel en el que queremos desarrollar la prueba. 

Dicho nivel lo arrancamos en la línea 17. Esa línea pertenece al método LoadScene(), el cual podemos llamar como queramos siempre y cuando lo marquemos con el atributo [BeforeTest] para señalar que queremos que LoadScene() se ejecute justo antes de cada prueba. De esta manera cargaremos el nivel desde el principio en cada prueba, con lo que nos aseguraremos de que sus elementos estén en el punto de partida cada vez y de que las pruebas anteriores no interfieren. Hay otros tributos que pueden resultarte útiles, como [Before], que ejecuta el método que decora una sola vez justo antes de que se ejecuten todas las pruebas de la clase. Puede ser útil para inicializar un recurso común a todas las pruebas y que no necesite ser reseteado entre una y otra. Por otro lado, para hacer limpia, existen las contrapartes, los atributos [AfterTest] y [After].

Una vez que tenemos una referencia al nivel recién arrancado podemos dar comienzo a la prueba:

El código de nuestra prueba
El código de nuestra prueba

Entre las líneas 24 y 30 recabamos referencias a los elementos de la prueba usando el método FindChild(). En cada una de sus llamadas pasamos el nombre que tenga el nodo que queremos obtener. Como FindChild() devuelve un tipo Node, tendremos que hacer cast al tipo real que sabemos que tiene.

Una vez que tenemos las referencias a los distintos elementos, colocaremos cada uno en su posición inicial correcta: _target en la posición marcada por _targetPosition (línea 33) y _movingAgent en la posición marcada por _agentStartPosition (línea 24). Podríamos haber situado a los elementos fijando su posición por código, pero en caso de tener que recolocarlos de nuevo siempre es más cómodo hacerlo de manera visual, ajustando la posición de los marcadores en el editor, que teniendo que modificar las posiciones en el código.

En la línea 37 activamos al agente para que empiece a moverse. En general, te recomiendo que mantengas desactivados tus agentes y sólo actives los imprescindibles para la prueba. Así, si estás probando varios en el mismo nivel, evitarás que unos entorpezcan a otros.

En la línea 39, nos sentamos a esperar (durante 2,5 segundos) para darle al agente el tiempo que hemos estimado que tardará en desplazarse a la posición del objetivo.

Finalizada esa espera, llegamos al momento de la verdad. Hay que evaluar si el agente ha llegado hasta la posición objetivo. Para eso, en la línea 41, medimos la distancia entre el agente y su objetivo. Si la distancia es menor que un umbral muy pequeñito (recuerda que en matemáticas de punto flotante no podemos hacer comparaciones por igualdad) entonces podemos suponer que el agente se encuentra correctamente situado en la posiión del objetivo (línea 43).

Conclusión


Como la anterior, podemos hacer multitud de pruebas. Lo normal es meter en una misma clase (fichero .cs) todas las que correspondan a las múltiples facetas de una misma funcionalidad.

La prueba de este ejemplo ha sido muy visual, porque evaluaba el movimiento, a través del cambio de posición. Pero habrá pruebas en las que quieras comprobar que un valor interno del agente resulte ser el correcto. En ese caso, lo que evaluaremos en nuestros Assert serán los valores de las propiedades del agente.

No quiero acabar sin enlazarte la página de documentación de GdUnit4. Parte del uso de GdScript pero ha empezado a adaptarse a C# desde el momento que GdScript empezó a ofrecer soporte en ese lenguaje. Hay secciones específicas con las peculiaridades de instalación de GdUnit4 para C# y los códigos de ejemplo tienen pestañas con ambos lenguajes. Se trata de una documentación muy completa y útil.