3

Tutorial de Mazmorras aleatorias con Godot Engine

¡Hola, queridos súbditos de la oscuridad! Desde que publiqué el último tutorial de nuestra serie de generación de mazmorras procedurales con Python, en el que os enseñaba cómo generar escenarios a partir de módulos prefabricados, algunos nos habéis escrito preguntando si podíamos hacer lo mismo con Godot Engine.

Como últimamente parece que Godot está de moda, he pensado que daría para un tutorial bastante interesante (¡y divertido!). Además veremos algunas herramientas que tiene el motor y que nos simplifican el trabajo, con lo que tendremos programado nuestro generador en un periquete.

Al terminar este tutorial habrás programado un pequeño “juego” con Godot capaz de generar escenarios como este:


Proceso para generar la mazmorra

Vamos a ver cuál es la idea que utilizaremos para generar nuestra mazmorra.

En primer lugar, nuestro programa dispondrá de una “paleta” con todos los tipos de habitaciones diferentes. La idea es combinarlas para formar un escenario.

Estos son todos los tipos de habitación que podrá tener la mazmorra.

Cada habitación tendrá varios conectores (flechas rojas en la imagen anterior), que indicarán las posiciones en las que es posible crear un pasillo que se conecte a otra habitación.

Empezaremos por colocar una habitación inicial sobre las coordenadas (0,0) del mapa. Forzaremos que sea de un tipo que tenga muchos conectores:

Empezamos por colocar la primera habitación sobre el mapa.

 

Después, mientras no se alcance el máximo de habitaciones, se elegirá un conector de una habitación del mapa al azar que no haya sido utilizado previamente. También se elegirá una habitación al azar de la paleta junto a uno de sus conectores:

Habitación seleccionada (naranja) y los dos conectores elegidos (verde)

 

Se creará una instancia de la nueva habitación sobre el mapa y se girará para que los dos conectores elegidos  apunten en direcciones opuestas.

Se girará la instancia de la nueva habitación para que los dos conectores apunten en direcciones opuestas.

 

Después se moverá la nueva habitación de tal forma que el centro de los dos conectores elegidos quede superpuesto.

Se traslada la nueva habitación para que los dos conectores coincidan.

 

Por último, se comprueba que esta nueva habitación no se solape con el área de alguna habitación previamente colocada. Si fuera el caso, se elimina la nueva habitación y se vuelve a empezar este proceso (se elige una nueva habitación, dos conectores, etc).

Los conectores utilizados se guardarán en un array de conectores utilizados. Los conectores que estén disponibles y que todavía no se hayan usado se almacenarán en un array de conectores libres.

Una vez alcanzado el máximo de habitaciones (o cuándo no puedan colocarse más por falta de espacio) se dibujará un pasadizo en cada conector de la lista de conectores utilizados. De esta forma parecerá que las habitaciones estén conectadas.

Se van colocando habitaciones hasta llegar al máximo.

Se coloca un pasadizo encima de cada conector utilizado.


Escena básica de Godot

Para no alargar innecesariamente este tutorial, os he preparado un proyecto de Godot básico sobre el que trabajaremos. Tenéis que descargarlo e importarlo dentro de Godot:

[Descargar proyecto Godot]

Como podéis ver, el proyecto contiene una escena principal (Main.tscn) con una cámara 2D. La cámara 2D lleva asociado un script que permite moverla con las flechas del teclado, pero de momento su movimiento está desactivado. También hay una máscara que bloquea la visión de la cámara. En nuestro script haremos que la máscara desaparezca y los controles para mover la cámara se activen cuándo el generador haya terminado de construir el mapa.

El proyecto también contiene una escena para cada una de las habitaciones (RoomX.tscn, dentro de la carpeta rooms). Cada habitación está formada por un Sprite con la imagen de la habitación, un área para detectar colisiones con otras habitaciones y sus conectores.

Los conectores (Conector.tscn, dentro de la carpeta assets) son esencialmente un nodo de tipo Node2D con un sprite en forma de flecha emparentado. La flecha es sólo una ayuda visual para ver la rotación del nodo en el editor; podéis borrarla o hacerla invisible antes de iniciar el juego.

Si ejecutáis el juego veréis que aparece una pantalla negra con el texto “Generando mapa…” indefinidamente, sin hacer nada más.


Código del generador

¡Vamos a programar el script para generar el mapa! Dentro de la escena Main.tscn, pulsad encima del nodo Main con el botón derecho → Attach Script. Cread un nuevo script en lenguaje gdscript con el nombre que queráis.

Dentro de Main.tscn, pulsar con el botón derecho encima del nodo Main.

Añadid un nuevo script.

Seleccionad el lenguaje GDScript y pulsad sobre “Create”

 

Veamos cómo programar el script paso a paso. Al final del tutorial también está el código completo para que podáis copiarlo directamente 🙂

Lo primero será definir los parámetros de nuestro generador, los arrays para las entradas, la “paleta” de habitaciones, etc:

"""		GENERADOR DE MAZMORRAS PARA GODOT

		Hecho con mucha ilusión por Glare y Transductor
		www.robologs.net
"""

extends Node2D

# "Paleta" de habitaciones
var array_habitaciones = [
	load("res://rooms/Room1.tscn"),
	load("res://rooms/Room2.tscn"),
	load("res://rooms/Room3.tscn"),
	load("res://rooms/Room4.tscn"),
	load("res://rooms/Room5.tscn")
]

#Sección de pasillo, que servirá para unir dos habitaciones
var pasillo = load("res://assets/Pasillo.tscn")

#Arrays para almacenar los conectores libres y los que ya se han utilizado
var lista_conectores_libres = []
var lista_conectores_usados = []

#Array para guardar todas las habitaciones del mapa
var habitaciones_colocadas = []
#(no tiene mucha utilidad en este script, pero puede interesarte para futuras ampliaciones)

#Definir el máximo de habitaciones que podrá tener el mapa
var maxhab = 20
#(probablemente acabará generando bastantes menos)

 

Después definimos la función _ready(). Tan sólo se encargará de generar una semilla aleatoria.

func _ready():
	#Generar semilla aleatoria
	randomize()
	

 

Ahora definiremos un seguido de funciones propias que nos ayudarán a encapsular bien el código de nuestro generador.

La primera de estas funciones es crear_habitacion_aleatoria(), que elige al azar una habitación de la “paleta”, la coloca sobre la posición (0,0) y la retorna fuera de la función.

func crear_habitacion_aleatoria():
	
	#Elegir una habitación de la paleta y colocarla a la posición (0,0) del mapa
	var habitacion_elegida = array_habitaciones[randi() % array_habitaciones.size()]
	var nueva_habitacion = habitacion_elegida.instance()
	add_child(nueva_habitacion)
	
	#Retornar la nueva habitación
	return nueva_habitacion

 

La segunda función se llama conectar(). Recibe como parámetros una instancia de una de las habitaciones de la paleta, y dos conectores (uno de ellos pertenecerá a una habitación previamente colocada, y el segundo pertenecerá a la instancia de la nueva habitación). La función se encargará de mover y rotar la nueva habitación de tal forma que los dos conectores queden superpuestos y apuntando en direcciones opuestas.

func conectar(hab_instance, conector_mapa, conector_hab):
	
	#Girar la habitación, de tal forma que conector_mapa y conector_hab apunten en direcciones opuestas
	hab_instance.rotation = conector_mapa.global_rotation  - conector_hab.global_rotation + PI
	
	#Mover la nueva habitación para que coincidan los conectores
	var x = conector_mapa.global_position[0] - conector_hab.global_position[0]
	var y = conector_mapa.global_position[1] - conector_hab.global_position[1]
	hab_instance.global_position.x += x
	hab_instance.global_position.y += y

 

La función comprobar_colision_habitaciones() recibe como parámetro una instancia de habitación y comprueba si se solapa con alguna otra. Para ello hacemos uso del método get_overlapping_areas() (propio de los nodos de tipo “Area2D”) que devuelve un array con todas las áreas con que se solapa. Si este array tiene longitud cero, significará que la nueva habitación no colisiona con ninguna otra.

func comprobar_colision_habitaciones(hab):
		#Comprobar si la habitación 'hab' se solapa con alguna otra
		var area = hab.get_node("Area2D")
		var sup = area.get_overlapping_areas()
		return (len(sup) == 0)

 

La última función propia que vamos a definir será colocar_pasillos(), que recorrerá el array de conectores utilizados y colocará dos secciones de pasillo encima de cada uno, girados 180 entre sí. De esta forma parecerá que las habitaciones estén conectadas.

func colocar_pasillos():
	#Por cada conector utilizado, colocar dos secciones de pasillo opuestas, que conectarán las habitaciones
	for con in lista_conectores_usados:
		var nuevo_pasillo = pasillo.instance()
		add_child(nuevo_pasillo)
		nuevo_pasillo.global_position = con.global_position
		nuevo_pasillo.global_rotation = con.global_rotation
		
		nuevo_pasillo = pasillo.instance()
		add_child(nuevo_pasillo)
		nuevo_pasillo.global_position = con.global_position
		nuevo_pasillo.global_rotation = con.global_rotation + PI

 

Por último vamos a escribir la función _process() para que genere el escenario llamando, cuando sea necesario, las funciones que acabamos de definir. Pero antes me gustaría hacer un paréntesis.

¿Por qué escribimos el código de nuestro generador dentro de _process()? Como sólo queremos generar la mazmorra una sola vez al iniciar el juego, ¿no sería mejor ponerlo todo dentro de la función _ready()? Pues sí… y no.

Para poder saber si dos áreas se solapan (con get_overlapping_areas() ) se necesita que el motor de físicas de Godot calcule las colisiones del área actual con todas las otras áreas del juego. Dicho de otra forma: para detectar las colisiones entre áreas se necesita invocar el motor de físicas aunque nuestro “juego” no tenga físicas. No tengo muy claro si esto es deliberado o es un fallo de Godot; el caso es que si colocamos el código del generador dentro de _ready(), no habrá forma de actualizar las colisiones porque esta función se ejecutará antes de que el motor de físicas empiece a calcular frames. Tampoco conozco ninguna manera de forzar manualmente al motor de físicas para que recalcule todas las colisiones.

La solución un poco chapucera es poner el código del generador dentro de la función _process(), y utilizar la función yield() para pausarla un frame cada vez que se intente calcular una colisión. Así se da tiempo al motor de físicas para que recalcule las colisiones. Si alguien tiene una idea mejor, soy toda oídos…

Por tanto, este será el código de _process():

func _process(delta):
	
	# La función _process() sólo debe ejecutarse UNA ÚNICA VEZ.
	# Por tanto, hay que desactivarla nada más empezar
	self.set_process(false)
	
	#Crear la primera habitacion
	var primera_habitacion = array_habitaciones[0].instance()
	add_child(primera_habitacion)
	habitaciones_colocadas.append(primera_habitacion)
	
	#Añadir todas sus conectores a la lista de conectores libres
	var n_conectores = primera_habitacion.get_node("conectores").get_child_count()
	for i in range(n_conectores):
		lista_conectores_libres.append(primera_habitacion.get_node("conectores").get_child(i))
	
	
	#Generar el resto de habitaciones:
	var intentos = 0
	var valido = false
	while len(habitaciones_colocadas) < maxhab-1 and intentos < 100:
		
		#Crear una nueva habitación al azar
		var nueva_habitacion = crear_habitacion_aleatoria()
		
		#Elegir un conector al azar de la nueva habitación
		n_conectores = nueva_habitacion.get_node("conectores").get_child_count()
		var conector_hab = nueva_habitacion.get_node("conectores").get_child(randi() % n_conectores)
		
		#Elegir un conector al azar de una habitación previamente colocada (que esté libre)
		var conector_mapa = lista_conectores_libres[randi() % lista_conectores_libres.size()]
		
		#Mover y rotar la nueva habitación, para que los dos conectores coincidan
		conectar(nueva_habitacion, conector_mapa, conector_hab)
		
		#Para que el motor de físicas pueda calcular las colisiones, hay que esperar algunos frames.
		#Tras varias pruebas de ensayo-error, he encontrado que 2-3 frames es adecuado para que el juego
		#actualice todas las colisiones de los objetos del mapa.
		yield(get_tree(),"idle_frame")
		yield(get_tree(),"idle_frame")
		yield(get_tree(),"idle_frame")
		
		#Comprobar si la nueva habitacion se solapa con la actual
		valido = comprobar_colision_habitaciones(nueva_habitacion)
		
		if not valido:
			#...eliminar la nueva habitación...
			nueva_habitacion.queue_free()
			
			#... sumar +1 al número de intentos y volver a empezar
			intentos += 1
			
		#Si no se solapa (i.e. si la posición es valida)...
		elif valido:
			#...resetear el contador de intentos...
			intentos = 0
			
			#...añadir la habitación a la lista de habitaciones colocadas...
			habitaciones_colocadas.append(nueva_habitacion)
			
			#...eliminar el conector utilizado de la lista de conectores libres...
			lista_conectores_libres.erase(conector_mapa)
			
			#...guardarlo a la lista de conectores utilizados...
			lista_conectores_usados.append(conector_mapa)
			
			#...añadir los conectores de la nueva hab. (menos el que se ha utilizado) a la lista de conectores libres...
			for i in range(n_conectores):
				var con = nueva_habitacion.get_node("conectores").get_child(i)
				if con != conector_hab:
					lista_conectores_libres.append(con)

	#Si quieres, puedes mostrar por consola el núm de habitaciones descomentando la siguiente línea:
	#print("Se han colocado " + str(len(habitaciones_colocadas)) + " habitaciones (contando inicial)")
		
	#Colocar los pasillos:
	colocar_pasillos()
	
	#"Destapar" la cámara
	get_node("Camera2D").get_node("mascara").visible = false
	get_node("Camera2D").activarMovimiento(true)

Como puede verse, se empieza por colocar la primera habitación y añadir todos sus conectores a la lista de conectores libres.

Después se van colocando habitaciones hasta alcanzar el máximo, siguiendo el método que he explicado al principio: se elige un conector libre, se escoge una habitación de la paleta junto a uno de sus conectores y se coloca sobre el mapa.

Al final de todo se colocan los pasillos y se desbloquea la cámara y los controles.


Código completo del generador

 

"""		GENERADOR DE MAZMORRAS PARA GODOT

		Hecho con mucha ilusión por Glare y Transductor
		www.robologs.net
"""

extends Node2D

# "Paleta" de habitaciones
var array_habitaciones = [
	load("res://rooms/Room1.tscn"),
	load("res://rooms/Room2.tscn"),
	load("res://rooms/Room3.tscn"),
	load("res://rooms/Room4.tscn"),
	load("res://rooms/Room5.tscn")
]

#Sección de pasillo, que servirá para unir dos habitaciones
var pasillo = load("res://assets/Pasillo.tscn")

#Arrays para almacenar los conectores libres y los que ya se han utilizado
var lista_conectores_libres = []
var lista_conectores_usados = []

#Array para guardar todas las habitaciones del mapa
var habitaciones_colocadas = []
#(no tiene mucha utilidad en este script, pero puede interesarte para futuras ampliaciones)

#Definir el máximo de habitaciones que podrá tener el mapa
var maxhab = 20
#(probablemente acabará generando bastantes menos)


func _ready():
	#Generar semilla aleatoria
	randomize()
	
	
func crear_habitacion_aleatoria():
	
	#Elegir una habitación de la paleta y colocarla a la posición (0,0) del mapa
	var habitacion_elegida = array_habitaciones[randi() % array_habitaciones.size()]
	var nueva_habitacion = habitacion_elegida.instance()
	add_child(nueva_habitacion)
	
	#Retornar la nueva habitación
	return nueva_habitacion
	
func conectar(hab_instance, conector_mapa, conector_hab):
	
	#Girar la habitación, de tal forma que conector_mapa y conector_hab apunten en direcciones opuestas
	hab_instance.rotation = conector_mapa.global_rotation  - conector_hab.global_rotation + PI
	
	#Mover la nueva habitación para que coincidan los conectores
	var x = conector_mapa.global_position[0] - conector_hab.global_position[0]
	var y = conector_mapa.global_position[1] - conector_hab.global_position[1]
	hab_instance.global_position.x += x
	hab_instance.global_position.y += y
	

func comprobar_colision_habitaciones(hab):
		#Comprobar si la habitación 'hab' se solapa con alguna otra
		var area = hab.get_node("Area2D")
		var sup = area.get_overlapping_areas()
		return (len(sup) == 0)

func colocar_pasillos():
	#Por cada conector utilizado, colocar dos secciones de pasillo opuestas, que conectarán las habitaciones
	for con in lista_conectores_usados:
		var nuevo_pasillo = pasillo.instance()
		add_child(nuevo_pasillo)
		nuevo_pasillo.global_position = con.global_position
		nuevo_pasillo.global_rotation = con.global_rotation
		
		nuevo_pasillo = pasillo.instance()
		add_child(nuevo_pasillo)
		nuevo_pasillo.global_position = con.global_position
		nuevo_pasillo.global_rotation = con.global_rotation + PI

func _process(delta):
	
	# La función _process() sólo debe ejecutarse UNA ÚNICA VEZ.
	# Por tanto, hay que desactivarla nada más empezar
	self.set_process(false)
	
	#Crear la primera habitacion
	var primera_habitacion = array_habitaciones[0].instance()
	add_child(primera_habitacion)
	habitaciones_colocadas.append(primera_habitacion)
	
	#Añadir todas sus conectores a la lista de conectores libres
	var n_conectores = primera_habitacion.get_node("conectores").get_child_count()
	for i in range(n_conectores):
		lista_conectores_libres.append(primera_habitacion.get_node("conectores").get_child(i))
	
	
	#Generar el resto de habitaciones:
	var intentos = 0
	var valido = false
	while len(habitaciones_colocadas) < maxhab-1 and intentos < 100:
		
		#Crear una nueva habitación al azar
		var nueva_habitacion = crear_habitacion_aleatoria()
		
		#Elegir un conector al azar de la nueva habitación
		n_conectores = nueva_habitacion.get_node("conectores").get_child_count()
		var conector_hab = nueva_habitacion.get_node("conectores").get_child(randi() % n_conectores)
		
		#Elegir un conector al azar de una habitación previamente colocada (que esté libre)
		var conector_mapa = lista_conectores_libres[randi() % lista_conectores_libres.size()]
		
		#Mover y rotar la nueva habitación, para que los dos conectores coincidan
		conectar(nueva_habitacion, conector_mapa, conector_hab)
		
		#Para que el motor de físicas pueda calcular las colisiones, hay que esperar algunos frames.
		#Tras varias pruebas de ensayo-error, he encontrado que 2-3 frames es adecuado para que el juego
		#actualice todas las colisiones de los objetos del mapa.
		yield(get_tree(),"idle_frame")
		yield(get_tree(),"idle_frame")
		yield(get_tree(),"idle_frame")
		
		#Comprobar si la nueva habitacion se solapa con la actual
		valido = comprobar_colision_habitaciones(nueva_habitacion)
		
		if not valido:
			#...eliminar la nueva habitación...
			nueva_habitacion.queue_free()
			
			#... sumar +1 al número de intentos y volver a empezar
			intentos += 1
			
		#Si no se solapa (i.e. si la posición es valida)...
		elif valido:
			#...resetear el contador de intentos...
			intentos = 0
			
			#...añadir la habitación a la lista de habitaciones colocadas...
			habitaciones_colocadas.append(nueva_habitacion)
			
			#...eliminar el conector utilizado de la lista de conectores libres...
			lista_conectores_libres.erase(conector_mapa)
			
			#...guardarlo a la lista de conectores utilizados...
			lista_conectores_usados.append(conector_mapa)
			
			#...añadir los conectores de la nueva hab. (menos el que se ha utilizado) a la lista de conectores libres...
			for i in range(n_conectores):
				var con = nueva_habitacion.get_node("conectores").get_child(i)
				if con != conector_hab:
					lista_conectores_libres.append(con)

	#Si quieres, puedes mostrar por consola el núm de habitaciones descomentando la siguiente línea:
	#print("Se han colocado " + str(len(habitaciones_colocadas)) + " habitaciones (contando inicial)")
		
	#Colocar los pasillos:
	colocar_pasillos()
	
	#"Destapar" la cámara
	get_node("Camera2D").get_node("mascara").visible = false
	get_node("Camera2D").activarMovimiento(true)

Ejecutar el juego

Al iniciar el juego debería aparecer la pantalla negra con el texto “Generando mapa…” durante algunos segundos. Después aparecerá la mazmorra, y podremos mover la cámara con las flechas del teclado.


FAQs:

-P: La mazmorra está un poco sosa. ¿Cómo podría añadir monstruos, tesoros, trampas…?
R: La forma de hacerlo dependerá de como quieras estructurar tu juego, pero una manera fácil que se me ocurre es crear nodos2D en las habitaciones que indiquen las posiciones en las que pueda haber un objeto (sea un tesoro, un arma o un enemigo). Cada vez que se coloque una nueva habitación, puedes ir almacenando estos nodos en un array. Una vez hayas acabado de generar todas las habitaciones, puedes recorrer este array y, para cada nodo, elegir un objeto y crearlo en la misma posición.

-P: ¿Este generador podría extenderse a 3D?
R: Aunque el código sería un poco más complejo por estar trabajando con objetos 3D y tener varios ejes de rotación, la idea a seguir es exactamente la misma.

-P: ¿Puedo utilizar los sprites de este tutorial para mi proyecto?
R: Ehm… realmente te recomendaría dibujar algo más currado, pero sí, puedes usarlos libremente 🙂

-P: ¿…y el script del generador?
R: Sí, aunque si lo copias directamente y sin modificarlo agradecería un poquito de atribución (y un café, si acabas ganando el premio Game of The Year)

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.

guest
3 Comments
más nuevos primero
más antiguos primero
Inline Feedbacks
Ver todos los comentarios
Dafne
Dafne
4 meses

Muy buen articulo, me ha ayudado mucho

Jonathan
Jonathan
7 meses

Muy buena guía, para no utilizar el motor de física podrías utilizar Rect2 en lugar de Area2D, pero tendrías que comparar que no se solape una a una con las habitaciones ya creadas.