Pong

28/12/2021

# Introducción

En estos momentos ya deberíamos tener montada nuestra consola, saber instalar el firmware MicroPython, acceder al Thumby IDE, conocer la Thumby API y saber leer el valor del potenciómetro de PiConsole. Con todos estos elementos vamos a aventurarnos a realizar nuestro primer videojuego para PiConsole. Una interpretación del clásico Pong (opens new window).

# Pong

Para todos aquellos que no lo conozcan, Pong es uno de los primeros videojuegos comerciales. Consiste en una especie de tenis primitivo en el que dos bates situados a ambos lados de la pantalla controlados por dos jugadores, habitualmente con un mando rotatorio, intentan devolver una pelota al campo contrario haciéndola rebotar en el bate. Si un jugador no consigue devolver la pelota, el contrario obtiene un punto.

Aquí vamos a implementarlo en MicroPython aprovechando la API de Thumby para el manejo de la pantalla y el sonido. El control lo haremos con el potenciómetro que tendremos que leer directamente con la API del GPIO de Raspberry Pi Pico como vimos en la unidad dedicada al hardware. Como PiConsole sólo tiene un potenciómetro, vamos a convertir el juego en monojugador haciendo que el mismo mando controle los dos bates.

Vamos a mostrar el listado del juego que podemos encontrar en el repositorio de software de PiConsole (opens new window), explicándolo por bloques. Podemos abrir un Editor nuevo en el Thumby IDE (opens new window) (recordamos que se hace por medio del menú UTILITIES > WIDGETS > +EDITOR) e ir pegando los distintos bloques que vamos a ir viendo hasta tener el programa completo.

Empezamos el programa importando las librerías que vamos a necesitar:

# Juego Pong con la API Thumby y controlado con potenciómetro
import thumby
import machine
import utime
import random
1
2
3
4
5

A continuación definimos una serie de constantes que nos servirán para parametrizar el comportamiento del juego. Por ejemplo si queremos hacer la bola o los bates más grandes, modificaremos sus dimensiones en estas constantes:

# Constantes
HALF_WIDTH = int(thumby.DISPLAY_W / 2)  # Media anchura de pantalla
HALF_HEIGHT = int(thumby.DISPLAY_H / 2) # Media altura de pantalla
BALL_SIZE = 3                           # Tamaño de la bola
PAD_WIDTH = 2                           # Anchura de los bates
PAD_HEIGHT = 8                          # Altura de los bates
HALF_PAD_HEIGHT = int(PAD_HEIGHT / 2)   # Media altura de los bates
POT_MIN = 20000                         # Valor del potenciómetro asociado a la posición superior del bate en pantalla
POT_MAX = 63000                         # Valor del potenciómetro asociado a la posición inferior del bate en pantalla
LOOP_DELAY = 20                         # Retraso en milisegundos en cada bucle. Controla velocidad del juego
1
2
3
4
5
6
7
8
9
10

Ahora empezamos a definir algunas funciones con tareas repetitivas y/o especializadas del código para que resulte más organizado o legible. Empezamos con tres funciones para producir los efectos de sonido que deseamos en el juego. Lo más destacable de estas tres funciones es que la melodía del comienzo del juego y el efecto que se produce cuando se marca un tanto se han hecho con la función bloqueante de la Thumby API para emitir sonido, ya que en esas situaciones conviene pausar el juego. Pero para la que reproduce el efecto del rebote, se ha utilizado la no bloqueante, para que no afecte a la fluidez del juego:

# Sonido de comienzo de juego
def play_startup_sound():
    thumby.audio.playBlocking(600, 250, 1000)
    thumby.audio.playBlocking(800, 250, 1000)
    thumby.audio.playBlocking(1200, 250, 1000)

# Sonido de rebote
def play_bounce_sound():
    thumby.audio.play(900, 250, 1000)

# Sonido de punto
def play_score_sound():
    thumby.audio.playBlocking(600, 250, 1000)
    thumby.audio.playBlocking(800, 250, 1000)
1
2
3
4
5
6
7
8
9
10
11
12
13
14

La siguiente función del programa sirve para hacer la conversión de un valor perteneciente a una escala en otra. La utilizaremos para transformar el valor medido en el potenciómetro a la coordenada vertical donde dibujaremos los bates. Es un simple problema de reparto proporcional:

# Toma el valor 'value' perteneciente al rango [istart, iend] y calcula el valor proporcional
# dentro del nuevo rango [ostart, oend]
def valmap(value, istart, iend, ostart, oend):
    return int(ostart + (oend - ostart) * ((value - istart) / (iend - istart)))
1
2
3
4

Terminamos la definición de funciones con un par que utilizaremos para dibujar los bates y la bola. La que dibuja el bate recibe como argumentos un número indicando el bate a dibujar (1: izquierdo; 2: derecho) y la posición vertical de su centro. La posición horizontal será fija (cada uno en su lado correspondiente de la pantalla). La función para dibujar la bola simplemente recibe las coordenadas de la esquina superior izquierda de la bola (habrá que tener en cuenta esto así como las dimensiones de la bola en los cálculos de colisiones):

# Dibuja el bate
def draw_paddle(paddle_no, paddle_center):
    if paddle_no == 1:
         x = 0
    else:
         x = thumby.DISPLAY_W - PAD_WIDTH
    y = paddle_center - HALF_PAD_HEIGHT
    thumby.display.fillRect(x, y, PAD_WIDTH, PAD_HEIGHT)

# Dibuja la bola
def draw_ball(x, y):
    thumby.display.fillRect(x, y, BALL_SIZE, BALL_SIZE)
1
2
3
4
5
6
7
8
9
10
11
12

Lo siguiente será un bloque donde inicializaremos nuestro programa, justo antes de entrar en el bucle infinito que controlará el desarrollo del juego. En él definimos y damos el valor inicial a las variables que determinarán el estado del juego. En los comentarios tenemos la descripción de todas ellas. Por último llamaremos a la función que ejecuta el sonido que marca el inicio de la partida:

# Variables globales
# Los dos bates los controlamos con el potenciómetro de PiConsole conectado a ADC1
pot_pin_l = machine.ADC(1)
pot_pin_r = machine.ADC(1)
# Puntuaciones
l_score = 0
r_score = 0
# Posición de la bola (inicialmente en el centro de la pantalla)
ball_x = HALF_WIDTH
ball_y = HALF_HEIGHT
# Dirección de la bola (inicialmente hacia abajo a la derecha)
ball_x_dir = 1
ball_y_dir = 1

play_startup_sound()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

Entramos por fin en el bucle principal. Lo haremos con un simple bucle infinito ya que podemos contar con el botón de reset para reiniciar la partida:

# Bucle principal
while True:
1
2

A partir de la línea anterior, todo el código deberá estar indentado una posición para que forme parte del cuerpo del bucle while. Por tanto todo lo que vamos a ver a partir de este punto se ejecutará indefinidamente y muchas veces por segundo (todo lo rápido que pueda gestionar la Pico). Los videojuegos suelen gestionarse de esta forma, es decir mediante un bucle infinito en el que se recoge el estado de los controles, se actualiza el estado del juego y finalmente se dibuja la escena. Vamos a desgranar todos estas partes para nuestro juego.

Empezamos leyendo el valor del controlador del juego, en este caso el potenciómetro. En el código vamos a prever la posibilidad de convertir el juego a multijugador, es decir añadir un segundo potenciómetro (lo que podremos hacer por medio del conector GPIO de PiConsole), aunque de momento vamos a asimilar las dos lecturas de los dos controles al valor leído en el único potenciómetro montado en PiConsole:

    # Lee los potenciómetros de los mandos
    pot_val_l = pot_pin_l.read_u16()
    pot_val_r = pot_pin_r.read_u16()
1
2
3

Como sabemos, la lectura del potenciómetro devuelve un número entero de 16 bit (entre 0 y 65535). Esto debemos convertirlo en la altura en pantalla donde dibujaremos el bate. Para hacer esta conversión utilizamos la función valmap(...) que habíamos presentado más arriba:

    # Escala los valores leídos del potenciómetro a la altura de la pantalla donde dibujar los bates
    paddle_l = valmap(pot_val_l, POT_MIN, POT_MAX, HALF_PAD_HEIGHT, thumby.DISPLAY_H - HALF_PAD_HEIGHT - 2)
    paddle_r = valmap(pot_val_r, POT_MIN, POT_MAX, HALF_PAD_HEIGHT, thumby.DISPLAY_H - HALF_PAD_HEIGHT - 2)
1
2
3

Ya tenemos el estado del juego en lo relativo a los controles. Ahora vamos a actualizar la posición de la bola. Básicamente le añadiremos el vector que marca su velocidad dado por las variables ball_x_dir y ball_y_dir:

    # Calcula la nueva posición de la bola
    ball_x = ball_x + ball_x_dir
    ball_y = ball_y + ball_y_dir
1
2
3

Empezamos con el control de colisiones del juego. Lo primero que haremos será detectar cuando la bola colisiona con los bordes horizontales de la pantalla, es decir el superior y el inferior. Como vemos se ha previsto que estas colisiones también produzcan el efecto sonoro de rebote. Si se desea sólo hay que descomentar las dos llamadas a la función play_bounce_sound():

    # Calcula los rebotes en la parte superior e inferior de la pantalla
    if ball_y < 0:
        ball_y_dir = 1
        #play_bounce_sound()
    if ball_y > thumby.DISPLAY_H - BALL_SIZE:
        ball_y_dir = -1
        #play_bounce_sound()
1
2
3
4
5
6
7

Llegamos a la colisión más compleja, que es en los bordes verticales de la pantalla, es decir el izquierdo y el derecho. Para no hacer muy extenso el bloque de código a analizar vamos a ver por separado la detección de la colisión en el borde izquierdo primero. Aquí lo que queremos determinar es si la bola toca el bate o no. Si lo toca, cambiaremos la dirección de la bola. Si no lo toca daremos un punto al jugador del lado contrario y volveremos a sacar la bola desde el centro de la pantalla:

    # Control del rebote en la pared izquierda
    if ball_x < PAD_WIDTH:
        top_paddle = paddle_l - HALF_PAD_HEIGHT
        bottom_paddle = paddle_l + HALF_PAD_HEIGHT
        if ball_y > top_paddle - BALL_SIZE and ball_y < bottom_paddle:
            # La bola rebota en el bate izquierdo
            play_bounce_sound()
            ball_x_dir = 1
            ball_x = PAD_WIDTH
        else:
            # Punto para el jugador derecho
            play_score_sound()
            r_score += 1
            ball_x = HALF_WIDTH
            ball_y = HALF_HEIGHT
            ball_x_dir = random.randint(0, 1)
            if ball_x_dir == 0:
                ball_x_dir = -1
            ball_y_dir = random.randint(-2, 2)
            utime.sleep_ms(250)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

La contraparte derecha va a continuación:

    # Control del rebote en la pared derecha
    if ball_x > thumby.DISPLAY_W - PAD_WIDTH - BALL_SIZE:
        top_paddle = paddle_r - HALF_PAD_HEIGHT
        bottom_paddle = paddle_r + HALF_PAD_HEIGHT
        if ball_y > top_paddle - BALL_SIZE and ball_y < bottom_paddle:
            # La bola rebota en el bate derecho
            play_bounce_sound()
            ball_x_dir = -1
            ball_x = thumby.DISPLAY_W - PAD_WIDTH - BALL_SIZE
        else:
            # Punto para el jugador izquierdo
            play_score_sound()
            l_score += 1
            ball_x = HALF_WIDTH
            ball_y = HALF_HEIGHT
            ball_x_dir = random.randint(0, 1)
            if ball_x_dir == 0:
                ball_x_dir = -1
            ball_y_dir = random.randint(-2, 2)
            utime.sleep_ms(250)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

Una vez determinadas todas las variables de estado, toca dibujar la escena. Vamos a verlo todo de una vez porque al haber previsto las funciones de dibujado de bates y bola, el bloque de código entero de dibujado es pequeño. Sólo necesitamos llamar a las funciones de la API de Thumby para dibujar rectángulos y textos en pantalla, y a las funciones que acabamos de mencionar:

    # Borra la pantalla
    thumby.display.fill(0)
    # Dibujamos la red
    thumby.display.drawLine(HALF_WIDTH, 0, int(thumby.DISPLAY_W / 2), thumby.DISPLAY_H)
    # Dibujamos el marcador de puntos
    thumby.display.drawText(str(l_score), HALF_WIDTH - 20, 5)
    thumby.display.drawText(str(r_score), HALF_WIDTH + 5, 5)
    # Dibuja los bates y la bola
    draw_paddle(1, paddle_l)
    draw_paddle(2, paddle_r)
    draw_ball(ball_x, ball_y)
    # Refrescamos pantalla con lo que hemos dibujado en el bucle principal
    thumby.display.update()
1
2
3
4
5
6
7
8
9
10
11
12
13

A pesar de ser código Python y dibujar la pantalla entera en cada bucle, la ejecución resulta ser demasiado rápida para un juego normal. Por ello se ha visto conveniente introducir un pequeño retardo en cada iteración del bucle. Será la última línea de código que comentaremos. La duración del retardo fue determinada experimentalmente y es uno de los parámetros controlados con las constantes que veíamos al principio. En concreto en este juego, un valor adecuado resultó ser de 20 milisegundos.

    # Retardo para ajustar velocidad del juego
    utime.sleep_ms(LOOP_DELAY)
1
2

Con esto habríamos completado el juego. El código completo del mismo puede encontrarse en el repositorio de software de PiConsole (opens new window).

Recordar que si queremos que el juego autoarranque al alimentar la consola con las pilas, debemos almacenar el fichero de código en la memoria interna de Pico con el nombre main.py.

Modificaciones

Una vez que hayamos tecleado todo el juego, resulta entretenido jugar con los parámetros definidos a través de las constantes para modificar el comportamiento del mismo. Podemos fácilmente modificar las dimensiones de los bates o la bola, así como ajustar la velocidad/dificultad del juego con el parámetro que gobierna el pequeño retardo que se introduce en cada iteración del bucle.

Última actualización: 13/4/2022, 10:46:53