Patrones procedimentales para usar con mapas de mosaicos, parte 2

En la parte 1, analizamos algunas de las formas en que podemos crear capas superiores de manera procedimental utilizando varios métodos, como Perlin Noise y Random Walk. En esta publicación, veremos algunas de las formas de crear cuevas con generación procedimental, lo que debería brindarle una idea de las posibles variaciones disponibles.
Todo lo que vamos a hablar en esta entrada del blog está disponible dentro de este proyecto. Siéntase libre de descargar los activos y probar los algoritmos de procedimiento.

Esta entrada del blog se rige por las mismas reglas que la Parte I. Para recordarle, estas reglas son:
- La forma en que distinguimos entre ser una ficha o no es mediante el uso del binario. 1 está encendido y 0 está apagado.
- Almacenaremos todos nuestros mapas en una matriz de enteros 2D, que se devuelve al usuario al final de cada función (excepto cuando renderizamos).
- Utilizaré la función de matriz GetUpperBound() para obtener la altura y el ancho del mapa. Esto significa que tenemos menos variables en cada función, lo que permite un código más limpio.
- A menudo uso Mathf.FloorToInt(), esto se debe a que el sistema de coordenadas del mapa de mosaicos comienza en la parte inferior izquierda y el uso de Mathf.FloorToInt() nos permite redondear los números a un entero.
- Todo el código proporcionado en esta publicación de blog está en C#.
En la publicación del blog anterior, analizamos algunas formas de utilizar el ruido Perlin para crear capas superiores. Por suerte, también podemos utilizar Perlin Noise para crear una cueva. Hacemos esto obteniendo un nuevo valor de ruido de Perlin, que toma los parámetros de nuestra posición actual multiplicados por un modificador. El modificador es un valor entre 0 y 1. Cuanto mayor sea el valor del modificador, más desordenada será la generación de Perlin. Luego procedemos a redondear este valor a un número entero de 0 o 1, que almacenamos en la matriz de mapas. Eche un vistazo a la implementación:
Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`
La razón por la que utilizamos un modificador en lugar de una semilla es porque los resultados de la generación de Perlin se ven mejor cuando multiplicamos los valores por un número entre 0 y 0,5. Cuanto menor sea el valor, más cuadriculado será el resultado. Eche un vistazo a algunos de los resultados. Este gif comienza con un valor modificador de 0,01 y va aumentando hasta 0,25 en incrementos.

Desde este gif, puedes ver que la generación Perlin en realidad solo agranda el patrón con cada tic.
En la publicación del blog anterior, vimos que podemos usar el lanzamiento de una moneda para determinar si la plataforma subirá o bajará. En esta publicación, vamos a utilizar la misma idea, pero con dos opciones adicionales para la izquierda y la derecha. Esta variación del algoritmo Random Walk nos permite crear cuevas. Para ello, obtenemos una dirección aleatoria, luego movemos nuestra posición y retiramos la ficha. Continuamos este proceso hasta que alcanzamos la cantidad de piso que necesitamos destruir. Por el momento solo utilizamos 4 direcciones: arriba, abajo, izquierda, derecha.
Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`
Comenzamos la función así:
Encontrar nuestra posición inicial
Calcular el número de baldosas del suelo que debemos retirar
Quitar la ficha en la posición inicial
Añadiendo uno más a nuestro recuento de pisos
A continuación, pasamos al bucle while. Esto creará la cueva para nosotros:
Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`
Bueno, en primer lugar, decidimos en qué dirección debemos movernos utilizando un número aleatorio. A continuación, comprobamos la nueva dirección con una declaración de caso switch. Dentro de esta afirmación, verificamos si la posición es un muro. Si no es así, eliminamos la pieza de mosaico de la matriz. Continuamos haciendo esto hasta que alcancemos la cantidad de piso requerida. El resultado final se muestra a continuación:

También he creado una versión personalizada de esta función, que también incluye direcciones diagonales. El código para esta función es un poco largo, por lo que si desea verlo, consulte el enlace al proyecto al comienzo de esta publicación del blog.
Un túnel direccional comienza en un extremo del mapa y luego se extiende hasta el extremo opuesto. Podemos controlar la curva y la rugosidad del túnel introduciéndolas en la función. También podemos determinar la longitud mínima y máxima de las partes del túnel. Echemos un vistazo a la implementación a continuación:
público estático int[,] DirectionalTunnel(int[,] mapa, int minPathWidth, int maxPathWidth, int maxPathChange, int rugosidad, int curvatura)
{
//Este valor va de su contraparte negativa a su valor positivo, en este caso con un valor de ancho de 1, el ancho del túnel es 3
int anchoTúnel = 1;
//Establezca la posición inicial X en el centro del túnel
int x = map.GetUpperBound(0) / 2;
//Configura nuestro random con la semilla
System.Random rand = new System.Random(Time.time.GetHashCode());
//Crea la primera parte del túnel
para (int i = -anchotúnel; i <= anchotúnel; i++)
{
mapa[x + i, 0] = 0;
}
Primero configuramos un valor de ancho. Este valor de ancho irá desde su contraparte negativa a su valor positivo. Esto terminará dándonos el tamaño real que queremos. En este caso, utilizamos un valor de 1. Esto, a su vez, nos dará un ancho total de 3, porque utilizaremos los valores -1, 0, 1.
Lo siguiente que hacemos es establecer la posición x inicial, esto se hace obteniendo la mitad del ancho del mapa. Ahora que tenemos esos primeros valores configurados, podemos tunelizar la primera parte del mapa.

Ahora pasemos a crear el resto del mapa.
Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`
Genere un número aleatorio para comparar con nuestro valor de rugosidad, si es superior al valor, podemos cambiar el ancho de la ruta. También verificamos si estamos haciendo el ancho demasiado pequeño. Con este siguiente fragmento de código, avanzamos a través del mapa, haciendo túneles a medida que avanzamos. Con cada paso hacemos lo siguiente:
Genere un nuevo número aleatorio para compararlo con nuestro valor de curva. Al igual que en la comprobación anterior, si está por encima del valor, podemos cambiar el punto central de la ruta. También verificamos que no nos salgamos de los bordes del mapa.
Finalmente, excavamos la nueva sección que hemos creado.
Los resultados finales de esta implementación se ven así:

Los autómatas celulares utilizan un vecindario de celdas para determinar si la celda actual está encendida (1) o apagada (0). La base de estos vecindarios utiliza una cuadrícula de celdas generada aleatoriamente. En nuestro caso, vamos a generar esta cuadrícula inicial utilizando la función Random.Next en C#.
Debido a que tenemos un par de implementaciones diferentes de Autómatas Celulares, he creado una función separada para generar esta cuadrícula base. La función se ve así:
Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`
En esta función también podemos determinar si queremos muros en nuestra cuadrícula. Aparte de eso, es relativamente simple. Comprobamos un número aleatorio con nuestro porcentaje de llenado para determinar si la celda actual está activada o desactivada. Echa un vistazo al resultado:

El vecindario de Moore se utiliza para ayudar a suavizar la generación inicial de autómatas celulares. El barrio de Moore luce así:

Las reglas para el vecindario son las siguientes:
- Revisa cada dirección en busca de un vecino.
- Si un vecino es un mosaico activo, agrega uno a los mosaicos circundantes.
- Si un vecino no es un mosaico activo, no haga nada.
- Si la celda tiene más de 4 fichas a su alrededor, conviértala en una ficha activa.
- Si la celda tiene exactamente 4 fichas circundantes, deja la ficha en paz.
- Repetir hasta que hayamos probado todas las fichas del mapa.
La función para verificar el Barrio Moore es la siguiente:
int estático GetMooreSurroundingTiles(int[,] mapa, int x, int y, bool los bordes son paredes)
{
/* El barrio de Moore se ve así ('T' es nuestro mosaico, 'N' son nuestros vecinos)
*
* NNN
* NTN
* NNN
*
*/
int recuentoDeMosaicos = 0;
para(int vecinoX = x - 1; vecinoX <= x + 1; vecinoX++)
{
para(int vecinoY = y - 1; vecinoY <= y + 1; vecinoY++)
{
si (vecinoX >= 0 && vecinoX < mapa.GetUpperBound(0) && vecinoY >= 0 && vecinoY < mapa.GetUpperBound(1))
{
//No queremos contar las fichas de las que estamos comprobando los alrededores.
si(vecinoX != x || vecinoY != y)
{
tileCount += mapa[vecinoX, vecinoY];
}
}
}
}
devuelve tileCount;
}
Después de verificar nuestro mosaico, procedemos a utilizar la información en nuestra función de suavizado. Nuevamente, al igual que en la generación inicial de Autómatas Celulares, podemos establecer si los bordes del mapa son paredes.
público estático int[,] SmoothMooreCellularAutomata(int[,] mapa, bool edgesAreWalls, int smoothCount)
{
para (int i = 0; i < smoothCount; i++)
{
for (int x = 0; x < map.GetUpperBound(0); x++)
{
for (int y = 0; y < map.GetUpperBound(1); y++)
{
int surroundingTiles = GetMooreSurroundingTiles(map, x, y, edgesAreWalls);
si (los bordes son muros && (x == 0 || x == (mapa.GetUpperBound(0) - 1) || y == 0 || y == (mapa.GetUpperBound(1) - 1)))
{
//Establezca el borde como una pared si tenemos edgeAreWalls como verdadero
mapa[x, y] = 1;
}
//La regla de Moore predeterminada requiere más de 4 vecinos
De lo contrario, si (azulejos circundantes > 4)
{
mapa[x, y] = 1;
}
De lo contrario, si (azulejos circundantes < 4)
{
mapa[x, y] = 0;
}
}
}
}
//Devuelve el mapa modificado
mapa de retorno;
}
Una cosa clave a tener en cuenta en esta función es el hecho de que tenemos un bucle for para suavizar el mapa una cierta cantidad de veces. Esto termina dándonos como resultado un mapa más bonito.

Siempre podríamos modificar este algoritmo conectando habitaciones entre sí si, por ejemplo, solo hay 2 bloques entre ellas.
El vecindario de von Neumann es otro método de implementación popular para autómatas celulares. Para esta generación, utilizamos un vecindario más simple que el que usamos en la Generación Moore. El barrio se ve así:

Las reglas para este vecindario son las siguientes:
- Verifique alrededor de la baldosa hasta los vecinos directos, sin incluir las diagonales.
- Si la celda está activa, agregue uno a nuestro conteo.
- Si la celda está inactiva, no haga nada.
- Si tenemos más de 2 vecinos, hacemos que la celda actual esté activa.
- Si tenemos menos de 2 vecinos, hacemos que la celda actual esté inactiva.
- Si tenemos exactamente 2 vecinos, no modifique la celda actual.
El segundo resultado retoma los mismos principios que el primero pero amplía el área del barrio.
Comprobamos los vecinos utilizando la siguiente función:
int estático GetVNSurroundingTiles(int[,] mapa, int x, int y, bool los bordes son paredes)
{
/* El vecindario de von Neumann se ve así ('T' es nuestro mosaico, 'N' es nuestro vecino)
*
* N
* NTN
* N
*
*/
int recuentoDeMosaicos = 0;
//Mantenga los bordes como paredes
si(los bordes son muros && (x - 1 == 0 || x + 1 == mapa.GetUpperBound(0) || y - 1 == 0 || y + 1 == mapa.GetUpperBound(1)))
{
tileCount++;
}
//Asegúrate de no tocar el lado izquierdo del mapa.
if(x - 1 > 0)
{
tileCount += mapa[x - 1, y];
}
//Asegúrate de no tocar la parte inferior del mapa.
si(y - 1 > 0)
{
tileCount += mapa[x, y - 1];
}
//Asegúrate de no tocar el lado derecho del mapa.
if(x + 1 < map.GetUpperBound(0))
{
tileCount += mapa[x + 1, y];
}
//Asegúrate de no tocar la parte superior del mapa.
if(y + 1 < map.GetUpperBound(1))
{
tileCount += mapa[x, y + 1];
}
devuelve tileCount;
}
Una vez que tenemos el resultado de cuántos vecinos tenemos, podemos pasar a suavizar la matriz. Como antes, tenemos un bucle for para iterar a través del suavizado para la cantidad requerida ingresada.
público estático int[,] SmoothVNCellularAutomata(int[,] mapa, bool edgesAreWalls, int smoothCount)
{
para (int i = 0; i < smoothCount; i++)
{
for (int x = 0; x < map.GetUpperBound(0); x++)
{
for (int y = 0; y < map.GetUpperBound(1); y++)
{
//Obtener los mosaicos circundantes
int surroundingTiles = GetVNSurroundingTiles(map, x, y, edgesAreWalls);
si (los bordes son muros && (x == 0 || x == mapa.GetUpperBound(0) - 1 || y == 0 || y == mapa.GetUpperBound(1)))
{
//Mantengamos nuestros bordes como muros
mapa[x, y] = 1;
}
//El barrio de von Neuemann solo requiere que 3 o más casillas circundantes se conviertan en una casilla.
De lo contrario, si (surroundingTiles > 2)
{
mapa[x, y] = 1;
}
De lo contrario, si (azulejos circundantes < 2)
{
mapa[x, y] = 0;
}
}
}
}
//Devuelve el mapa modificado
mapa de retorno;
}
El resultado final parece mucho más cuadrado que el barrio de Moore, como se puede ver a continuación:

Nuevamente, al igual que con el vecindario de Moore, podríamos proceder a ejecutar un script adicional además de la generación para proporcionar mejores conexiones entre áreas del mapa.
Espero haberte inspirado para comenzar a utilizar algún tipo de generación procedimental en tus proyectos. Si aún no has descargado el proyecto, puedes obtenerlo desde aquí. Si quieres aprender más sobre la generación de mapas mediante procedimientos, consulta la Wiki de generación mediante procedimientos o Roguebasin.com, ya que ambos son excelentes recursos.
Si creas algo interesante usando generación procedimental, ¡no dudes en dejarme un mensaje en Twitter o dejar un comentario a continuación!
¿Quieres saber más y obtener una demostración en vivo? También hablaré sobre patrones procedimentales para usar con mapas de mosaicos en Unite Berlin, en el mini-teatro de la sala de exposiciones el 20 de junio. ¡Estaré por aquí después de la charla si quieres charlar un rato en persona!
