30 agosto 2024

Cómo implementar una niebla de guerra de dos niveles (FOW de dos niveles) en Godot

Mapa con FOW de dos niveles
En mi anterior artículo expliqué cómo se puede implementar un mapa de nivel en Godot con niebla de guerra. Aquel mapa aparecía al principio en negro, pero se iba destapando conforme el personaje se iba paseando por él. Se trataba de un mapa de un único nivel, porque una vez que el personaje transitaba por una zona, esta quedaba completamente destapada y eran visibles todos los enemigos que pasasen por ella, aunque el personaje principal estuviese lejos.

Otros juegos implementan FOW de dos niveles, en los que el área inmediatamente circundante al personaje, dentro de su rango de visión, queda completamente destapada, y los enemigos que pasan por ella plenamente visibles, mientras que las zonas que para las zonas que se hayan quedado atrás se ve sólo la parte estática del mapa, pero no los enemigos que pueda haber por allí. Generalmente, los mapas con FOW de dos niveles muestran con un tono más apagado las zonas alejadas, donde sólo se ve la parte estática, para distinguirla de la parte donde sí puede aparecer enemigos. 

Resulta que, cuando se tiene un mapa con un nivel de FOW, implementar un segundo nivel es tremendamente fácil. Así que voy a partir del punto en el que lo dejamos al final del artículo anterior. Si no lo has leído aún, es imprescindible que lo hagas, utilizando si quieres el enlace al comienzo de este. Para no repetirme, aquí voy a dar por entendido todo lo tratado en aquel artículo.

Tienes todo el código de este artículo en su repositorio de GitHub.

Partiendo de aquel mapa, debemos plantearnos que el shader que utilizábamos para el mapa (Assets/Shaders/Map.gdshader) lo que hacía era comprobar para cada pixel de la imagen del mapa si ese mismo pixel estaba coloreado en el mapa de la máscara renderizado por SubViewportMask. Si no estaba coloreado en el mapa de la máscara, asumía que el pixel no era visible y lo pintaba de negro, en vez de pintarlo con el color que le llegaba desde el SubViewport del mapa (recuerda que el TextureRect del mapa estaba conectado a la imagen renderizada por SubviewportMap). Conceptualmente, el mapa de la máscara se corresponde con las zonas ya recorridas.

En este nuevo mapa queremos darle una vuelta más de tuerca al tema distinguiendo las zonas ya recorridas (el mapa de la máscara) de las zonas directamente visibles. A las primeras, las dejaremos en un tono más apagado y sólo mostrando la parte estática, y a las segundas las seguiremos renderizando como antes.

¿Cuáles son las zonas directamente visibles? las del mapa de shapes. Las que se renderizan en SubviewportShapes con una cámara que sólo capta el círculo blanco colocado sobre el personaje. Hasta ahora habíamos usado el mapa de shapes para el shader de la máscara (Assets/Shaders/MapMask.gdshader), pero ahora lo usaremos también en el del mapa para saber qué píxeles del mapa se encuentran en la zona visible del jugador.

Una vez que ya sabemos distinguir, dentro de las zonas ya descubiertas, cuáles están en las zonas visibles y cuales no, tenemos que renderizar una imagen que sólo muestre la parte visible del mapa. Al igual que en el resto de los casos, esto se puede lograr con un SubViewport. En mi caso, me ha bastado con copiar SubviewportMap y a su cámara hija. A la copia la he llamado SubViewportStatic.

Jerarquía del mapa

Para que ese SubViewport sólo muestre la parte estática, hay que configurar la Cull Mask de su cámara para que sólo capte la capa 1, en la que he situado todos los elementos estáticos del escenario.

Captura del inspector de la cámara

Fíjate que la misma cámara, en el SubViewportMap, está configurada para captar las capas 1 y 4 (la 1 para los objetos estáticos del escenario y la 4 para los iconos identificativos de los personajes, colocados sobre ellos).

Para que el tono de la imagen captada por la cámara sea más apagado, tiene que dotarla de un recurso Environment (en el campo del mismo nombre). Cuando hayas creado dicho recurso, puedes pinchar en él para configurarlo. En el apartado Adjustments, he pinchado en Enabled y he bajado el brillo que venía por defecto a la mitad.

Configuración del Environment de la cámara

 Fíjate que el recurso Environment tiene muchísimos más apartados, así que imagina la cantidad de cosas que podrías hacerle a la imagen captada por la cámara.

Con eso ya tenemos una imagen más apagada del escenario, pero sin personajes. Justo la parte estática que necesitábamos. El shader del mapa recibirá esa imagen a través de un Uniform (Static Map Texture), que configuraremos a través del inspector para que apunte a SubViewportStatic.

Inspector del shader del mapa

Por debajo, el código del shader es muy parecido al del artículo anterior.

Nuevo código del shader

La principal novedad es el nuevo uniform que mencionábamos antes (línea 12) para recibir la imagen del mapa de elementos estáticos, y las líneas 16 a 18. Si el código llega hasta ese punto es porque en la línea 14 se ha llegado a la conclusión de que, al estar marcado con color en el mapa de la máscara, ese pixel se corresponde con una zona ya explorada. En la línea 16 se comprueba si ese pixel, además de ser de una zona explorada, se corresponde con un pixel coloreado en el mapa de shapes (es decir de las zonas directamente visibles). De no ser así, el pixel recibe el color del pixel equivalente en la imagen del mapa estático (línea 17). En caso de que el pixel sí estuviese coloreado en el mapa de shapes (y su canal rojo fuera distinto de 0), ese pixel recibiría el color por defecto que le llega desde SubViewportMap (el que muestra los iconos de los enemigos).

El resultado es el de la imagen que abre este artículo, con el área circundante al personaje mostrando a los enemigos más cercanos, las zonas exploradas más lejanas mostrando sólo los elementos estáticos del escenario, y las no exploradas coloreadas en negro.

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.

06 agosto 2024

Cómo probar juegos multijugador en Unity

Estoy haciendo el curso de GameDevTV sobre desarrollo de juegos multijugador con Unity. Cuando lo acabe, ya os contaré mi opinión, como ya he hecho con otros cursos. Hasta entonces, quería contaros un truco nuevo que he descubierto en Unity: cómo crear varias instancias de un juego multijugador para probarlo en un PC.

Tener esta capacidad resulta indispensable durante la fase de desarrollo para asegurar que sincronizamos bien todos los elementos del juego entre los diferentes participantes. Godot incluye de manera nativa la capacidad de arrancar hasta 4 instancias independientes del juego para probarlo. Sin embargo, en Unity no ha sido así, al menos hasta ahora.

El curso que estoy haciendo, por ejemplo, se limita a probar las cosas compilando y ejecutando una instancia del juego, externa al editor (con la opción "Build and Run"), y ejecutando una segunda instancia dentro del editor de Unity. No se explica por ningún lado cómo probar con mas de dos jugadores y es que la cosa no resultaba sencilla. Buscando por internet he llegado a la conclusión de que lo más parecido que tenía Unity a la funcionalidad de Godot era una extensión de terceros llamada ParrelSync.

La novedad es que en Unity 6 van a incluir por fin la posibilidad de la que ya disfrutan los usuarios de Godot. Aunque aún no hay versión estable de Unity 6, ya se puede probar todo lo que vamos a ver en las versiones Preview que ya se pueden instalar desde el Unity Hub.

La funcionalidad se denomina Multiplayer Play Mode y te permite simular hasta 4 jugadores. Uno desde el editor y otras 3 instancias virtuales del juego.

Para instalarlo, tenemos que ir al Package Manager el editor de Unity 6 e instalar el paquete Multiplayer Play Mode del Unity Registry.

Para activar una instancia virtual del juego tienes que ir a Window > Multiplayer Play Mode y marcar cuántas instancias vas a querer arrancar. 



Aquellas instancias que marques iniciarán un proceso de arranque y, cuando pasen a estar activas, aparecerán sus respectivas ventanas. No te preocupes por el largo tiempo que emplean para arrancar. Sólo tardan tanto la primera vez. A partir de ahí, la información queda cacheada y los siguientes arranques son mucho más rápidos.


A partir de ese momento, cada vez que arranquemos el juego desde el editor, este se reproducirá tanto en el editor como en las instancias virtuales de manera, con lo que podremos simular el comportamiento de jugadores independientes.

Para operar como uno de los jugadores, bastará con seleccionar la ventana de su instancia e interactuar con el juego como haría el jugador.

Para parar el juego lo haremos desde el editor, como haríamos con un juego monojugador. Eso parará el juego en todas las instancias virtuales.

Para hacer que las ventanas desaparezcan sólo tendremos que desmarcarlas de la ventana del Multiplayer Play Mode.

Con esto ya tendríamos todo lo que necesitamos para probar cualquier juego multijugador. 

Espero que te haya resultado interesante.