26 agosto 2024

Cómo implementar un mapa de nivel con niebla de guerra (Fog Of War) en Godot

En los juegos que ofrecen un mapa del nivel es habitual tapar lar zonas que el jugador no ha explorado aún. Conforme el jugador avance por el terreno, esas secciones del mapa se irán descubriendo. 

Esto se conoce como niebla de guerra (Fog Of War, FOW) y, partiendo de ese concepto tan sencillo, se puede complicar bastante. Una sofisticación habitual es que el jugador tenga un rango de visión limitado, por lo que las zonas descubiertas del mapa sólo muestran a otros jugadores o NPC en el mapa si estos se encuentran dentro del rango de visión. Fuera de él el mapa sólo muestra los elementos estáticos del escenario: edificios, ríos, bosque, montañas, etc. Otra sofisticación es aplicar la niebla de guerra no sólo al mapa sino también al escenario 3D del nivel.

En este artículo explicaré el planteamiento más sencillo. Iremos destapando el mapa y las zonas desveladas conservarán plena visibilidad aunque el jugador se aleje de ellas. En cuanto entendamos la base veremos, que no es tan difícil aplicar las sofisticaciones.

Los elementos a los que me refiera en este artículo están en mi repositorio de DungeonRPG. Se trata del código que desarrollé mientras seguía el curso de GameDevTV "Godot 4 C# Action Adventure: Build your own 2.5D RPG", el cual recomiendo muchísimo. El curso no abarca mapas ni FOW, pero el minijuego que implementa es una base excelente para aprender a crearlos

Creación del mapa

Aquí no voy a pararme, porque ya lo expliqué, en realidad, en el artículo sobre cómo crear un minimap. Crear un mapa completo es similar. Sólo tienes que asegurarte de que la cámara ortográfica se sitúe en el centro del escenario (por encima) y tenga un tamaño de apertura (parámetro Camera3D > Size) lo suficientemente grande para que el enfoque abarque la totalidad del escenario. Por su parte, el SubviewPort al que proyecte la Camera3D tiene que cubrir la totalidad de la pantalla.

Te bastará con una escena que tenga la siguiente estructura:


A la hora de situar la escena anterior en el escenario, tendrás que ubicar la cámara en el punto adecuado del escenario. El problema es que no tendrás acceso a ella, por estar en su propia escena. Yo lo he resuelto, haciendo que el script del nodo raíz de la escena tenga el atributo [Tool], para la clase principal. Este atributo permite que el script puede ejecutar lógica mientras lo manipules dentro del editor.

El script que he puesto en la raíz de la escena está en la ruta Scripts/General/Map.cs. Su código fuente es Godot C# (como todo el que desarrollo), pero no creo que los desarrolladores que prefieran GDScript tengan problema en entenderlo. En concreto, al estar marcado con el atributo [Tool] su método _Process() se ejecuta constantemente en el editor, mientras haya un nodo con ese script en la jerarquía. El contenido de ese método es el siguiente:

 

Fragmento de Scripts/General/Map.cs

El método Engine.IsEditorHint() devuelve verdadero cuando el código llamante se esté llamando desde el editor. Es muy útil para definir código que queramos que se ejecute cuando estemos trabajando con el editor, pero no cuando ejecutemos el juego.

En este caso se hacen dos cosas: se busca un Marker3D para obtener su posición y dichas posición se emplea para ubicar el nodo Camera3D del mapa.

El Marker3D debe situarse como hijo de la escena, cuando la instanciemos en el escenario. Es similar a cuando instanciamos un CollisionShape como hijo de un CharacterBody3D.

Fragmento de Scripts/General/Map.cs

Lo que hace el método GetCameraPositionMarker() es mirar si la escena tiene algún Marker3D como hijo. En caso de que el usuario no haya configurado un Marker3D como hijo de la escena, lo normal en Godot es mostrar una advertencia con un icono amarillo.


La decisión de si mostrar o no esa advertencia la toma el método UpdateConfigurationWarnings(). al que llamamos en la línea 59 de la última captura de código. Ese método es propio de Godot y, para tomar su decisión, se basa en la información que le hayamos pasado en la implementación del método _GetConfigurationWarnings(), el cual es un método abstracto que pueden implementar las clases que hereden de nodos de Godot. En mi caso lo he implementado de la siguiente manera:

Fragmento de Scripts/General/Map.cs

Este método es muy sencillo. Devuelve un array de mensajes de alerta. Si el array está vacío, entonces UpdateConfigurationWarnings() interpreta que todo va bien y que no se requiere mostrar mensajes de alerta. Pero si, por el contrario, el array tiene alguna cadena, entonces muestra el icono de alerta con la cadena que hayamos incluido en el array como mensaje.

En mi caso, me he limitado a comprobar si _cameraPosition sigue siendo null (línea 87) tras la llamada a GetNodeOrNull() de la línea 58 de GetCameraPositionMarker(). Si resulta ser null (línea 87) es señal de que el usuario no ha puesto un Marker3D como hijo de la escena, por lo que se añade un mensaje de error al mensaje retornado.

Un Marker3D no es más que un Node3D con una apariencia llamativa. Es estupendo para marcar lugares dentro del escenario y que tus objetos puedan usarlos como referencias. La idea en este caso es situar el Marker3D en el punto del escenario donde queramos colocar la cámara del mapa (lo normal será el centro del nivel). 

Una vez que ya cuenta con el Marker3D, el método _Process() llama al método UpdateCameraConfiguration() (desde la línea 80), para configurar la posición de la cámara.

Fragmento de Scripts/General/Map.cs

Ese método actualiza la configuración de dos cámaras, la del mapa y la de las zonas de visibilidad (shapes), que veremos en un momento. Con la posición del Marker3D se configura la posición de la cámara del mapa, mientras que su tamaño y su relación de aspecto (líneas 66 y 67) se configuran a partir de los que hayamos configurado en el inspector, a través de campos exportados:

Fragmento de Scripts/General/Map.cs

En mi caso, he creado un InputAction para que, cuando se pulse la tecla M, la pantalla quede cubierta con el mapa, y que este desaparezca en cuanto dejemos de pulsar dicha tecla:

Fragmento de Scripts/General/Map.cs

El resto de elementos del GUI se suscriben a las señales MapShown (línea 106) y MapHidden (lçinea 110), para saber cuándo deben ocultarse o volverse a mostrar.

El mapa de zonas de visibilidad

El mapa anterior es el completo del nivel. El que ofreceríamos al jugador si este pudiera conocerlo desde el principio del juego. 

Pero hemos decidido que el jugador no tenga visibilidad infinita, sino que más bien pueda ver hasta determinada distancia. Esta distancia suele modelarse como un círculo, alrededor del jugador, cuyo radio es el rango de visión máximo del jugador.

Lo que vamos a hacer es esconder el mapa detrás de una capa negra, la niebla de guerra (FOW), y sólo abriremos agujeros en la niebla de guerra por donde pase el círculo de visión del jugador. Para hacerlo usaremos el concepto de máscara que ya empleamos en el artículo de los minimaps. En aquel caso, usamos la imagen de un círculo para definir que la parte visible del minimap fuera circular. En este caso he empleado una técnica similar ya que he creado una imagen dinámica con fondo negro en la que todo lo que aparezca de blanco definirá las zonas del mapa que se podrán ver.

Para hacer esa imagen dinámica he utilizado una aproximación similar a la de los iconos de los personajes en el minimap. He situado un sprite circular encima del personaje principal y he asignado dicho sprite a la capa 5. 


Esa capa se usa en exclusiva para el FOW. Ni la cámara del juego, ni la del minimapa incluyen la capa 5 en culling map, por lo que para ellas, ese círculo será invisible. La que sí incluye esa capa es la cámara que he añadido a su propio Subviewport en la escena del mapa con FOW.


Como se puede ver en la figura, esa cámara sólo ve los círculos blancos de la capa 5 y el resto lo coloreará de negro, que es el color que he configurado de background. El resultado será que el Subviewport renderizará una pantalla negra por la que se paseará un círculo blanco.

Aparte de lo mencionado de las capas y el color de background, es importante que el Subviewport del mapa (SubViewPortMap en la figura) y el de las zonas de visibilidad (SubViewPortShapes), así como sus respectivas cámaras, tengan la misma configuración para que tengan la misma escala y cubran la misma zona del escenerio, de manera que se solapen de manera perfecta.

Configuración de una máscara dinámica

Llegados a este punto tenemos un mapa, y una imagen dinámica con un círculo blanco que se mueve con el personaje. Si quisiéramos destapar única y exclusivamente el mapa en torno al jugador, ya tendríamos casi todos los elementos para dar el paso final. Pero queremos complicar un poco más la cosa, ya que queremos que el personaje vaya dejando un rastro por donde pase y que el mapa sea visible a largo de ese rastro. Resulta necesario por tanto que el círculo de visón vaya dejando un rastro.

Esa función la llevará un Subviewport más (SubViewportMask en la última figura) que albergará un ColorRect completamente en negro. La imagen que renderice este SubviewPort será la que usemos para tapar la del mapa, a modo de FOW. La peculiaridad es que ese ColorRect lleva un shader propio:




Ese shader se encuentra en Assets/Shaders/MapMask.gdshader y es muy sencillo:


El tema de los shaders da para libros enteros, pero voy a intentar hacer una introducción muy bestia. Utilizando la terminología de Godot, este shader "exporta" dos variables marcándolas con el término "uniform": shapesTexture y prev_frame_text. El valor de la primera se fija a través del inspector y la segunda va marcada con un atributo especial (hint_screen_texture) que marca esa variable para que Godot le de valor automáticamente con los datos de la última imagen renderizada por el Subviewport que sea padre del nodo del shader (en este caso SubViewPortMask). 

El método fragment() de un shader se ejecuta para cada uno de los pixels de la pantalla (sabes cual gracias a las variables UV y SCREEN_UV) y en función del pixel en el que te encuentres, puedes modificar el color que finalmente se renderizará en ese pixel, usando la variable COLOR. Por defecto, el método fragment() se ejecuta sobre una pantalla sin datos previos, por lo que si quieres tener en cuenta la imagen anterior, debes marcar una variable uniform con el atributo (hint_screen_texture) y asegurarte que el Subviewport del shader no se borre en cada fotograma, dejando su valor Clear Mode en Never:


He dejado el Update Mode en Always, para que los cálculos que vamos a ver ahora se realicen aunque el mapa no sea visible, de manera que, cuando el jugador decida, verlo se encuentre actualizado.

Si continuamos con el shader MapMask veremos que he cogido el valor que tiene el pixel en el mapa de zonas de visibilidad (shapesTexture, al que le asigné en el inspector una referencia a SubViewportShapes), así como el valor del pixel en el fotograma anterior (previousColor).

El mapa de zonas de visibilidad sólo tiene dos colores, el negro del fondo y el blanco de los círculos de visibilidad, por lo que, en el momento en que el pixel de la zona de visibilidad sea distinto de negro ya sabremos que es un pixel del mapa que debe hacerse visible por lo que lo marcamos de blanco fijando su COLOR a ese valor (línea 10 del shader). Para saber si el pixel es distinto de 0 me limito a mirar el canal rojo. Dado que los círculos que he usado son blancos, estoy seguro de que el canal rojo también se ve afectado a su paso, dado que ese color tiene componentes en los 3 canales. Si el pixel de la zona de visibilidad es negro significa que es una zona del mapa que no está dentro del rango de visibilidad en este momento, pero pudo estarlo en momentos previos, así que, como queremos dejar un rastro de zonas visibles, dejamos ese pixel con el valor que ya tenía en el fotograma previo (línea 12). 

El efecto de este shader será que el círculo de visibilidad del personaje se comportará como un pincel e irá dibujando de blanco, sobre un fondo negro, el rastro del recorrido del personaje.

La vista final

Nos queda mezclar la máscara dinámica y el mapa, para mostrar el resultado en algún lado. Ese será el papel del TextureRect Map, el cual he situado lo más abajo posible de la jerarquía de la escena, para asegurarme de que se dibuje por encima de todos los demás elementos, tapándolos.

Para que este TextureRect pueda leer la información del mapa, he hecho que su propiedad Texture referencie al SubViewportMap. Si no hiciéramos nada más, esto valdría para que este TextureRect reprodujese fielmente lo que renderizase SubViewportMap.

Pero nosotros queremos incorporar la información de la máscara dinámica, motivo por el cual este TextureRect tiene su propio shader, el cual puedes encontrar en Assets/Shaders/MapMask.gdshader.

Este shader consigue el efecto que queremos en unas pocas líneas:


En este caso se exportan dos variables, ambas configurables a través del inspector. En maskTexture he dejado una referencia a SubviewportMask (el de la máscara dinámica). Por su parte, en fogColor he dejado el color que queremos darle a las zonas cubiertas por el FOW.

El shader mira el valor del pixel de la textura dinámica, en la misma posición que el pixel que estamos renderizando. Si el pixel de la máscara dinámica (maskSample) es negro entonces renderizo en negro el pixel de la imagen final. Aquí vuelvo a comprobar solamente el canal rojo ya que como mis máscaras con blancas, sé que tienen presencia en el canal rojo.  En caso de que el pixel de la máscara no fuese negro significaría que el ese pixel debe ser visible, por lo que no hacemos nada y dejamos que renderice el color que le llega de SubViewportMap.

Conclusión

El resultado es el de la imagen que abre este artículo. Un mapa con FOW que va ampliando sus zonas visibles conforme el jugador se vaya paseando por el escenario.

Como comentaba al comienzo del artículo, este se trata del caso más básico de FOW. Quiero probar en un artículo posterior la opción de un mapa con FOW en dos niveles, la que tapa por completo la parte nunca explorada y la que limita la visión a los elementos estáticos en aquellas zonas exploradas, pero fuera del rango de visión del personaje. Se trata de una evolución que creo que es muy sencilla de conseguir, pero que no quiero incluir en este artículo para no alargarlo más.

En cuanto a aplicar FOW al escenario 3D y no sólo al mapa, se trata de algo que aún estoy intentando entender cómo podría hacerse. En cuanto dé con la tecla lo reflejaré en un artículo por aquí.

Espero que os haya resultado interesante.