4

Cómo generar mazmorras procedurales con Python – Parte I

[Avanzar a la Parte II]

¡Saludos, homo sapiens! Como sabréis los que me conocéis un poco, me encantan los juegos tipo Roguelike. Lo mejor que tienen estos juegos es su rejugabilidad infinita; cada partida es diferente a la anterior. Esto se debe a que los niveles están generados al azar mediante algoritmos, ¡nunca habrá dos mapas iguales!

En esta série de tutoriales veremos distintos métodos para crear mazmorras procedurales. En la primera parte aprenderemos cómo programar un algoritmo en lenguaje Python que genere mazmorras aleatorias al estilo de los primeros juegos roguelike de finales de los ochenta (Nethack, Rogue, DungeonCrawl…).

Al terminar este tutorial sabréis generar mazmorras tan bonicas como estas:

Así pues coged vuestra armadura y pociones, afilad vuestras espadas y poned un poco de música ambiental … ¡Vamos a adentrarnos en el profundo calabozo de la generación procedural!


Librerías

¿Qué necesitamos para hacer nuestra mazmorra?

  • Python 3 instalado en el sistema. También debería funcionar igual en otras versiones.
  • Numpy – Esta librería tiene herramientas que nos serán muy útiles para crear y manipular arrays.
  • Pathfinding1 – Contiene todos los algoritmos habituales de pathfinding: Dijkstra, A*, BFS, etc. Nos servirá para trazar los pasadizos entre las habitaciones.
  • Pygame – Para dibujar la mazmorra.
  • Librerías random y system. La primera para generar números aleatorios y la segunda para mostrar el mapa por terminal. No tenéis que instalarlas; ya vienen por defecto con Python.

 

1 he decidido utilizar la librería Pathfinding para no complicar este tutorial con explicaciones sobre teoría grafos, que darían para hacer un artículo mucho más largo. ¡No obstante, una vez funcione os animo a intentar implementar vuestro propio algoritmo de pathfinding!


Mazmorreo para dummies: pasos para construir un calabozo

Hay muchas formas diferentes de construir una mazmorra, y ninguna es realmente mejor que otra: dependerá de vuestro proyecto y del tiempo y recursos que queráis dedicar a programar.

El algoritmo que váis a ver hoy genera niveles parecidos a los de los juegos Roguelike de los años ochenta. Este método coloca un cierto número de habitaciones al azar y después intenta conectarlas todas con pasillos. Finalmente añade bucles y túneles adicionales para hacer una mazmorra más realista.

El contenido mapa se guardará dentro de una matriz de 2D. Cada casilla de la matriz será un número entero que indicará qué tipo de objeto se encuentra en esa posición: un muro, el interior de habitación, un pasillo…

 

Después se siguen estos pasos:

  1. Llenar de “tierra” el mapa, escribiendo un ‘1’ a todas las casillas de la matriz.
  2. Colocar una habitación en una posición que esté libre.
  3. Colocar las puertas de la habitación.
  4. Repetir el paso 2 hasta llegar al máximo de habitaciones (o hasta que no quepan más).
  5. Unir las habitaciones con pasadizos, utilizando el algoritmo A* para encontrar el camino más corto.

 


Programa en Python

Nota: si no queréis seguir las explicaciones podéis descargar el código acabado y los sprites pulsando aquí.

Vamos a ver cómo programar el generador de mazmorras en lenguaje Python paso a paso. En vez de escribir todo el código de golpe vamos a empezar por un programa sencillo que vamos a ir perfeccionando.

1. Colocar las habitaciones

Este primer código simplemente colocará un conjunto de habitaciones rectangulares sobre el mapa y lo dibujará en la consola. Empezamos importando las librerías necesarias:

import numpy as np
import sys
import random
import pygame
from pathfinding.core.grid import Grid
from pathfinding.finder.a_star import AStarFinder
from pathfinding.core.diagonal_movement import DiagonalMovement

 

Después definimos la función main(). Esta función creará la matriz del mapa, establecerá el máximo de habitaciones, llamará la función colocarHabitaciones() para colocar las habitaciones y dibujarMapa() para mostrar el contenido por línea de comandos.

def main():

	#Dimensiones del mapa
	dimx = 30
	dimy = 30

	#Crear la matriz del 'mapa' guarda el contenido de cada casilla de la mazmorra:
	mapa = np.ones((dimy,dimx), dtype=int)
	#Significado del valor de las casillas:
	#	'0' - El espacio esta ocupado por el interior de una habitacion
	#	'1' - El espacio contiene tierra
	#	'2' - El espacio contiene el muro de una habitacion
	#	'3' - El espacio contiene un pasillo
	#	'4' - El espacio contiene una puerta

	#Formula para establecer el maximo de habitaciones en funcion de las dimensiones del mapa
	maximo_habitaciones = int((dimx*dimy)/300) + random.randint(0, 4)

	#Colocar habitaciones sobre la matriz del mapa
	colocarHabitaciones(dimx, dimy, mapa, maximo_habitaciones)

	#Dibujar las habitaciones
	dibujarMapa(dimx, dimy, mapa)

Un apunte: la fórmula que marca el número máximo de habitaciones

maximo_habitaciones = int((dimx*dimy)/300) + random.randint(0, 4)

la he hecho a ojo para que haya un número de habitaciones razonable y proporcional a las dimensiones del mapa. Podéis cambiarla por otra fórmula que os guste más o poner un número constante de habitaciones.

 

Ahora hay que escribir la función colocarHabitaciones(), que colocará aleatoriamente las salas en el mapa. Para hacerlo elegiremos al azar una posición y unas dimensiones para la nueva habitación, pero respetando tres normas.

La primera es que una habitación no puede quedar por encima de otra; tenemos que separarlas.

1. Las habitaciones no pueden solaparse.

La segunda norma es que las habitaciones tampoco pueden tocarse. De esta forma no podrán bloquear el paso y crear zonas inaccesibles cuando coloquemos los pasillos

2. Las habitaciones no pueden tocarse.

Por último, las habitaciones no podrán tocar los bordes del mapa. Esto hará que no tengamos que escribir casos especiales para no salirnos del rango de la matriz cuando comprobemos si la nueva habitación choca con otra.

3. Las habitaciones no pueden tocar los bordes

Para evitar entrar en un bucle infinito, el programa tendrá 200 intentos para colocar una nueva habitación. Si no consigue colocarla saldrá del bucle.

def colocarHabitaciones(dimx, dimy, mapa, maximo_habitaciones):

	#Variable que guarda el numero de habitaciones colocadas hasta el momento
	habitaciones_colocadas = 0

	#Tiene que haber, por lo menos, dos habitaciones
	while habitaciones_colocadas < 2:

		#Para evitar un bucle infinito, hay 200 intentos para colocar las habitaciones
		for i in range(200):

			#Elegir las dimensiones del interior de la habitacion
			sizex = random.randint(3,7)
			sizey = random.randint(3,7)

			# Elegir una posicion al azar para la esquina superior izquierda de la habitacion,
			# evitando los bordes del mapa
			posx = random.randint(2, dimx-3-sizex)
			posy = random.randint(2, dimy-3-sizey)

			# Comprobar que se pueda colocar la habitacion sin que quede por encima o quede pegada
			# a otra habitacion
			submapa = mapa[(posy-2):(posy+sizey+2), (posx-2):(posx+sizex+2)]
			if np.all(submapa == 1):

				#Construir la habitacion con sus muros
				mapa[(posy-1):(posy+sizey+1), (posx-1):(posx+sizex+1)] = 2
				mapa[(posy):(posy+sizey), (posx):(posx+sizex)] = 0			

				#Si se ha llegado al maximo de habitaciones, salir del bucle
				habitaciones_colocadas += 1
				if maximo_habitaciones == habitaciones_colocadas:
					break

 

La función dibujarMapa() recorrerá la matriz y escribirá un carácter que dependerá del contenido de cada celda. Fijáos que utilizamos la función sys.stdout.write() para escribir los carácteres porque print() añade automáticamente un salto de línea, ¡y no nos interesa!

def dibujarMapa(dimx, dimy, mapa):

	#Dibujar las casillas del mapa
	for y in range(dimy):
		for x in range(dimx):
			if mapa[y][x] == 0:
				sys.stdout.write(". ") #Interior de habitacion
			if mapa[y][x] == 1:
				sys.stdout.write("  ") #Espacio vacio
			elif mapa[y][x] == 2:
				sys.stdout.write("# ") #Pared
			elif mapa[y][x] == 3:
				sys.stdout.write(', ') #Pasillo
			elif mapa[y][x] == 4:
				sys.stdout.write('D ') #Puerta
		print("")
	
	print("\n\n")

 

Al final se llama a la función main:

main()

 

Al juntar todos estos fragmentos el programa debería quedar así:

import numpy as np
import sys
import random
import pygame
from pathfinding.core.grid import Grid
from pathfinding.finder.a_star import AStarFinder
from pathfinding.core.diagonal_movement import DiagonalMovement

def main():

	#Dimensiones del mapa
	dimx = 30
	dimy = 30

	#El array 'mapa' guarda el contenido de cada casilla de la mazmorra:
	mapa = np.ones((dimy,dimx), dtype=int)
	#Significado del valor de las casillas:
	#	'0' - El espacio esta ocupado por el interior de una habitacion
	#	'1' - El espacio contiene tierra
	#	'2' - El espacio contiene el muro de una habitacion
	#	'3' - El espacio contiene un pasillo
	#	'4' - El espacio contiene una puerta

	#Formula para establecer el maximo de habitaciones en funcion de las dimensiones del mapa
	maximo_habitaciones = int((dimx*dimy)/300) + random.randint(0, 4)

	#Colocar habitaciones sobre la matriz del mapa
	colocarHabitaciones(dimx, dimy, mapa, maximo_habitaciones)

	#Dibujar las habitaciones
	dibujarMapa(dimx, dimy, mapa)



def colocarHabitaciones(dimx, dimy, mapa, maximo_habitaciones):

	#Variable que guarda el numero de habitaciones colocadas hasta el momento
	habitaciones_colocadas = 0

	#Tiene que haber, por lo menos, dos habitaciones
	while habitaciones_colocadas < 2:

		#Para evitar un bucle infinito, hay 200 intentos para colocar las habitaciones
		for i in range(200):

			#Elegir las dimensiones del interior de la habitacion
			sizex = random.randint(3,7)
			sizey = random.randint(3,7)

			# Elegir una posicion al azar para la esquina superior izquierda de la habitacion,
			# evitando los bordes del mapa
			posx = random.randint(2, dimx-3-sizex)
			posy = random.randint(2, dimy-3-sizey)

			# Comprobar que se pueda colocar la habitacion sin que quede por encima o quede pegada
			# a otra habitacion
			submapa = mapa[(posy-2):(posy+sizey+2), (posx-2):(posx+sizex+2)]
			if np.all(submapa == 1):

				#Construir la habitacion con sus muros
				mapa[(posy-1):(posy+sizey+1), (posx-1):(posx+sizex+1)] = 2
				mapa[(posy):(posy+sizey), (posx):(posx+sizex)] = 0			

				#Si se ha llegado al maximo de habitaciones, salir del bucle
				habitaciones_colocadas += 1
				if maximo_habitaciones == habitaciones_colocadas:
					break

def dibujarMapa(dimx, dimy, mapa):

	#Dibujar las casillas del mapa
	for y in range(dimy):
		for x in range(dimx):
			if mapa[y][x] == 0:
				sys.stdout.write(". ") #Interior de habitacion
			if mapa[y][x] == 1:
				sys.stdout.write("  ") #Espacio vacio
			elif mapa[y][x] == 2:
				sys.stdout.write("# ") #Pared
			elif mapa[y][x] == 3:
				sys.stdout.write(', ') #Pasillo
			elif mapa[y][x] == 4:
				sys.stdout.write('D ') #Puerta
		print("")
	
	print("\n\n")


main()

Al ejecutar el programa os aparecerán las habitaciones de la mazmorra en texto ASCII:

De momento tenemos las habitaciones. ¡Yaay! 🙂


2. Pasillos

A no ser que vuestro juego se llame Dwarf Simulator y trate de excavar túneles, necesitamos unir las habitaciones con pasadizos sin dejar ninguna zona aislada. ¿Cómo lo hacemos?

Vamos a crear una segunda matriz, llamada mapa de costes. Cada espacio de esta matriz indica la dificultad de excavar un pasillo en esa posición, siendo 1 el coste mínimo y 3 el coste máximo (el cero indica coste infinito; no puede excavarse ningún pasillo). Como veréis dentro de un momento, este mapa de costes nos será indispensable para trazar los pasillos de forma óptima…

Mapa de mazmorra y su mapa de costes equivalente.

 

Ahora, dentro de la función colocarHabitaciones() no sólo vamos a ir llenando la matriz del mapa con nuevas salas, sino que también actualizaremos el mapa de costes y guardaremos las coordenadas (x,y) del centro de cada habitación en un vector llamado centros.

 

Además, como las paredes de las habitaciones son terreno infranqueable habrá que crear entre 1 y 4 “puertas falsas” en el mapa de costes. Estas sólo se convertirán en puertas “reales” en la matriz del mapa si al final hay algún pasillo que pasa por ellas.

Al colocar cada sala le abriremos entre 1 y 4 puertas al azar, pero sólo en el mapa de costes (de momento).

 

Bien… ¿qué hacemos con los centros y el mapa de costes? ¿Cuál es el sentido de haberlos creado?

La función crearPasillos() recibirá este mapa de costes y el vector de centros. Después utilizaremos la función findPath() de la librería pathfinding para encontrar el camino más corto entre el centro de la primera habitación y el resto de centros. De esta forma conseguiremos que todas las habitaciones estén conectadas.

Cada vez que se excave un nuevo pasillo este se añadirá en el mapa de costes. Como los pasillos tienen menor coste que las casillas de tierra, el algoritmo aprovechará pasillos previamente excavados formando bifurcaciones.

De acuerdo, ¡ya vale de explicaciones! Empezaremos por modificar la función main() para crear una segunda matriz y llamar a la función crearPasillos():

def main():

	#Dimensiones del mapa
	dimx = 30
	dimy = 30

	#El array 'mapa' guarda el contenido de cada casilla de la mazmorra:
	mapa = np.ones((dimy,dimx), dtype=int)
	#Significado del valor de las casillas:
	#	'0' - El espacio esta ocupado por el interior de una habitacion
	#	'1' - El espacio contiene tierra
	#	'2' - El espacio contiene el muro de una habitacion
	#	'3' - El espacio contiene un pasillo
	#	'4' - El espacio contiene una puerta


	#Este segundo mapa indica el coste de excavar un pasillo en una casilla de la mazmorra
	#Necesario para trazar los pasillos
	mapa_costes = np.ones((dimy, dimx), dtype=int)
	mapa_costes.fill(3)
	#Significado de los valores del mapa de costes:
	#	'0' - Coste 'infinito' (muro de habitacion - no se puede excavar)
	#	'1' - Coste bajo (interior de habitaciones)
	#	'2' - Coste medio (pasadizos ya excavados)
	#	'3' - Coste alto (casillas aun no excavadas)

	#Formula para establecer el maximo de habitaciones en funcion de las dimensiones del mapa
	maximo_habitaciones = int((dimx*dimy)/300) + random.randint(0, 4)

	#Colocar habitaciones sobre la matriz del mapa y guardar sus centros
	centros = colocarHabitaciones(dimx, dimy, mapa, mapa_costes, maximo_habitaciones)

	#Unir las habitaciones con pasillos
	crearPasillos(mapa, mapa_costes, centros)

	#Dibujar las habitaciones
	dibujarMapa(dimx, dimy, mapa)

 

La funcion colocarHabitaciones() ahora también va a actualizar el mapa de costes al crear una nueva habitación, y devolverá un vector con los centros de cada una.

def colocarHabitaciones(dimx, dimy, mapa, mapa_costes, maximo_habitaciones):

	#Creamos un array para guardar las coordenadas de todos los centros de las habitaciones
	centros = []

	#Variable que guarda el numero de habitaciones colocadas hasta el momento
	habitaciones_colocadas = 0

	while habitaciones_colocadas < 2:
		for i in range(200):
			#Elegir las dimensiones del interior de la habitacion
			sizex = random.randint(3,7)
			sizey = random.randint(3,7)

			#Elegir una posicion al azar para la esquina superior de la habitacion
			posx = random.randint(2, dimx-3-sizex)
			posy = random.randint(2, dimy-3-sizey)

			#Comprobar que se pueda colocar la habitacion sin chocar con ninguna otra
			submapa = mapa[(posy-2):(posy+sizey+2), (posx-2):(posx+sizex+2)]
			if np.all(submapa == 1):

				#Actualizar el coste de estas casillas en el mapa de costes
				mapa_costes[(posy-1):(posy+sizey+1), (posx-1):(posx+sizex+1)] = 0 #Los muros tienen coste infinito
				mapa_costes[(posy):(posy+sizey), (posx):(posx+sizex)] = 1 #El interior tiene coste bajo

				#Construimos la habitacion con sus muros
				mapa[(posy-1):(posy+sizey+1), (posx-1):(posx+sizex+1)] = 2
				mapa[(posy):(posy+sizey), (posx):(posx+sizex)] = 0

				#Guardamos las coordenadas del centro de la habitacion en el array de habitaciones
				cx = posx + int(sizex/2)
				cy = posy + int(sizey/2)
				centros.append((cx, cy))
			
			
				#Elegimos al azar las paredes de la habitacion donde vamos a colocar las puertas
				paredes_elegidas = random.sample(["norte", "este", "oeste", "sur"], k = random.randint(1,4))

				for pared in paredes_elegidas:
					if pared == "norte":
						#Coordenadas (y,x) para la puerta norte
						cy = posy-1
						cx = int(posx+(sizex+1)/2)

					elif pared == "este":
						#Coordenadas (y,x) para la puerta este
						cy = int(posy-1+(sizey+1)/2)
						cx = posx+sizex

					elif pared == "oeste":
						#Coordenadas (y,x) para la puerta oeste
						cy = int(posy-1+(sizey+1)/2)
						cx = posx-1
					else:
						cy = posy+sizey
						cx = int(posx+(sizex+1)/2)

					#Colocamos una apertura en el muro de la habitacion
					mapa_costes[cy][cx] = 1

			

				#Si se ha llegado al maximo de habitaciones salimos del bucle
				habitaciones_colocadas += 1
				if maximo_habitaciones == habitaciones_colocadas:
					break

	return centros

 

Por último escribimos la función crearPasillos():

def crearPasillos(mapa, mapa_costes, centros):

	#Crear el objeto AStarFinder()
	finder = AStarFinder()

	#Guardar la primera habitacion creada
	primera_habitacion = centros[0]

	#Array para guardar las casillas de los pasillos
	pasillos = []

	for habitacion in centros[1:]:
		#Crear un objeto Grid a partir del mapa de costes
		grid = Grid(matrix=mapa_costes) #NOTA: Grid es un objeto propio de la libreria pathfinding

		#Guardar las dos casillas como nodos inicial y final
		start = grid.node(primera_habitacion[0], primera_habitacion[1])
		end = grid.node(habitacion[0], habitacion[1])

		#Calcular el camino entre los nodos 'start' y 'end'
		camino, _ = finder.find_path(start, end, grid)

		#Actualizar el mapa de costes y guardar los pasillos en el array
		for casilla in camino:
			#Guardar las casillas del camino en el array de pasillos
			pasillos.append(casilla)

			#Actualizar el mapa de costes con el nuevo pasadizo
			if mapa_costes[casilla[1]][casilla[0]] == 3:
				mapa_costes[casilla[1]][casilla[0]] = 2

	#Pintar los pasillos en el array del mapa
	for casilla in pasillos:
		#Si el espacio contiene tierra, colocar un pasillo
		if mapa[casilla[1]][casilla[0]] == 1:
			mapa[casilla[1]][casilla[0]] = 3

		#Si el espacio es un muro, colocar una puerta
		elif mapa[casilla[1]][casilla[0]] == 2:
			mapa[casilla[1]][casilla[0]] = 4

 

Una vez hechos estos cambios, el código nos tendría que quedar así:

import numpy as np
import sys
import random
import pygame
from pathfinding.core.grid import Grid
from pathfinding.finder.a_star import AStarFinder
from pathfinding.core.diagonal_movement import DiagonalMovement
 
def main():
 
    #Dimensiones del mapa
    dimx = 30
    dimy = 30
 
    #El array 'mapa' guarda el contenido de cada casilla de la mazmorra:
    mapa = np.ones((dimy,dimx), dtype=int)
    #Significado del valor de las casillas:
    #   '0' - El espacio esta ocupado por el interior de una habitacion
    #   '1' - El espacio contiene tierra
    #   '2' - El espacio contiene el muro de una habitacion
    #   '3' - El espacio contiene un pasillo
    #   '4' - El espacio contiene una puerta
 
 
    #Este segundo mapa indica el coste de excavar un pasillo en una casilla de la mazmorra
    #Necesario para trazar los pasillos
    mapa_costes = np.ones((dimy, dimx), dtype=int)
    mapa_costes.fill(3)
    #Significado de los valores del mapa de costes:
    #   '0' - Coste 'infinito' (muro de habitacion - no se puede excavar)
    #   '1' - Coste bajo (interior de habitaciones)
    #   '2' - Coste medio (pasadizos ya excavados)
    #   '3' - Coste alto (casillas aun no excavadas)
 
    #Formula para establecer el maximo de habitaciones en funcion de las dimensiones del mapa
    maximo_habitaciones = int((dimx*dimy)/300) + random.randint(0, 4)
 
    #Colocar habitaciones sobre la matriz del mapa y guardar sus centros
    centros = colocarHabitaciones(dimx, dimy, mapa, mapa_costes, maximo_habitaciones)
 
    #Unir las habitaciones con pasillos
    crearPasillos(mapa, mapa_costes, centros)
 
    #Dibujar las habitaciones
    dibujarMapa(dimx, dimy, mapa)
 
 
 
def colocarHabitaciones(dimx, dimy, mapa, mapa_costes, maximo_habitaciones):
 
    #Creamos un array para guardar las coordenadas de todos los centros de las habitaciones
    centros = []
 
    #Variable que guarda el numero de habitaciones colocadas hasta el momento
    habitaciones_colocadas = 0
 
    while habitaciones_colocadas < 2:
        for i in range(200):
            #Elegir las dimensiones del interior de la habitacion
            sizex = random.randint(3,7)
            sizey = random.randint(3,7)
 
            #Elegir una posicion al azar para la esquina superior de la habitacion
            posx = random.randint(2, dimx-3-sizex)
            posy = random.randint(2, dimy-3-sizey)
 
            #Comprobar que se pueda colocar la habitacion sin chocar con ninguna otra
            submapa = mapa[(posy-2):(posy+sizey+2), (posx-2):(posx+sizex+2)]
            if np.all(submapa == 1):
 
                #Actualizar el coste de estas casillas en el mapa de costes
                mapa_costes[(posy-1):(posy+sizey+1), (posx-1):(posx+sizex+1)] = 0 #Los muros tienen coste infinito
                mapa_costes[(posy):(posy+sizey), (posx):(posx+sizex)] = 1 #El interior tiene coste bajo
 
                #Construimos la habitacion con sus muros
                mapa[(posy-1):(posy+sizey+1), (posx-1):(posx+sizex+1)] = 2
                mapa[(posy):(posy+sizey), (posx):(posx+sizex)] = 0
 
                #Guardamos las coordenadas del centro de la habitacion en el array de habitaciones
                cx = posx + int(sizex/2)
                cy = posy + int(sizey/2)
                centros.append((cx, cy))
             
             
                #Elegimos al azar las paredes de la habitacion donde vamos a colocar las puertas
                paredes_elegidas = random.sample(["norte", "este", "oeste", "sur"], k = random.randint(1,4))
 
                for pared in paredes_elegidas:
                    if pared == "norte":
                        #Coordenadas (y,x) para la puerta norte
                        cy = posy-1
                        cx = int(posx+(sizex+1)/2)
 
                    elif pared == "este":
                        #Coordenadas (y,x) para la puerta este
                        cy = int(posy-1+(sizey+1)/2)
                        cx = posx+sizex
 
                    elif pared == "oeste":
                        #Coordenadas (y,x) para la puerta oeste
                        cy = int(posy-1+(sizey+1)/2)
                        cx = posx-1
                    else:
                        cy = posy+sizey
                        cx = int(posx+(sizex+1)/2)
 
                    #Colocamos una apertura en el muro de la habitacion
                    mapa_costes[cy][cx] = 1
 
             
 
                #Si se ha llegado al maximo de habitaciones salimos del bucle
                habitaciones_colocadas += 1
                if maximo_habitaciones == habitaciones_colocadas:
                    break
 
    return centros

def crearPasillos(mapa, mapa_costes, centros):
 
    #Crear el objeto AStarFinder()
    finder = AStarFinder()
 
    #Guardar la primera habitacion creada
    primera_habitacion = centros[0]
 
    #Array para guardar las casillas de los pasillos
    pasillos = []
 
    for habitacion in centros[1:]:
        #Crear un objeto Grid a partir del mapa de costes
        grid = Grid(matrix=mapa_costes) #NOTA: Grid es un objeto propio de la libreria pathfinding
 
        #Guardar las dos casillas como nodos inicial y final
        start = grid.node(primera_habitacion[0], primera_habitacion[1])
        end = grid.node(habitacion[0], habitacion[1])
 
        #Calcular el camino entre los nodos 'start' y 'end'
        camino, _ = finder.find_path(start, end, grid)
 
        #Actualizar el mapa de costes y guardar los pasillos en el array
        for casilla in camino:
            #Guardar las casillas del camino en el array de pasillos
            pasillos.append(casilla)
 
            #Actualizar el mapa de costes con el nuevo pasadizo
            if mapa_costes[casilla[1]][casilla[0]] == 3:
                mapa_costes[casilla[1]][casilla[0]] = 2
 
    #Pintar los pasillos en el array del mapa
    for casilla in pasillos:
        #Si el espacio contiene tierra, colocar un pasillo
        if mapa[casilla[1]][casilla[0]] == 1:
            mapa[casilla[1]][casilla[0]] = 3
 
        #Si el espacio es un muro, colocar una puerta
        elif mapa[casilla[1]][casilla[0]] == 2:
            mapa[casilla[1]][casilla[0]] = 4
 
def dibujarMapa(dimx, dimy, mapa):
 
    #Dibujar las casillas del mapa
    for y in range(dimy):
        for x in range(dimx):
            if mapa[y][x] == 0:
                sys.stdout.write(". ") #Interior de habitacion
            if mapa[y][x] == 1:
                sys.stdout.write("  ") #Espacio vacio
            elif mapa[y][x] == 2:
                sys.stdout.write("# ") #Pared
            elif mapa[y][x] == 3:
                sys.stdout.write(', ') #Pasillo
            elif mapa[y][x] == 4:
                sys.stdout.write('D ') #Puerta
        print("")
     
    print("\n\n")
 
 
main()

 

3. Grafos aleatorios

Imaginad por un momento que nuestra mazmorra es un grafo dónde las habitaciones son los vértices y los pasillos las aristas. Ahora mismo nuestro algoritmo genera un grafo de árbol porque la primera habitación se conecta con todas las otras de forma jerárquica. No hay bucles de habitaciones que se interconecten entre sí (a no ser que por azar dos pasillos se toquen).

 

A ver, las mazmorras no nos quedan mal pero… ¿existe una forma para hacer que el mapa sea más imprevisible? ¿No podríamos conectar las habitaciones al azar, y no de forma jerárquica? ¿Y que además formen bucles que puedan desorientar al jugador?

¡Pues sí! Y la forma de hacerlo será crear un grafo aleatorio. Dividiremos el vector con los centros de las habitaciones en dos sub-vectores: habitaciones “visitadas” (en rojo, abajo) y “pendientes” (en azul). Inicialmente sólo habrá el centro de la habitación inicial en la lista de habitaciones visitadas.

 

Mientras queden habitaciones en la lista de “pendientes” elegiremos al azar una habitación de cada lista y las conectaremos. Después moveremos la habitación que hemos elegido de “pendientes” a “visitadas” y repetiremos el proceso.

 

Cada vez que se elija una habitación de la lista de “visitadas” habrá una pequeña probabilidad de que en vez de conectarse con una habitación pendiente se conecte con una de ya visitada. De esta forma se podrán formar bucles.

 

Por tanto tendremos cambiar algunas cosas de la función crearPasillos()

def crearPasillos(mapa, mapa_costes, centros):

	#Crear el objeto AStarFinder()
	finder = AStarFinder()

	#Guardar la primera habitacion creada
	primera_habitacion = centros[0]

	#Crear los dos vectores de habitaciones
	hab_visitadas = [centros[0]]
	hab_pendientes = centros[1:]

	#Array para guardar las casillas de los pasillos
	pasillos = []

	while len(hab_pendientes) > 0:
		#Crear un objeto Grid a partir del mapa de costes
		grid = Grid(matrix=mapa_costes)

		#Elegimos una habitacion de la lista de habitaciones visitadas
		hab1 = random.choice(hab_visitadas)

		#Cada habitacion tiene 1/5 probabilidades de formar un bucle con una habitacion ya visitada
		if random.randint(1,5) == 1 and len(hab_visitadas) > 1:
			hab2 = random.choice(hab_visitadas)
			while(hab2 == hab1):
				hab2 = random.choice(hab_visitadas)
				
		else:
			#Elegimos otra habitacion de la lista de habitaciones pendientes
			hab2 = random.choice(hab_pendientes)

			#Ponemos la 2a habitacion en la lista de visitadas
			hab_visitadas.append(hab2)
			hab_pendientes.remove(hab2)

		#Guardar las dos casillas como nodos
		start = grid.node(hab1[0], hab1[1])
		end = grid.node(hab2[0], hab2[1])

		#Calcular el camino entre los nodos 'start' y 'end'
		camino, _ = finder.find_path(start, end, grid)

		#Actualizar el mapa de costes y guardar los pasillos en el array
		for casilla in camino:
			#Guardar las casillas del camino en el array de pasillos
			pasillos.append(casilla)

			#Actualizar el mapa de costes con el nuevo pasadizo
			if mapa_costes[casilla[1]][casilla[0]] == 3:
				mapa_costes[casilla[1]][casilla[0]] = 2

	#Pintar los pasillos en el array del mapa
	for casilla in pasillos:
		#Si el espacio contiene tierra, colocar un pasillo
		if mapa[casilla[1]][casilla[0]] == 1:
			mapa[casilla[1]][casilla[0]] = 3

		#Si el espacio es un muro, colocar una puerta
		elif mapa[casilla[1]][casilla[0]] == 2:
			mapa[casilla[1]][casilla[0]] = 4

4. Integración con Pygame

Dibujar los mapas con carácteres ASCII puede dar un toque muy retro a tu juego, pero en pleno siglo XXI puede que prefieras dibujar cada casilla del mapa con sprites…

A modo de ejemplo os he preparado una carpeta con losetas de 16×16 píxeles que podéis utilizar. Podéis descargarla desde aquí.

Para construir el mapa utilizaremos estos sprites de 16×16 píxeles.

Añadiremos un par de modificaciones a la función main() para que aparezca una ventana de Pygame. Y en vez de tener un valor constante para el tamaño de la matriz le pediremos al usuario que introduzca sus dimensiones.

def main():

	#Pedir al usuario las dimensiones del mapa
	dimx = int(input("Introduce ancho del mapa: "))
	dimy = int(input("Introduce largo del mapa: "))

	#El mapa deberia tener, por lo menos, una dimension de 20x20
	dimx = max(dimx, 20)
	dimy = max(dimy, 20)

	#El array 'mapa' guarda el contenido de cada casilla de la mazmorra:
	mapa = np.ones((dimy,dimx), dtype=int)
	#Significado del valor de las casillas:
	#	'0' - El espacio esta ocupado por el interior de una habitacion
	#	'1' - El espacio contiene tierra
	#	'2' - El espacio contiene el muro de una habitacion
	#	'3' - El espacio contiene un pasillo
	#	'4' - El espacio contiene una puerta


	#Este segundo mapa indica el coste de excavar un pasillo en una casilla de la mazmorra
	#Necesario para trazar los pasillos
	mapa_costes = np.ones((dimy, dimx), dtype=int)
	mapa_costes.fill(3)
	#Significado de los valores del mapa de costes:
	#	'0' - Coste 'infinito' (muro de habitacion - no se puede excavar)
	#	'1' - Coste bajo (interior de habitaciones)
	#	'2' - Coste medio (pasadizos ya excavados)
	#	'3' - Coste alto (casillas aun no excavadas)

	#Formula para establecer el maximo de habitaciones en funcion de las dimensiones del mapa
	maximo_habitaciones = int((dimx*dimy)/300) + random.randint(0, 4)

	#Colocar habitaciones sobre la matriz del mapa y guardar sus centros
	centros = colocarHabitaciones(dimx, dimy, mapa, mapa_costes, maximo_habitaciones)

	#Unir las habitaciones con pasillos
	crearPasillos(mapa, mapa_costes, centros)



	#-----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))
	gameDisplay2 = pygame.display.set_mode((ancho_ventana, alto_ventana))
	pygame.display.set_caption('Dungeon Party')
	
	#Iniciar pygame
	pygame.init()

	
	#Dibujar la ventana con el mapa
	dibujarMapa(dimx, dimy, 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

	pygame.quit()

 

dibujarMapa() se encargará de pintar los sprites sobre la ventana.

def dibujarMapa(dimx, dimy, mapa, gameDisplay, casillax, casillay):

		gameDisplay.fill((25,25,25)) #Resetear la pantalla

		#Recorrer todo el array del mapa
		for y in range(dimy):
			sys.stdout.write("\n")
			for x in range(dimx):

				#Interior de habitacion
				if mapa[y][x] == 0:
					sys.stdout.write(". ")
					tile = pygame.image.load('tiles/floor1.bmp').convert_alpha()
					gameDisplay.blit(tile, (x*casillax,y*casillay))

				#Espacio vacio
				if mapa[y][x] == 1:
					sys.stdout.write("  ")
					if y != 0:
						if mapa[y-1][x] == 2 or mapa[y-1][x] == 3:
							tile = pygame.image.load('tiles/abyss.bmp').convert_alpha()
							gameDisplay.blit(tile, (x*casillax,y*casillay))
				#Pared de habitacion	
				elif mapa[y][x] == 2:
					sys.stdout.write("# ")
					
					#Si en la casilla de abajo hay una pared, habra que colocar un muro de tipo 2
					if mapa[y+1][x] == 2:
						tile = pygame.image.load('tiles/wall2.bmp').convert_alpha()
					else:
						tile = pygame.image.load('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 mapa[y-1][x] != 2:
						image = pygame.image.load('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))

				#Pasillo
				elif mapa[y][x] == 3:
					sys.stdout.write(', ')
					tile = pygame.image.load('tiles/floor1.bmp').convert_alpha()
					gameDisplay.blit(tile, (x*casillax,y*casillay))

				#Puerta
				elif mapa[y][x] == 4:
					sys.stdout.write('D ')
					tile = pygame.image.load('tiles/floor2.bmp').convert_alpha()
					gameDisplay.blit(tile, (x*casillax,y*casillay))
				
		pygame.display.update()

Fijáos en un detalle. Cuándo colocamos los muros (valor ‘2’ en la matriz), también comprobamos qué tipo de casilla hay arriba (y-1) y abajo (y+1) para elegir qué sprite hay que colocar.

Si la casilla de abajo es otra pared, significa que la casilla actual se encuentra en el muro este u oeste de una habitación, por tanto hay que dibujar el sprite “wall2.bmp”. De lo contrario hay que dibujar el sprite “wall1.bmp”. Del mismo modo, si la casilla que hay encima no es un muro, dibujamos el sprite “wall_border_alpha.bmp”, que bloqueará parcialmente la visión y creará un efecto 2.5D.

Del mismo modo, cuándo hay un espacio vacío (valor ‘1’ en el mapa) colocaremos el sprite “abyss.bmp” si la casilla de encima no es otro espacio vacío. De esta forma parecerá que la mazmorra esté flotando sobre el vacío. And that’s so cool.

Más adelante en esta serie de tutoriales veremos formas más sofisticadas para dibujar nuestro mapa y lo poblaremos con más objetos: tesoros, puertas, diferentes tipos de baldosas…


Conclusión

¡Bueno! Si habéis llegado hasta aquí ya conocéis las bases para construir mazmorras aleatorias. Por supuesto hay métodos mucho más complejos y elegantes para generar mapas. Pero ya los veremos más adelante…

Como siempre, no dudéis en escribirme si hay algún punto de este tutorial que no queda claro o encontráis algún error. Y por supuesto, si os ha gustado esta guía dejadnos un like en nuestra página de Facebook o de Twitter.

¡Un saludo, gente, y hasta la próxima!

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.

4
Deja un comentario

avatar
4 Hilos iniciados
0 Respuestas a hilos
0 Followers
 
Most reacted comment
Hottest comment thread
4 Nº autores comentarios
electronicwoodDariozeroxjavier Autores de comentarios recientes
más nuevos primero más antiguos primero
electronicwood
Humano
electronicwood

Interesante artículo.

Dario
Humano
Dario

Gracias me ayudó mucho

zerox
Humano
zerox

Interesante! Espero la segunda parte con ganas…

javier
Humano
javier

Muy interesante. Mil gracias,