0

Cómo generar mazmorras procedurales con Python – Parte III: Simplex Noise

[Volver a la Parte II]

¡Hola! En la última parte de esta serie de tutoriales sobre generación de mazmorras aleatorias estuvimos hablando de cómo crear un sistema de cuevas utilizando un autómata celular.

Antes de continuar viendo otros sistemas para generar mazmorras “tradicionales”, aprenderemos otro método muy efectivo para crear cuevas a partir de un mapa de Ruido Simplex. Obtendremos un resultado parecido al del último día pero el código será mucho más sencillo…

 

 


¿Ruido Simplex? ¿Qué es esto?

Una vez me dijeron que el Ruido Simplex es la sal y la pimienta de la generación procedural de contenido, pues es indispensable para crear mapas con apariencia natural.

El objetivo es llenar la matriz del mapa con valores aleatorios entre -1 y 1, que después se convertirán en paredes o espacios vacíos.

Si intentáis llenar la matriz con valores totalmente aleatorios obtendréis una especie de patchwork sin ningún tipo de sentido. El mapa debe ser al azar, pero hay que “domesticar” esta aleatoriedad. La solución está en utilizar funciones de ruido, como Perlin Noise o Simplex Noise que generan mapas aleatorios pero con una aspecto mucho más suave y natural.

Esta imagen muestra cómo se vería una matriz con ruido totalmente aleatorio (izquierda) vs. Simplex Noise (derecha). Los píxeles oscuros serían valores próximos a -1, y los blancos próximos a 1.

 


¿Cómo generaremos nuestra cueva?

Empezaremos por crear una matriz de dos dimensiones y la llenaremos con ruido simplex con valores entre -1 y 1. Python dispone de la librería OpenSimplex que nos permitirá crear mapas de Ruido Simplex sin ninguna dificultad.

Después, convertiremos todos los valores dentro de los intervalos [-1, -0.15] y [0.45, 1] en paredes, y el resto en casillas vacías. Esto creará un mapa binario con la forma del sistema de cuevas:

Por último, eliminaremos las regiones aisladas utilizando un algoritmo Flood Fill, siguiendo la misma estrategia que vimos el último día.

Nuestro algoritmo separa el mapa en distintas regiones y las elimina todas menos la de mayor área


Librerías de Python

Necesitamos estas cinco librerías:

  1. Numpy: para crear y manipular matrices.
  2. Pygame: para dibujar el mapa con sprites.
  3. OpenSimplex: para crear mapas de ruido simplex.
  4. System y random, que ya deberían venir con vuestra versión de Python

Nota: la librería OpenSimplex a veces no funciona bien con la versión 2 de Python. Os recomiendo actualizar a Python 3 antes de continuar.


Código

(Si no queréis seguir la explicación, podéis descargar el fichero .zip con el código acabado desde aquí)

¡Vamos a programar! Partiremos del código que escribimos el último día, pero sustituyendo las funciones del autómata celular por un nuevo método que creará el mapa de ruido simplex. Como veréis, conseguiremos resultados parecidos a los de la Parte II pero con un código algo más corto.

En primer lugar descargad el .zip con las imágenes desde [aquí] y descomprimidlo.

Ahora cread un script de python en el mismo directorio dónde habéis descomprimido el zip. Empezaremos por importar las librerías:

import pygame
from opensimplex import OpenSimplex
import numpy as np
import random
import sys

 

El punto importante de este tutorial es la función generar_mapa(). Recibirá las dimensiones del mapa como parámetro y devolverá una matriz con valores 0 y 1 formando las cavernas.

def generar_mapa(ancho, alto):
	#==Esta funcion genera el mapa de la cueva==

	#Generar una semilla para el mapa
	semilla = random.randint(1,1000)

	#Crear el objeto opensimplex
	gen_mazmorra = OpenSimplex(seed = semilla)

	# Valor de la frecuencia
	# Aumentar este valor hace el mismo efecto que un zoom hacia afuera
	freq = 0.15
	# Recomiendo valores entre 0.25 y 0.1

	#Array para guardar el mapa
	mapa_cueva = np.empty([alto, ancho])


	#Generar el mapa de terreno mezclando ruido a diferentes octavas
	for y in range(alto):
		for x in range(ancho):
	 
			#Generar el valor de la posicion actual
			valor = gen_mazmorra.noise2d(freq*x, freq*y) 

			#Llenar el mapa de la cueva - podeis probar a cambiar los valores -0.15 y 0.45
			if valor < -0.15 or valor >= 0.45:
				mapa_cueva[y][x] = 1 #Pared
			else:
				mapa_cueva[y][x] = 0 #Espacio vacio


	#Devolver la matriz del mapa de la cueva
	return mapa_cueva

Esta función empieza generando una semilla aleatoria y creando un objeto de tipo OpenSimplex al que llamamos gen_mazmorra.

Después definimos la variable freq, que indica la frecuencia que tendrá el ruido. Aumentar la frecuencia hace el mismo efecto que un zoom hacia afuera: cuándo más alto sea el valor, más convoluciones tendrá el ruido.

Después se recorre todo el array con un doble bucle y se genera un valor de ruido entre [-1,1] para la posición actual. Según en que intervalo se encuentre este valor, se colocará un 0 ó un 1 en la matriz del mapa. Por último se retorna esta matriz.

 

El segundo paso será hacer algunas modificaciones menores a la función main() para que llame a la función generar_mapa() en vez de las funciones del autómata celular:

def main():
 
 
    #Pediremos al usuario que entre a mano la dimension:
    print("\n--Introduce las dimensiones del mapa--\n")
 
    #Dimensiones del mapa (por lo menos tiene que ser 20)
    dimx = max(int(input("Ancho del mapa: ")), 20)
    dimy = max(int(input("Alto del mapa: ")), 20) 
 
    #Inicializar el mapa aleatoriamente
    mapa = generar_mapa(dimx, dimy)
 
    #Eliminar las zonas aisladas
    mapa = eliminarZonasAisladas(mapa)
 
    print("\n\n------------------------------------------\n\n")
 
    #-----PYGAME------
 
    #Dimensiones (en px) del dibujo de cada casilla
    casillax = 16
    casillay = 16
 
    #Dimensiones de la ventana de pygame donde se dibuja el mapa
    ancho_ventana = dimx*16
    alto_ventana = dimy*16
 
    #Crear la ventana y cambiar su nombre
    gameDisplay = pygame.display.set_mode((ancho_ventana, alto_ventana))
    pygame.display.set_caption('Cave Party')
 
     
    #Dibujar la ventana con el mapa
    gameDisplay.fill((0,0,0))
    dibujarMapa(mapa, gameDisplay, casillax, casillay)
 
 
    #Esperar a que el jugador se canse de mirar el mapa...
    print("\nCierra la ventana de Pygame para detener el programa")
    continuar = True
    while continuar:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                continuar = False

 

Y básicamente esto es todo. El resto del código con las funciones dibujarMapa(), eliminarZonasAisladas() y floodFill() son exactamente las mismas que vimos en la Parte II, por tanto no os volveré a aburrir comentándolas 😛

def dibujarMapa(mapa, gameDisplay, casillax, casillay):
    # Esta funcion dibuja el mapa con caracteres en la terminal y
    # con imagenes en la ventana gameDisplay
 
    #Guardar las dimensiones del mapa original
    dimy = len(mapa)
    dimx = len(mapa[0])
  
    #Borrar la ventana
    gameDisplay.fill((25,25,25))
 
    #Dibujar las casillas del mapa
    for y in range(dimy):
        for x in range(dimx):

            if mapa[y][x] == 0:
                sys.stdout.write("  ") #Espacio vacio
                tile = pygame.image.load('mazmorra_pygame_p3_tiles/floor.bmp').convert_alpha()
                gameDisplay.blit(tile, (x*casillax,y*casillay))
 
            elif mapa[y][x] == 1:
                sys.stdout.write("# ") #Muro
 
                #Si en la casilla de abajo hay una pared, habra que colocar un muro de tipo 2
                if (y+1) >= dimy or mapa[y+1][x] == 1:
                    tile = pygame.image.load('mazmorra_pygame_p3_tiles/wall2.bmp').convert_alpha()
 
                #De lo contrario, un muro de tipo 1
                else:
                    tile = pygame.image.load('mazmorra_pygame_p3_tiles/wall1.bmp').convert_alpha()
                    gameDisplay.blit(tile, (x*casillax,y*casillay))
              
                gameDisplay.blit(tile, (x*casillax,y*casillay))
  
                # Para dar el efecto de 2.5D, las paredes van a tapar una parte de la casilla que tienen
                # detras (siempre que no sea otro muro)
                if y-1 >= 0:
                    if mapa[y-1][x] != 1:
                        image = pygame.image.load('mazmorra_pygame_p3_tiles/wall_border_alpha.bmp')
                         
                        #Pintar objetos con alpha es mas complicado en pygame...
                        surface = pygame.Surface(image.get_size())
                        key = (255,0,255)
                        surface.fill(key, surface.get_rect())
                        surface.set_colorkey(key)
                        surface.blit(image, (0,0))
                        surface.set_alpha(255)
 
                        gameDisplay.blit(surface, (x*casillax,(y-1)*casillay))
     
        print("")
      
    print("\n\n")
 
    #Update!
    pygame.display.update()
 
def eliminarZonasAisladas(mapa):
    # Esta funcion separa cada region del mapa y las elimina todas excepto
    # la que tenga mayor area
 
    #Guardar las dimensiones del mapa original
    dimy = len(mapa)
    dimx = len(mapa[0])
 
    #La primera region tendra la etiqueta '2'
    region = 2
 
    #Vector para guardar el valor de las areas de cada region
    areas = []
    #NOTA: el elemento [0] se corresponde a la region '2', [1] es la region '3', etc.
 
    #Crear un mapa auxiliar que sea una copia del mapa original
    mapa_aux = np.copy(mapa)   
 
    #Recorremos cada casilla del mapa
    for x in range(dimx):
        for y in range(dimy):
 
            #Si la casilla actual es un espacio vacio, aplicar FloodFill para "pintar" toda la region
            if mapa_aux[y][x] == 0:
 
                #Aplicar Flood-Fill y guardar el area en el vector
                areas.append(floodFill(mapa_aux, y, x, region))
 
                #Sumar +1 al contador de regiones
                region = region + 1
 
    #Nos quedamos con la region que tenga area maxima
    region_maxima = areas.index(max(areas)) + 2
 
    #En el mapa original, eliminamos todas las casillas que no esten en region_maxima
    for x in range(dimx):
        for y in range(dimy):
            if mapa_aux[y][x] != 1 and mapa_aux[y][x] != region_maxima:
                mapa[y][x] = 1
 
    #Devolver el mapa actualizado
    return mapa
 
 
def floodFill(mapa, posy, posx, num_region):
    # Llena todas las casillas de una region con el mismo numero (num_region)
    # y devuelve el area de la region
 
    #Guardar las dimensiones del mapa original
    ysize = len(mapa)
    xsize = len(mapa[0])
 
    #Crear una estructura de pila ('stack'), donde guardaremos las casillas de la region
    pila = set(((posy, posx),))
 
    #Variable para guardar el area de la region
    area = 0
 
    #Repetir mientras haya elementos en la pila
    while len(pila) > 0:
 
        #Guardar las coordenadas (fila, columna) del objeto de la pila
        y, x = pila.pop()
 
        #Sumar +1 al area de la region
        area = area + 1
 
        #"Pintar" la casilla
        if mapa[y][x] == 0:
            mapa[y][x] = num_region
            if y > 0:
                pila.add((y - 1, x))
            if y < (ysize - 1):
                pila.add((y + 1, x))
            if x > 0:
                pila.add((y, x - 1))
            if x < (xsize - 1):
                pila.add((y, x + 1))
 
    #Devolver el area total de la region
    return area
 
 
#Llamar funcion main al iniciar el programa
main()

 

El programa completo debería quedar así:

"""
    GENERADOR DE CUEVAS CON PYGAME
    Genera mazmorras en forma de cueva a partir de un mapa de ruido simplex
 
    Escrito por Glare y Transductor
    www.robologs.net
"""

import pygame
from opensimplex import OpenSimplex
import numpy as np
import random
import sys


def generar_mapa(ancho, alto):
	#==Esta funcion genera el mapa de la cueva==

	#Generar una semilla para el mapa
	semilla = random.randint(1,1000)

	#Crear el objeto opensimplex
	gen_mazmorra = OpenSimplex(seed = semilla)

	# Valor de la frecuencia
	# Aumentar este valor hace el mismo efecto que un zoom hacia afuera
	# (y, por tanto, los pasillos son mas estrechos)
	freq = 0.15
	# Recomiendo valores entre 0.25 y 0.1

	#Array para guardar el mapa
	mapa_cueva = np.empty([alto, ancho])


	#Generar el mapa de terreno mezclando ruido a diferentes octavas
	for y in range(alto):
		for x in range(ancho):
	 
			#Generar el valor de la posicion actual
			valor = gen_mazmorra.noise2d(freq*x, freq*y) 

			#Llenar el mapa de la cueva - podeis probar a cambiar los valores -0.15 y 0.45
			if valor < -0.15 or valor >= 0.45:
				mapa_cueva[y][x] = 1 #Pared
			else:
				mapa_cueva[y][x] = 0 #Espacio vacio


	#Devolver la matriz del mapa de la cueva
	return mapa_cueva
 
def main():
 
 
    #Pediremos al usuario que entre a mano la dimension:
    print("\n--Introduce las dimensiones del mapa--\n")
 
    #Dimensiones del mapa (por lo menos tiene que ser 20)
    dimx = max(int(input("Ancho del mapa: ")), 20)
    dimy = max(int(input("Alto del mapa: ")), 20) 
 
    #Inicializar el mapa aleatoriamente
    mapa = generar_mapa(dimx, dimy)
 
    #Eliminar las zonas aisladas
    mapa = eliminarZonasAisladas(mapa)
 
    print("\n\n------------------------------------------\n\n")
 
    #-----PYGAME------
 
    #Dimensiones (en px) del dibujo de cada casilla
    casillax = 16
    casillay = 16
 
    #Dimensiones de la ventana de pygame donde se dibuja el mapa
    ancho_ventana = dimx*16
    alto_ventana = dimy*16
 
    #Crear la ventana y cambiar su nombre
    gameDisplay = pygame.display.set_mode((ancho_ventana, alto_ventana))
    pygame.display.set_caption('Cave Party')
 
     
    #Dibujar la ventana con el mapa
    gameDisplay.fill((0,0,0))
    dibujarMapa(mapa, gameDisplay, casillax, casillay)
 
 
    #Esperar a que el jugador se canse de mirar el mapa...
    print("\nCierra la ventana de Pygame para detener el programa")
    continuar = True
    while continuar:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                continuar = False
 
 
def dibujarMapa(mapa, gameDisplay, casillax, casillay):
    # Esta funcion dibuja el mapa con caracteres en la terminal y
    # con imagenes en la ventana gameDisplay
 
    #Guardar las dimensiones del mapa original
    dimy = len(mapa)
    dimx = len(mapa[0])
  
    #Borrar la ventana
    gameDisplay.fill((25,25,25))
 
    #Dibujar las casillas del mapa
    for y in range(dimy):
        for x in range(dimx):

            if mapa[y][x] == 0:
                sys.stdout.write("  ") #Espacio vacio
                tile = pygame.image.load('mazmorra_pygame_p3_tiles/floor.bmp').convert_alpha()
                gameDisplay.blit(tile, (x*casillax,y*casillay))
 
            elif mapa[y][x] == 1:
                sys.stdout.write("# ") #Muro
 
                #Si en la casilla de abajo hay una pared, habra que colocar un muro de tipo 2
                if (y+1) >= dimy or mapa[y+1][x] == 1:
                    tile = pygame.image.load('mazmorra_pygame_p3_tiles/wall2.bmp').convert_alpha()
 
                #De lo contrario, un muro de tipo 1
                else:
                    tile = pygame.image.load('mazmorra_pygame_p3_tiles/wall1.bmp').convert_alpha()
                    gameDisplay.blit(tile, (x*casillax,y*casillay))
              
                gameDisplay.blit(tile, (x*casillax,y*casillay))
  
                # Para dar el efecto de 2.5D, las paredes van a tapar una parte de la casilla que tienen
                # detras (siempre que no sea otro muro)
                if y-1 >= 0:
                    if mapa[y-1][x] != 1:
                        image = pygame.image.load('mazmorra_pygame_p3_tiles/wall_border_alpha.bmp')
                         
                        #Pintar objetos con alpha es mas complicado en pygame...
                        surface = pygame.Surface(image.get_size())
                        key = (255,0,255)
                        surface.fill(key, surface.get_rect())
                        surface.set_colorkey(key)
                        surface.blit(image, (0,0))
                        surface.set_alpha(255)
 
                        gameDisplay.blit(surface, (x*casillax,(y-1)*casillay))
     
        print("")
      
    print("\n\n")
 
    #Update!
    pygame.display.update()
 
def eliminarZonasAisladas(mapa):
    # Esta funcion separa cada region del mapa y las elimina todas excepto
    # la que tenga mayor area
 
    #Guardar las dimensiones del mapa original
    dimy = len(mapa)
    dimx = len(mapa[0])
 
    #La primera region tendra la etiqueta '2'
    region = 2
 
    #Vector para guardar el valor de las areas de cada region
    areas = []
    #NOTA: el elemento [0] se corresponde a la region '2', [1] es la region '3', etc.
 
    #Crear un mapa auxiliar que sea una copia del mapa original
    mapa_aux = np.copy(mapa)   
 
    #Recorremos cada casilla del mapa
    for x in range(dimx):
        for y in range(dimy):
 
            #Si la casilla actual es un espacio vacio, aplicar FloodFill para "pintar" toda la region
            if mapa_aux[y][x] == 0:
 
                #Aplicar Flood-Fill y guardar el area en el vector
                areas.append(floodFill(mapa_aux, y, x, region))
 
                #Sumar +1 al contador de regiones
                region = region + 1
 
    #Nos quedamos con la region que tenga area maxima
    region_maxima = areas.index(max(areas)) + 2
 
    #En el mapa original, eliminamos todas las casillas que no esten en region_maxima
    for x in range(dimx):
        for y in range(dimy):
            if mapa_aux[y][x] != 1 and mapa_aux[y][x] != region_maxima:
                mapa[y][x] = 1
 
    #Devolver el mapa actualizado
    return mapa
 
 
def floodFill(mapa, posy, posx, num_region):
    # Llena todas las casillas de una region con el mismo numero (num_region)
    # y devuelve el area de la region
 
    #Guardar las dimensiones del mapa original
    ysize = len(mapa)
    xsize = len(mapa[0])
 
    #Crear una estructura de pila ('stack'), donde guardaremos las casillas de la region
    pila = set(((posy, posx),))
 
    #Variable para guardar el area de la region
    area = 0
 
    #Repetir mientras haya elementos en la pila
    while len(pila) > 0:
 
        #Guardar las coordenadas (fila, columna) del objeto de la pila
        y, x = pila.pop()
 
        #Sumar +1 al area de la region
        area = area + 1
 
        #"Pintar" la casilla
        if mapa[y][x] == 0:
            mapa[y][x] = num_region
            if y > 0:
                pila.add((y - 1, x))
            if y < (ysize - 1):
                pila.add((y + 1, x))
            if x > 0:
                pila.add((y, x - 1))
            if x < (xsize - 1):
                pila.add((y, x + 1))
 
    #Devolver el area total de la region
    return area
 
 
#Llamar funcion main al iniciar el programa
main()

 

Si habéis copiado bien cada parte, al correr el programa debería aparecer el mapa dibujado con carácteres de texto, y también debería abrirse una ventana con el dibujo con sprites:

 

¡Esto es todo por hoy, humanos! Recordad que podéis dejarme un comentario si tenéis alguna duda y contestaré lo más rápido que pueda. Y si os ha gustado el post, podéis dejar un like a nuestra página de Twitter y Facebook 😉

Gl4r3

Brillante, luminosa y cegadora a veces, Glare es tan artista como técnica. Le encanta dar rienda suelta a sus módulos de imaginación y desdibujar los ya de por si delgados límites que separan el mundo de la electrónica y el arte. Su mayor creación hasta la fecha es un instrumento capaz de convertir los colores y la luz en música. Cuándo sus circuitos no están trabajando en una nueva obra electrónica, le gusta dedicar sus ciclos a la lectura o a documentar sus invenciones para beneficio de los humanos. Sus artilugios favoritos son aquellos que combinan una funcionalidad práctica con un diseño elegante y artístico.

Deja un comentario

avatar