Lo anterior sería una manera perfectamente válida de implementar la navegación por nuestro escenario. Pero podría ocurrir que, a pesar de haber utilizados tilemaps para construir el escenario, prefiriésemos recurrir al rendimiento que ofrece el mesh pathfinding de Godot, partiendo de lo que expliqué en mi artículo sobre navegación 2D en Godot. El problema es que en aquel artículo asumimos un escenario que sólo comprendía obstáculos y una zona transitable uniforme, por lo que no explicamos cómo aplicar costes a determinadas áreas de la zona transitable. Vamos a ver cómo hacerlo.
Para empezar, hay que aclarar que el interfaz de Godot mezcla los distintos métodos de navegación, empleando también términos similares, por lo que es fácil confundirse y sentirse perdido.
Navegación integrada en los TileMaps
En mi opinión, la primera causa de confusión fue que cuando entramos en la configuración de un TileSet, por ejemplo expandiendo el recurso Tile Set del Tilemap que lo emplea, la categoría de Navigation Layers no tiene nada que ver con la navegación con mesh pathfinding. Estos navigation layers se asignan al TileSet aquí, pero se crean en Project > Project Settings... > General > Layer Names > 2D Navigation.
| Creación de navigation layers dentro de los Project Settings |
| Asignación de navigation layers al recurso de un TileSet en la configuración de un TileMapLayer |
Una vez asignados al TileSet, a nivel de recurso, se puede definir la presencia de cada Tile en cada navigation layer "dibujando" un polígono en el apartado Navigation de la pestaña TileSet, de manera similar a como se definiría la capa física del Tile.
Hecho eso, tendríamos que activar la navegación en el TileMap, marcando su opción Navigation.
| Opción Navigation, dentro de la configuración del TileMap |
Con eso ya tendríamos todo para que un agente recorriese el escenario, usando una API similar a la que vimos en el mencionado artículo sobre navegación 2D en Godot. La diferencia respecto a aquel artículo sería que no habría que poner un NavigationRegion2D por encima de los TileMapLayer, y que a nivel de agente el nodo a utilizar no sería un MeshNavigationAgent2D, sino un NavigationAgent2D a secas.
Este método es más rápido y directo, pero tiene dos inconvenientes graves:
- Tiene un rendimiento muy malo, por lo que sólo es viable para escenarios pequeños.
- No permite establecer áreas de coste diferenciados en la zona transitable, por lo que sólo tiene sentido si tu zona transitable es uniforme.
En realidad la utilidad de asignar navigation layers aquí es para definir los tiles por los que pueden transitar los diferentes agentes, no para asignar costes. Por ejemplo, el agente de un murciélago navegará por los tiles del cielo, mientras que el agente del protagonista se moverá por los tiles del suelo.
Estos inconvenientes son lo que me llevaron a enfocar el artículo anterior sobre navegación 2D en Godot al uso de mesh pathfinding. Como vamos a ver, este método resuelve los inconvenientes anteriores, pero es algo más complicado de configurar.
Mesh navigation con zonas lentas
Para explicar este método, vamos a partir de lo que ya conté en el artículo sobre navegación 2D en Godot que mencionaba más arriba. Así que si no te lo has leído aún, este sería el momento. En ese artículo dimos forma a un escenario que sólo tenía tres tipos de elementos: muros y obstáculos, ambos tiles con colliders configurados en sus correspondientes capas físicas, y baldosas de suelo, las cuales carecían de collider. También definimos un NavigationRegion2D, del cual colgaban los nodos de los TileMapLayers, y que abarcaba todo el recinto intramuros del escenario. Partiendo de ese escenario, en al de este artículo incorporaremos baldosas que serán transitables pero con un coste superior de lo normal. Sólo como ejemplo, esas baldosas "lentas" serían de hierba (verdes), arena (naranjas) y agua (azules).
Fíjate que, cuando pulsamos "Bake AnimationPolygon", lo que hace NavigationRegion2D es superponer un polígono sobre el recinto que le hayamos dicho y recortar las zonas donde detecte un collider. El polígono resultante será lo que le de forma al NavigationRegion2D. Los collider que tendrá en cuenta en el proceso son los incluidos en las capas físicas marcadas en el campo Parsed Collision Mask de la configuración del NavigationPolygon.
| Configuración de las capas físicas a tener en cuenta para darle forma a un NavigationRegion2D |
Hay que tener cuidado porque la configuración del NavigationRegion2D tiene un campo de Navigation Layers, por lo que es fácil creer que al darle forma al NavigationPolygon sólo se tendrán en cuenta los tiles asociados a determinados Navigation Layer. Sin embargo, no es así, nada más lejos de la realidad. A la hora de darle forma al NavigationPolygon sólo se tendrán en cuenta los collider asociados a los tiles, independientemente del navigation layer al que estén asociados estos. De hecho, si utilizas este método probablemente no tengas que asociar ningún navigation layer a los TileMapLayers. Eso sólo tenía sentido con la navegación integrada en los TileMap.
| Configuración del NavigationRegion2D |
Entonces, ¿para qué sirve el campo Navigation Layers de la configuración del NavigationRegion2D? En realidad, no es un parámetro de entrada, sino de salida. Sirve para asociar el NavigationPolygon generado a un navigation layer concreto. Como vamos a ver, puedes tener varios NavigationRegion2D asociados a un mismo navigation layer. Cuando un agente transite por un navigation layer, el algoritmo de búsqueda conformará un mapa que será la suma de todos los NavigationRegion2D asociados a ese navigation layer.
Fíjate también que en la configuración de un NavigationRegion2D hay un campo de Travel Cost. Este parámetro es un multiplicador de la distancia recorrida. Para calcular el coste de transitar por esa región, el algoritmo de búsqueda de caminos entenderá que cada unidad recorrida será equivalente al multiplicador introducido en Travel Cost.
Por tanto, aunque el sistema de mesh navigation de Godot no permita asignarle costes a tiles específicos, si puede poner costes por región, y puede montar un mapa uniendo varias regiones. Partiendo de eso, lo que vamos a hacer es crear una región que abarque los tiles con el coste 1 de tránsito (el coste por defecto), y otra región por cada zona de tiles "lentos que tengamos en el mapa". A esas últimas regiones les pondremos un Travel Cost acorde a los tiles recogidos en ellas. Asociaremos todas esa regiones al mismo navigation layer para que conformen un mapa unificado.
La pega, es que eso supone asociar colliders a las baldosas transitables. Sólo para ser detectadas al generar el NavigationPolygon. En el caso de mi ejemplo, eso supone crear una capa física para cada uno de los tipos de navegación. Como con el resto de las capas físicas, se crean en Project > Project Settings... > General > Layer Names > 2D Physics
| Las capas 6, 7, 8, 9 son para definir los costes de navegación |
Se puede ver en la captura que he creado cuatro capas físicas (NavGround, NavGrass, NavSand y NavWater) para asociarlas a sus respectivas baldosas. No queremos que nuestro agentes choquen esas baldosas. Nos bastará asegurarnos de no marcar esas capas en las máscaras de colisión de nuestros agentes.
Creadas las capas físicas, hay que asociarlas al TileMapLayer donde hayamos colocado las baldosas lentas. Para ello añadiremos cuatro elementos al campo Physics Layers del recurso TileSet del TileMapLayer. Añadiremos un elemento por cada capa física recién creada y nos aseguraremos de que su Collision Layer apunte a los números asignados a las nuevas capas. Los Collision Mask de los nuevos elementos pueden dejarse en blanco.
Con las capas físicas añadidas al TileMapLayer, ya podremos verlas en la pestaña de TileSet para crear polígonos que definan los collider asociados al tile en esa capa física. Por ejemplo, teniendo en cuenta que la capa NavWater ha quedado en el elemento de índice 6 de los Physics Layers asociados al TileMapLayer, la configuración del collider de la baldosa de agua tal y como puede verse en la captura.
| Configuración del collider de la baldosa de agua |
Tendremos que repetir la misma operación en el resto de baldosas, asegurándonos de definir los collider en sus respectivas capas físicas (en mi caso NavGround, NavGrass, NavSand y NavWater).
Ahora que podemos diferenciar cada tipo de baldosa, en función de la capa física de sus respectivos collider, podemos definir una NavigationRegion2D por cada tipo de baldosa transitable.
| Una región por cada baldosa transitable |
En cada una de esas regiones, configuraremos su respectivo NavigationPolygon para que en su Parsed Collision Mask tenga en cuenta todas las capas físicas de los obstáculos y resto de baldosas, salvo la de la baldosa que da nombre a la región. Sólo entonces podremos regenerar el NavigationPolygon. Por ejemplo, la configuración para la región de la hierba (GrassNavigationRegión2D) sería la de la captura.
| Configuración para generar el NavigationPolygon de la región de la hierba. |
Como los collider de las capas físicas que marquemos en Parsed Collision Mask recortarán el NavigationPolygon resultante, la única capa que no hemos marcado es precisamente la de NavGrass.
Acuérdate también de fijar el radio de tu agente, para evitar que se roce con las paredes al desplazarse. En el caso de este ejemplo, lo he fijado en 50 píxeles, tal y como se puede ver en la captura anterior.
En mi caso, dado que sólo tengo un tipo de agente, todas las NavigationRegion2D tienen el campo de Navigation Layers fijado a su valor por defecto (1). Como ya he comentado, eso hará que todas las regiones se sumen para conformar un único mapa.
Una vez generado el nuevo NavigationPolygon de cada región, será un buen momento de fijar el Travel Cost de cada una al coste que queramos que tenga transitar por ella. Dado que en mi ejemplo mido las distancias en píxeles, las baldosas normales de suelo tienen un coste de 100 (su anchura en píxeles), mientras que a las de agua le he fijado un coste de 8.000.
Llegados a este punto, la suma de todas las regiones transitables como en la siguiente captura.
| Suma de las diferentes regiones transitables |
Sin duda te darás cuenta de un problema evidente. Las regiones "lentas" no se unen con la región transitable con el coste por defecto. Se debe a que en el proceso de formación del NavigationPolygon, no sólo se recortan las formas de los collider de las capas físicas, sino que también se recorta una distancia equivalente al radio del agente. Se hace para evitar que el agente se acerque tanto a una pared que roce con ella. Podríamos reducir el radio del agente a cero, y con eso las regiones transitables se tocarían, pero entonces los agentes rozarían las paredes de una manera muy poco convincente.
Afortunadamente, hay una manera de resolverlo. Vía código, podemos decirle al sistema de navegación que suelde los laterales de regiones de navegación contiguas que se encuentren a una distancia inferior a un determinado umbral. He situado dicho código en el script que hay en el nodo del que cuelgan las diferentes NavigationRegion2D (puedes ver cual es en la captura de la jerarquía que puse hace unos párrafos).
| Código para fusionar los laterales de regiones transitables cercanas |
Si nos fijamos en cómo han quedado las regiones, veremos que la distancia máxima entre dos contiguas es del doble del radio del agente. Dado que en la configuración de los NavigationPolygon metí un radio de 50 pixeles, en este script he fijado 100 como umbral (línea 13).
En la línea 26, saco el identificador del mapa de navegación activo. Es el que contiene las regiones que hemos generado.
Y ahora viene la magia: gracias al método estático MapSetEdgeConnectionMargin() de la línea 26, se podrá pasar entre las regiones transitables cuyas aristas contiguas estén a menos de 100 píxeles de distancia.
Como el script es además un [Tool], el editor de Godot refleja el cambio inmediatamente.
| Regiones soldadas gracias a nuestro script |
Fíjate en las marcas rosas con las que el editor ha marcado los lados de las regiones en las que ha aplicado soldadoras con otra contigua lo suficientemente cercana.
Al probar un ejemplo similar a este, verás que funciona si tu agente se desplaza evitando las zonas de mayor coste de tránsito, y entrando en ellas sólo si no tiene más remedio o si pones el objetivo explícitamente en el interior de una de ellas.
Otra cosa que observarás es que el agente no aminorará la velocidad cuando atraviese zonas de mayor coste. Eso es lógico porque debes ser tú quien lo implemente en función de cómo quieres que le afecte a tu agente el coste de una una zona. Lo habitual es que el mayor coste de una zona se refleje en una reducción de la velocidad, pero también podría ser que no quisieras que afectase a la velocidad sino al consumo de combustible o a la energía de tu agente. En todo caso, para averiguar el coste del NavigationRegion2D que esté travesando el agente no tienes más que llamar a NavigationServer2D.MapGetClosestPointOwner() para obtener el identificador de la region sobre la que se encuentre el agente en ese momento. Acto seguido, no tendrías más que usar ese identificador para que NavigationServer2D.RegionGetTravelCost() te diese el coste de tránsito de la región. Con ese dato ya es cosa tuya reflejar el mayor o manor impacto de viajar por una zona determinada.
Conclusión
Entre el anterior artículo y este hemos revisado las diferentes opciones que hay para navegar por un escenarios construido con un tilemap 2D:
- Usar datos de coste asociados a los tiles para hacer una búsqueda de caminos clásica con nuestra implementación de Dijkstra o con la de A* que integra Godot.
- Usar la navegación integrada con los tilemaps.
- Usar mesh navigation con zonas lentas.
Si tienes claro que vas a necesitar zonas de tránsito lentas, ya puede tachar la opción 2. En ese caso. la navegación integrada con los tilemaps no te servirá. A partir de ahí, si tus escenarios tienen un tamaño moderado, empezaría intentando la aproximación de la opción 2 y sólo pasaría a la 3 si el rendimiento de la otra fuese insuficiente. Como has podido ver, la opción 3 es la más potente, pero también más engorrosa de configurar y mantener.
