LIFE: El Juego de la Vida

Hoy os traigo una entrada un poco diferente, ya que retomamos el curso de Fundamentos de la Programación. ¿Cómo? Bueno, me explico: en la última entrega de la Historia de los Videojuegos se mencionó un juego que tuvo mucho éxito entre la comunidad de programadores, LIFE y me pareció interesante añadir un nuevo ejercicio al curso donde se implemente. Así que, ¡vamos a ello!

Si no tienes ni idea de programación y estás interesado, te recomiendo completar el curso antes de continuar con el ejercicio.

Una Simulación de la Vida Celular

LIFE fue diseñado por John Conway y publicado en la revista “Scientific American”. El juego simula la vida y muerte de un conjunto de células, a partir de unas reglas simples. No obstante, la combinación de las mismas da origen a hermosos y variantes patrones. Las reglas que siguen estas células son:

  1. Nacimiento: una célula muerta resucitará si tiene exactamente tres vecinos vivos.
  2. Supervivencia: una célula permanecerá con vida si tiene dos o tres vecinos.
  3. Superpoblación: una célula morirá o permanecerá muerta si tiene cuatro o más vecinos.
  4. Aislamiento: una célula morirá o permanecerá muerta si tiene menos de dos vecinos.

En nuestra implementación utilizaremos una matriz para gestionar el tablero, y el mismo irá evolucionando durante una serie de pulsos de reloj. La dimensión del tablero estará definida por dos constantes, de forma que podremos modificarla sin problemas más adelante de ser necesario. El estado de las células lo definiremos con los valores lógicos True (viva) y False (muerta). La siguiente función genera un tablero de 25×25 celdas:

# Variables Globales
FIL = 10
COL = 10
tablero = []

# Inicializa un tablero sin ninguna célula
def inicializaTablero():
	for i in range(FIL):
		tablero.append([False] * COL)

Ahora que ya tenemos inicializado nuestro tablero sin células vivas, debemos introducir en el mismo algunas, ya que si no, nuestro pequeño universo no comenzará a “evolucionar”. Aquí podríamos elegir diversos patrones iniciales, y cada uno generaría una secuencia distinta. Por simplicidad, vamos a iniciar el juego con una línea vertical de tres células:

# Patrones para inicializar el tablero
def generaLinea():
	tablero[12][13] = True
	tablero[13][13] = True
	tablero[14][13] = True

Lo siguiente es poder visualizar el estado del tablero por pantalla. En este ejemplo vamos a utilizar la salida estándar por consola, dado que en el curso no se ha visto nada de programación gráfica (si estáis interesados, indicarlo y le pondremos remedio :P). Hemos definido una casilla con una célula viva con el carácter “X”, y una vacía o muerta con el carácter “·”:

# Muestra el tablero por pantalla
def muestraTablero():
	for i in range(FIL):
		for j in range(COL):
			if tablero[i][j]:
				print("X", end = '')
			else:
				print("·", end = '')
		print()

¿Te llama algo la atención? En algunas funciones print() hemos añadido la variable end. Esta sirve para indicar que carácter queremos utilizar como final de línea. Por defecto viene un salto de línea, pero como para este ejemplo nos interesa poder visualizar toda una línea de la matriz de forma continua, se ha modificado por un carácter nulo.

Bien, ya tenemos todo lo necesario para comenzar a generar vida en nuestro mundo. Para poder decidir si se deben aplicar o no las reglas antes descritas, necesitamos conocer el número de vecinos de una célula en concreto. Dado que es un cálculo que se ha de repetir mucho, es recomendable crearnos una función para ello:

# Calcula el número de vecinos de una célula
def calculaVecinos(fila, columna):
    vecinos = 0
    # Vecinos en la columna anterior
    if fila > 0 and columna > 0 and tablero[fila-1][columna-1]:
        vecinos += 1
    if columna > 0 and tablero[fila][columna-1]:
        vecinos += 1
    if fila < FIL-1 and columna > 0 and tablero[fila+1][columna-1]:
        vecinos += 1
    # Vecinos en la misma columna
    if fila > 0 and tablero[fila-1][columna]:
        vecinos += 1
    if fila < FIL-1 and tablero[fila+1][columna]:
        vecinos += 1
    # Vecinos en la columna siguiente
    if fila > 0 and columna < COL-1 and tablero[fila-1][columna+1]:
        vecinos += 1
    if columna < COL-1 and tablero[fila][columna+1]:
        vecinos += 1
    if fila < FIL-1 and columna < COL-1 and tablero[fila+1][columna+1]:
        vecinos += 1
    return vecinos

Cuidado a la hora de comprobar los vecinos, ya que si la célula se encuentra en el borde del mundo, habrán posiciones a su alrededor en las que no podrá haber ningún vecino, y si intentáramos comprobarlas, nos saldríamos del rango de la matriz y eso generaría un error. En el código anterior podéis observar como en cada sentencia if se ha comprobado también si la fila y la columna están dentro del rango.

Por último, toca aplicar las acciones correspondientes a cada regla. Para ello recorreremos el tablero casilla a casilla, comprobando si alguna de las condiciones se cumple. La función resultante sería algo así:

# Calcula los nuevos estados de las células tras un pulso de reloj
def avanzaPulso():
    for i in range(FIL):
        for j in range(COL):
            n = calculaVecinos(i, j)
            # Nacimiento
            if not tablero[i][j] and n == 3:
                tablero[i][j] = True
            # Supervivencia
            elif tablero[i][j] and (n == 3 or n == 2):
                tablero[i][j] = True
            # Superpoblación y Aislamiento
            else:
                tablero[i][j] = False

¡Ya tenemos listo nuestro código! Sólo nos falta definir el programa principal, el cual consistirá en un bucle que se ejecutara un número determinado de pulsos. Para ello hemos declarado una nueva variable que almacena el número total de pulsos que se debe ejecutar:

# Programa Principal
inicializaTablero()
generaLinea()
for t in range(pulsos):
    print("PULSO: ", t+1)
    muestraTablero()
    avanzaPulso()

Bien. ¿Has llegado a ejecutar este código? ¿Observas algo raro? ¡El tablero está vacío! ¿Sabes por qué ha ocurrido? La causa está en que hemos estado comprobando el estado del tablero a la vez que lo estamos modificando por aplicar las condiciones definidas. Esto causa que durante un mismo pulso se estén matando y resucitando células. Una solución sencilla es realizar la actualización de las células sobre un nuevo tablero, y al finalizar sobreescribir el contenido del tablero original. Con esta modificación, la función avanzaPulso() quedaría así:

# Calcula los nuevos estados de las células tras un pulso de reloj
def avanzaPulso():
    global tablero
    copia = []
    for i in range(FIL):
        copia.append([False] * COL)
        
    for i in range(FIL):
        for j in range(COL):
            n = calculaVecinos(i, j)
            # Nacimiento
            if not tablero[i][j] and n == 3:
                copia[i][j] = True
            # Supervivencia
            elif tablero[i][j] and (n == 3 or n == 2):
                copia[i][j] = True
            # Superpoblación y Aislamiento
            else:
                copia[i][j] = False
                
    tablero = copia

Ahora nuestro programa ya funciona correctamente. A continuación te dejo el código completo del mismo. Puedes probar a modificar el patrón inicial y el número de pulsos, a ver que resultados obtienes.

# Variables Globales
FIL = 25
COL = 25

pulsos = 5
tablero = []

# Inicializa un tablero sin ninguna celula
def inicializaTablero():
	for i in range(FIL):
		tablero.append([False] * COL)

# Patrones para inicializar el tablero
def generaLinea():
	tablero[12][13] = True
	tablero[13][13] = True
	tablero[14][13] = True

# Muestra el tablero por pantalla
def muestraTablero():
	for i in range(FIL):
		for j in range(COL):
			if tablero[i][j]:
				print("X", end = '')
			else:
				print("·", end = '')
		print()

# Calcula el número de vecinos de una célula
def calculaVecinos(fila, columna):
    vecinos = 0
    # Vecinos en la columna anterior
    if fila > 0 and columna > 0 and tablero[fila-1][columna-1]:
        vecinos += 1
    if columna > 0 and tablero[fila][columna-1]:
        vecinos += 1
    if fila < FIL-1 and columna > 0 and tablero[fila+1][columna-1]:
        vecinos += 1
    # Vecinos en la misma columna
    if fila > 0 and tablero[fila-1][columna]:
        vecinos += 1
    if fila < FIL-1 and tablero[fila+1][columna]:
        vecinos += 1
    # Vecinos en la columna siguiente
    if fila > 0 and columna < COL-1 and tablero[fila-1][columna+1]:
        vecinos += 1
    if columna < COL-1 and tablero[fila][columna+1]:
        vecinos += 1
    if fila < FIL-1 and columna < COL-1 and tablero[fila+1][columna+1]:
        vecinos += 1
    return vecinos
        
# Calcula los nuevos estados de las células tras un pulso de reloj
def avanzaPulso():
    global tablero
    copia = []
    for i in range(FIL):
        copia.append([False] * COL)
        
    for i in range(FIL):
        for j in range(COL):
            n = calculaVecinos(i, j)
            # Nacimiento
            if not tablero[i][j] and n == 3:
                copia[i][j] = True
            # Supervivencia
            elif tablero[i][j] and (n == 3 or n == 2):
                copia[i][j] = True
            # Superpoblación y Aislamiento
            else:
                copia[i][j] = False
                
    tablero = copia

# Programa Principal
inicializaTablero()
generaLinea()
for t in range(pulsos):
    print("PULSO: ", t+1)
    muestraTablero()
    avanzaPulso()

¿Os ha gustado el ejercicio? El patrón que hemos utilizado es uno de los denominados osciladores, que son patrones que tras un número de iteraciones regresan a su estado inicial. Aquí os dejo algunos patrones para ir probando. La semana que viene, regresamos con la Historia de los Videojuegos. ¡Nos leemos!

Regresar al Indice del curso Fundamentos de la Programación

¿Quieres estar al día de todo lo publicado en el blog?
Pues para ello puedes suscribirte mediante ese bonito formulario que tienes más abajo, o simplemente puedes seguirme en mi cuenta personal de Twitter (o ambas cosas si te animas :D)

Marcar como favorito enlace permanente.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *