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.