0

Cómo generar mazmorras procedurales con Python – Parte II: Cuevas

[Volver a la Parte I]

[Avanzar a la Parte III]

El último día vimos cómo crear una mazmorra clásica: un conjunto de habitaciones rectangulares unidas por un laberinto de pasillos serpenteantes…¡el sueño de cualquier Señor Oscuro! No obstante, esto de hacer el mal no suele ser un trabajo muy bien remunerado y muchos Señores Oscuros tienen que conformarse con vivir en cuevas abandonadas en vez de poder construirse una mazmorra con todos los lujos…

En la segunda parte de esta série de tutoriales sobre generación procedural veremos otra técnica para construir mazmorras que tengan el aspecto de un conjunto de cavernas. Para conseguirlo programaremos un autómata celular que a partir de unas reglas muy sencillas creará mapas de este estilo:

 

¡Venga! Coged vuestro equipo de espeleología, poned música ambiental de fondo y… ¡manos a la obra!


Autómatas celulares

Un autómata celular es un sistema dinámico que evoluciona en el tiempo a partir de unas reglas sencillas. El autómata celular más famoso es el Juego de la Vida (conocido como “Life”), diseñado en 1970 por el matemático John Conway.

El Juego de la vida está compuesto por una cuadrícula bidimensional dónde cada casilla puede tener el estado “vivo” o “muerto”. A cada iteración del juego se aplican cuatro reglas:

  1. Una celda viva con menos de dos vecinos vivos, muere (aislamiento)
  2. Una celda viva con dos o tres vecinos vivos continúa viva
  3. Una celda viva con más de tres vecinos vivos, muere (sobrepoblación)
  4. Una celda muerta con exactamente tres vecinos vivos se convierte en una celda viva (nacimiento)

Si se corre el Juego de la Vida durante suficiente tiempo siguiendo estas normas, empiezan a formarse estructuras caóticas muy interesantes.

Glider Gun, una de las estructuras que pueden crearse en Life. Imagen de Wikipedia.


¿Cómo generamos las cavernas?

Haciendo algunas modificaciones al Juego de la Vida podemos generar formas parecidas a cuevas. Las celdas vivas representarán las “paredes” de la cueva mientras que las celdas muertas serán espacios vacíos.

Nuestro generador de cavernas tendrá cuatro parámetros:

  1. La probabilidad de que una celda se inicialice como viva (P)
  2. El límite de vecinos para que una celda viva muera de aislamiento (lim_aislamiento)
  3. El límite de vecinos para que una celda muerta se convierta en viva (lim_nacimiento)
  4. El número de iteraciones que va a correr nuestro autómata celular (N)

 

Al principio inicializamos el mapa con todas las casillas muertas y lo recorremos, convirtiendo cada casilla en una de viva con una probabilidad P. Después se calculan N pasos del autómata celular siguiendo estas dos normas:

  • Una celda viva morirá si tiene un número de vecinos inferior a lim_aislamiento.
  • Una celda muerta pasará a estar viva si tiene un número de vecinos superior a lim_nacimiento.

 

Ajustando correctamente estos cuatro parámetros se pueden crear cavernas bastante convincentes. Por ejemplo con P = 0.4, lim_aislamiento = 3, lim_nacimiento = 4 y N = 6 se consigue un mapa como este:

 

Después habrá que realizar un poco de Quality Checking para eliminar las regiones aisladas. Utilizaremos el algoritmo Flood-Fill para separar cada región del mapa y dejaremos sólo la que tenga mayor área.


Librerías de Python necesarias

Este tutorial está testeado con Python 3. Váis a necesitar estas librerías:

  1. Numpy, muy útil para crear y manipular matrices.
  2. Random y System, que ya deberían venir instaladas con la versión de Python 3.
  3. Pygame, si queréis dibujar el mapa con sprites.

¡A programar!

(Nota: podéis descargar el código del tutorial acabado desde aquí si no queréis seguir las explicaciones)

¡Es hora de programar! Como siempre, voy a ir explicando cada parte del código por separado. Vosotros tenéis que ir copiando cada fragmento de programa en un archivo de texto. No os preocupéis si os perdéis: al final de cada sección he puesto el código completo.

Empezamos por importar las librerías:

import random
import numpy as np
import sys

 

Ahora escribimos la función main():

def main():


	#Pediremos al usuario que entre a mano los parametros:
	print("\n--Introduce los parametros del generador--\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)

	#Minimo de vecinos que debe tener una celda para sobrevivir
	lim_aislamiento = int(input("Limite para aislamiento (recomendado 3): "))

	#Minimo de vecinos para que una celda muerta pase a estar viva
	lim_nacimiento = int(input("Limite para nacimiento (recomendado 4): "))

	#Numero de iteraciones para el automata celular
	n = int(input("Num. de pasos para el automata celular (recomendado 5-6): "))

	#Probabilidad de inicializar una celda como viva
	P = float(input("Probabilidad inicializar una casilla como viva (recomendado 0.4): "))


 
	# El array 'mapa' guarda el contenido de cada casilla de la mazmorra.
	# Lo inicializamos con todas las celdas = 0
	mapa = np.zeros((dimy,dimx), dtype=int)

	#Inicializar el mapa aleatoriamente
	inicializarMapa(mapa, P)

	#Hacer 'n' pasos del automata celular
	for i in range(n):
		mapa = calcularPaso(mapa, lim_aislamiento, lim_nacimiento)

	#Poner un separador y dibujar el mapa
	print("\n\n------------------------------------------\n\n")
	dibujarMapa(mapa)

Como podéis ver, dentro de la función main() empezamos por pedir al usuario que introduzca los parámeros del autómata celular (tamaño del mapa, límite de aislamiento y nacimiento, etc). Fijáos que en las líneas dónde calculamos el tamaño del mapa:

dimx = max(int(input("Ancho del mapa: ")), 20)
dimy = max(int(input("Alto del mapa: ")), 20)

nos quedamos con el máximo entre ’20’ y el valor que ha introducido el usuario. De esta forma evitamos que el usuario introduzca unas dimensiones demasiado pequeñas para hacer una caverna decente.

Una vez el usuario ha introducido los parámetros, se inicializa la matriz del mapa y se llama la función inicializarMapa() para llenarla con casillas al azar. Después se calculan n pasos del automata celular con la función calcularPaso() y finalmente se dibuja el mapa con la función dibujarMapa(). Habrá que escribir estas tres funciones.

 

La función inicializarMapa() simplemente recorrerá la matriz del mapa y convertirá cada celda muerta en una de viva con una probabilidad P:

def inicializarMapa(mapa, P):
	# Esta funcion inicializa el mapa. Cada casilla tiene una probabilidad P de
	# inicializarse como viva.

	#Recorrer el mapa e inicializar todas las casillas:
	for i in range(len(mapa)):
		for j in range(len(mapa[0])):

			#Generar un valor aleatorio entre 0 y 1
			valor = random.uniform(0, 1)

			#Si este valor es inferior a la probabilidad, crear una casilla viva:
			if valor < P:
				mapa[i][j] = 1

			#(De lo contrario, la casilla permanece muerta)

 

Antes de escribir la función calcularPaso() necesitamos otra función auxiliar que dadas las coordenadas de una casilla en el mapa nos retorne el número de vecinos vivos que tiene. La vamos a llamar calcularVecinosVivos():

def contarVecinosVivos(mapa, x, y):
	# Esta funcion cuenta el numero de vecinos vivos que tiene 
	# la casilla situada en mapa[y][x]

	#Guardar las dimensiones del mapa
	dimy = len(mapa)
	dimx = len(mapa[0])
	
	#Contador para el numero de vecinos vivos:
	contador = 0
	
	#Doble bucle para comprobar los vecinos
	for i in range(-1, 2):
		for j in range(-1, 2):
			
			#No queremos contar la casilla del centro!
			if (i == 0 and j == 0):
				pass

			#Si no estamos en el centro...
			else:
				#Guardar las coordenadas del vecino
				vecinox = x + i
				vecinoy = y + j

				#Supondremos que todas las casillas que hay fuera del mapa estan 'vivas' (son paredes)
				if (vecinox < 0 or vecinox >= dimx or vecinoy < 0 or vecinoy >= dimy):
					contador += 1

				# Si es una casilla del interior del mapa se suma su valor al contador
				# (0 si esta muerta y 1 si esta viva)
				else:
					contador += mapa[vecinoy][vecinox]

	#Devolver el numero de vecinos
	return contador

Esta función recibe la matriz del mapa y unas coordenadas (x,y). Utilizando un doble bucle, examina el estado de las casillas de la sub-matriz 3×3 centrada en (x,y) y calcula cuántas de ellas están vivas. También hemos añadido un condicional dentro del doble bucle para que ignore la casilla del centro.

 

La función calcularPaso() calculará una iteración (paso) del autómata celular:

def calcularPaso(mapa, lim_aislamiento, lim_nacimiento):
	#Esta funcion calcula una iteracion del automata celular

	#Guardar las dimensiones del mapa original
	dimy = len(mapa)
	dimx = len(mapa[0])

	#Crear un mapa auxiliar
	mapa_aux = np.zeros((dimy, dimx), dtype = int)
	#El valor de las casillas se actualizara en el mapa auxiliar

	#Recorrer cada casilla del mapa y aplicar las reglas del automata cel.
	for x in range(dimx):
		for y in range(dimy):

			#Calcular el numero de vecinos
			num_vecinos = contarVecinosVivos(mapa, x, y)
			
			#Si la celda esta viva, comprobar si muere de aislamiento
			if mapa[y][x] == 1:

				if num_vecinos < lim_aislamiento:
					mapa_aux[y][x] = 0

				else:
					mapa_aux[y][x] = 1

			#Si la celda esta muerta, comprobar si tiene que nacer
			elif mapa[y][x] == 0:

				if num_vecinos > lim_nacimiento:
					mapa_aux[y][x] = 1
				else:
					mapa_aux[y][x] = 0

	#Actualizar el mapa original
	return mapa_aux

Esta función define una matriz auxiliar (‘mapa_aux‘) inicializada a cero. Después recorre cada casilla y llama a la función calcularVecinosVivos() para saber el número de vecinos que tiene la celda en el mapa original. Después, para cada una de las casillas se aplican las dos reglas que habéis visto antes:

  • Una celda viva morirá si tiene un número de vecinos inferior a lim_aislamiento.
  • Una celda muerta pasará a estar viva si tiene un número de vecinos superior a lim_nacimiento.

Y se actualiza su valor en el mapa auxiliar. Finalmente se devuelve el mapa auxiliar, que se convertirá en el nuevo mapa.

 

Por último, la función dibujarMapa() dibujará el mapa en la consola con carácteres:

def dibujarMapa(mapa):
	#Esta funcion dibuja el mapa con caracteres ASCII

	#Guardar las dimensiones del mapa original
	dimy = len(mapa)
	dimx = len(mapa[0])
 
	#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
			elif mapa[y][x] == 1:
				sys.stdout.write("# ") #Muro
    
		print("")
     
	print("\n\n")

 

¡Y no nos olvidemos de llamar a la función main() al final!

#Llamar funcion main al iniciar el programa
main()

 

Por tanto, una vez juntados todos estos fragmentos, el código final debería quedar así:

"""
	GENERADOR DE CUEVAS EN PYTHON
	Genera mazmorras en forma de cueva utilizando un automata celular.

	Escrito por Glare y Transductor
	www.robologs.net
"""


import random
import numpy as np
import sys

def main():


	#Pediremos al usuario que entre a mano los parametros:
	print("\n--Introduce los parametros del generador--\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)

	#Minimo de vecinos que debe tener una celda para sobrevivir
	lim_aislamiento = int(input("Limite para aislamiento (recomendado 3): "))

	#Minimo de vecinos para que una celda muerta pase a estar viva
	lim_nacimiento = int(input("Limite para nacimiento (recomendado 4): "))

	#Numero de iteraciones para el automata celular
	n = int(input("Num. de pasos para el automata celular (recomendado 5-6): "))

	#Probabilidad de inicializar una celda como viva
	P = float(input("Probabilidad inicializar una casilla como viva (recomendado 0.4): "))


 
	# El array 'mapa' guarda el contenido de cada casilla de la mazmorra.
	# Lo inicializamos con todas las celdas = 0
	mapa = np.zeros((dimy,dimx), dtype=int)

	#Inicializar el mapa aleatoriamente
	inicializarMapa(mapa, P)

	#Hacer 'n' pasos del automata celular
	for i in range(n):
		mapa = calcularPaso(mapa, lim_aislamiento, lim_nacimiento)

	#Poner un separador y dibujar el mapa
	print("\n\n------------------------------------------\n\n")
	dibujarMapa(mapa)

def inicializarMapa(mapa, P):
	# Esta funcion inicializa el mapa. Cada casilla tiene una probabilidad P de
	# inicializarse como viva.

	#Recorrer el mapa e inicializar todas las casillas:
	for i in range(len(mapa)):
		for j in range(len(mapa[0])):

			#Generar un valor aleatorio entre 0 y 1
			valor = random.uniform(0, 1)

			#Si este valor es inferior a la probabilidad, crear una casilla viva:
			if valor < P:
				mapa[i][j] = 1

			#(De lo contrario, la casilla permanece muerta)

def contarVecinosVivos(mapa, x, y):
	# Esta funcion cuenta el numero de vecinos vivos que tiene 
	# la casilla situada en mapa[y][x]

	#Guardar las dimensiones del mapa
	dimy = len(mapa)
	dimx = len(mapa[0])
	
	#Contador para el numero de vecinos vivos:
	contador = 0
	
	#Doble bucle para comprobar los vecinos
	for i in range(-1, 2):
		for j in range(-1, 2):
			
			#No queremos contar la casilla del centro!
			if (i == 0 and j == 0):
				pass

			#Si no estamos en el centro...
			else:
				#Guardar las coordenadas del vecino
				vecinox = x + i
				vecinoy = y + j

				#Supondremos que todas las casillas que hay fuera del mapa estan 'vivas' (son paredes)
				if (vecinox < 0 or vecinox >= dimx or vecinoy < 0 or vecinoy >= dimy):
					contador += 1

				# Si es una casilla del interior del mapa se suma su valor al contador
				# (0 si esta muerta y 1 si esta viva)
				else:
					contador += mapa[vecinoy][vecinox]

	#Devolver el numero de vecinos
	return contador

def calcularPaso(mapa, lim_aislamiento, lim_nacimiento):
	#Esta funcion calcula una iteracion del automata celular

	#Guardar las dimensiones del mapa original
	dimy = len(mapa)
	dimx = len(mapa[0])

	#Crear un mapa auxiliar
	mapa_aux = np.zeros((dimy, dimx), dtype = int)
	#El valor de las casillas se actualizara en el mapa auxiliar

	#Recorrer cada casilla del mapa y aplicar las reglas del automata cel.
	for x in range(dimx):
		for y in range(dimy):

			#Calcular el numero de vecinos
			num_vecinos = contarVecinosVivos(mapa, x, y)
			
			#Si la celda esta viva, comprobar si muere de aislamiento
			if mapa[y][x] == 1:

				if num_vecinos < lim_aislamiento:
					mapa_aux[y][x] = 0

				else:
					mapa_aux[y][x] = 1

			#Si la celda esta muerta, comprobar si tiene que nacer
			elif mapa[y][x] == 0:

				if num_vecinos > lim_nacimiento:
					mapa_aux[y][x] = 1
				else:
					mapa_aux[y][x] = 0

	#Actualizar el mapa original
	return mapa_aux



def dibujarMapa(mapa):
	#Esta funcion dibuja el mapa con caracteres ASCII

	#Guardar las dimensiones del mapa original
	dimy = len(mapa)
	dimx = len(mapa[0])
 
	#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
			elif mapa[y][x] == 1:
				sys.stdout.write("# ") #Muro
    
		print("")
     
	print("\n\n")


#Llamar funcion main al iniciar el programa
main()

 

Si todo ha funcionado bien, el script dibujará las cuevas con carácteres planos en la consola:

Más adelante veremos cómo dibujar el mapa con imágenes.


Eliminar las zonas aisladas

Ahora mismo nuestro programa nos genera zonas aisladas dentro de la caverna. Lo ideal sería poder eliminarlas y quedarnos sólo con la región más grande.

Para hacerlo utilizaremos el algoritmo Flood-Fill para separar el mapa en regiones diferentes. Crearemos un mapa auxiliar y a cada casilla le asignaremos un número según la región a la que pertenezca (es como si pintásemos cada región de un color diferente). También guardaremos el número de casillas que contiene cada región a medida que las vayamos “pintando”. Al final, convertiremos en paredes a todas las casillas que no pertenezcan a la región de mayor tamaño.

 

Por tanto, tenemos que ampliar el código con algunas funciones más…

Empezaremos por escribir la función floodFill(). Esta función recibirá como parámetros el mapa, unas coordenadas (x, y) de una casilla y un número para la región (podéis pensar este número como el color de la región) y cambiará el valor de todas las casillas que estén en la misma región. También calculará el área.

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

 

El plato fuerte de este apartado es la función eliminarZonasAisladas(), que separa las regiones del mapa y las elimina todas excepto la que tenga mayor tamaño:

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

Esta función tiene algunos puntos poco intuitivos, así que voy a explicarla lo mejor que pueda.

La función empieza declarando una variable ‘region‘, que guarda el número de la región que se está rellenando. Se inicializa a ‘2’, pues supondremos que ‘0’ son las casillas que todavía no tienen asignada una región y ‘1’ son las paredes.

Después se crea un vector para almacenar las áreas de cada región, llamado ‘areas‘. La posición [0] se corresponderá a la región 2, la posición [1] a la región 3, etc. No almacenamos la región 1 (las paredes) porque no nos interesa saber su área.

mapa_aux es una matriz copia del mapa original. Vamos a “pintar” las regiones dentro de esta matriz auxiliar.

Después se procede a recorrer todas las casillas de mapa_aux con un doble bucle. Cuándo se encuentra una casilla cuyo valor es 0 (es decir, una casilla que aún no tiene una región asignada) se llama a FloodFill() para cambiar su valor (y el de todas las casillas de la misma región) por el valor de la variable region. Después se guarda el área de la región en el vector areas y se suma + 1 a la variable region.

Por último se busca cuál de las regiones tiene la mayor área y se eliminan todas las casillas del mapa original cuyo valor sea 0 en el mapa original y cuyo valor en el mapa auxiliar no coincida con el de la región máxima.

 

La última modificación del código será en la función main(). Ahora va a llamar la función eliminarZonasAisladas() justo después del bucle que calcula los ‘n’ pasos del autómata celular.

def main():


	#Pediremos al usuario que entre a mano los parametros:
	print("\n--Introduce los parametros del generador--\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)

	#Minimo de vecinos que debe tener una celda para sobrevivir
	lim_aislamiento = int(input("Limite para aislamiento (recomendado 3): "))

	#Minimo de vecinos para que una celda muerta pase a estar viva
	lim_nacimiento = int(input("Limite para nacimiento (recomendado 4): "))

	#Numero de iteraciones para el automata celular
	n = int(input("Num. de pasos para el automata celular (recomendado 5-6): "))

	#Probabilidad de inicializar una celda como viva
	P = float(input("Probabilidad inicializar una casilla como viva (recomendado 0.4): "))


 
	# El array 'mapa' guarda el contenido de cada casilla de la mazmorra.
	# Lo inicializamos con todas las celdas = 0
	mapa = np.zeros((dimy,dimx), dtype=int)

	#Inicializar el mapa aleatoriamente
	inicializarMapa(mapa, P)

	#Hacer 'n' pasos del automata celular
	for i in range(n):
		mapa = calcularPaso(mapa, lim_aislamiento, lim_nacimiento)

	#Eliminar las zonas aisladas
	mapa = eliminarZonasAisladas(mapa)

	#Poner un separador y dibujar el mapa
	print("\n\n------------------------------------------\n\n")

	dibujarMapa(mapa)

 

Por tanto, con estas modficaciones, el programa os tendría que quedar así:

"""
	GENERADOR DE CUEVAS EN PYTHON
	Genera mazmorras en forma de cueva utilizando un automata celular.

	Escrito por Glare y Transductor
	www.robologs.net
"""


import random
import numpy as np
import sys

def main():


	#Pediremos al usuario que entre a mano los parametros:
	print("\n--Introduce los parametros del generador--\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)

	#Minimo de vecinos que debe tener una celda para sobrevivir
	lim_aislamiento = int(input("Limite para aislamiento (recomendado 3): "))

	#Minimo de vecinos para que una celda muerta pase a estar viva
	lim_nacimiento = int(input("Limite para nacimiento (recomendado 4): "))

	#Numero de iteraciones para el automata celular
	n = int(input("Num. de pasos para el automata celular (recomendado 5-6): "))

	#Probabilidad de inicializar una celda como viva
	P = float(input("Probabilidad inicializar una casilla como viva (recomendado 0.4): "))


 
	# El array 'mapa' guarda el contenido de cada casilla de la mazmorra.
	# Lo inicializamos con todas las celdas = 0
	mapa = np.zeros((dimy,dimx), dtype=int)

	#Inicializar el mapa aleatoriamente
	inicializarMapa(mapa, P)

	#Hacer 'n' pasos del automata celular
	for i in range(n):
		mapa = calcularPaso(mapa, lim_aislamiento, lim_nacimiento)

	#Eliminar las zonas aisladas
	mapa = eliminarZonasAisladas(mapa)

	#Poner un separador y dibujar el mapa
	print("\n\n------------------------------------------\n\n")

	dibujarMapa(mapa)

def inicializarMapa(mapa, P):
	# Esta funcion inicializa el mapa. Cada casilla tiene una probabilidad P de
	# inicializarse como viva.

	#Recorrer el mapa e inicializar todas las casillas:
	for i in range(len(mapa)):
		for j in range(len(mapa[0])):

			#Generar un valor aleatorio entre 0 y 1
			valor = random.uniform(0, 1)

			#Si este valor es inferior a la probabilidad, crear una casilla viva:
			if valor < P:
				mapa[i][j] = 1

			#(De lo contrario, la casilla permanece muerta)

def contarVecinosVivos(mapa, x, y):
	# Esta funcion cuenta el numero de vecinos vivos que tiene 
	# la casilla situada en mapa[y][x]

	#Guardar las dimensiones del mapa
	dimy = len(mapa)
	dimx = len(mapa[0])
	
	#Contador para el numero de vecinos vivos:
	contador = 0
	
	#Doble bucle para comprobar los vecinos
	for i in range(-1, 2):
		for j in range(-1, 2):
			
			#No queremos contar la casilla del centro!
			if (i == 0 and j == 0):
				pass

			#Si no estamos en el centro...
			else:
				#Guardar las coordenadas del vecino
				vecinox = x + i
				vecinoy = y + j

				#Supondremos que todas las casillas que hay fuera del mapa estan 'vivas' (son paredes)
				if (vecinox < 0 or vecinox >= dimx or vecinoy < 0 or vecinoy >= dimy):
					contador += 1

				# Si es una casilla del interior del mapa se suma su valor al contador
				# (0 si esta muerta y 1 si esta viva)
				else:
					contador += mapa[vecinoy][vecinox]

	#Devolver el numero de vecinos
	return contador

def calcularPaso(mapa, lim_aislamiento, lim_nacimiento):
	#Esta funcion calcula una iteracion del automata celular

	#Guardar las dimensiones del mapa original
	dimy = len(mapa)
	dimx = len(mapa[0])

	#Crear un mapa auxiliar
	mapa_aux = np.zeros((dimy, dimx), dtype = int)
	#El valor de las casillas se actualizara en el mapa auxiliar

	#Recorrer cada casilla del mapa y aplicar las reglas del automata cel.
	for x in range(dimx):
		for y in range(dimy):

			#Calcular el numero de vecinos
			num_vecinos = contarVecinosVivos(mapa, x, y)
			
			#Si la celda esta viva, comprobar si muere de aislamiento
			if mapa[y][x] == 1:

				if num_vecinos < lim_aislamiento:
					mapa_aux[y][x] = 0

				else:
					mapa_aux[y][x] = 1

			#Si la celda esta muerta, comprobar si tiene que nacer
			elif mapa[y][x] == 0:

				if num_vecinos > lim_nacimiento:
					mapa_aux[y][x] = 1
				else:
					mapa_aux[y][x] = 0

	#Actualizar el mapa original
	return mapa_aux



def dibujarMapa(mapa):
	#Esta funcion dibuja el mapa con caracteres ASCII

	#Guardar las dimensiones del mapa original
	dimy = len(mapa)
	dimx = len(mapa[0])
 
	#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
			elif mapa[y][x] == 1:
				sys.stdout.write("# ") #Muro
    
		print("")
     
	print("\n\n")

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()

 


Integración con Pygame

En esta última parte veremos cómo modificar el código para dibujar la mazmorra en una ventana de Pygame. Al igual que el último día, os he preparado algunos assets que podéis descargar y utilizar para este ejemplo… ¡aunque os animo a dibujar los vuestros!

[Descargar assets]

Tenéis que descomprimir el .zip y guardarlo en el mismo directorio dónde esté el fichero ‘.py’ que estáis programando.

 

En cuánto al código, vamos a añadir algunos cambios más. El primero será importar la librería Pygame:

import random
import numpy as np
import sys
import pygame

 

También modificaremos la función main() para crear una ventana de Pygame.

def main():


	#Pediremos al usuario que entre a mano los parametros:
	print("\n--Introduce los parametros del generador--\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)

	#Minimo de vecinos que debe tener una celda para sobrevivir
	lim_aislamiento = int(input("Limite para aislamiento (recomendado 3): "))

	#Minimo de vecinos para que una celda muerta pase a estar viva
	lim_nacimiento = int(input("Limite para nacimiento (recomendado 4): "))

	#Numero de iteraciones para el automata celular
	n = int(input("Num. de pasos para el automata celular (recomendado 5-6): "))

	#Probabilidad de inicializar una celda como viva
	P = float(input("Probabilidad inicializar una casilla como viva (recomendado 0.4): "))


 
	# El array 'mapa' guarda el contenido de cada casilla de la mazmorra.
	# Lo inicializamos con todas las celdas = 0
	mapa = np.zeros((dimy,dimx), dtype=int)

	#Inicializar el mapa aleatoriamente
	inicializarMapa(mapa, P)

	#Hacer 'n' pasos del automata celular
	for i in range(n):
		mapa = calcularPaso(mapa, lim_aislamiento, lim_nacimiento)

	#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, por último, modificamos la función dibujarMapa() para que también pinte la cueva sobre la ventana de Pygame. Al igual que la última vez tendremos en cuenta las casillas adyacentes a la hora de colocar las paredes:

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_p2_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_p2_tiles/wall2.bmp').convert_alpha()

				#De lo contrario, un muro de tipo 1
				else:
					tile = pygame.image.load('mazmorra_pygame_p2_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_p2_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()

 

¡Y ya está! Con estas últimas modificaciones, el programa tendría que quedar así:

"""
	GENERADOR DE CUEVAS EN PYGAME
	Genera mazmorras en forma de cueva utilizando un automata celular y dibuja el mapa
	en una ventana de Pygame

	Escrito por Glare y Transductor
	www.robologs.net
"""


import random
import numpy as np
import sys
import pygame

def main():


	#Pediremos al usuario que entre a mano los parametros:
	print("\n--Introduce los parametros del generador--\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)

	#Minimo de vecinos que debe tener una celda para sobrevivir
	lim_aislamiento = int(input("Limite para aislamiento (recomendado 3): "))

	#Minimo de vecinos para que una celda muerta pase a estar viva
	lim_nacimiento = int(input("Limite para nacimiento (recomendado 4): "))

	#Numero de iteraciones para el automata celular
	n = int(input("Num. de pasos para el automata celular (recomendado 5-6): "))

	#Probabilidad de inicializar una celda como viva
	P = float(input("Probabilidad inicializar una casilla como viva (recomendado 0.4): "))


 
	# El array 'mapa' guarda el contenido de cada casilla de la mazmorra.
	# Lo inicializamos con todas las celdas = 0
	mapa = np.zeros((dimy,dimx), dtype=int)

	#Inicializar el mapa aleatoriamente
	inicializarMapa(mapa, P)

	#Hacer 'n' pasos del automata celular
	for i in range(n):
		mapa = calcularPaso(mapa, lim_aislamiento, lim_nacimiento)

	#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 inicializarMapa(mapa, P):
	# Esta funcion inicializa el mapa. Cada casilla tiene una probabilidad P de
	# inicializarse como viva.

	#Recorrer el mapa e inicializar todas las casillas:
	for i in range(len(mapa)):
		for j in range(len(mapa[0])):

			#Generar un valor aleatorio entre 0 y 1
			valor = random.uniform(0, 1)

			#Si este valor es inferior a la probabilidad, crear una casilla viva:
			if valor < P:
				mapa[i][j] = 1

			#(De lo contrario, la casilla permanece muerta)

def contarVecinosVivos(mapa, x, y):
	# Esta funcion cuenta el numero de vecinos vivos que tiene 
	# la casilla situada en mapa[y][x]

	#Guardar las dimensiones del mapa
	dimy = len(mapa)
	dimx = len(mapa[0])
	
	#Contador para el numero de vecinos vivos:
	contador = 0
	
	#Doble bucle para comprobar los vecinos
	for i in range(-1, 2):
		for j in range(-1, 2):
			
			#No queremos contar la casilla del centro!
			if (i == 0 and j == 0):
				pass

			#Si no estamos en el centro...
			else:
				#Guardar las coordenadas del vecino
				vecinox = x + i
				vecinoy = y + j

				#Supondremos que todas las casillas que hay fuera del mapa estan 'vivas' (son paredes)
				if (vecinox < 0 or vecinox >= dimx or vecinoy < 0 or vecinoy >= dimy):
					contador += 1

				# Si es una casilla del interior del mapa se suma su valor al contador
				# (0 si esta muerta y 1 si esta viva)
				else:
					contador += mapa[vecinoy][vecinox]

	#Devolver el numero de vecinos
	return contador

def calcularPaso(mapa, lim_aislamiento, lim_nacimiento):
	#Esta funcion calcula una iteracion del automata celular

	#Guardar las dimensiones del mapa original
	dimy = len(mapa)
	dimx = len(mapa[0])

	#Crear un mapa auxiliar
	mapa_aux = np.zeros((dimy, dimx), dtype = int)
	#El valor de las casillas se actualizara en el mapa auxiliar

	#Recorrer cada casilla del mapa y aplicar las reglas del automata cel.
	for x in range(dimx):
		for y in range(dimy):

			#Calcular el numero de vecinos
			num_vecinos = contarVecinosVivos(mapa, x, y)
			
			#Si la celda esta viva, comprobar si muere de aislamiento
			if mapa[y][x] == 1:

				if num_vecinos < lim_aislamiento:
					mapa_aux[y][x] = 0

				else:
					mapa_aux[y][x] = 1

			#Si la celda esta muerta, comprobar si tiene que nacer
			elif mapa[y][x] == 0:

				if num_vecinos > lim_nacimiento:
					mapa_aux[y][x] = 1
				else:
					mapa_aux[y][x] = 0

	#Actualizar el mapa original
	return mapa_aux



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_p2_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_p2_tiles/wall2.bmp').convert_alpha()

				#De lo contrario, un muro de tipo 1
				else:
					tile = pygame.image.load('mazmorra_pygame_p2_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_p2_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()

 

Al correr este último programa debería aparecer una ventana con el mapa de la cueva dibujada:

¡Nuestra cueva está lista para ser poblada con montones de monstruos i tesoros! :3


 

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