7. Juego del Solitario

portada

Los juegos de cartas son una muy buena manera de practicar los conocimientos de programación. Una baraja puede simularse como una lista de elementos, y cualquier mecánica como barajar, repartir o intercambiar cartas puede programarse de manera muy sencilla operando con los elementos de esa lista.

En ese capítulo vamos a implementar un sencillo juego de cartas que se inicializa distribuyendo una serie de elementos aleatoriamente en forma de una cuadrícula. El juego consiste en ir destapando una serie de cartas cubiertas para buscar parejas. Cuando el jugador destapa dos cartas iguales, las parejas permanecen descubiertas, en caso contrario, vuelve a voltearse. El juego finaliza cuando todos los elementos de la cuadrícula han sido descubiertos.

See also

El juego que te proponemos lo puedes ver como una versión muy simplificada del popular Mahjong.

7.1. Lectura de símbolos

En lugar de jugar con cartas clásicas que podríamos simular como tuplas con un valor y un palo, vamos a utilizar símbolos unicode. En el fichero unicode.txt encotrarás dos listas de símbolos. Una primera con símbolos bastante diferentes entre sí, que utilizaremos para el juego estándar, y una segunda, con una lista de emojis, que permitirán jugar con mayor dificultad.

En este apartado vamos a escribir una función get_symbols(filename) que recibe el nombre del fichero unicode.txt como argumento de entrada y que devuelve la primera fila de símbolos en una variable easy de tipo LIST y la segunda fila en una variable difficult, también de tipo LIST.

See also

Puedes encontrar más información sobre el estándar unicode aquí y revisar la lista de súmbolos disponibles aquí.

Solución:

def get_symbols(filename):
    with open(filename,'r', encoding='utf-8') as f:
        easy=f.readline().replace("\n","").split(";")
        difficult=f.readline().replace("\n","").split(";")
    return easy, difficult

Antes de seguir, como siempre, comprobamos que la función hace lo que le hemos pedido. Vamos a invocarla incluyendo el nombre del fichero que contiene lo símbolos que vas a usar en el resto del juego.

easy, difficult=get_symbols("unicode.txt")
print(easy[:5])
print(difficult[:5])
['⏳', '⏰', '☕', '☔', '⛔']
['😀', '😁', '😂', '😃', '😄']

Validar el un numero de casillas

En el programa principal solicitarmos al jugador las dimensiones del tablero. Si queremos distribuir nuestros símbolos en forma de rejilla, las dimensiones del tablero deben permitir un número par de casillas ya que los símbolos deben de estar distribuidos en parejas. Por ejemplo, un tablero de 4x4 sería válido, pero un tablero de 3x3 no porque no podríamos distribuir parejas de símbolos en 9 casillas.

La función validate_even recibe dos orgumentos:

  • rows contiene un valor de tipo INT con el número de filas del tablero

  • cols contiene unv alor de tipo INT con el número de columnas del tablero

La función validate_even devuelve un valor de tipo BOOL igual a True si el número total de casillas es par o False si el número total es impar.

Solución:

def validate_even(rows,cols):
    if rows*cols % 2 ==0:
        return True
    else:
        return False

Aunque la implementación de esta función es trivial, siempre te recomendaremos que pruebes todos y cada uno de los métodos que programes. A veces cometemos errores tontos que se podrían haber detectado con una simple ejecución.

validate_even(4,4)
True
validate_even(3,3)
False

En este caso vemos que las funciones devuelven los valores esperados. Podemos seguir adelante!

7.2. Validar el número de símbolos para la dificultad elegida

Aunque el fichero unicode.txt contiene suficientes símbolos para generar tableros grandes, hay un límite. Podría ocurrir que un jugador quisiera echar una partida en un tablero de 20x20; este tamaño implica una rejilla de 400 casillas o lo que es lo mismo, 200 símbolos emparejados. Ninguna de nuestras colecciones de símbolos dispone de tantos símbolos diferentes.

Para evitar que nuestro código falle, podríamos contar el número de símbolos de nuestro fichero, y con una operación sencilla ver cual es el máximo número de parejas que se pueden generar. Este valor podríamos usarlo como umbral para detectar si el tamaño solicitado por el jugador es realizable. El problema de hardcodear el valor del umbral en el código es que si decidimos añadir o borrar símbolos de nuestro fichero, el umbral quedaría obsoleto y deberíamos actualizar el código. Esta práctica no es en ningún caso recomendable. Nuestros códigos deben ser siempre generalizables y una vez dados por finalizados, no deberían requirir modificar su código. En este caso, podemos resolver el problema calculando la longitud de las variables easy y difficult y evaluando si las dimensiones y la dificultad solicitadas son coherentes con el número de símbolos disponibles.

La función check_symbols(difficulty, easy, difficult, rows,cols) recibe los siguientes argumentos de entrada:

  • easy con la lista de símbolos sencilla

  • difficult con la lista de síbolos dificil

  • row con el número de filas del tablero

  • cols con el número de columnas del tablero

  • difficulty con una variable STRING que puede tomar los valores “easy” o “difficult” indicando la dificultad del juego

Dependiendo del contenido de la variable difficulty, la función debe verificar si hay suficientes símbolos para el tamaño del tablero. En caso afirmativo debe devolver True y en caso negativo False.

Solución:

def check_symbols(difficulty, easy, difficult, rows,cols):
    if difficulty=="easy" and rows*cols > 2*len(easy):        
        return False
    elif difficulty=="difficult" and rows*cols > 2*len(difficult): 
        return False
    else:
        return True

De esta manera, podemos verificar si es posible generar un tablero de 10x10 con 100 casillas utilizando la colección de símbolos fácil:

check_symbols("easy",easy,difficult,10,10)
False

Comprobamos que no tenemos suficientes símbolos porque serían necesarios 50 parejas de símbolos y la colección easy sólo dispone de 49 símbolos. Sin embargo, sí que podemos generar ese tablero con la colección de símbolos difficult:

check_symbols("difficult",easy,difficult,10,10)
True

7.3. Entrada de parámetros del juego

Con las funcioners anteriores ya podemos solicitar al jugador todos los valores necesarios para configurar una partida. Esos parámetros son:

  • Número de filas rows

  • Número de columnas cols

  • Dificultad del juego difficulty

Para ello vamos a escribir una funcion input_params(simb_easy, simb_diff) que recibe como argumentos las listas de símbolos easy y difficult. La función debe preguntar al jugador por las dimensiones del tablero y la dificultad del juego. Los valores proporcionados por el usuario deben ser validados por las funciones validate_even y check_symbols. Si alguna de estas validaciones no se cumple, el usuario debe ser informado y preguntado nuevamente.

Cuando el usuario proporcione unos valores que cumplan los criterios anteriores, la función debe devolver estas tres variables difficulty, row, col.

Solución:

def input_params(simb_easy, simb_diff):
    
    difficulty = input("Choose difficulty (easy, difficult): ")
    rows = int(input("number of rows: "))
    cols  = int(input("number of columns: "))
    
    while not(validate_even(rows,cols) and check_symbols(difficulty,simb_easy, simb_diff,rows,cols)):
        
        if not validate_even(rows,cols):
            print("\nThe number of cells must be even")
            rows = int(input("number of rows: "))
            cols  = int(input("number of columns: "))
        else:
            print("\nToo many symbols for that difficulty. Select smaller dimensions")
            rows = int(input("number of rows: "))
            cols  = int(input("number of columns: "))
            
    print("\nYou will play a {}x{} grid in level: {}".format(rows,cols,difficulty))
    return rows,cols,difficulty
filas, columnas, dificultad=input_params(easy, difficult)
Choose difficulty (easy, difficult): easy
number of rows: 10
number of columns: 10

Too many symbols for that difficulty. Select smaller dimensions
number of rows: 3
number of columns: 4

You will play a 3x4 grid in level: easy

7.4. Generar tablero

Una vez que disponemos de las dimensiones del tablero de juego, vamos a escribir una función generate_board(rows,cols). Esta función recibe dos argumentos:

  • rows es el número de filas del tablero como una variable de tipo INT

  • cols es el número de columnas del tablero como una variable de tipo INT

La función generate_board(rows,cols) generará una variable board de tipo LIST que a su vez contendrá como elementos variables de tipo LIST. Es lo que se conoce como una lista de listas anidadas. Estas listas anidadas las inicializaremos con el valor: “🞺”.

Por ejemplo, si queremos jugar con un tablero de 2 filas por 4 columnas, este sería el resultado:

board=generate_board(2,4)
print(board)
[['🞺', '🞺', '🞺', '🞺'], ['🞺', '🞺', '🞺', '🞺']]

Tip

Recuerda que cuando trabajamos con colecciones como los STRING o las LIST, podemos utilizar el operador multiplicación para extender esas variables N veces. Por ejemplo:

var = "AEIOU"
print(var*3)

Ejecutando este código obtendrías como resultado “AEIOUAEIOUAEIOU”. Esto mismo se puede hacer con las listas. Por ejemplo:

var = ["A"]
print(var*3)

Ejecutando este código obtendrías como resultado [‘A’, ‘A’, ‘A’]. Aunque no es oblgatorio, puede resultarte muy útil de cara a simplificar el código de la función generate_board(rows,cols).

Solución:

def generate_board(rows,cols):
    board=[]
    for f in range(rows):
        row=["🞺"]*cols
        board.append(row)
    return board

7.5. Mostrar tablero

Para que el jugador pueda llevar la cuenta de qué casillas han sido descubiertas y cuales no, vamos a escribir una función que reciba estos argumentos:

  • board contiene la lista de listas anidadas

  • rows es el número de filas del tablero como una variable de tipo INT

  • cols es el número de columnas del tablero como una variable de tipo INT

La función debe permitir visualizar la variable board en formato de tabla. Por ejemplo:

display_board(board,2,4)
|🞺| |🞺| |🞺| |🞺| 
|🞺| |🞺| |🞺| |🞺| 

Tip

El comando print siempre introduce el caracter salto de línea “\n”. En algunas ocasiones, puede resultar interesante desactivar esa característica. Podemos hacerlo usando el argumento end:

print("This line has no character 'newline'!! ",end="")
print("Did you see?")

Puedes comparar la salida de esos comandos con la salida de estos:

print("This line has a character 'newline'!! ")
print("Did you see?")

Solución:

def display_board(board, rows, cols):
    for i in range(rows):
        for j in range(cols):
            print("|", end="")
            print (str(board[i][j])+"| ", end="")
        print("\n", end="")

7.6. Seleccion aleatoria de símbolos

El primer paso para distribuir una colección aleatoria de símbolos en la cuadrícula del tablero, es seleccionar un número aleatorio de símbolos de entre las listas easy y difficult. Para ello vamos a programar una función choose_symbols que tome como argumentos:

  • easy contiene la lista de símbolos sencillos

  • difficult contiene la lista de símbolos difíciles

  • rows es el número de filas del tablero como una variable de tipo INT

  • cols es el número de columnas del tablero como una variable de tipo INT

  • difficulty el nivel de dificultad elegido por el usuario como una variable STRING que puede tomar los valores “easy” o “difficult”

En función de estas variables, el código debe devolver una lista con los símbolos necesarios para rellenar el tablero.

Solución:

import random

def choose_symbols(easy, difficult, rows, cols, difficulty):
    if difficulty == "easy":
        symbols=random.sample(easy,int(rows*cols/2))
    else:
        symbols=random.sample(difficult,int(rows*cols/2))
    return symbols

Podemos validar su funcionamiento invocando a la función con los dos niveles de dificultad “easy” y “difficult”.

symbols=choose_symbols(easy, difficult, 2,4,"easy")
symbols
['⭐', '🐠', '⏰', '🦋']
symbols=choose_symbols(easy, difficult, 2,4,"difficult")
symbols
['😇', '😽', '😘', '😱']

Comprobamos que cuando seleccionamos el nivel de dificultad alto, los símbolos se corresponden con la lista de emojis.

Note

Para simplificar el código de la función choose_symbols sólo estamos chequeando si el contenido de la variable difficulty es igual o no al STRING “easy”. Esto significa que cualquier otro valor de la variable difficulty será interpretada como dificil.

Por otro lado, date cuenta que al invocar la función choose_symbols no estamos validando si el número de elementos requeridos para el tablero solicitado es o no coherente con el número de símbolos disponible en el fichero unicode.txt. Esto es así, porque la invocación de la función choose_symbols se realizará después de haber llamado a la función input_params que es la encargada de realizar esa verificación.

7.7. Distribución de los símbolos por el tablero

En este apartado vamos a escribir la función deploy_symbols que recibe tres argumentos:

  • symbols es una variable tipo LIST con la lista de símbolos necesarios

  • rows es el número de filas del tablero como una variable de tipo INT

  • cols es el número de columnas del tablero como una variable de tipo INT

El código debe devolver una variable board de tipo LIST que es una lista de listas anidadas con la distribución aleatoria de los símbolos de la variable symbols.

Solución:

def deploy_symbols(symbols,rows,cols):
    board=generate_board(rows, cols)
    total=symbols*2
    random.shuffle(total)
    index=0
    for r in range(rows):
        for c in range(cols):
            board[r][c]=total[index]
            index=index+1
    return board

Podemos verificar su funcionamiento utilizando algunas de las funciones implementadas hasta el momento:

symbols=choose_symbols(easy, difficult, rows=2,cols=4, difficulty="easy")
board=deploy_symbols(symbols,rows=2,cols=4)
display_board(board, rows=2,cols=4)
|🧲| |👌| |🧲| |🐠| 
|👌| |🐠| |🦋| |🦋| 

7.8. Comprobación de símbolo descubierto

La dinámica del juego consiste en ir proporcionando las coordenadas de parejas de cartas con la intención de encontrar símbolos iguales. Aunque el jugador debería proporcionar las coordenadas correctas, puede ocurrir que en alguna ocasión proporcione las coordenadas de una carta que ya ha sido revelada.

Para detectar si una carta ha sido o no descubierta vamos a implementar la funcion available(board, row, column) que recibe los siguientes argumentos:

  • board es una lista de listas que vamos a usar para llevar la cuenta de los símbolos que han sido descubiertos

  • rows es el número de filas del tablero como una variable de tipo INT

  • cols es el número de columnas del tablero como una variable de tipo I

El código debe chequear si el elemento correspondiente con esas coordenadas ya ha sido descubierto con anterioridad. Si el elemento ya ha sido descubierto debe devolver False y si todavía está oculto debe devolver True.

Solución:

def available(board, row, column):
    if board[row][column]=="🞺":
        return True
    else:
        return False

Para validar su funcionamiento, vamos a simular el siguiente tablero:

board=[['🞺', '👌', '🞺', '🞺'], ['👌', '🞺', '🦋', '🦋']]

El chequeo de la coordenada (0,0) debería devolver True porque todavía no ha sido descubierta mientras que el chequeo de la coordinada (1,3) debería devolver False porque ya ha sido revelada.

available(board, 0, 0)
True
available(board, 1, 3)
False

7.9. Implementación del juego

Ya sólo nos queda implementar el juego completo. Es una buena idea utilizar dos variables para el tablero, una board con el tablero que está viendo en todo momento el jugador y otra board_secret con todos los símbolos y las posiciones reales que ocupan. Al inicializar el juego, la variable board sólo muestra los símbolos 🞺. Según el juegador vaya encontrando parejas, estos 🞺 se van reemplazando por los símbolos descubiertos. Esto implica ir modificando la variable board con los valores de board_secret según avanza el juego. Aunque como siempre, existen múltiples formas de implementar el código. Nosotros os proporcionamos una de ellas, pero seguro que se te ocurren otras alternativas.

Recuerda que tu código debe ir contando el número de intentos que ha necesitado el jugador para revelar el tablero completo, de esta forma podrás comparar como de eficiente han sido las decisiones del jugador en diferentes partidas.

Tip

Vamos a utilizar el recurso clear_output de la librería IPython.display que permite limpiar la salida de las celdas de los notebooks de Jupyter, para que nuestro juego pueda desarrollarse sin tener que realizar mucho scroll. Además, esta funcionalidad nos va a permitir poder mostrar el contenido de la pareja de cartas seleccionada por el jugador durante un tiempo determinado, por ejemplo 5 segundos, antes de volver a ocultarlas.

Solución:

from IPython.display import clear_output
import time
import matplotlib.pyplot as plt
# Read symbols
easy, difficult=get_symbols("unicode.txt")

# Ask player for difficulty and board dimensions
rows, cols, difficulty=input_params(easy, difficult)

# Generate random symbols
symbols=choose_symbols(easy, difficult, rows, cols, difficulty)

# Deploy them in the board
board_secret=deploy_symbols(symbols, rows, cols)

# Display them
display_board(board_secret,rows, cols)
Choose difficulty (easy, difficult): easy
number of rows: 3
number of columns: 4

You will play a 3x4 grid in level: easy
|🎱| |👽| |🍀| |🎱| 
|⚓| |🌼| |🐬| |🐬| 
|👽| |🍀| |⚓| |🌼| 

Este es el tablero con el que jugaremos. Pero recuerda que durante la partida real no debes incluir este código o el jugador sabrá exactamente dónde se encuentran las parejas.

# hits=0
attempts=0
board=generate_board(rows, cols)
while (hits!=rows*cols):
    attempts=attempts+1
    display_board(board, rows, cols)  
    
    row1,col1 =input("Select coordinates of first card in format x,y: ").split(",")
    row1,col1 =int(row1),int(col1)
    while not available(board,row1,col1):
        print("That card was already flipped")
        row1,col1 =input("Select coordinates of first card in format x,y: ").split(",")
        row1,col1 =int(row1),int(col1)
    
    row2,col2 =input("Select coordinates of second card in format x,y: ").split(",")
    row2,col2 =int(row2),int(col2)
    while not available(board,row2,col2):
        print("That card was already flipped")
        row2,col2 =input("Select coordinates of second card in format x,y: ").split(",")
        row2,col2 =int(row2),int(col2)
    
    board[row1][col1]=board_secret[row1][col1]
    board[row2][col2]=board_secret[row2][col2]
    clear_output(wait=False)  
    
    if board_secret[row1][col1]!=board_secret[row2][col2]:
        display_board(board, rows, cols) 
        time.sleep(5)
        clear_output(wait=False)  
        board[row1][col1]="🞺"
        board[row2][col2]="🞺"
    else:
        hits=hits+2     
    
clear_output(wait=False) 
display_board(board, rows, cols)
print("Congratulations! You needed {} attempts".format(attempts))

Como ves, la implementación del juego es muy sencilla. El juego se limita a procesar las coordenadas dadas por el juegador y a chequear si las cartas son iguales. Como te adelantábamos al principio del capítulo, a partir de este momento estás en condiciones de implementar muchos otros solitarios que utilizan principios parecidos, como por ejemplo, el conocidísimo juego del Mahjong.

7.10. Extensiones del Juego

Si has ejecutado el juego tal y como te lo hemos proporcionado, habrás notado un par de características que hacen que la experiencia no sea muy buena:

  • Las coordenadas hay que darlas utilizando las coordenadas en formato Python, esto quiere decir que la primera fila o la primera columna se indexan con el 0. Esto no es muy intuitivo y puede requerir un cierto esfuerzo de cálculo por parte del jugador

  • Al ir reemplazando los símbolos “🞺” por los iconos unicode, la rejilla se “descuadra”. Esto tiene que ver con que el ancho de los simbolos no es siempre el mismo

Corregir estos dos aspectos podrían ser las dos primeras mejoras a realizar y resultarían en una experiencia mucho mejor. Algunas otras ideas a implementar podrían ser:

  • Constuir un interfaz gráfico independiente en el que las celdas se seleccionan con el uso del ratón

  • Introducir un sistema de puntuación basado en el tiempo requerido para la resolución del tablero, en las dimensiones y en la dificultad del juego

  • Establecer un sistema de puntuaciones como el de los antiguos sistemas arcade, en el que se pueda almacenar la puntuación en un fichero junto con el nombre del jugador. Esas puntuaciones serían persistentes y se podrían mostrar al inicio o al final del juego para mostrar los 5 o 10 jugadores con mejores puntuaciones

Algunas de estas mejoras propuestas parecen muy avanzadas, pero si realizas todos los ejercicios propuestos en este libro, verás como puedes implementarlas con los conocimientos adquiridos en los siguientes capítulos.