Hace poco vimos cómo se podía hacer en Godot para asignarle costes a los tiles de un tilemap, de manera que se pudiera usar esos costes en los algoritmos de navegación. Unity tiene su manera particular de hacerlo, pero, como en el caso de Godot, dependerá de si queremos utilizar esos costes con un algoritmo personalizado de navegación o con la navegación con navmesh del engine. Vamos a ver cómo se hace en ambos casos.
En este artículo asumiré que has leído mi artículo anterior sobre cómo asignar datos a los tiles en Unity. En este aprovecharemos algunas cosas lo que conté allí.
Costes para algoritmos personalizados de navegación
Cuando usemos algoritmos personalizados, como nuestras propias implementaciones de Dijkstra o A*, querremos acceder al coste de tránsito de una celda determinada. Podríamos optar por asignarle un game object al tile, tal y como vimos en el artículo mencionado antes, y que ese game object tuviese un script con el dato del coste. Pero eso sería excesivamente complejo, ya que tendríamos que asignarle un collider a ese game object para poder detectarlo con un sensor de volumen, al aplicar el sensor a la posición de la celda. No, definitivamente es mucho mejor en este caso la otra aproximación que veíamos en ese artículo: la de crear una clase que herede de Tile, hacer que incluya un campo de coste y utilizarla para dibujar las celdas que tengan un coste de tránsito más elevado. Sería una clase como la siguiente:
| Implementación de nuestro tile personalizado |
La clase se limita a tener un campo para poder meter el coste del tile a través del inspector (línea 13).
Para poder consultar desde nuestro código el coste del tile, en la línea 18 hay una propiedad de sólo lectura.
En el fondo, un Tile es una especie de Scriptable Object, por lo que se extiende de una manera muy parecida a como se hacía con estos. Como con los Scriptable Object, podemos decorarlos con el atributo [CreateAssetMenu] (línea 6) para que el editor muestre una entrada de menú desde las que crear instancias de este tile. En este caso lo he creado para que me lo muestre en la ruta "Scriptable Objects > CoutyardTile".
La creación de un tile de este tipo sería similar a la de un Scriptable Object. Pulsaríamos con el botón derecho en la carpeta donde quisiéramos guardar el tile y elegiríamos la opción configurada en el párrafo anterior.
| Creación de una instancia del tile personalizado |
Creada la instancia del tile, podremos darle la apariencia que queramos y asignarle un valor al campo del coste. Mi ejemplo es visualmente muy sencillo. A un tile de agua le he dado un coste de 80.
| Configuración de un tile de agua |
En mi ejemplo he creado cuatro tipos de tiles transitables:
- Ground: Gris. Es el que no tiene dificultades de tránsito, por lo que su coste es 1.
- Grass: Verde. Tiene un coste de 2.
- Sand: Naranja. Coste 4.
- Water: Azul. Coste 80.
Acuérdate también de asignarle un sprite. De otra manera, por mucho que le asignes un valor al color, el tile no se verá. Yo me he limitado a usar un sprite de color blanco, para que se aplique sobre él el color.
Creados los tiles personalizados, te bastará con poner en modo edición la pestaña del Tile Palette y arrastrar sobre ella los tiles, desde su carpeta.
| Mi tile palette |
Con eso, ya podemos dibujar las zonas "lentas" del escenario. El mío está bordeado de muros infranqueables y tiene muros internos de color negro.
| El escenario de mi ejemplo |
Una vez que los costes están asociados a sus tiles, ¿cómo los recuperaríamos? Nos bastaría con un método como el de la siguiente captura.
| Método para recuperar el coste asociado a cada tile |
El método supone que existe una variable global walkableTilemap con una referencia al tilemap donde se encuentren dibujados todos los tiles transitables del escenario. Con esa referencia, usamos su método WorldToCell() (línea 319) para convertir la posición global del escenario que queremos analizar a la coordinada del tilemap que se corresponde con dicha posición. Luego, usamos el método GetTile() (línea 320) para recuperar el tile asociado a esas coordenadas. Una vez que tengamos el tile, podemos acceder a su propiedad Cost para devolver su valor (línea 321).
Mesh navigation con zonas lentas
El método anterior es útil si queremos que nuestros agentes se muevan por el tilemap usando nuestro propio algoritmos de navegación. Sin embargo, puede ser que por rendimiento, o sencillez, prefiramos utilizar el sistema de mesh navigation que ya viene con el engine. El problema en Unity es que su sistema de mesh navigation está pensado para 3D y no sirve tal cual para escenarios 2D como los del ejemplo. Afortunadamente, hay un módulo libre llamado NavMeshPlus que solventa este problema para el 2D y se usa prácticamente igual que el mesh navigation nativo de Unity para 3D. Expliqué su instalación y uso en un artículo anterior en el que cubrí la navegación 2D en Unity. A partir de lo dicho en ese articulo, en este explicaremos cómo implementar zonas lentas que sean tenidas en cuenta por el algoritmo de búsqueda de caminos.
Por tanto, partiendo de que te has leído ese artículo, y que has seguido sus indicaciones para generar un un NavMesh, ahora hay que crear un tipo de área transitable por cada tipo de baldosa que tengamos. Para ello, ve a Window > AI > Navigation. La ventana que se abre tiene dos pestañas: Agents y Areas. Como te imaginarás, la que nos interesa es Areas. Allí ya están definidas las áreas por defecto del sistema de navegación de Unity: Walkable, Not Walkable y Jump. Fíjate que en la columna de la derecha se definen el coste de tránsito de cada una de las áreas. Es aquí donde definiremos los tipos de áreas que vimos en la sección anterior, con sus correspondientes costes.
Definidas las áreas, hay que asociar cada tile al tipo de área al que pertenecen. Si seguiste el artículo de navegación 2D en Unity, habrás incluido un componente NavigationModifier en el mismo GameObject del tilemap donde tuvieras los tiles transitables. Desmárcale el campo Override Area.
Acto seguido añade un componente NavigationModifierTilemap. En él hay una lista llamada Tile Modifiers. Añade a esa lista un elemento por cada tipo de tile transitable que queras definir. Luego arrastra a cada uno de los elementos el recurso de su respectivo tile, desde su carpeta. De esta manera podrás definir a qué area pertenece cada tile.
En mi caso, la configuración queda de la siguiente manera:
| Asignación de tiles a cada una de las áreas |
Ahora hay que regenerar el NavMesh para que incluya los costes de tránsito que acabamos de definir para cada una de las áreas.
Sin embargo, antes de pulsar Bake en el componente NavigationSurface tienes que asegurarte de cambiar el parámetro Render Meshes. Si lo tienes configurado tal y como lo dejamos en el artículo de navegación 2D en Unity, este parámetro tendrá el valor Physics Colliders, por lo que sólo tendrá en cuenta los objetos con colliders físicos asociados para "recortar" la zona transitable del NavMesh. Tenemos que cambiarlo a Render Meshes. De esta manera usará como referencia los tiles que hemos definido como transitables (que son componentes visuales, renderizables) para dar forma al NavMesh. Una vez cambiado podremos pulsar Bake.
| Configuración de mi NavMesh |
Si haces que el editor te muestre el NavMesh, verás que este tiene varios colores para señalar las distintas áreas que ha detectado.
| NavMesh generado con diferentes áreas |
Ya tienes tu NavMesh, pero si ejecutas el escenario en este punto es probable que tu agente se quede quieto. Para que se mueva tienes que asegurarte de que esté configurado para usar las áreas transitables. Eso se hace configurando el parámetro AreaMask del NavMeshAgent, de manera que contenga todas las zonas por las que se podrá andar.
| Configuración de NavMeshAgent |
En la captura anterior, puedes ver que he configurado el AreaMask para que incluya las áreas Ground, Grass, Water y Sand.
Ahora sí, si ejecutas el juego, tu agente debería ser capaz de moverse por las zonas transitables, eligiendo siempre el camino con menor coste.
Probablemente te des cuenta de que, aunque el agente elija la ruta de menor coste, no disminuirá la velocidad si se ve obligado a atravesar una zona lenta. Para que eso ocurra, tendrás que llamar al método estático en NavMesh.SamplePosition() desde el Update() del agente, para analizar en todo momento la posición donde se encuentre. Ese método devuelve como parámetro de salida un objeto de tipo AI.NavMeshHit que tiene una propiedad mask con el área a la que está asociada ese punto. Lo normal es que un punto sólo esté asociado a una área, pero a pesar de eso la propiedad mask es (como su nombre indica) una máscara de 32 bits. Un bit por cada una de las áreas de navegación que Unity permite que se definan. Te basta averiguar el índice del bit que está marcado a 1 para saber el área de navegación del punto analizado. Con es índice puedes llamar a otro método estático, NavMesh.GetAreaCost() para obtener el coste del área sobre la que estemos y actuar en consecuencia (habitualmente dividiendo la velocidad del agente entre el coste del área).
Conclusión
Con esto, ya hemos revisado las dos opciones que tenemos en Unity para navegar por un escenario construido con un tilemap.
- 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 mesh navigation con zonas lentas.
Si tus escenarios tienen un tamaño moderado, puedes permitirte la opción 2, si por la razón que fueses prefirieses modelar el escenario con un grafo.
Sin embargo, en cuanto tu escenario crezca de tamaño preferirás la opción 3. Es la que mejor rendimiento ofrece y la más sencilla de configurar.