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 |
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 |
Una vez creado el recurso, al pinchar sobre él, veremos que tiene bastantes propiedades a configurar.
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.
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 |
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 |
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:
- En aquellos TileMapLayer que contengan obstáculos tendremos que marcar el parámetro Physics -> Collision Enabled.
- 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.
- También debes añadir una capa de navegación y fijarla al mismo valor que la capa de navegación del nodo NavigationRegion2D.
Uso del mapa
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. |
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.
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 |
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.