27 abril 2025

Navegación 2D en Unity

Hace un par de meses, publiqué un artículo sobre cómo usar el sistema de navegación 2D en Godot. Va siendo hora de que escriba un artículo análogo para el caso de Unity.

No me voy a complicar la vida, así que voy a replicar la estructura de aquel artículo, pero adaptando las instrucciones a Unity.

Recuerda que la premisa de partida es que queremos tener personajes NPC que sean capaces de orientarse por el escenario para poder ir de un punto a otro de este. Vamos a empezar por ver cómo crear un escenario que pueda ser procesado por el sistema de navegación.

Creación de un escenario navegable

Como escenario, nos podría vale cualquier conjunto de sprites, siempre que tuvieran colliders asociados para definir cuáles suponen un obstáculo al NPC. Como eso no tendría mucho misterio, vamos a optar por un caso algo más complejo, pero también bastante habitual en un juego 2D. Vamos a suponer que nuestro escenario está construido a base de tilemaps. El uso de tilemaps, bien merece su propio artículo y los encontrarás a raudales en Internet, así que aquí voy a suponer que ya sabes utilizarlos.

En nuestro ejemplo vamos a suponer un escenario construido con tres niveles de tilemaps: uno para los muros perimetrales del escenario (Tilemap Wall), otro para los obstáculos internos del escenario (Tilemap Obstacles) y otro para el Tilemap del suelo (TilemapGround).

Jerarquía de tilemaps del ejemplo
Jerarquía de tilemaps del ejemplo

Courtyard es el nodo padre de los tilemaps y el que contiene el componente Grid. Con la cuadrícula del tilemap.

Tilemap Ground sólo contiene los componentes visuales Tilemap y TilemapRenderer para poder mostrar los tiles del suelo. Los tilemaps Wall y Obstacles también tienen esos componentes, pero además incorporan dos componentes adicionales: un Tilemap Collider 2D y un Composite Collider 2D. El componente Tilemap Collider 2D sirve para tener en cuenta el collider de cada uno de los tiles. Dicho collider se define en el sprite editor, para cada uno de los sprites utilizados como tiles. El problema de Tilemap Collider 2D es que cuenta el collider de cada tile de manera individual, lo cual es muy poco eficiente por la cantidad de tiles que llega a acumular cualquier escenario basado en tilemaps. Por esa razón, es muy habitual acompañar al componente Tilemap Collider 2D de otro componente llamado Composite Collider 2D. Este último componente se encarga de fundir todos los collider de los tiles, generando un único collider conjunto que es mucho más ligero de manipular por el engine. 

Cuando uses estos componentes en tus tilemaps te aconsejo dos cosas:

  • Fija el atributo "Composite Operation" del Tilemap Collider 2D al valor Merge. Eso le dirá al componente Composite Collider 2D que la operación que tiene que hacer es juntar todos los colliders individuales en uno solo.
  • En el Composite Collider 2D, yo fijaría el atributo "Geometry Type" al valor Polygons. Si lo dejas en el valor por defecto, Outlines, el collider generado estará hueco, es decir sólo tendrá bordes, por lo que algunas operaciones de detección de colliders podrían fallar, tal y como expliqué en otro artículo anterior.

Creación de un NavMesh 2D

Un NavMesh es un componente que analiza el escenario y genera un grafo basado en él. Este grafo es el que usará el algoritmo de búsqueda de caminos para guiar al NPC. Crear un NavMesh en Unity 3D es muy sencillo. 

El problema es que en 2D no funcionan los componentes que sí sirven en 3D. No hay avisos, ni mensajes de error. Sencillamente, sigues las instrucciones de la documentación y el NavMesh no se genera si estás en 2D. Al final, tras mucho buscar por Internet, he llegado a la conclusión de que la implementación nativa de Unity para los NavMesh 2D está rota. Sólo parece funcionar para 3D. 

Por lo que he visto, al final todo el mundo utiliza un paquete open source, externo a Unity, llamado NavMeshPlus. Este paquete implementa una serie de componentes, muy parecidos a los nativos de Unity, pero que sí que funcionan a la hora de generar un NavMesh en 2D. 

El enlace anterior te lleva a la página en GitHub del paquete, donde se explica cómo instalarlo. Hay varias maneras de hacerlo, la más sencilla quizás es añadir la URL del repositorio usando la opción "Install package from git URL..." del icono "+" del package manager de Unity. Una vez que lo hagas, y se refresque el índice de paquetes, podrás instalarte NavMeshPlus, así como sus posteriores actualizaciones. 

Opción para añadir un repositorio de git al package manager de Unity.
Opción para añadir un repositorio de git al package manager de Unity.

Una vez que hayas instalado NavMeshPlus, tienes que seguir los siguientes pasos:

  1. Crear un GameObject vacío en la escena. No deberá depender de ningún otro GameObject.
  2. Añadir un componente Navigation Surface al GameObject anterior. Asegúrate de usar el componente de NavMeshPlus y no el nativo de Unity. Mi consejo es que te fijes en el icono identificativos de los componentes que muestro en las capturas, y que te asegures de que los componentes que uses tengan los mismo iconos.
  3. Hay que añadir también el componente Navigation CollectSources2d. En ese mismo componente hay que pulsar el botón "Rotate Surface to XY"; por eso es importante que estos componentes se instalen en un GameObject vacío que no depende de ningún otro. Si lo has hecho bien, parecerá que no pasa nada. En mi caso, me equivoqué y añadí los componentes al GameObject Courtyard que mencionaba antes, y al pulsar el botón se me giró todo el escenario. Así que mucho ojo.
  4. Luego hay que añadir un componente Navigation Modifier a cada uno de los elementos del escenario. En mi caso se lo añadí a cada uno de los GameObjects de los tilemaps que veíamos en la captura con la jerarquía de tilemaps del ejemplo. Estos componentes nos servirán para discriminar qué tilemaps definen áreas que se puede recorrer y qué tilemaps definen obstáculos.
  5. Por último, en el componente Navigation Surface ya podremos pulsar el botón "Bake" para generar el el NavMesh.

Vamos a examinar cada uno de los pasos anteriores con algo más de detalle.

El GameObject donde he colocado los dos componentes anteriores cuelga directamente de la raíz de la jerarquía. No le he dado muchas vueltas y lo he llamado NavMesh2D. En la captura siguiente puedes ver los componentes que incluye y su configuración.

Configuración de los componentes principales de NavMeshPlus
Configuración de los componentes principales de NavMeshPlus

Como puedes ver en la figura anterior, la principal utilidad del componente NavigationSurface es definir qué capas vamos a tener en cuenta para construir nuestro NavMesh ("Include Layers"). Supongo que, si tienes capas muy cargadas, te puede interesar limitar el parámetro "Include Layers" sólo a las capas donde haya elementos del escenario. En mi caso, el escenario era tan sencillo que aun incluyendo todas las capas no he notado ralentización a la hora de crear el NavMesh.

Otra personalización que he hecho es fijar el parámetro "Use Geometry" a "Physics Colliders". Este valor presenta mejor rendimiento cuando usas tilemaps ya que se usan unas formas geométricas más sencillas para representar el escenario. La opción "Render Meshes" permite crear un NavMesh mucho más detallado, pero menos optimizado, sobre todo al usar tilemaps.

Si te estás preguntando cómo se modelan las dimensiones físicas del agente de navegación (su radio y altura, por ejemplo), aunque se muestran en la parte superior del componente "Navigation Surface", no se configuran ahí sino en la pestaña Navigation, que también es visible en la captura anterior. Si no la vieses en tu editor, la puedes abrir en Window --> AI --> Navigation.

Pestaña Navigation
Pestaña Navigation

Por último, los componentes Navigation Modifier nos permiten distinguir los tilemaps que contienen obstáculos de los tilemaps que contienen zonas deambulables. Para ello, tenemos que marcar el check de "Override Area" y luego definir el tipo de zona que contiene este tilemap. Por ejemplo, los GameObject de los tilemaps Wall y Obstacles cuentan con el componente Navigation Modifier de la siguiente captura:

Navigation Modifier aplicado a los tilemaps con obstáculos
Navigation Modifier aplicado a los tilemaps con obstáculos

Al marcar el área como "Not Walkable" estamos diciendo que lo que pinta este tilemap son obstáculos. Si fuera una zona transitable, comoe le tilemap de  Ground, la pondríamos a Walkable.

Una vez configurados todos los Navigation Modifier, ya podemos crear nuestro NavMesh pulsando en el botón "Bake" del componente Navigation Surface. Para poder verlo, hay que pulsar en el icono de la brújula del toolbar inferior (es el segundo por la derecha del toolbar) de la pestaña escena. Eso abrirá un panel emergente a la derecha donde podremos marcar el check "Show NavMesh". Si el NavMesh se ha generado correctamente entonces aparecerá en la pestaña escena, superponiéndose al escenario. Todas las zonas marcadas en azul serán las transitables por nuestro NPC.

Visualización del NavMesh
Visualización del NavMesh

Uso del NavMesh 2D

Una vez creado el NavMesh 2D, nuestros NPC deben ser capaces de leerlo. 

En el caso de Godot, eso significaba incluir un nodo MeshNavigationAgent2D en los NPC. A partir de ahí, se le iba diciendo a ese nodo a dónde se deseaba ir y este calculaba la ruta y devolvía la localización de las distintas escalas de la ruta. El resto de los nodos del gente se encargaban de moverlo hacia esa localización. 

Unity también cuenta con un componente NavMeshAgent, el problema es que no es pasivo como el de Godot; es decir, no se limita a dar las diferentes escalas de la ruta, sino que se encarga también de mover al agente hacia esas escalas. Eso puede ser muy cómodo en muchas ocasiones cuando el movimiento es sencillo, con un único componente suples dos necesidades: orientas el movimiento y lo ejecutas. Sin embargo, pensado fríamente no es una buena arquitectura porque no respeta el principio de Separación de Responsabilidades, en el que se supone que cada componente debe centrarse en realizar una sola cosa. Mi caso es un buen ejemplo de los problemas que puede haber cuando no se respeta ese principio. Mi proyecto configura fuertemente el movimiento; este no es homogéneo sino que va cambiando a lo largo de una ruta en función de múltiples factores. Es un nivel de personalización que excede lo que que permite el NavMeshAgent de Unity. Si Unity hubiera respetado el principio de Separación de Responsabilidades, como si ha hecho Godot en este caso, habría separado la generación de la ruta y el movimiento del agente en dos componentes separados. De esta manera, se podría haber aprovechado el componente generador de la ruta tal cual, mientras que el del movimiento del agente se podría haber envuelto en otros componentes para personalizarlo de manera adecuada.

Afortunadamente,  hay una manera poco publicitada de hacerle consultas al NavMesh 2D, para sacar rutas sin necesidad de un NavMeshAgent, con lo que se puede replicar la manera de funcionar de Godot. Voy a enfocar este artículo por ese lado, porque es lo que he hecho en mi proyecto. Si lo que te interesa es cómo usar el NavMeshAgent entonces te recomiendo que consultes la documentación de Unity que explica con todo lujo de detalles cómo usarlo. 

Consulta al NavMesh para obtener una ruta entre dos puntos
Consulta al NavMesh para obtener una ruta entre dos puntos

En la captura anterior he puesto un ejemplo de cómo realizar estas consultas. 

La clave está en la llamada al método NavMesh.CalculatePath() de la línea 99.  A ese método se le pasan 4 parámetros:
  • Punto de partida: Generalmente es la posición actual del NPC, por eso yo le he pasado directamente transform.posicion.
  • Punto de destino: En este caso, le he pasado una variable global del NPC donde se encuentra l ubicación de su objetivo.
  • Un filtro de áreas del NavMesh: En casos complejos, puedes tener tu NavMesh dividido en áreas. Este bitmask te permite definir a qué áreas quieres restringir la consulta. En un caso sencillo como este, lo normal es pasarle un NavMesh.AllAreas para que las tenga en cuenta todas.
  • Una variable de salida del tipo AI.NavMeshPath: es la variable en la que se depositará la ruta resultante hasta el punto de destino. Yo le he pasado una variable privada global del NPC.
La llamada a CalculatePath() es síncrona, lo que quiere decir que el juego se detendrá un instante hasta que CalculatePath() calcule la ruta. Para rutas pequeñas y actualizaciones puntuales, la interrupción no afectará al rendimiento del juego; pero si te pasas el rato calculando muchas rutas largas, entonces te encontrarás con que el rendimiento empezará a resentirse. En esos casos, lo suyo es que dividas los trayectos en varios tramos cortos más livianos de calcular. En el caso de formaciones, en vez de hacer que cada miembro de la formación calcule su ruta, es más eficiente que sólo el "comandante" calcule la ruta y el resto se limite a seguirle manteniendo la formación.

La variable de salida de tipo AI.NavMeshPath, en la que CalculatePath() vuelca la ruta calculada, aún se le podría pasar a un NavMeshAgent a través de su método SetPath(). Yo, sin embargo, he preferido prescindir del NavMeshAgent, por lo que he procesado la variable de salida, en el método UpdatePathToTarget() de la línea 107, para que me resultase más fácil de usar.

Una variable de tipo AI.NavMeshPath cuenta con el campo "corners" donde guarda un array con las localizas de las diferentes escalas de la ruta. Esas localizaciones son tridimensionales (Vector3), mientras que en mi proyecto trabajo con puntos bidimensionales (Vector2), esa es la razón por la que en el método UpdatePathToTarget() recorro todos los puntos del campo "corners" (línea 111) y los voy convirtiendo a elementos de un array de Vector2 (línea 113). Ese array es el que luego utilizo para dirigir mis componentes de movimiento a cada una de las escalas de la ruta.

Conclusión

Listo, con esto ya tienes todo lo que necesitas para hacer que tus NPC se muevan de manera inteligente por el escenario, orientándose hasta alcanzar el objetivo. A alto nivel es cierto que los conceptos son muy similares entre Godot y Unity, pero el demonio está en los detalles. A la hora de bajar al nivel de la implementación te encontrarás los matices y diferencias que hemos analizado en este artículo, pero con las indicaciones que te he dado el resultado que obtengas en Unity y en Godot debería ser similar. 

23 abril 2025

Cómo detectar obstáculos en Unity 2D

En los juegos es bastante habitual tener que averiguar si un punto del escenario está libre de obstáculos para poder ubicar a un personaje o a otro elemento del juego. Piensa por ejemplo en un RTS: a la hora de construir un edificio tienes que elegir una sección de terreno libre ¿pero cómo puede saber tu código si un emplazamiento tiene o no ya un edificio u otro tipo de objeto?

En un juego 3D, la solución más habitual suele ser proyectar un rayo desde el foco de visión de la cámara, pasando por el punto donde se sitúa el cursor del ratón, en el plano de la pantalla, hasta chocar con un collider. Si el collider es el del suelo, ese punto está libre, y si no es que hay un obstáculo.

Claro, si el objeto que queremos colocar es más grande que un punto, proyectar un simple rayo se nos queda corto. Imagina que queremos colocar un edificio rectangular y el punto donde iría su centro está libre, pero la zona de la esquina no. Afortunadamente, para esos casos, Unity nos permite proyectar formas complejas, más allá de un mero punto. Por ejemplo, los métodos SphereCast permiten desplazar una esfera invisible a lo largo de una línea, devolviendo el primer collider con el que tope, Otro de los métodos, BoxCast, permitiría resolver la problemática del edificio rectangular proyectando una caja de base rectangular a lo largo de una línea. No tendríamos más que hacer esa proyección a lo largo de una línea vertical a la posición del suelo que queremos revisar.

En 2D, también hay métodos de proyección, BoxCast y CircleCast, pero sólo sirven cuando la proyección tiene lugar en el plano XY (el de la pantalla). Es decir, equivalen a mover una caja o un círculo en línea recta, a lo largo de la pantalla, a ver si tocan un collider. Por supuesto, eso tiene su utilidad. Imagina que estás haciendo un juego cenital y quieres comprobar si el personaje será capaz de atravesar una abertura en un muro. En ese caso te bastará con hacer un CircleCast de un círculo, con un diámetro como el ancho de hombros de nuestro personaje, proyectando a través de la abertura para ver si el círculo llega a rozar los collider del muro. 

Un CircleCast, proyectando un círculo a lo largo de un vector.
Un CircleCast, proyectando un círculo a lo largo de un vector.

¿Pero qué pasa cuando tienes que proyectar en el eje Z en un juego 2D? Por ejemplo, para un caso en 2D equivalente al ejemplo que poníamos antes para 3D. Ahí ya no nos servirían ni BoxCast ni CircleCast porque esos métodos definen el vector de proyección mediante un parámetro Vector2, limitado al plano XY. En esos casos, se usa una familia diferente de métodos: los métodos "Overlap".

Los métodos Overlap colocan una forma geométrica en un punto determinado del espacio 2D y, en caso de que la forma se superponga con algún collider, lo devuelven. Al igual que las proyecciones, hay métodos especializados en diferentes formas geométricas: OverlapBox, OverlapCapsule u OverlapCircle entre otros.

Supongamos un caso como el de la figura siguiente. Queremos saber si una forma con el tamaño del círculo rojo tocaría algún obstáculo (en negro) en caso de ubicarse en el punto marcado en la figura. 

Ejemplo de uso de OverlapCircle.
Ejemplo de uso de OverlapCircle.


En ese caso, usaríamos OverlapCircle para "dibujar" un circulo invisible en ese punto (el círculo que se ve en la figura es un mero gizmo) y comprobaríamos si el método devuelve algún collider. De no ser así, significaría que el emplazamiento elegido estaría libre de obstáculos.

Un método que llamase a OverlapCircle podría ser tan sencillo como el siguiente:

Llamada a OverlapCircle
Llamada a OverlapCircle

El método de la figura devuelve verdadero si no hay ningún collider en un radio  (MinimumCleanRadius) de la posición candidateHidingPoint. En caso de que haya algún collider, el método devolverá false. Para eso, el método IsCleanHidingPoint se limita a llamar a OverlapCircle, pasándoles como parámetros:

  • candidateHidingPoint (línea 224): Un Vector2 con la posición del centro del círculo a trazar.
  • MinimumCleanRadius (línea 225): Un float con el radio del círculo.
  • NotEmpyGroundLayers (línea 226): Un LayerMask con las capas de los collider que queremos detectar. Sirve para filtrar los collider que no queramos detectar. OverlapCircle descartará un collider que no se encuentre en una de las capas que hayamos pasado en el LayerMask.

En caso de que el área esté limpia de colliders, OverlapCircle devolverá null. En caso de que soí los haya, devolverá el primer collider que encuentre. Si te interesase conseguir el listado de todos los collider que pudiera haber en el área podríamos usar la variante OverlapCircleAll, que devuelve una lista con todos ellos.

Podríamos finalizar aquí, pero no quiero hacerlo sin advertirte de un quebradero de cabeza que te encontrarás sin duda en 2D. Por suerte, puede solucionarse fácilmente si estás avisado. 

El problema puede darse si usas tilemaps. Estos son muy habituales para dar forma a los escenarios en 2D. La cuestión es que para conformar los collider de un tilemap, lo normal es usar un componente "Tilemap Collider 2D", y también es bastante habitual añadir un componente "Composite Collider 2D" para sumar todos los collider individuales de cada tile en uno sólo y mejorar así el rendimiento. El problema viene de que por defecto el componente "Composite Collider 2D" genera un collider hueco, sólo definido por su perfil. Supongo que lo hace por rendimiento.  Eso ocurre cuando el parámetro "Geometry Type" tiene el valor Outlines.

Valores posibles del parámetro Geometry Type.
Valores posibles del parámetro Geometry Type.

¿Por qué supone un problema que el collider sea hueco? porque en ese caso la llamada a OverlapCircle sólo detectará el collider si el círculo que dibuje llega a cruzarse con el borde del collider. Si por el contrario, el círculo encajase limpiamente dentro del collider, sin tocar ninguno de sus bordes, entonces OverlapCircle no devolvería collider alguno y supondríamos erróneamente que la zona estaría limpia.

La solución es sencilla, una vez que te lo han explicado. Hay que cambiar el valor por defecto de "Geometry Type" a Polygons. Este valor hace que el collider generado sea "macizo" por lo que OverlapCollider lo detectará aunque el círculo dibujado encaje dentro sin tocar sus bordes.

Parece una tontería, porque lo es, pero fue una tontería que me llevó un par de horas resolver hasta que conseguí dar con la tecla. Espero que este artículo sirva para que no te pase a ti también. 

18 febrero 2025

Asignación de datos a Tiles en Unity

Es muy habitual recurrir a Tilemaps para dar forma a juegos 2D. La sencillez que ofrecen los hacen ideales a la hora de darle forma a un escenario de estética retro. 

Sin embargo, a primera vista, toda la implementación que hace Unity de los Tilemap parece limitarse a su aspecto estético. Por eso, es bastante frecuente encontrarse a gente en los foros preguntando cómo se puede asociar datos a los diferentes Tiles utilizados en un Tilemap.

¿Para que podemos necesitar asociarle datos a un tile? Por múltiples razones. Por ejemplo, supongamos que estemos usando un Tilemap para conformar el mapa de un juego en perspectiva cenital. En ese caso, es probable que queramos añadirle un valor de "resistencia al avance" a los diferentes tiles, de manera que nuestro personaje se mueva más lento en los tiles que representen una ciénaga, más rápido en los tiles que representen un camino, y que no pueda travesar los tiles que muestren piedras infranqueables.

Para nuestros ejemplos, vamos a suponer un escenario como el de la captura:

Escenario de nuestros ejemplos
Escenario de nuestros ejemplos


Representa un recinto cerrado que incluye tres obstáculos en su interior, uno a la izquierda (con una sola baldosa), otro en el centro (con cuatro baldosas) y otro a la derecha (con tres). El origen de coordenadas del escenario está en su centro; lo he señalado con la cruceta de un GameObject vacío.

La problemática que queremos resolver en nuestro ejemplo es cómo hacer para que un script pueda analizar el escenario, identificar las losetas negras y tomar nota de sus posiciones.

Como en muchos otros casos, no hay una solución única a esta problemática. Tenemos una opción rápida de implementar y que ofrece más posibilidades, pero que puede suponer una sobrecarga excesiva para el juego. Por otro lado, tenemos otra opción que es más ardua de implementar y está más limitada, pero que sobrecargará menos el juego. Vamos a analizar ambas.

Asociar un GameObject a un tile

Generalmente, cuando queremos identificar de una tacada los GameObject que pertenecen a una misma categoría, lo más sencillo sería marcarlos con una etiqueta y buscarlos por el escenario con el método estático GameObject.FindGameObjectsWithTag(). El problema es que los tiles son ScriptableObjects, así que no pueden marcarse con etiquetas.

Los ScriptableObjects de los tiles se crean cuando arrastramos sprites sobre la pestaña del Tile Palette. En ese momento, el editor nos deja elegir el nombre y la ubicación del asset con el ScriptableObject que queramos crear, asociado al tile. A partir de ese momento, si pinchamos en el asset de ese ScriptableObject podremos editar sus parámetros a través del inspector. Por ejemplo, para el tile que he utilizado para los muros perimetrales, los parámetros son:

Configuración de un Tile
Configuración de un Tile

Los campos que se pueden configurar son:
  • Sprite: Es el sprite con la apariencia visual del tile. Una vez fijado el sprite podemos pulsar el botón "Sprite Editor" de debajo para configurar tanto el pivot point como el collider asociado al sprite.
  • Color: Permite colorear el sprite con el color que fijemos aquí. El color neutro es el blanco; si lo usamos, Unity entenderá que no queremos forzar el color del sprite.
  • Collider Type: Define si queremos asociar un Collider al tile. Si elegimos "None" significará que no queremos que el Tile tenga un Collider asociado; si fijamos "Sprite", el collider será el que hayamos definido a través del Sprite Editor; por último si el valor elegido es "Grid", el colider tendrá la forma de las celdas del Tilemap.
  • GameObject to Instantiate: Es el parámetro que nos interesa. Lo explicaremos en un momento.
  • Flags: Sirven para modificar cómo se comporta un tile cuando se coloca en un Tilemap. Para nuestra explicación, basta con que lo dejes en su valor por defecto.
Como avanzaba, el parámetro que nos interesa para nuestro propósito es "GameObject to instantiate" si arrastramos un prefab a este campo, el Tilemap se encargará  de crear una instancia de ese prefab en cada ubicación donde aparezca ese Tile.

Por ejemplo, para poder localizar fácilmente los tiles negros, los de los obstáculos, le he asociado a ese parámetro de su Tile un prefab que he denominado ObstacleTileData.

Configuración del Tile de los obstáculos
Configuración del Tile de los obstáculos

Como lo único que quiero es poder asociarle a los tiles una etiqueta, para poder localizarlos con  FindGameObjectsWithTag(), me ha bastado con que ObstacleTileData sea un simple transform con la etiqueta que me interesa. En la captura se puede ver que he usado la etiqueta InnerObstacle.

ObstacleTileData con la etiqueta InnerObstacle


Hecho eso, y una vez desplegados por el escenario los tiles que queremos localizar, nos basta el siguiente código para hacer inventario de los tiles con la etiqueta InnerObstacle.

Código para localizar los tiles que hayamos marcado con la etiqueta InnerObstacle
Código para localizar los tiles que hayamos marcado con la etiqueta InnerObstacle

Basta que coloquemos el script anterior en cualquier GameObject situado junto al Tilemap del escenario. Yo, por ejemplo, lo tengo colgando del mismo transform que el componente Grid de los Tilemaps de mi escenario.

Cuando arranque el nivel, el Tilemap creará una instancia del prefab ObstacleTileData en cada una de las posiciones del escenario donde aparezca una loseta negra de obstáculo. Dado que el prefab ObstacleTileData no tiene componente visual, sus instancias serán invisibles para el jugador, pero no para nuestros scripts. Como esas instancias están marcadas con el tag "InnerObstacle" nuestro script puede localizarlas llamando a FindGameObjectsWithTag(), en la línea 16 del código. 

Como demostración de que el código localiza correctamente las ubicaciones de los tiles de obstáculos, he fijado un punto de interrupción en la línea 17, para que podamos analizar el contenido de la variable "obstacles" tras llamar a FindGameObjectsWithTag(). Al ejecutar el juego en modo debug, el contenido de esa variable es el siguiente:

Posiciones de los tiles de obstáculos
Posiciones de los tiles de obstáculos

Si comparamos las posiciones de los GameObjects con las de los tiles, veremos que obstacles[7] es el obstáculo que estaba la izquierda, con un único tile. Los GameObjects obstacle[2], [3], [5] y [6] se corresponden con los cuatro tiles del obstáculo central. Los tres GameObjects restantes ([0], [1] y [4]) son los tiles del obstáculo de la derecha, el que tiene forma de codo.

De esta forma, hemos conseguido un inventario rápido y sencillo de todos los tiles de un determinado tipo.

De todos modos, tirar de etiquetas no es la única manera de localizar las instancias de los GameObject asociados a cada Tile. Los objetos Tilemap ofrecen el método GetInstantiatedObject(), al que se le pasa una posición dentro del Tilemap y, a cambio, devuelve el GameObject instanciado para el tile de esa pasición. Usar este método es menos directo que localizar los objetos por etiqueta, ya que te obliga a ir examinando una a una las posiciones del Tilemap, pero habrá situaciones en las que no te quede otro remedio.

Por último, antes de abandonar esta sección del artículo, es necesario que seas consciente de que puede haber situaciones en las que instanciar un GameObject por tile puede pesar sobre el rendimiento del juego. En el caso del ejemplo se trata unos pocos tiles, pero en escenarios mucho más grandes puede que hablemos de cientos de tiles, por lo que instanciar cientos de GameObject puede ser algo que debamos pensar dos veces.

Extender la clase Tile

Por defecto, yo usaría la estrategia anterior; pero puede ser que haya situaciones en las que no te convenga instanciar un número elevado de GameObjects. En ese caso, puede convenirte el enfoque que voy a explicar ahora.

Los Tiles son una clase que hereda de ScriptableObject. Podemos extender la clase Tile para añadir los parámetros que queramos. Por ejemplo, podríamos crear un Tile especializado con un booleano para definir si el tile es un obstáculo o no.

Tile con un parámetro especializado
Tile con un parámetro especializado

Ese tile se puede instanciar como cualquier ScriptableObject para crear un asset. Cuando lo hagamos veremos que aparecerá el parámetro especializado y lo podremos configurar a través del inspector.

Configuración del tile con el parámetro especializado
Configuración del tile con el parámetro especializado

La clave está en que los assets que creemos así se pueden arrastrar hacia el Tile Palette para poder ser dibujados en el escenario.

Hecho eso, podríamos utilizar el método Tilemap.GetTile() para ir recuperando los tiles de cada una de la posición, hacerles cast a nuestro tipo de tile personalizado (en nuestro caso CourtyardTile) y, acto seguido, analizar el valor del parámetro personalizado.

La pega de este método es que no podemos usar ni etiquetas ni capas para buscar los datos asociados a los tiles, lo que nos condena a recorrer los tilemap celda a celda para encontrarlos, pero tiene la ventaja de librar a nuestro juego de la carga de crear un GameObject por tile.

Conclusión

Ya sea creando un GameObject por tile o extendiendo la clase Tile, ya tienes los recursos necesarios para asociar datos a cada uno de los tiles. Esto te permitirá dotar a los tiles de una semántica imprescindible para multitud de algoritmos, como por ejemplo los de pathfinding.

08 febrero 2025

Navegación 2D en Godot

Los NPC o Non-Playable-Characters son todos los personajes del juego que no están controlados por el jugador, pero que interactúan con él. Pueden abarcar desde los aliados del jugador hasta los enemigos que intentan acabar con él. Uno de los grandes retos del desarrollador de juegos es conseguir dotar a sus NPC de una serie de comportamientos que transmitan la apariencia de vida y de inteligencia.

Uno de los síntomas más claros de vida es el movimiento. Si algo se mueve por iniciativa propia, una de las causas que contempla instintivamente nuestra mente es que pueda estar vivo. Por otro lado, uno de los síntomas de un mínimo de inteligencia es que ese movimiento se produzca evitando los obstáculos que se presente. Si una criatura se mueve cuando nos acercamos, pero inmediatamente se da de bruces con la pared que tiene enfrente, podremos pensar que esa criatura está viva, pero no que sea muy inteligente.

Por eso, la mayor parte de los engines de videojuegos, a poco que tengan un paquete de inteligencia artificial, lo primero que meten en él es alguna herramienta de búsqueda de caminos (pathfinding), para permitir que los NPC puedan orientarse por el escenario.

Godot no es una excepción y por eso incorpora la funcionalidad pathfinding mediante mallas, tanto en 2D como en 3D. Por sencillez, vamos a centrarnos en el primer ámbito.

Creación de un mapa

Para orientarnos, lo que mejor nos viene a la personas es un mapa. A un engine le pasa lo mismo. Ese mapa se basa en nuestro escenario, pero modelarlo hace falta un nodo denominado NavigationRegion2D. Debemos añadirlo a la escena de cualquier nivel que queramos mapear. Por ejemplo, suponiendo que nuestro nivel de basase en varios TileMapLayer (uno con los tiles del suelo, otro con los muros perimetrales y otro con los tiles de los obstáculos del interior del perímetro), la estructura de nodos con el del NavigationRegion2D podría ser la siguiente:

Jerarquía de nodos con un NavigationRegion2D
Jerarquía de nodos con un NavigationRegion2D

Observa que el nodo del NavigationRegion2D es el padre de los TileMapLayer ya que por defecto sólo construye el mapa con las conclusiones que saque de analizar sus nodos hijos.

Al configurar el NavigationRegion2D a través del inspector, veremos que requiere la creación de un recursos NavigationPolygon, en ese recurso será donde se guarde la información del mapa de nuestro nivel.

Configuración de un NavigationRegion2D
Configuración de un NavigationRegion2D

Una vez creado el recurso, al pinchar sobre él, veremos que tiene bastantes propiedades a configurar.

Configuración de un NavigationPoligon
Configuración de un NavigationPoligon

En un caso sencillo como el de nuestro ejemplo, sólo hay que configurar dos parámetros:

  • Radius: Aquí pondremos el radio de nuestro NPC, con un pequeño margen adicional. De esta manera, el algoritmo de pathfinding añadirá esta distancia al contorno de los obstáculos para evitar que los NPC rocen con ellos.
  • Parsed Collision Mask: Todos los objetos de los nodos hijos que se encuentren en las capas de colisión que marquemos aquí se considerarán un obstáculo.
Hecho lo anterior, nos bastará con marcar los límites de nuestro mapa. Para hacerlo fíjate en que, cuando pulses el recurso del NavigationPolygon en el inspector, aparecerá el siguiente toolbar en la pestaña de la escena:

Toolbar para definir la forma de un NavigationPolygon
Toolbar para definir la forma de un NavigationPolygon

Gracias a dicho toolbar podremos definir las lindes de nuestro mapa. Luego, el NavigationRegion2D se encargará de identificar los obstáculos y hacer "agujeros" en nuestro mapa para señalar las zonas por las que nuestro NPC no podrá pasar.

El primer botón (verde) del toolbar sirve para añadir nuevos vértices a la forma de nuestro mapa, el segundo botón (azul) para editar un vértice ya situado, mientras que el tercero (rojo) sirve para borrar vértices.

En un escenario sencillo, como un tilemap, puede que nos baste con dibujar los cuatro vértices que limiten la extensión máxima del tilemap. En la siguiente captura se puede ver que he situado un vértice en cada esquina del recinto que quiero mapear.

Vértices de nuestro NavigationPolygon
Vértices de nuestro NavigationPolygon

Una vez definidos los límites de nuestro mapa, tendremos que lanzar su generación. Para eso pulsaremos el botón Bake NavigationPolygon, del toolbar. 

El resultado será que NavigationRegion2D marcará de azul las zonas por las que se puede puede deambular, una vez analizados los límites del mapa y detectados los obstáculos dentro de estos.

Tendremos que acordarnos de pulsar el botón Bake NavigationPolygon, siempre que añadamos nuevos obstáculos al nivel o movamos los ya existentes, o si no no se actualizará el mapa de las zonas navegables.

NavigationPolygon, una vez construido
NavigationPolygon, una vez construido

Para que un objeto sea identificado como obstáculo, tiene que ser configurado para pertenecer a alguna de las capas de colisión que hayamos configurado en el campo Parsed Collision Mask del NavigationPolygon. En el caso de un TileMapLayer esto se configura de la siguiente manera:

  1. En aquellos TileMapLayer que contengan obstáculos tendremos que marcar el parámetro Physics -> Collision Enabled.
  2. En el recurso Tile Set que estemos usando en el TileMapLayer, tendremos que asegurarnos de añadir una capa física y situarla en una de las capas de las contempladas en el Parsed Collision Mask del NavigationPolygon.
  3. También debes añadir una capa de navegación y fijarla al mismo valor que la capa de navegación del nodo NavigationRegion2D.
Por ejemplo, el TileMapLayer que contiene los obstáculos interiores en mi ejemplo, tiene la siguiente configuración para el Tile Set:

Configuración del TileMapLayer
Configuración del TileMapLayer


Luego, dentro del TileSet, no todos los tiles tienen que ser obstáculos, solo aquellos que tengan un collider configurado. Recuerda que los collider de los tiles se configuran desde la pestaña TileSet. No entro en más detalles al respecto porque eso tiene más que ver con la configuración de los TileMaps que con la navegación en sí.

Configuración del collider de un tile
Configuración del collider de un tile


Uso del mapa

Una vez creado el mapa, nuestros NPC deben ser capaces de leerlo. Para eso necesitan contar con una nodo MeshNavigationAgent2D en su jerarquía.

El nodo MeshNavigationAgent2D dentro de la jerarquía de un NPC
El nodo MeshNavigationAgent2D dentro de la jerarquía de un NPC

En un caso sencillo, puede ser que ni siquiera tengas que cambiar nada de su configuración por defecto. Basta con que te asegures de que su campo Navigation Layers este en el mismo valor que el del NavigationRegion2D.

Desde tu script, una vez que tengas una referencia al nodo MeshNavigationAgent2D, te bastará con ir colocando la posición a la que quiera ir en la propiedad TargetPosition del MeshNavigationAgent2D. Por ejemplo, si tuviéramos un NPC que quisiera esconderse en un punto del mapa, podría incluir una propiedad en la que se le pidiera al nodo MeshNavigationAgent2D que buscase la ruta para alcanzar ese punto, tal y como se puede ver en la captura.

Fijado de una posición de destino en un MeshNavigationAgent2D

Una vez que le hayamos dicho al nodo a dónde queremos ir, este calculará una ruta y nos irá dando las diferentes escalas de esa ruta, conforme las vayamos alcanzando.

Para que el nodo nos diga dónde se encuentra la siguiente escala de la ruta, tendremos que llamar al método GetNextPathPosition(). Es importante tener en cuenta que este método se encarga de actualizar bastantes cosas internas del algoritmo de pathfinding, por lo que un requisito es que lo llamemos una vez en cada llamada a _PhysicsProcess() de nuestro NPC.

En la captura tienes el _PhysicProcess() del agente que estoy utilizando de ejemplo. La mayor parte del código de la captura se refiere a temas que no son objeto de este artículo, pero lo incluyo por ofrecer algo de contexto. En realidad, para lo que nos ocupa, sólo tienes que fijarte en las líneas 221 a 225.

Obtención de la siguiente escala en la ruta, dentro del _PhysicsProcess.
Obtención de la siguiente escala en la ruta, dentro del _PhysicsProcess.

En la línea 225 se puede ver cómo llamamos a GetNextPathPosition() para que obtener cuál es el siguiente punto al que debemos dirigirnos para seguir la ruta trazada hasta el objetivo fijado en TargetPosition. Cómo hagamos para llegar hasta ese punto será cosa nuestra. El MeshNavigationAgent2D se limita a garantizar dos cosas: 

  • Que no hay ningún obstáculo entre el NPC y el siguiente punto de la ruta que te marque.
  • Que si vas siguiendo los puntos de ruta que te vaya dando acabarás llegando al objetivo... si existe alguna ruta que lleve a él.
Hago especial hincapié porque, a diferencia del NavigationAgent de Unity, el de Godot no mueve al NPC en el que vaya incrustado. Se limita a ir dando indicaciones sobre a dónde ir.

Aparte del caso general, en el código de la captura hay ciertas salvedades que hay que tener claras.

Para empezar, la documentación de Godot comenta que no hay que seguir llamando a GetNextPathPosition() una vez que hayamos acabado de recorrer la ruta o, de otro modo, el NPC puede mostrar "temblores" por seguir forzando actualizaciones del algoritmo de pathfinding habiendo alcanzado ya el objetivo. Ese es el motivo por el que, en la línea 224, compruebo que no hemos alcanzado aún el final de la ruta, antes de llamar a GetNextPathPosition(). Así que no te olvides de comprobar que IsNavigationFinished() devuelve falso, antes de consultar a GetNextPathPosition().

Por otro lado, el algoritmo de pathfinding tarda un cierto tiempo en converger, sobre todo al principio. Si le preguntamos demasiado pronto provocará una excepción. Lo normal es que tarde uno o dos fotogramas físicos (es decir, una o dos llamadas a _PhysicsProcess). Ese ese el motivo de que en la línea 222 compruebe si la propiedad IsReady es verdadero antes de continuar. El problema es que MeshNavigationAgent2D no tiene una propiedad IsReady (aunque debería tenerla), lo que pasa es que es una propiedad que me he creado yo para hacer una consulta nada intuitiva:

Manera de comprobar que el algoritmo de pathfinding está listo para responder consultas
Manera de comprobar que el algoritmo de pathfinding está listo para responder consultas

Básicamente, lo que hace la propiedad es asegurarse de que el algoritmo de pathfinding ha llegado a generar al menos una versión de la ruta. 

Conclusión

Y ya está, fijando el TargetPosition del MeshNavigationAgent2D, y dirigiéndote a los sucesivos puntos que te vayan marcando las llamadas a GetNextPathPosition(), podrás llegar a cualquier punto alcanzable del NavigationRegion2D que hayas definido. 

Con eso ya tienes lo básico, pero si en algún momento necesitases analizar la ruta completa que haya calculado el algoritmo, puedes pedírsela llamando al método GetCurrentNavigationPath(). Ese método te devolverá un array con las posiciones de las diferentes escalas de la ruta.

01 febrero 2025

Estructura de carpetas de un proyecto de desarrollo de un videojuego

Foto de Niklas Ohlrogge
Un juego utiliza una cantidad masiva de assets. Imágenes, sonidos, ficheros de código, etc, necesitas una manera de organizarlos o, pasado poco tiempo, te costará encontrar las cosas. Por eso, una pregunta típica en los foros es ¿Qué estructura de carpetas le doy a mi proyecto?

Bueno, lo cierto es que esta pregunta no tiene una única respuesta. Muchas veces depende del proyecto, de tus propias particularidades como desarrollador y de los requisitos que a veces imponen los diferentes engines. De todos modos, sí que hay cierto consenso en algunas buenas prácticas que enumeraré en este artículo para que elijas la que más se ajuste a ti.

Los requisitos de los engines

Para empezar, vamos a enumerar las carpetas que siempre tendrás como requisito del engine que estés usando.

En el caso de Unity, este te exige tener 3 carpetas:

  • Assets: Aquí depositarás todos los recursos que crees para el juego.
  • Packages: Aquí se guardan los paquetes que te descargues del package manager.
  • Project Settings: Es donde el editor almacena las configuraciones de tu proyecto.

Las tres carpetas anteriores carpetas anteriores son lo mínimo que deberías guardar en tu repositorio de versiones, en caso de usar uno (lo más recomendable).

Lo normal es que toques dentro de la carpeta Assets, ya que del contenido de las otras carpetas ya se ocupa el editor por ti. La estructura de carpetas que tengas dentro de la de Assets es cosa tuya, pero el editor te exige tener al menos dos:

  • Scripts: Debes contar con esta carpeta para guardar en ella los scripts con los que vaya a funcionar tu juego. Unity compila el juego con los scripts que encuentre en esta carpeta.
  • Editor: Si desarrollas extensiones para el editor o inspectores personalizados, deberás crear esta carpeta y guardar el código de las extensiones allí. No los guardes en la carpeta de Scripts, porque la compilación fallará cuando quieras construir el paquete final, para ejecutar el juego al margen del editor.

En el caso de Godot, los requisitos son más laxos. La única carpeta fija es la de addons, ya que es ahí donde se recogen tanto los plugins que hayas desarrollado tú como los que te hayas bajado del AssetLib.

Partiendo del cumplimiento de los requisitos anteriores, el cielo es el límite a la hora de organizar nuestros assets. Veamos ahora los esquemas habituales que más usa la gente.

Distribución por tipos

Esta es la distribución más clásica. Yo me basé en ella para organizar los recursos de Prince of Unity.

Creas una carpeta para albergar cada tipo de asset: una para imágenes, otra para sonidos, otra para prefabs/escenas, otra para niveles, otra para shaders, etc.

Esta es la estructura que suele recomendar la documentación de Unity y por eso es probable que sea la primera que utilices. 

https://unity.com/es/how-to/organizing-your-project
Ejemplo de organización por tipos, según la documentación de Unity

Distribución funcional

Sin embargo, hay muchos que reniegan de la estructura anterior. Alegan que para distinguir unos assets de otros por el tipo resulta inútil clasificarlos a nivel de carpeta, porque los editores (tanto de Unity como de Godot) te permiten buscar los assets, filtrando por su tipo.

Por eso, la documentación de Godot aboga por situar los assets lo más cerca posible de las escenas que los utilizan. Hay guías de estilo para Unreal Engine que recomiendan lo mismo.

En la práctica, eso supone crear una carpeta por escena y agrupar allí los assets que utiliza, independientemente de su tipo. En Unity, eso significa crear una carpeta por prefab.

Generalmente, la excepción a lo anterior es el código que se suele preferir que vaya en una carpeta aparte donde se concentren todos los ficheros de código fuente (en el caso de Unity ya hemos dicho que contar con la carpeta Scripts es un requisito).

Pero lo normal es que nuestras escenas/prefabs no sean islas autónomas, sino que compartan recursos entre si. ¿Cómo se hace en esos casos? ¿se copia el recurso en la carpeta de cada escena/prefab? No, en esos casos se suele crear una carpeta para los recursos compartidos, puede ser una carpeta  del mismo nivel que la de las escenas/prefab o una carpeta situada en un nivel superior a ellas.

Por ejemplo, la guía de estilo para Unreal Engine que mencionaba antes ofrece esta estructura de ejemplo:

Ejemplo de organización funcional
Ejemplo de organización funcional

Los defensores de esta práctica alegan que es preferible tener una estructura profunda de carpetas, cada una de ellas con unos pocos archivos (aunque sean de distintos tipos), que tener pocos directorios pero abarrotados de archivos. Resulta más fácil saber qué recursos moviliza una escena/prefab si lo ves todo dentro de la misma carpeta.

Conclusión

Organizar tu proyecto es una decisión muy personal. No hay grandes reglas grabadas en piedra. Al final todo se reduce a ordenar tus assets de la manera que te resulte más cómoda y te haga ser más productivo. Quizás, probar alguno de los sistemas de organización de los que te he hablado aquí te ayude a encontrar el tuyo propio.

 

30 enero 2025

"Game Development Patterns with Godot 4" por Henrique Campos

Hace tanto que se desarrollan aplicaciones y videojuegos que será difícil que, cuando lo hagas tú, te encuentres con una situación que no haya resuelto otro desarrollador antes. Con el tiempo, eso ha dado lugar a un conjunto de soluciones generales consideradas como las mejores prácticas a la hora de solucionar problemas comunes. Es lo que se conoce como patrones de diseño; un conjunto de "recetas" o "plantillas" que los desarrolladores pueden seguir para estructurar su código a la hora de implementar las soluciones a determinadas problemáticas.

Es una materia que se estudia en cualquier carrera de relacionada con la programación, siendo el libro "Design Patterns, Elements of Reusable Object-Oriented Software", de Gamma, Helm, Johnson y Vlissides (el célebre Grupo de los Cuatro) el libro, ya clásico, que inició esta rama de estudio. 

Sin embargo, contados de manera académica, los patrones de diseño pueden ser arduos de entender y no siempre queda clara cual es su aplicación. Es precisamente aquí donde brilla la obra "Game Development Patterns with Godot 4" de Henrique Campos.

Campos elige con bastante acierto 9 de los 23 patrones de diseño enunciados por el Grupo de los Cuatro y los explica de una manera clara, sencilla y rica en ejemplos aplicados al desarrollo de juegos. Los patrones elegidos por Campos me parecen los más útiles y los que tienen aplicación más inmediata en cualquier juego. No he echado de menos ninguno de los otros patrones de diseño restantes porque siempre me parecieron demasiado abstractos y esotéricos.

De manera muy original, Campos plantea sus ejemplos a partir de diálogos o comunicaciones entre el diseñador de un juego ficticio y su programador. En esas comunicaciones, el diseñador demanda funcionalidades enfocadas a enriquecer aspectos concretos de un juego, mientras que el programador recibe esas demandas y las tiene que encajar con su objetivo de crear un código escalable y fácil de mantener. En cada uno de esos ejemplos, se explica un patrón de diseño concreto como la mejor solución para encajar la demanda del diseñador con el objetivo de programador.

Siguiendo ese esquema, el autor explica el patrón singleton, el del observador, la factoría, las máquinas de estados, el patrón comando, el de estrategia, los decoradores, los localizadores de servicios y las colas de eventos. Todo ello en un orden creciente de complejidad y apoyándose en lo anterior. Por el camino, explica también de manera sencilla pero efectiva principios básicos del desarrollo como los de la programación orientada a objetos o los principios SOLID.

La implementación de los ejemplos se hace en GDScript, con Godot 4. He de reconocer que al principio me acerqué al libro con desconfianza porque no creía que GDScript fuera un lenguaje lo suficientemente rico como para ilustrar estos patrones (reconozco que desarrollo en Godot C#). Sin embargo, el código de campos es muy expresivo y el GDScript es tan conciso que al final los ejemplos se leen como si fueran pseudocódigo. Al final no he echado de menos al C#, porque el código de GDScript conseguía transmitir en unas pocas líneas la idea de los ejemplos lo que los hacía muy fáciles de leer y comprender.

Por tanto, creo que es un libro muy recomendable que hace ameno y divertido una materia que suele repeler por el tratamiento excesivamente académico que ha recibido en otras obras anteriores. Si le das una oportunidad, creo que lo disfrutarás y que te ayudará considerablemente a mejorar la calidad de tu código. 

26 enero 2025

Creación de Gizmos interactivos en Unity

En un artículo anterior expliqué cómo usar los callback OnDrawGizmos() y OnDrawGizmosSelected(), presentes en todos los MonoBehaviour, para dibujar Gizmos que representasen visualmente las magnitudes de los campos de un GameObject. Sin embargo, ya expliqué entonces que los Gizmos implementados de esta manera eran pasivos en el sentido de que se limitaban a representar los valores que metiésemos en el inspector. Pero el editor de Unity está lleno de Gizmos con los que se puede interactuar para cambiar los valores del GameObject, simplemente pinchando sobre el Gizmo y arrastrando. Un ejemplo es el Gizmo que sirve para definir las dimensiones de los Collider. 

¿Cómo se implementan esos Gizmos interactivos? La clave está en los Handles. Estos son "agarraderas" visuales que podemos situar sobre nuestro GameObject y que permiten ser pinchados y arrastrados con el ratón, devolviendo los valores de su cambio de posición, para que podamos calcular con ellos los cambios resultantes sobre las magnitudes representadas. Se entenderá mejor cuando veamos los ejemplos.

Lo primero que hay que tener en cuenta es que los Handles pertenecen al espacio de nombres del editor y por tanto sólo se pueden usar dentro de él. Eso quiere decir que no podemos usar los Handles en las compilaciones finales del juego que ejecutemos de manera independiente al editor. Por esa razón, todo el código que use Handles tiene que situarse o bien en la carpeta Editor o bien con guardas #if UNITY_EDITOR ... #endif. En este artículo explicaré la primera aproximación, por ser más limpia.

Teniendo en cuenta lo anterior, el código de los Gizmos que utilicen Handles deberán situarse en la carpeta Editor y no en la de Scripts. Unity sólo tiene en cuenta la carpeta Scripts a la hora de compilar el código C# para el ejecutable final. Es importante ser riguroso con esto porque si mezclas el código de los Handles con el de los MonoBehaviour parecerá que todo funciona mientras arranques el juego desde el editor, pero cuando intentes compilarlo con File --> Build Profiles... --> Windows --> Build te saldrán errores que te impedirán la compilación. Llegados a ese punto tendrás dos opciones: o reconduces la distribución del código que use Handles a la estructura que voy a explicar aquí o llenas tu código de guardas #if UNITY_EDITOR ... #endif en torno a todas las llamadas a objetos de la librería Handles. De todos modos, creo que la estructura que voy a explicarte aquí es lo suficientemente genéricas como para que no necesites mezclar el código de tus Handles con la de los MonoBehaviour.

Para empezar con los ejemplos, vamos a suponer que tenemos un MonoBehaviour (en nuestra carpeta Scripts) llamado EvadeSteeringBehavior que se encarga de iniciar la huida de su GameObject en caso de detectar que una amenaza se acerca por debajo de un determinado umbral. Ese umbral es un radio, una distancia en torno al GameObject que huye.  Si la distancia a la amenaza es inferior a ese radio, el EvadeSteeringBehavior empezará a ejecutar la lógica de huida. Supongamos que la propiedad que guarda ese radio es EvadeSteeringBehavior.PanicDistance.

Para representar PanicDistance como una circunferencia alrededor del GameObject, podríamos usar llamadas a Gizmos.DrawWireSphere() desde el método OnDrawGizmos() de MonoBehaviour, pero con esa aproximación sólo podríamos cambiar el valor de PanicDistance modificándolo en el inspector. Queremos ir más allá y poder alterar el valor de PanicDistance pinchando sobre la circunferencia y arrastrando en la ventana Scene. Para conseguirlo, vamos a usar Handles a través de un editor personalizado.

Para eso, podemos crear una clase en la carpeta Editor. El nombre no importa demasiado. En mi caso he llamado al fichero DrawEvadePanicDistance.cs. Su contenido es el siguiente:

Código del editor personalizado DrawEvadePanicDistance
Código del editor personalizado DrawEvadePanicDistance

Fíjate en la línea 1, deberían encenderse todas tus alarmas si te encontrases importando esta librería en alguna clase destinada a formar parte del compilado final de tu juego, ya que en ese caso te saldrán los errores que mencionaba antes. Sin embargo, al situar este fichero en la carpeta Editor ya no tendremos que preocuparnos por este problema.

La línea 6 es clave ya que permite asociar este editor personalizado con un MonoBehaviour concreto. Con esta línea le estamos diciendo al editor que queremos ejecutar la lógica recogida en OnSceneGUI() siempre que vaya a representar una instancia de EvadeSteeringBehavior en la pestaña Scene.

Como editor personalizado, nuestra clase debe heredar de UnityEditor.Editor, tal y como puede verse en la línea 7.

Hasta ahora, siempre que me he visto en la necesidad de usar Handles, he acabado estructurando el contenido de OnSceneGUI() de la misma manera que ves entre las líneas 10 y 24. Así que al final se trata casi de una plantilla.

Si queremos acceder a los datos del MonoBehaviour cuyos valores queremos modificar, lo tendremos en el campo target al que tienen acceso todos los editores personalizados. Si bien es cierto que tendrás que hacer casting al tipos que sepas que tiene el MonoBehaviour que estamos representando, tal y como se puede ver en la línea 11.

Debemos situar el código de nuestros Handles entre una llamada a EditorGUI.BeginChangeCheck() (línea 13) y una llamada a EditorGUI.EndChangeCheck() (línea 19) de esta manera, el editor monitorizará si se produce alguna interacción con los Handles. La llamada EditorGUI.EndChangeCheck() devolverá true en caso de que el usuario hubiese interactuado con alguno de los Handles creados desde la llamada a EditorGUI.BeginChangeCheck().

Para definir el color con el que se dibujarán los Handles se hace de una manera muy parecida a como hacíamos con los Gizmos, en este caso cargando un valor en Handles.color (línea 14).

Tenemos múltiples Handles para elegir, los principales son:

  • PositionHandle: Dibuja un origen de coordenadas en la pestaña Scene, idéntico al de los Transforms. Devuelve la posición en la que se encuentre Handle.
  • RotationHandle: Dibuja unas circunferencias de rotación iguales a las que aparecen cuando quieres rotar un objeto en el editor. Si el usuario interactúa con el Handle, este devolverá un Quaternion con el nuevo valor de rotación, mientras que si no lo toca, lo que devolverá será el mismo valor de rotación inicial con el que se creó el Handle.
  • ScaleHandler: De funcionamiento similar al RotationHandle, pero en este caso saca un los ejes y cubos de escalado habituales cuando quieres modificar la escala de un objeto en Unity. Lo que devuelve es un Vector3 con la nueva escala, en caso de que el usuario haya tocado el Handle o la inicial, en caso contrario.
  • RadiusHandle: Dibuja una esfera (o una circunferencia si estamos en 2D) con puntos de agarre para modificar si radio. En este caso, lo que devuelve es un float con dicho radio.

En el ejemplo que nos ocupa, lo natural era elegir el RadiusHandle (línea 15), dado que lo que buscamos es definir el radio que hemos denominado PanicDistance. Cada Handle tiene sus parámetros de creación, para configurar como se muestran en pantalla. En este caso, RadiusHandle requiere de una rotación inicial (línea 16), la posición de su centro (línea 17) y el radio inicial (línea 18).

En caso de que el usuario interactuase con el Handle, su nuevo valor se devolvería como retorno del método de creación del Handle. En nuestro ejemplo lo hemos guardado en la variable newPanicDistance (línea 15). En esas ocasiones, el método EditorGUI.EndChangeCheck() devolvería verdadero, por lo que podríamos guardar el nuevo valor en la propiedad del MonoBehaviour cuyo valor estamos definiendo (línea 22).

Para asegurar que podamos deshacer con Ctrl+Z los cambios en el MonoBehaviour, es conveniente precederlos con una llamada a Undo.RecordObject() (línea 21), señalando el objeto que vamos a cambiar y facilitando un mensaje explicativo del cambio que se va a realizar.

El resultado de todo el código anterior será que, cada vez que se pulse un GameObject que tenga el script EvadeSteeringBehavior, se dibujará una una circunferencia en torno a él, con puntos que se pueden pinchar y arrastrar. Si se interactúa con esos puntos, cambiara el tamaño de la circunferencia, pero también el valor mostrado en el inspector para la propiedad PanicDistance de EvadeSteeringBehavior.

El RadiusHandle mostrado gracias al código anterior
El RadiusHandle mostrado gracias al código anterior

Lo que podemos conseguir con los Handles no acaba aquí. Si nos pusiéramos en lugar de Unity, lo lógico habría sido ofrecer los Handles a los usuarios, para la modificación interactiva de las propiedades de los scripts, y dejar los Gizmos para la representación visual de esos valores desde las llamadas a OnDrawGizmos(). Sin embargo, Unity no dejó una separación de funciones tan clara y, en vez de eso, dotó a la librería Handles de primitivas de dibujo muy similares a las que ofrecen los Gizmos. Eso quiere decir que hay cierto solapamiento entre las funcionalidades de Handles y las de Gizmos, sobre todo en lo que se refiere a dibujado.

Es importante conocer las primitivas de dibujado que puede ofrecer Handles. En muchas ocasiones es más rápido y directo dibujar con las primitivas de Gizmos, desde el OnDrawGizmos(), pero hay cosas que no se pueden conseguir con Gizmos que sí que se pueden dibujar con los métodos de Handles. Por ejemplo, con Gizmos no puedes definir el grosor de las líneas (siempre serán de un pixel de ancho), mientras que las primitivas de Handles sí que tienen un parámetro para definir el grosor. Handles también permite pintar líneas discontinuas, así como arcos o rectángulos con rellenos traslúcidos.

Mucha gente aprovecha las ventajas de los Handles y los usa no sólo para la introducción de nuevos valores sino también para el dibujado, sustituyendo por entero a los Gizmos. Lo que pasa es que eso obliga a extraer toda la lógica de dibujado a un editor personalizado como el que hemos visto antes, lo que implica crear un fichero más y guardarlo en la carpeta Editor.

En todo caso, no se trata de cumplir dogma alguno, sino de conocer lo que ofrecen los Handles y los Gizmos para elegir lo que más nos convenga en cada ocasión.