3

Tutorial de generación de mapas aleatorios con Python y OpenSimplex

[O sobre cómo ahorrarse un curro enorme en diseño de niveles]

Buenos días, homo fabers. A ver, ¡que levante la mano quién no se haya pulido tardes enteras jugando al Civilization o al Age of Empires! Si sóis aficionados a este tipo de juegos, os habréis fijado que muchos de ellos incluyen la opción de generar mapas aleatorios, haciendo que cada partida sea totalmente única.

¿Cuál es la magia negra que hay detrás de la generación procedural de mapas? Pues en realidad, es un proceso bastante sencillo que hoy os voy a explicar. ¡Vamos a intentar programar un pequeño script en python que nos genere mapas aleatorios!

Al terminar este tutorial, habréis aprendido a generar mapas tan bonitos como éste:

Generaremos el relieve del mapa, y diferentes biomas que variarán según la altitud y la humedad. ¿Qué os parece? ¿Empezamos?

¡Pues poned un poco de música y vayamos a ello!


Librerías y más librerías…

¿Qué se necesita para hacer este programa?

  • Python 3 instalado y funcionando. También debería funcionar igual en versiones anteriores, pero yo sólo lo he probado con la versión 3.5.2
  • OpenSimplex. Esta librería nos dará las funciones para generar mapas de ruido.
  • PIL, para mostrar el mapa
  • Numpy, para generar arrays vacíos.

Un poco de ruido…

En la mayoría de videojuegos de estrategia en tiempo real y por turnos, los mapas se almacenan en un array de 2 o más dimensiones. El valor de cada uno de los espacios puede indicar el tipo de terreno (agua, desierto, montaña, llanura…), la elevación, la temperatura…

En juegos clásicos como Civilization 2, el mapa se guarda dentro de un array de dos dimensiones. Cada espacio del array almacena un tipo de terreno.

Muchos juegos de este tipo permiten además generar mapas de forma aleatoria, la cuál cosa añade mucha rejugabilidad a un videojuego, haciendo que cada partida sea diferente.

Pero… ¿cómo puede crearse un mapa al azar? Si intentáis llenar el array con valores totalmente aleatorios obtendréis una especie de patchwork sin ningún tipo de patrón. El mapa debe ser al azar, sí, pero hay que “domesticar” esta aleatoriedad. La solución es usar funciones de Ruido, p. ej. Perlin Noise o Simplex Noise, como punto de partida. La siguiente imagen muestra como se vería una imagen con ruido totalmente aleatorio (izquierda) vs. Simplex Noise (derecha):


Ejemplo básico

¡Vamos al meollo! En este apartado veréis como crear un mapa básico, que más adelante vamos a mejorar.

La idea es generar un array y llenarlo con Simplex Noise con valores entre -1 y 1. Esto se corresponderá con la elevación del terreno, siendo cero el nivel del mar. Cada píxel se pintará de un color diferente según su elevación y se mostrará el mapa como una imagen:

Es un comienzo… pero no es muy realista. Más adelante añadiremos mejoras para hacerlo más natural.

from opensimplex import OpenSimplex
from PIL import Image
import numpy as np

#Crear el objeto opensimplex con una semilla (podeis introducirla a mano o poner una aleatoria)
gen_altura = OpenSimplex(seed = 3)

#Dimensiones del mapa
alto = 500
ancho = 500

#Array para guardar la elevacion
mapa_altura = np.empty([alto, ancho])

#Array para guardar los colores de la imagen
imagen = np.zeros((alto, ancho, 3), dtype = np.uint8)

#Valor de la frecuencia
#Aumentar este valor hace el mismo efecto que un zoom hacia afuera
freq = 3


#Llenar el array de elevacion
for y in range(alto):
	for x in range(ancho):
		#Dividir la posicion por la altura
		nx = x/ancho
		ny = y/alto

		#Generar el valor de la posicion actual
		mapa_altura[y][x] = gen_altura.noise2d(freq*nx, freq*ny)


#Dibujar el mapa
for j in range(len(mapa_altura)):
	for i in range(len(mapa_altura[j])):
		#Mar - Azul oscuro
		if mapa_altura[j][i] < -0.2:
			imagen[j][i] = [50,80,160]

		#Aguas costeras - Cyan
		elif mapa_altura[j][i] < 0:
			imagen[j][i] = [50, 100, 200]

		#Playa - Marron
		elif mapa_altura[j][i] < 0.07:
			imagen[j][i] = [221, 197, 111]

		#Llanura - verde claro
		elif mapa_altura[j][i] < 0.5:
			imagen[j][i] = [70, 160, 70]

		#Colinas - Gris oscuro
		elif mapa_altura[j][i] < 0.6:
			imagen[j][i] = [90, 90, 80]

		#Montes - Gris oscuro
		elif mapa_altura[j][i] < 0.8:
			imagen[j][i] = [110, 110, 95]

		#Monte - Gris claro
		else:
			imagen[j][i] = [140, 130, 120]


img = Image.fromarray(imagen, 'RGB')
img.show()

Veamos qué hace cada parte de este script…

En las primeras líneas cargamos las librerías que necesitamos: OpenSimplex, PIL y Numpy.

Después procedemos con las declaraciones. gen_altura = OpenSimplex(seed = 3)  (línea 7) crea un objeto OpenSimplex con una semilla para sus funciones aleatorias. Si queréis, podéis generar una semilla aleatoria alguna función de la libería random.

alto y ancho (líneas 10 y 11) son las dimensiones del mapa. mapa_altura (línea 14) es un array vacío dónde se almacenarán los valores de la elevación del terreno. imagen (línea 17) es un array de ceros que almacenará la imagen del mapa que se mostrará por pantalla.

También crearemos una variable para guardar la frecuencia del ruido (la variable freq, línea 21). ¿Qué es la frecuencia? El ruido puede generarse a cualquier frecuencia, que indica del período de muestreo. Cuánta más alta sea la frecuencia, más se repetirán las convoluciones del ruido, como si alejáramos el zoom de la imagen.

 

 

Después se llena el array de elevación con un bucle anidado (líneas 24-32). ¿Qué hacen las líneas nx = x/ancho y ny = y/largo? Al dividir la posición actual por las dimensiones del mapa lo que hacemos es que si aumentamos las dimensiones del array estaremos añadiendo más resolución al mapa, en vez de aumentarlo y añadir nuevas casillas de terreno.

El ruido se genera en la línea mapa_altura[y][x] = gen_altura.noise2d(freq*nx, freq*ny). Pasándole como argumento una posición x,y del array, devuelve el valor de ruido para esa casilla. Esta función generará sus valores en el intervalo [-1,1]

Por último se llena el array de la imagen con colores según cuál sea la elevación (líneas 35-64). No hay nada sofisticado que pueda comentaros de esta parte. Si no os gusta como queda podéis provar a jugar con los intervalos o cambiar los colores.

Las dos últimas líneas sirven para crear una ventana con la imagen.


Octavas

Este mapa es un buen punto de partida, pero… ¿no véis algo raro? No está todo muy… ¿perfecto? Las líneas de la costa están bien definidas y las montañas están todas agrupadas. Pero si miramos un mapa real, veremos que es todo tiene mucha más diversidad (excepto si vivís enmedio del desierto del Kalahari).

El relieve del mundo real presenta multitud de irregularidades, como podemos ver en cualquier mapa topográfico.

Si bien el ruido Simplex proporciona un cierto grado de comportamiento natural, por sí sólo no puede crear las perfectas imperfecciones que hay en la naturaleza. ¡Pero tenemos una solución muy fácil! Podemos mezclar ruido a diferentes frecuencias, obteniendo un resultado más natural.

 

Sólo tenéis que cambiar el bucle que llena el array de elevación:

#Llenar el array de elevacion
for y in range(alto):
	for x in range(ancho):
		#Dividir la posicion por la altura
		nx = x/ancho
		ny = y/alto

		#Generar el valor de la posicion actual
		mapa_altura[y][x] = gen_altura.noise2d(freq*nx, freq*ny) + 0.5 * gen_altura.noise2d(4*freq*nx, 4*freq*ny)+0.25 * gen_altura.noise2d(8*freq*nx, 8*freq*ny) + 0.125 * gen_altura.noise2d(16*freq*nx, 16*freq*ny)

Con esto se generan mapas más detallados. He aquí algunos pueden conseguirse variando los parámetros y el número de octavas (frecuencias diferentes):


Biomas

A ver, el mapa va mejorando poco a poco, pero aún le falta algo muy importante para ser natural: ¡biomas!

Nuestro bonito planeta azul tiene gran cantidad de ecosistemas diferentes: tundras heladas, junglas tropicales, bosques templados, boreales, etc. Las condiciones de estos biomas dependen no sólo de la elevación sobre el nivel del mar, sino también de otros factores: precipitaciones, humedad, temperatura…

Aunque dos regiones tengan la misma elevación sobre el nivel del mar, pueden presentar biomas distintos debido a la cantidad de lluvia y la temperatura.

Para simular esto podemos generar dos mapas de ruido (elevación y humedad) y sobreponerlos. En el Mundo Real la elevación podría afectar a la humedad (las montañas altas bloquean la lluvia) pero para no complicar el algoritmo vamos a generar estos dos mapas independientemente.

Habrá que elegir un color para cada bioma y cambiar la función que nos pinta los píxeles para que también tenga en cuenta la humedad. Nos serviremos de esta tabla para elegir cada bioma:

El código cambiará un poco. Tendremos que añadir un segundo mapa de ruido con un nuevo objeto OpenSimplex que genere el ruido con una semilla distinta. También habrá que modificar el apartado que se encarga de pinar la imagen para que tenga en cuenta el mapa de humedad.

from opensimplex import OpenSimplex
from PIL import Image
import numpy as np

#Crear el objeto opensimplex con una semilla (podeis introducirla a mano o poner una aleatoria)
gen_altura = OpenSimplex(seed = 6)
gen_humedad = OpenSimplex(seed = 3)

#Dimensiones del mapa
alto = 500
ancho = 500

#Array para guardar la elevacion
mapa_altura = np.empty([alto, ancho])
mapa_humedad = np.empty([alto, ancho])

#Array para guardar los colores de la imagen
imagen = np.zeros((alto, ancho, 3), dtype = np.uint8)

#Valor de la frecuencia
#Aumentar este valor hace el mismo efecto que un zoom hacia afuera
freq = 3


#Llenar el array de elevacion
for y in range(alto):
	for x in range(ancho):
		#Dividir la posicion por la altura
		nx = 2*x/ancho
		ny = 2*y/alto

		#Generar el valor de la posicion actual
		mapa_altura[y][x] = gen_altura.noise2d(freq*nx, freq*ny) + 0.5 * gen_altura.noise2d(4*freq*nx, 4*freq*ny)+0.25 * gen_altura.noise2d(8*freq*nx, 8*freq*ny)

		#La humedad la escalaremos para que el rango vaya de 0 a 1 en vez de -1 a 1
		mapa_humedad[y][x] = (gen_humedad.noise2d(freq*nx, freq*ny) + 0.5 * gen_humedad.noise2d(4*freq*nx, 4*freq*ny)+0.25 * gen_humedad.noise2d(8*freq*nx, 8*freq*ny))/2.0+0.5


#Dibujar el mapa
for j in range(len(mapa_altura)):
	for i in range(len(mapa_altura[j])):
		#Mar - Azul oscuro
		if mapa_altura[j][i] < -0.2:
			imagen[j][i] = [50,80,160]

		#Aguas costeras - Cyan
		elif mapa_altura[j][i] < 0:
			imagen[j][i] = [50, 100, 200]

		#Playa (no es un bioma, pero respetaremos la linea de la costa)
		elif mapa_altura[j][i] < 0.07:
			imagen[j][i] = [242,223,152]

		#Elevacion baja
		elif mapa_altura[j][i] < 0.25: if mapa_humedad[j][i] > 0.5:
				imagen[j][i] = [59, 136, 64] #Bosque tropical
			elif mapa_humedad[j][i] > 0.25:
				imagen[j][i] = [118, 183, 54] #Praderia
			else:
				imagen[j][i] = [242,223,152] #Desierto tropical

		#Elevacion media
		elif mapa_altura[j][i] < 0.5: if mapa_humedad[j][i] > 0.75:
				imagen[j][i] = [102, 136, 59] #Bosque templado humedo
			elif mapa_humedad[j][i] > 0.55:
				imagen[j][i] = [122, 166, 71] #Bosque templado seco
			elif mapa_humedad[j][i] > 0.25:
				imagen[j][i] = [118, 183, 54] #Praderia
			else:
				imagen[j][i] = [208,196,146] #Desierto tropical

		#Elevacion alta
		elif mapa_altura[j][i] < 0.75: if mapa_humedad[j][i] > 0.66:
				imagen[j][i] = [120, 143, 91] #Taiga
			elif mapa_humedad[j][i] > 0.33:
				imagen[j][i] = [157, 167, 117] #Matorrales
			else:
				imagen[j][i] = [186, 182, 163] #Desierto frio

		#Elevacion muy alta
		else:
			if mapa_humedad[j][i] > 0.45:
				imagen[j][i] = [234, 234, 234] #Nieve
			elif mapa_humedad[j][i] > 0.33:
				imagen[j][i] = [203, 218, 192] #Tundra
			else:
				imagen[j][i] = [146, 137, 127] #Yermo


img = Image.fromarray(imagen, 'RGB')
img.show()

Además de humedad y elevación, también podríamos añadir un tercer factor: la temperatura. Aquí hemos usado la altura como sinónimo de temperatura, pero en la realidad dos biomas podrían tener la misma elevación y precipitaciones anuales pero presentar características muy diferentes según si hace más o menos calor. Pero para no alargar este tutorial, os lo dejo para vosotros 😉

Y hasta aquí llegamos. Cómo siempre, si tenéis alguna duda o sugerencia podéis dejarme un comentario y os responderé encantada. ¡Chau!

 

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.

3
Deja un comentario

avatar
1 Hilos iniciados
2 Respuestas a hilos
0 Followers
 
Most reacted comment
Hottest comment thread
3 Nº autores comentarios
Gl4r3KevinDario Autores de comentarios recientes
más nuevos primero más antiguos primero
Dario
Humano
Dario

Hola podrias poner un ejemplo para hacerlo con pygame?

Kevin
Humano
Kevin

+1