9. Stop de Clock

portada

El juego que te proponemos en este capítulo está en el nivel VETERAN no tanto por su complejidad como por el uso de recursos algo más avanzados.

Este juego está inspirado en una infancia aburrida con los primeros relojes de pulsera digitales. En aquella época en la que los relojes se limitan a dar la hora, uno con cronómetro era una auténtica revolución. Y si añadimos a la ecuación un grupo de niños aburridos… el resultado es el juego que te proponemos.

El juego consiste en intentar conseguir lecturas de un cronómetro en el que las décimas y las centésimas de segundo estén lo más próximas a cero.

La lógica es la siguiente:

  1. El primer paso consiste en implementar unas funciones que muestren unos números bien visibles

  2. El segundo paso pasa por construir un cronómetro

  3. El último paso implementa un sistema que permita detener el cronómetro al pulsar una tecla y contabilizar como de cerca nos hemos quedado de nuestro objetivo

See also

En este caso nos hemos tomado una pequeña licencia poética y hemos llamado al capítulo como la canción del grupo español L.A.. Si no la conoces, te recomendamos encarecidamente que te la pongas de fondo mientras programas este juego. Otra muy buena recomendación, es un tema con el título “Stop the Clocks!” de la mítica banda inglesa Oasis. Un tema que no se llegó a publicar y tocaron en muy pocas ocasiones.

9.1. Lectura de los números

En la carpeta del repositorio que se corresponde con este capítulo, se proporciona un fichero de texto llamado “numbers.txt” que contine una construcción ASCII de los números del 0 al 9. Al ser un fichero de texto, los 10 símbolos están compuestos por caracteres. Cada símbolo ocupa 11 líneas. Por lo que el primer paso es leer esos 10 símbolos y construir un diccionario similar a este:

numbers={0: "........",
         1: "........",
         .
         .
         9: "........"}

En el diccionario numbers, el valor que se corresponde con la clave 0 es un string resultado de concatenar las 11 líneas que definen ese símbolo en el fichero numbers.txt, es decir:

numbers[0]
" .--------------. \n| .------------. |\n| |   _____    | |\n| |  ( ___ )   | |\n| |  |(   )|   | |\n| |  ||   ||   | |\n| |  |(___)|   | |\n| |  (_____)   | |\n| |            | |\n| '------------' |\n '--------------' \n"

Ese string contiene los saltos de línea, de manera que si haces un print de su valor, obtienes una representación ASCII de ese número:

print(numbers[0])
 .--------------. 
| .------------. |
| |   _____    | |
| |  ( ___ )   | |
| |  |(   )|   | |
| |  ||   ||   | |
| |  |(___)|   | |
| |  (_____)   | |
| |            | |
| '------------' |
 '--------------' 

Vamos a comenzar implementando una función read_file(filename) que recibe como argumento una variable de tipo STRING con el nombre del fichero numbers.txt y que devuelve una variable de tipo DICT en el que las claves son los dígitos del 0 al 9 y los valores la secuencia de las 11 líneas correspondientes de caracteres.

Solución

def read_file(filename):
    numbers={}
    with open(filename,"r") as f:
        for i in range(0,10):
            number=""
            for j in range(11):
                number=number+f.readline()
            numbers[i]=number
    return numbers

Probamos su funcionamiento:

numbers=read_file("numbers.txt")
print(numbers[1])
 .--------------. 
| .------------. |
| |    __      | |
| |   /  |     | |
| |   `| |     | |
| |    | |     | |
| |   _| |_    | |
| |  |_____|   | |
| |            | |
| '------------' |
 '--------------' 

9.2. Display de la pantalla de un reloj digital

En este apartado vas a programar una función print_time(numbers, hour, mins, secs, centsec=None) que recibe como argumentos de entrada:

  • numbers que contiene el diccionario con los dígitos

  • hour que es una variable INT con las horas

  • mins que es una variable INT con los minutos

  • secs que es una variable INT con los segundos

  • centsec que es una variable INT con las centésimas de segundo

La función no tiene que devolve ninguna variable pero sí tiene que representarlos en grande, como si se tratara el display de un cronómetro gigante.

Para separar los dígitos vamos a usar la representación ASCII de dos puntos:

two_points="""    \n    \n    \n  _ \n (_)\n    \n    \n  _ \n (_)\n    \n    \n"""
print(two_points)

Por último, para hacer la función más flexible, vamos a definir el último argumento centsec como opcional. Esto quiere decir que si pasamos un valor para las cuatro variables enteras, la función pinta un cronómetro:

print_time(numbers,1,1,2,89)
 .--------------.        .--------------.        .--------------.        .--------------.   .--------------.  
| .------------. |      | .------------. |      | .------------. |      | .------------. | | .------------. | 
| |    __      | |      | |    __      | |      | |   _____    | |      | |    ____    | | | |   ______   | | 
| |   /  |     | |   _  | |   /  |     | |   _  | |  / ___ `.  | |   _  | |  .' __ '.  | | | | .' ____ '. | | 
| |   `| |     | |  (_) | |   `| |     | |  (_) | | |_/___) |  | |  (_) | |  | (__) |  | | | | | (____) | | | 
| |    | |     | |      | |    | |     | |      | |  .'____.'  | |      | |  .`____'.  | | | | '_.____. | | | 
| |   _| |_    | |      | |   _| |_    | |      | | / /____    | |      | | | (____) | | | | | | \____| | | | 
| |  |_____|   | |   _  | |  |_____|   | |   _  | | |_______|  | |   _  | | `.______.' | | | |  \______,' | | 
| |            | |  (_) | |            | |  (_) | |            | |  (_) | |            | | | |            | | 
| '------------' |      | '------------' |      | '------------' |      | '------------' | | '------------' | 
 '--------------'        '--------------'        '--------------'        '--------------'   '--------------'  

Pero si sólo le pasamos horas, minutos y segundos el resultado es algo parecido al display de un reloj:

print_time(numbers,10,15,22)
 .--------------.   .--------------.        .--------------.   .--------------.        .--------------.   .--------------.  
| .------------. | | .------------. |      | .------------. | | .------------. |      | .------------. | | .------------. | 
| |    __      | | | |   _____    | |      | |    __      | | | |  _______   | |      | |   _____    | | | |   _____    | | 
| |   /  |     | | | |  ( ___ )   | |   _  | |   /  |     | | | | |  _____|  | |   _  | |  / ___ `.  | | | |  / ___ `.  | | 
| |   `| |     | | | |  |(   )|   | |  (_) | |   `| |     | | | | | |____    | |  (_) | | |_/___) |  | | | | |_/___) |  | | 
| |    | |     | | | |  ||   ||   | |      | |    | |     | | | | '_.____''. | |      | |  .'____.'  | | | |  .'____.'  | | 
| |   _| |_    | | | |  |(___)|   | |      | |   _| |_    | | | | | \____) | | |      | | / /____    | | | | / /____    | | 
| |  |_____|   | | | |  (_____)   | |   _  | |  |_____|   | | | |  \______.' | |   _  | | |_______|  | | | | |_______|  | | 
| |            | | | |            | |  (_) | |            | | | |            | |  (_) | |            | | | |            | | 
| '------------' | | '------------' |      | '------------' | | '------------' |      | '------------' | | '------------' | 
 '--------------'   '--------------'        '--------------'   '--------------'        '--------------'   '--------------'  

Tip

Te recomendamos convertir los argumentos hour, mins, secs y centsec a formato STRING. Esto parece no tener mucho sentido porque para acceder al diccionario numbers los necesitamos como tipo INT. El objetivo de esta primera conversión es poder tratar los números de dos dígitos. Imagina que la variable hour vale 12. Al convertirlo a STRING, la variable hour_str tomaría el valor ‘12’. Las variables de tipo string también se consideran colecciones, por lo que podemos iterar entre sus dígitos, cosa que no podríamos hacer con la variable hour en formato numérico. De esta forma, el código:

for num in hour_str:
    print(numbers[int(num)])

Va a procesar primero el “1” y luego el “2”. Siendo capaces de construir números de dos dígitos con un diccionario que únicamente incluye del 0 al 9.

Solución

import random

def print_time(numbers, hour, mins, secs, centsec=None):
    two_points="""    \n    \n    \n  _ \n (_)\n    \n    \n  _ \n (_)\n    \n    \n"""
    hour_str=str(hour)
    mins_str=str(mins)
    secs_str=str(secs)
    centsec_str=str(centsec)
    sequence_of_numbers=[]
    for num in hour_str:
        sequence_of_numbers.append(numbers[int(num)])
    sequence_of_numbers.append(two_points)
    for num in mins_str:
        sequence_of_numbers.append(numbers[int(num)])
    sequence_of_numbers.append(two_points)
    for num in secs_str:
        sequence_of_numbers.append(numbers[int(num)])
    if centsec!=None:
        sequence_of_numbers.append(two_points)
        for num in centsec_str:
            sequence_of_numbers.append(numbers[int(num)])
    sequence_of_numbers_per_lines= [number.split("\n") for number in sequence_of_numbers]
    for line in range(11):
        for number in range(len(sequence_of_numbers)):
            print(sequence_of_numbers_per_lines[number][line],end=" ")
        print()

Note

La solución que hemos propuesto, puede parecer complicada, pero no lo es. La solución se estructura en torno a la lista sequence_of_numbers en la que vamos a ir añadiendo las versiones ASCII de 11 líneas de caracteres de cada uno de los números. Por ejemplo, si invocamos a la función así:

print_time(numbers,10,15,22)

La lista sequence_of_numbers va a contener 6 elementos que son las versiones ASCII del 1, del 0, del 1, del 5, del 2 y del 2.

El último bucle anidado:

for line in range(11):
    for number in range(len(sequence_of_numbers)):
        print(sequence_of_numbers_per_lines[number][line],end=" ")
    print()

Se limita a ir recorriendo e imprimiendo por pantalla la primera línea de los seis dígitos, la segunda línea de los seis dígitos,… y así sucesivamente con las 11 líneas.

9.3. Construcción de un cronómetro

Para simular el comportamiento de un cronómetro necesitamos una función que a partir de los valores de las variables hour, mins, secs y centsec, vaya incrementándolas cada centésima de segundo. Este problema no es complejo, pero sí hay que tener mucho cuidado. Cuando aumentamos centsec tenemos que controlar cuándo llegamos a 100 centésimas para reiniciar la variable y al mismo tiempo aumentar la variable secs. Al aumentar la variable secs hay que controlar cuándo llegamos a los 60 segundos para reiniciar la variable y aumentar la variable mins. Y al aumentar esta variable, también hay que vigilar cuándo llegamos a los 60 minutos para aumentar la variable hour sabiendo que esta variable hay que reiniciarla al llegar a 24.

Esta lógica la vamos a implementar en una función increase(hour, minute,sec, centsec, inc) que recibe como argumentos de entrada:

  • hour, mins, secs y centsec que son las cuatro variables numéricas de tipo INT

  • inc que es una variable INT con el número de centésimas de segundo que vamos a usar para actualizar nuestro cronómetro

La función increase(hour, minute,sec, centsec, inc) debe devolver las cuatro variables hour, mins, secs y centsec modificadas.

Con esta función, la implementación del cronómetro es muy sencilla. Vamos a definir una segunda función chronometer(numbers) que recibe como único argumento el diccionario con los dígitos ASCII, y que internamente inicializa las variables hour, mins, secs y centsec a cero e invoca a la función increase para aumentar las variables convenientemente.

Por el momento vamos a implementar la función chronometer(numbers) para que repita el proceso 1000 veces actualizando las variables cada centésima de segundo. Es decir, la función chronometer(numbers) debería terminar en 10 segundos. Para depurar su funcionamiento, la función va a mostrar por pantalla el resultado en formato HH:MM:SS:CC.

El resultado debería ser similar al que muestra la siguiente animación:

chronometer

Tip

Para evitar que nuesto notebook muestre 1000 mensajes por pantalla, vamos a utilizar el siguiente código que permite limpiar la salida de una celda de jupyter:

from IPython.display import clear_output
clear_output(wait=True)

Y implementar el delay, podemos utilizar la función sleep de la libraría time.

Solución

def increase(hour, minute,sec, centsec, inc=1):
    centsec=(centsec+inc)%100
    if centsec==0:
        sec=(sec+1)%60
        if sec==0:
            minute=(minute+1)%60
            if minute==0:
                hour=(hour+1)%24
    return (hour,minute,sec, centsec)

Para verificar su funcionamiento siempre es recomendable probar el caso más estremo:

increase(hour=23, minute=59, sec=59, centsec=99)
(0, 0, 0, 0)

Puedes comprobar como al incrementar una centésima de segundo, todos los contadores se ponen a cero.

from IPython.display import clear_output
import time

def chronometer(numbers):
    hour=0
    minute=0
    sec=0
    centsec=0
    for i in range(1000):
        hour,minute,sec, centsec=increase(hour,minute,sec, centsec)
        print(hour,minute,sec, centsec)
        time.sleep(0.01)
        clear_output(wait=True)

Una vez que hemos verificado que nuestra función responde correctamente, podemos reemplazar la función:

print_time(hour,minute,sec, centsec)

por:

print_time(numbers,hour,minute,sec, centsec)   

Para conseguir que nuestra función muestre el resultado en grande:

chronometer

Podemos jugar con el valor del delay de la función time.sleep y la variable inc de la función increase para conseguir que nuestra celda parpadee menos. La solución que proponemos a continuación se actualiza cada 5 centésimas de segundo repitiendo el bucle 200 veces para contar los mismos 10 segundos.

from IPython.display import clear_output
import time

def chronometer(numbers):
    hour=0
    minute=0
    sec=0
    centsec=0
    for i in range(200):
        hour,minute,sec, centsec=increase(hour,minute,sec, centsec, inc=5)
        print_time(numbers,hour,minute,sec,centsec)
        time.sleep(0.05)
        clear_output(wait=True)
chronometer(numbers)
 .--------------.        .--------------.        .--------------.   .--------------.        .--------------.  
| .------------. |      | .------------. |      | .------------. | | .------------. |      | .------------. | 
| |   _____    | |      | |   _____    | |      | |    __      | | | |   _____    | |      | |   _____    | | 
| |  ( ___ )   | |   _  | |  ( ___ )   | |   _  | |   /  |     | | | |  ( ___ )   | |   _  | |  ( ___ )   | | 
| |  |(   )|   | |  (_) | |  |(   )|   | |  (_) | |   `| |     | | | |  |(   )|   | |  (_) | |  |(   )|   | | 
| |  ||   ||   | |      | |  ||   ||   | |      | |    | |     | | | |  ||   ||   | |      | |  ||   ||   | | 
| |  |(___)|   | |      | |  |(___)|   | |      | |   _| |_    | | | |  |(___)|   | |      | |  |(___)|   | | 
| |  (_____)   | |   _  | |  (_____)   | |   _  | |  |_____|   | | | |  (_____)   | |   _  | |  (_____)   | | 
| |            | |  (_) | |            | |  (_) | |            | | | |            | |  (_) | |            | | 
| '------------' |      | '------------' |      | '------------' | | '------------' |      | '------------' | 
 '--------------'        '--------------'        '--------------'   '--------------'        '--------------'  

9.4. Parada del Cronómetro

En programación, intervenir en la ejeción de un progrma que se está ejecutando no es tan facil como puede parecer. Existen recursos como las hebras (threads) que permiten tener flujos de ejecución concurrentes, pero suele ser un recurso que se aprende cuando ya se tiene cierta experiencia.

En nuestro juego, vamos a tener el código principal encargándose de ejecutar la función chronometer y mostrando los valores por pantalla. Se te podría ocurrir capturar la orden de parada mediante una instrucción del tipo:

keystroke = input()

Puedes hacer la prueba modificando la función chronometer y comprobar por qué no es una opción aceptable:

def chronometer(numbers):
    hour=0
    minute=0
    sec=0
    centsec=0
    for i in range(200):
        hour,minute,sec, centsec=increase(hour,minute,sec, centsec, inc=5)
        print_time(numbers,hour,minute,sec, centsec)
        time.sleep(0.05)
        clear_output(wait=True)
        keystroke = input()

Afortunadamente vamos a resolver este problema sin tener que aprender a utilizar hebras. La librería de Python pynput nos permite abstraernos de su uso.

En su documentación encontramos un ejemplo que hemos modificado para hacerlo más sencillo:

from pynput import keyboard

def on_press(key):
    print('Key {0} pressed'.format(key))


def on_release(key):
    print('Key {0} released'.format(key))


listener = keyboard.Listener(on_press=on_press, on_release=on_release)
listener.start()

La función keyboard.Listener nos permite detectar diferntes eventos, como el pulsar una tecla o el liberarla. Si ejecutas el código anterior, cada vez que pulses una tecla y la sueltes aparecerán dos mensajes por pantalla.

Warning

Si has ejecutado el código anterior, ahora, cada vez que pulses una tecla se seguirán imprimiendo los mensajes por pantalla ya que el proceso sigue funcionando en background. La solución más sencilla es que reinicies el kernel de Jupyter. Más adelante te explicamos mejores maneras de detener el keyboard.Listener.

Nosotros vamos a utilizar esta librería para definir una función on_press que se va a encargar de modificar una variable global que va a obligar al método chronometer(numbers) a deternerse. La variable que on_press va a modificar, debe estar definida como variable global para que ambas funciones tenga acceso a ella. La funcionalidad de parada también nos va a obligar a modificar la función chronometer(numbers) para que chequée esa variable global y así poder tomar la decisión de parar.

Solución

from pynput import keyboard

exit = None

def on_press(key):
    global exit
    exit=True
    return False

def chronometer(numbers):
    hour, minute, sec, centsec=0,0,0,0
    for i in range(200):
        hour,minute,sec, centsec=increase(hour,minute,sec, centsec, inc=5)
        print_time(numbers,hour,minute,sec, centsec)
        time.sleep(0.05)
        clear_output(wait=True)
        if exit==True:
            break
    return hour,minute,sec,centsec
         
listener = keyboard.Listener(on_press=on_press)
listener.start()
    
hour,minute,sec, centsec=chronometer(numbers)
print_time(numbers,hour,minute,sec, centsec)
listener.stop()

Warning

Para parar un keyboard.Listener que se está ejecuando en background hay tres opciones:

  • Invocar la función pynput.keyboard.Listener.stop

  • Lanzar una excepción del tipo StopException

  • Ejecutar un return False desde cualquiera de las funciones callback definidas en el listener

Si no lo paras, quedará una hebra de Python residente en memoria poniendo constántemente el valor de la variable exit a True. Este proceso sólo se dentendrá reiniciando el kernel de Jupyter.

See also

El uso de variables globales no es muy recomendado en estudiantes de programación, pero en ocasiones pueden ayudar a simplificar mucho el desarrollo de algunos códigos. Aquí puedes encontrar más información al respecto.

9.5. Cálculo de la puntuación

El último paso es muy sencillo. En cuanto el usuario pulsa cualquier tecla, la función chronometer(numbers) termina y devuelve el contenido de las variables hour,minute,sec,centsec en los que el juego se ha detenido. La forma de calcular la puntuación es trivial. Si el objetivo del juego es detener el cronómetro en 0 centésimas, la puntuación podría definirse como:

score = 100 - centsec

Con esta definición, un jugador que detuviera el cronómetro en centsec=99 obtendría una puntuación muy mala. Podemos corregir este error empleando el valor absoluto de la diferencia:

score = abs(100 - centsec)

De esta manera, los que pararan el cronómetro en centsec=1 o en centsec=99 obtendrían ambos muy buena puntuación.

9.6. Extensiones del Juego

En este capítulo hemos visto un par de recursos muy interesantes. Por un lado la posibilidad de borrar la salida de una celda de Jupyter para transmitir una sensación más cercana a la de una aplicación en lugar de una ejecución en un notebook. Por otro lado, hemos conocido la librería pynput que nos va a permitir capturar la actividad de nuestro teclado. Estos recursos te van a permitir:

  • Prescindir de los input() explícitos de Jupyter que suelen interferir con el flujo de ejecución, consiguiendo que nuestros juegos tengan mucho mejor aspecto

  • Un ejercicio muy interesante, aunque no es un juego, es implementar un reloj despertador. Puedes utilizar pynput para definir diferentes teclas para “poner en hora”, “establecer alarma”, “silenciar alarma”…