×
Preliminares
1 Primeros pasos
2 El mundo desde un ordenador
3 Ampliando horizontes
4 Teoría y teoremas
5 Jugando con Python
5.1 Conceptos básicos sobre videojuegos
5.1.1 ¿Qué es un videojuego?
5.1.2 Estructura de un videojuego
5.1.3 Aspectos gráficos en la programación de juegos
Imagenes monocromas (1 bpp)
Imágenes RGB (24 bpp)
Transparencias. Imágenes RGBA (32 bpp)
Imágenes con paleta de colores (8 bpp)
5.1.4 Empleo de sonidos en la programación de juegos
winsound
Conceptos de sonido digital
Formatos de audio
pydub
5.1.5 Interacción: Dispositivos de entrada de usuario
Entrada mediante eventos
Entrada mediante teclado
Entrada mediante ratón
Joystick y otros dispositivos
5.2 La plataforma arcade
5.2.1 Instalación de arcade
5.2.2 Introducción a la programación con arcade
5.2.3 La clase arcade.Window
5.2.4 Ventanas con vistas: La clase arcade.View
5.2.5 Los protagonistas del juego: La clase arcade.Sprite
5.2.6 Un juego de ejemplo con arcade: Frontón
Etapa 1: Creando el interfaz
Etapa 2: Detalles del menú
Etapa 3: Añadimos la raqueta
Etapa 4: Le damos vida a la raqueta
Etapa 5: Creamos la pelota
5.2.7 Otro juego de ejemplo con arcade: Marcianitos
Constantes y módulo principal
Construcción del menú
Visualización y actualización del menú
Control del menú
Métodos auxiliares del menú

Aprender Python:

5 Jugando con Python

Los ordenadores son máquinas de enorme utilidad, y pueden ayudarnos de muchas formas en todo tipo de trabajos, pero también tienen una faceta lúdica muy potente. Los videojuegos no nacieron en las consolas, sino en los antiguos ordenadores, y además de una experiencia placentera constituyen un campo apasionante para su programación. Programar un juego nos exige el mayor control de muchos aspectos del hardware de nuestra máquina: pantalla gráfica, sistema de sonido y sistemas de entrada. Posiblemente también medios de almacenamiento y de comunicación.

De este modo, programar juegos no solo es un reto fascinante sino que nos permite aprender mucho sobre el arte de la programación y sobre el funcionamiento íntimo del ordenador.

Vamos a concentrarnos en dos bibliotecas de Python dedicadas a ello, la primera y más sencilla es arcade, que proporciona herramientas para programar juegos en 2D. Arcade está construida sobre pyglet, otra biblioteca a la que aporta sencillez de manejo y estructuras de mayor nivel, más adelante en el curso veremos las posibilidades de esta biblioteca básica. Después de arcade, más potente y por ello más compleja veremos la biblioteca pygame, un completo sistema de desarrollo de juegos pero que también podemos emplear para programas multimedia por su manejo de gráficos, sonido, vídeo y prácticamente todo aquello montado en la caja de nuestro ordenador.

Antes de entrar en la práctica de la programación de juegos resulta conveniente un somero repaso de sus bases teóricas.

5.1 Conceptos básicos sobre videojuegos

La inmensa mayoría de los usuarios de ordenador que no lo son por exigencias profesionales (y muchos de los que lo son) ha aprovechado las posibilidades de ocio que proporcionan los juegos de ordenador. Las máquinas domésticas más potentes son aquellas que se destinan al sector de los jugones (Gamers), dado que los juegos son las aplicaciones más exigentes en cuanto a capacidad de proceso.

5.1.1 ¿Qué es un videojuego?

El aspecto más evidente de un juego de ordenador es la imagen (no en vano se los llama videojuegos) y concretamente las imágenes animadas. Estas imágenes van acompañadas de los oportunos sonidos que contribuyen a darles emoción y verosimilitud, y se diferencian del vídeo en que son interactivas, no siguen un patrón predeterminado sino que responden a las acciones del usuario transmitidas a través de dispositivos de entrada: teclado y ratón o los sistemas más especializados de joysticks o gamepads como los más comunes.

Esto es el aspecto físico, el envoltorio del videojuego, pero en realidad lo que hace a los juegos interesantes es la historia en la que son capaces de sumergirnos, o una buena dinámica de juego en aquellos que carecen de historia. Hay juegos antiguos que no han perdido atractivo pese a que su apariencia técnica sea muy inferior a la de los desarrollos actuales.

Un videojuego es un programa como cualquier otro que nos muestra imágenes dándoles animación y efectos de sonido en respuesta a la entrada de datos. La programación de juegos implica el dominio de la representación digital de imágenes y de sonidos y de la gestión de los dispositivos de entrada.

Por supuesto, para hacer un buen juego hay aspectos ajenos al mundo de la programación como la creación de una buena historia o planteamiento de juego y el desarrollo de los aspectos artísticos: la elaboración de las imágenes y sonidos que darán vida al programa. Normalmente los videojuegos son realizados por equipos de especialistas en cada campo, pero en el contexto de este curso solo pretendemos aumentar nuestros conocimientos de programación, y los juegos son un estupendo banco de pruebas para ello.

5.1.2 Estructura de un videojuego

Desde el punto de vista del programador, los videojuegos siguen la habitual estructura de bucle principal, podemos considerarlos como una serie de bloques que envuelven un gran bucle de juego. Algo como lo siguiente:

Estructura general de un videojuego

El bucle de juego gestiona lo que sería una partida, en los juegos con "vidas" habría otro bucle interior para cada una. Dentro de estos bucles se desarrolla todo el proceso del juego, según el siguiente esquema:

Un bucle de juego genérico

Al igual que una película, cada imagen del juego constituye un cuadro (frame) que al ser mostrados con una cadencia adecuada producen el efecto de movimiento. El bucle del juego se organiza así como una vuelta para generar cada cuadro. El juego posee un estado en cada momento (normalmente determinado mediante un conjunto de variables) y el resultado gráfico debe reflejar ese estado. Como consecuencia de los eventos que se producen entre un cuadro y otro el estado cambiará y el siguiente cuadro se irá ajustando a los cambios. Los eventos pueden ser acciones del usuario o sucesos producidos en el propio juego, como la colisión entre un disparo y un enemigo o el simple discurrir del reloj, que implica un cambio en la posición de los objetos en movimiento.

5.1.3 Aspectos gráficos en la programación de juegos

En la inmensa mayoría del juego las imágenes se pueden agrupar en dos grandes bloques:

Las imágenes pueden obtenerse de ficheros o generarse mediante instrucciones pero, como el rendimiento gráfico es crucial, conviene tener todas guardadas en memoria, almacenadas en objetos o listas de objetos, antes de comenzar el bucle. Esto es factible en juegos 2D, pero en el caso de imágenes tridimimensionales no podemos generar un nuevo cuadro hasta procesar los eventos, el jugador puede girar o cambiar el punto de vista al moverse y debemos reflejar el cambio exacto. Los juegos 3D son mucho más exigentes, necesitan procesadores de gran potencia y tarjetas gráficas con chips especializados en manejar polígonos en tres dimensiones. Además conviene emplear lenguajes de programación compilados como C o C++. Python no es un el lenguaje más adecuado para ello, por lo que nos concentraremos de momento en el desarrollo de juegos bidimensionales.

El proceso para generar cada cuadro implica pintar el fondo y sobre este superponer los sprites en sus respectivas posiciones. Esto debe hacerse muy rápidamente para que el ojo no sea capaz de percibir el momento del cambio, de lo contrario veremos vibraciones y distorsiones en la imagen que nos impediran disfrutar del programa. Hay técnicas que ayudan a conseguirlo como la sincronización con el retrazo vertical de la pantalla y el doble buffer. En juegos sencillos con pocos objetos la potencia actual de los ordenadores nos permite desentendernos de todo esto. Ya veremos con detenimiento estas técnicas en otro estadio posterior.

Una imagen consiste en una matriz rectangular de puntos (pixels) representados mediante valores numéricos. El primer aspecto a considerar sobre las imágenes es la información de cada pixel, concretamente el número de bits que necesitamos para guardar esa información o bits por pixel (bpp). En este esbozo veremos las imágenes monocromas con un bit por pixel, y las de color RGB con un byte por componente, es decir, 24 bits por pixel. También el modo RGBA que añade un canal para la transparencia (canal alfa) y el que emplea una paleta de 256 colores de 24 bits mediante 8 bits por pixel. El segundo aspecto es la resolución; ancho y alto. La resolución y el tamaño coinciden cuando mostramos las imágenes en su tamaño natural pero dejan de hacerlo si escalamos la imagen ampliándola o reduciéndola.

Para almacenar una imagen necesitamos una secuencia de bytes del tamaño:   bytes_por_pixel * ancho * alto.
Normalmente se almacenan de forma secuencial en la que los datos de cada línea están inmediatamente despues de la línea anterior, de forma que la distancia entre un punto y el punto de debajo es:   bytes_por_pixel * ancho, pero no es imprescindible.

Imagenes monocromas (1 bpp)

Una imagen con un bit por pixel puede representar solo dos colores; primer plano (foreground) y fondo (background) que asociamos con valores de bit 1 y 0 respectivamente. Por defecto el fondo será negro y el primer plano blanco. Es el modo que empleaban los antiguos monitores monocromos de ordenador en los cuales el primer plano tenía color verde por la tecnología de la pantalla. Cada byte contiene 8 pixels. Veamos un pequeño programa en el que emplearemos Pillow para crear una imagen monocroma:

# Pillow monocroma

from PIL import Image

imB =b"\xFF\xFF" +\
b"\x80\x01" +\
b"\xBF\xFD" +\
10 * b"\xA0\x05" +\
b"\xBF\xFD" +\
b"\x80\x01" +\
b"\xFF\xFF"

im = Image.frombytes("1", (16, 16), imB)

for i in range(im.height):
print(f"{imB[2*i]:08b}{imB[2*i+1]:08b}")

im.resize((200,200)).show()

En la línea 5 definimos una secuencia de 16 líneas de 2 bytes cada una que nos dan una imagen de 16x16 pixels en monocromo. La función Image.frombytes() de la librería Pillow nos permite crear una imagen a partir de una secuencia de valores de byte. El primer argumento indica el modo de la imagen, en este caso "1" representa un bit por pixel, el segundo el tamaño en forma de tupla y en tercer lugar los datos a partir de los cuales se crea la imagen. El tamaño de dichos datos debe ser suficiente para representar todos los pixels para el tamaño y la profundidad de color requeridos.

En el bucle de las líneas 15-16 mostramos el valor de imB en forma binaria, agrupados de acuerdo con la geometría de la imagen para que puedas comprobar la equivalencia entre bits y pixels. Esta es la salida del programa

============= RESTART: C:/Users/User/Documents/Python/Pillow monocroma.py =============
1111111111111111
1000000000000001
1011111111111101
1010000000000101
1010000000000101
1010000000000101
1010000000000101
1010000000000101
1010000000000101
1010000000000101
1010000000000101
1010000000000101
1010000000000101
1011111111111101
1000000000000001
1111111111111111

>>>

Por último en la línea 18 mostramos una versión ampliada de la imagen obtenida para poder apreciarla mejor.

Imagen monocroma

A simple vista podemos comprobar la equivalencia entre puntos blancos y dígitos 1 y puntos negros y dígitos 0.

Las imágenes monocromas no resultan demasiado interesantes, en un juego preferiremos usar colores, pero hay una aplicación en la que resultan la mejor elección; las máscaras. Una máscara no se visualiza sino que determina las partes de otra imagen que resultan visibles y aquellas que no lo son. Esto nos permite crear zonas transparentes y superponer imágenes de formas irregulares sobre el fondo o sobre otras imágenes.

Otro uso muy interesante es como máscaras de colisión, de forma que podamos comprobar cuando dos sprites de forma irregular se superponen produciéndose una colisión entre ellos: una nave alcanzada por un disparo o un personaje que encuentra un muro en su camino son dos ejemplos.

Imágenes RGB (24 bpp)

Este es el tipo de imagen que empleamos habitualmente para una calidad fotográfica. Con tres canales que contienen la información de los componentes rojo, verde y azul de cada punto mediante valores de 8 bits podemos representar prácticamente todos los matices de color que el ojo humano puede distinguir.
Concretamente 2 ** 24 o   16.777.216, cerca de diecisiete millones.

Mientras que en una imagen monocroma empleabamos 1/8 de byte por pixel, para representar una imagen RGB usamos 3 bytes por pixel (uno para el componente rojo, otro para el verde y otro para el azul, en este orden), necesitamos una cantidad de memoria 24 veces mayor para las mismas dimensiones. Podemos comprobarlo mediante un pequeño programa que parte de la imagen monocroma del ejemplo anterior:

# Pillow RGB1

from PIL import Image

imB =b"\xFF\xFF" +\
b"\x80\x01" +\
b"\xBF\xFD" +\
10 * b"\xA0\x05" +\
b"\xBF\xFD" +\
b"\x80\x01" +\
b"\xFF\xFF"

im = Image.frombytes("1", (16, 16), imB)

print(f"Monocroma: {len(im.tobytes())} bytes")
print(f"RGB: {len(im.convert('RGB').tobytes())} bytes")

Empleamos el método .tobytes() que devuelve una secuencia de bytes a partir de una imagen de Pillow (exactamente lo opuesto a la función Image.frombytes que aprendimos antes). La salida del programa es la que cabe imaginar:

============= RESTART: C:/Users/User/Documents/Python/Pillow RGB1.py =============
Monocroma: 32 bytes
RGB: 768 bytes

>>>

En el caso de imágenes de dimensiones más amplias las secuencias de bytes pueden tener decenas o centenares de miles de elementos, no son manejables para una persona (aunque si lo son para un paciente bucle de programa). Vamos a aprovechar nuestras habilidades de manipulación de pixels recien aprendidas para generar una imagen RGB con un doble gradiente. De izquierda a derecha modificaremos los valores de rojo de 0 a 255, y de arriba abajo los de verde del mismo modo.

# Pillow RGB2 - Doble gradiente de color

from PIL import Image

print("Creando la secuencia de bytes...")
# Creamos una secuencia vacía de bytes
imB = bytes()
for j in range(256):# La fila y el valor del componente verde
for i in range(256): # La columna y el valor del componente rojo
imB = imB + bytes([i]) + bytes([j]) + b"\x00"

im = Image.frombytes("RGB", (256, 256), imB)

im.show()

La clave está en la conversión de un valor entero en una representación de byte en la línea 10. Si empleasemos bytes(i) produciría una secuencia de bytes de longitud i inicializada con ceros. El programa produce esta imagen:

Imagen RGB

Si comprobamos la longitud de imB obtendremos el valor 196608. Esta cantidad de datos ya no pueden ser manejados más que por medio de programas. En realidad, por cuestiones de rendimiento, no resulta práctico (en el mundo de los videojuegos) el trabajar con imágenes a nivel de bits, aquí lo planteamos como un medio para entender la estructura de las imágenes digitales. El proceso de conversión de una imagen a secuencia de bytes y al revés son, sin embargo, muy eficaces, dado que la imagen en sí es ya una secuencia de bytes.

Cada color se compone de tres valores para los canales rojo, verde y azul. Puesto que dedicamos un byte a cada componente los valores pueden moverse entre 0 y 255 (0xFF), siendo el 0 la ausencia del componente de color y 0xFF su máxima intensidad. Podemos observar que si los tres componentes tienen el valor 0 obtenemos el color negro, y si tienen el valor 0xFF obtenemos el blanco. Podemos comprobar que si los tres componentes tienen idéntico valor el color obtenido será un tono de gris entre negro y blanco, lo que nos proporciona 256 tonos de grises.

Vamos a ver cómo crear un algoritmo para reducir una imagen en color a una en tonos de gris. La clave está en comprobar el brillo de cada punto (de 0 a 255) e igualar los tres componentes a ese brillo. Es sencillo; podemos simplemente sumar los tres componentes de color y dividir el resultado entre 3.

# Pillow RGB3 - Escala de grises

from PIL import Image

im = Image.open("Goku.png")

imB = bytearray(im.tobytes())

for i in range(0, len(imB), 4):
imB[i] = imB[i + 1] = imB[i + 2] = (imB[i] + imB[i + 1] + imB[i + 2]) // 3

imG = Image.frombytes(im.mode, (im.width, im.height), bytes(imB))

iMix = Image.new(im.mode, (im.width * 2, im.height))
iMix.paste(im)
iMix.paste(imG, (im.width, 0))

iMix.show()

Aprovechamos para ahondar en nuestros conocimientos de la biblioteca Pillow. En la línea 5 abrimos el fichero y extraemos la secuencia de bytes en la línea 7. Como tenemos que modificar la secuencia la convertimos al tipo mutable bytearray. Recorremos la secuencia de bytes en el bucle de las líneas 9-10 y realizamos el cálculo de intensidad de cada pixel según el argumento planteado antes del listado. Todo el trabajo duro lo realiza la línea 10. Observa que empleamos grupos de tres bytes pero recorremos la secuencia con un intervalo de 4 bytes. Esto se debe a que la imagen tiene un cuarto canal con la información de transparencia (canal alfa) que no modificamos.

A continuación creamos a partir de la secuencia modificada una versión en tonos de gris de la imagen. El método Image.frombytes() requiere volver a convertir la secuencia en el tipo inmutable bytes.

A continuación, en la línea 14, creamos una imagen de suficiente tamaño para mostrar simultáneamente las dos versiones, para ello duplicamos el ancho. Sobre esta imagen superponemos con el método .paste() las dos versiones. Los argumentos de .paste() son la imagen a pegar y las coordenadas. Si no incluimos estas se asume el valor (0,0) correspondiente a la esquina superior izquierda. Podemos especificar unas coordenadas sencillas que corresponden al punto superior izquierdo de inserción de la imagen (y esta es insertada en su tamaño total) o dos puntos que definen un área rectangular en la cual se produce la inserción, en cuyo caso la imagen pegada se recorta según el tamaño del recuadro. También podríamos indicar un tercer argumento que representaría la esquina superior izquierda de la imagen que vamos a pegar, para pegar solamente una zona de esta.

Utilizando este método podemos tener varias imágenes en una, por ejemplo diferentes cuadros de un sprite animado (un personaje andando, o la secuencia de una explosión...). Pasemos a ver el resultado directamente. Pulsa aquí para descargar la imagen utilizada en el ejemplo.

Conversión a escala de grises

Vamos a concluir este esbozo comprobando como funcionan los tres canales de color de una imagen RGB. Vamos a crear el típico gráfico que explica la síntesis aditiva de colores mezclando los componentes RGB mediante tres círculos parcialmente superpuestos.

# Pillow RGB4 - Síntesis aditiva de color

import math
from PIL import Image, ImageDraw

def circle(imD, x, y, r):
x0 = x - 1.7 * r
y0 = y - 1.7 * r
x1 = x + 1.7 * r
y1 = y + 1.7 * r
imD.ellipse((x0, y0, x1, y1), BLANCO)

BLANCO = "#FFFFFF"

RADIO  = 50
SIZE= 6 * RADIO
CENTRO = SIZE // 2

# Círculo rojo
imR = Image.new("L", (SIZE, SIZE))
imD = ImageDraw.Draw(imR)
angle = math.radians(60)
x = CENTRO + math.cos(angle) * RADIO
y = CENTRO - math.sin(angle) * RADIO
circle(imD, x, y, RADIO)

# Círculo verde
imG = Image.new("L", (SIZE, SIZE))
imD = ImageDraw.Draw(imG)
y = CENTRO + math.sin(angle) * RADIO
circle(imD, x, y, RADIO)

# Círculo azul
imB = Image.new("L", (SIZE, SIZE))
imD = ImageDraw.Draw(imB)
x = CENTRO - RADIO
y = CENTRO
circle(imD, x, y, RADIO)

im = Image.merge("RGB",(imR, imG, imB))
im.show()

Empleamos el módulo ImageDraw de la biblioteca Pillow para poder pintar sobre las imágenes. Como el método .ellipse() requiere las coordenadas de un rectángulo que contendrá la elipse definimos una función para trazar círculos a partir del centro y el radio.

La imagen está formada mediante tres círculos que a su vez tienen sus centros en puntos equidistantes de un círculo interior (que no es dibujado). El radio se refiere al círculo interior, y lo ajustamos con un valor de 1.7 para que los círculos de cada componente se solapen. Usamos las funciones trigonométricas seno y coseno para obtener los centros de los tres círculos. El esquema es el siguiente:

Estructura gráfica

El círculo del rojo está a 600 a la derecha y arriba. El verde igualmente pero a la derecha y abajo. El círculo azul directamente a la izquierda. Para los primeros empleamos el seno y el coseno de los sesenta grados (convertidos a radianes) restando o sumando según corresponda. Para el azul el coseno vale -1 y el seno 0.

Para cada círculo creamos la imagen en modo de paleta de colores (un par de bloques más adelante veremos en que consiste esto) y una vez calculado el centro del círculo lo pintamos invocando la función circle().

Una vez obtenidas las tres imágenes las mezclamos una en cada canal de color con la función Image.merge(). Los argumentos de dicha función son el número de canales (definido por el modo, "RGB" corresponde a 3 canales) y una tupla con las imágenes para cada canal. Este es el resultado.

Síntesis aditiva de colores

Transparencias. Imágenes RGBA (32 bpp)

Hemos visto que las imágenes se almacenan en forma de matrices rectangulares de puntos, pero en la mayoría de las ocasiones no deseamos tener objetos de forma rectangular en nuestro juego. Para generar imágenes de formas libres debemos utilizar el concepto de transparencia. La imagen sigue siendo rectangular pero no pintamos todos los puntos sobre la pantalla.

Este efecto puede conseguirse de tres maneras:

Una máscara es una imagen cuyos puntos no son pintados sino que definen aquellos puntos de la imagen principal que deben ser pintados y cuales no. Un color clave es un color seleccionado entre los de la imagen de forma que los puntos de dicho color no son mostrados, convirtiendose en transparentes. Por último, un canal alfa (Alpha channel) es un cuarto canal similar a los de los componentes RGB que define un nivel de transparencia, siendo el 0 totalmente transparente y el 255 completamente opaco. Las imágenes con canal alfa son de tipo "RGBA" y emplean 32 bit por pixel.

Una máscara puede realizar la misma función que un canal alfa, pero este viene ya incorporado a la propia imagen. Vamos a realizar un programita para ver en funcionamiento estos conceptos. Emplearemos una ventana de tkinter con un canvas en el cual mostraremos tres versiones de una imagen: un fondo, una imagen RGBA superpuesta al fondo y la misma imagen sin el canal alfa.

# Pillow RGBA1

import tkinter as tk
from PIL import Image, ImageTk

# Preparamos la imagen de fondo
fondo = Image.open("Bonk-Fondo.png")
ancho = fondo.width * 2
alto  = fondo.height * 2
fondo = fondo.resize((ancho, alto), resample = Image.NEAREST)

# Añadimos el personaje con máscara (canal alfa)
bonk  = Image.open("Bonk.png")
bonk  = bonk.resize((bonk.width * 2, bonk.height * 2), resample = Image.NEAREST)
fondobonk = fondo.convert("RGBA").copy()
fondobonk.alpha_composite(bonk, ((ancho - bonk.width) // 2 ,(alto - bonk.height) // 2))
fondobonk = fondobonk.convert("RGB")

# Añadimos el personaje sin máscara
bonk2 = bonk.convert("RGB")
fondobonk2 = fondo.copy()
fondobonk2.paste(bonk, ((ancho - bonk.width) // 2 ,(alto - bonk.height) // 2))

def solofondo():
global imTK
imTK = ImageTk.PhotoImage(fondo)
lienzo.delete("all")
lienzo.update()
lienzo.create_image((lienzo.winfo_width() // 2, lienzo.winfo_height() // 2), image = imTK)

def mask():
global imTK
imTK = ImageTk.PhotoImage(fondobonk)
lienzo.delete("all")
lienzo.update()
lienzo.create_image((lienzo.winfo_width() // 2, lienzo.winfo_height() // 2), image = imTK)

def nomask():
global imTK
imTK = ImageTk.PhotoImage(fondobonk2)
lienzo.delete("all")
lienzo.update()
lienzo.create_image((lienzo.winfo_width() // 2, lienzo.winfo_height() // 2), image = imTK)

root  = tk.Tk()
root.geometry(f"{ancho + 6}x{alto + 6}")
root.title("RGBA1")

# Necesitamos una variable global para que no sea borrada por el sistema
imTK = None

lienzo = tk.Canvas(relief = "sunken", bd = 3)
lienzo.pack(side = "top", expand = True, fill = "both")

status = tk.Frame()
status.pack(side = "top")
Bfondo = tk.Button(status, text = "Fondo", command = solofondo)
Bfondo.pack(side = "left", padx = 10)
Bnomask = tk.Button(status, text = "Sin máscara", command = nomask)
Bnomask.pack(side = "left", padx = 10)
Bmask = tk.Button(status, text = "Con máscara", command = mask)
Bmask.pack(side = "left", padx = 10)

solofondo()

root.mainloop()

Comenzamos, despues de importar las bibliotecas necesarias, por preparar las tres imágenes a partir del gráfico del fondo y el del personaje. Para la primera versión nos limitamos a cargar el fondo y duplicar su tamaño (línea 10). El parámetro resample indica el modo de procesar los pixels con el cambio de tamaño; al usar el valor Image.NEAREST convertimos cada pixel en un cuadrado de pixels del mismo valor, y así no alteramos la imagen original.

Una vez preparado el fondo, sobre una copia de este (línea 15), superponemos el personaje (línea 16). Para invocar el método .alpha_composite() debemos convertir previamente el fondo al modo RGBA, y al terminar el proceso volvemos al modo RGB, que es el que tkinter acepta.

En las líneas 20-22 hacemos lo mismo pero eliminando el canal alfa del personaje antes de superponerlo sobre el fondo. En este caso, al no haber transparencias de por medio, empleamos el método .paste()

Las funciones solofondo(), mask() y nomask() borran el contenido del canvas y muestran la imagen correpondiente. Para ello empleamos la función ImageTk.PhotoImage() que nos proporciona el formato de imagen adecuado para tkinter y el método .create_image() que coloca la imagen sobre el canvas.

A partir de la línea 45 creamos la ventana principal y le damos las dimensiones adecuadas (contando con el borde del canvas y el tamaño de la barra inferior). Creamos una variable global para guardar el contenido de la imagen que mostraremos en el canvas. Si la variable no fuese global se borraría al salir de las funciones y no veríamos nada.

A continuación creamos el interfaz, el canvas ocupa casi toda la ventana excepto el espacio para la barra inferior con los botones. Invocamos la función solofondo() para mostrar el gráfico del fondo y a partir de ahí, en el bucle principal, dependiendo de qué botón pulsemos veremos una de las tres versiones. He aquí el resultado.

Empleo de máscaras
Imágenes empleadas en el programa

En el ejemplo podemos comprobar la importancia de las transparencias para emplear imágenes de forma libre sobre fondos complejos. ¡No nos interesa que nuestro juego tenga la apariencia de la figura central!. Como ya hemos comentado más arriba, otra utilidad de las máscaras aplicadas a nuestros sprites es para gestionar las colisiones, de forma que no se produzcan al mezclar los rectángulos sino por la propia silueta del sprite.

Imágenes con paleta de colores (8 bpp)

Vamos a ver en último lugar un modo de color particularmente interesante, porque empleando solo 8 bits permite mostrar hasta 256 colores RGB diferentes. Normalmente en los juegos no necesitamos una calidad fotográfica, sino una colección limitada de colores. El modo "P" nos permite mapear colores de 24 bits sobre una paleta de solo 8 bits, con lo que conseguimos un ahorro sustancial en el tamaño de las imágenes. Además podemos conseguir interesantes efectos modificando la paleta, de forma que veamos los pixels de otros colores.

Comencemos con un ejemplo para ir entendiendo el concepto:

# Pillow paleta 01

from PIL import Image

# Definimos los colores
NEGRO  = b"\x00\x00\x00"
BLANCO = b"\xFF\xFF\xFF"
ROJO= b"\xFF\x00\x00"
VERDE  = b"\x00\xFF\x00"
AZUL= b"\x00\x00\xFF"

# Creamos una paleta de 256 colores
paleta = ROJO + NEGRO + AZUL + VERDE + 252 * BLANCO

# Empleamos una cadena de bytes para formar la imagen
imB= 4 * b"\x00\x01\x02\x03"

im = Image.frombytes("L", (4, 4), imB)
im.putpalette(paleta)

# Mostramos los valores de byte de la imagen
alto, ancho = im.height, im.width
for i in range(alto):
for j in range(ancho):
print(f"{imB[4*i+j]:02X}", end = "")
print()

# Mostramos las dimensiones y el modo
print(f"\nim ({im.width}x{im.height}), modo \"{im.mode}\"")

# Ampliamos y exhibimos la imagen
im.resize((200,200), resample = Image.NEAREST).show()

Los comentarios son suficientes para entender el funcionamiento, empleamos secuencias de bytes tanto para definir los colores de la paleta como la propia imagen, cuyas dimensiones son de 4x4 pixels. Cada pixel posee un valor de 0 a 255 que constituye un índice a la paleta de color, de forma que se emplea el color definido en la posición correspondiente. Dado el orden empleado en nuestra paleta, el valor 0 corresponde a ROJO, el 1 a NEGRO, el 2 a AZUL y el 3 a VERDE.

De este modo la imagen está formada por cuatro columnas de un pixel con esos cuatro colores. La salida de texto del programa es la siguiente:

============= RESTART: C:/Users/User/Documents/Python/Pillow paleta 01.py =============
00010203
00010203
00010203
00010203

im (4x4), modo "P"

>>>

Y la imagen obtenida:

Imagen con paleta de color

No cierres la ventana del shell de IDLE despues de ejecutar el programa, vamos a comprobar la diferencia de tamaño de una imagen en modo paleta y otra en modo RGB. Si tienes en tu directorio del curso las imágenes del epígrafe anterior prueba en el modo interactivo lo siguiente.

>>> im2 = Image.open("Bonk-fondo.png")
>>> im2.size
(256, 239)
>>> im = im.resize(im2.size)
>>> im.size
(256, 239)
>>> len(im.tobytes())
61184
>>> len(im2.tobytes())
183552
>>>

La imagen RGB ocupa exactamente el triple de la otra, como era de prever. A la hora de almacenar el fichero en disco hay que añadir la información de la paleta de colores, pero como mucho supone 768 bytes extra si empleamos los 256 valores.

Vamos a jugar un poco con la paleta. Mantente en el modo interactivo para seguir las indicaciones:

>>> paleta = im.getpalette()
>>> paleta[:3] = [0x80, 0x40, 0x60]
>>> im.putpalette(paleta)
>>> im.show()
>>>

La nueva imagen que veremos es así:

Modificando la paleta

Con solo tres líneas hemos reemplazado un color de la imagen por otro. Los métodos nuevos son .getpalette(), que devuelve una lista de 768 números con los valores de la paleta y putpalette() que requiere una lista de valores como argumento. Ten en cuenta que los valores no pueden ser mayores de 255 (0xFF) o se producirá un error. Cada tres números corresponden con los componentes RGB de un color. Hemos modificado los primeros tres elementos, es decir, el color de índice = 0.

Podemos modificar el aspecto de la imagen sin intervenir directamente en ella, simplemente modificando la paleta. Esto puede aplicarse para efectos como un desvanecimiento de un sprite o una explosión, por ejemplo. Es cuestion de usar la imaginación.

No necesitamos emplear los 256 colores, en el programa anterior podríamos dejar la línea 13 así:

paleta = ROJO + NEGRO + AZUL + VERDE

De todas formas, al emplear el métod .putpalette() Pillow rellena el resto de los valores con colores predeterminados. Vamos a comprobar mediante un programa en ventanas cómo podemos crear un efecto de fundido con una imagen jugando con la paleta.

# Pillow paleta 02 - Usamos tkinter para conseguir una presentación animada

import tkinter as tk
from PIL import Image, ImageTk

# Definimos los colores mediante tuplas
ROJO= (0xFF, 0x00, 0x00)
NEGRO= (0x00, 0x00, 0x00)
VERDE= (0x00, 0xFF, 0x00)
AZUL= (0x00, 0x00, 0xFF)

COLORES = (ROJO, NEGRO, VERDE, AZUL)

# Empleamos una cadena de bytes para formar la imagen
imB= b"\x00\x01\x02\x03"

im = Image.frombytes("L", (4, 1), imB)

# Usamos una versión orientada a objetos
class myApp():
def __init__(self):
self.root = tk.Tk()
self.root.geometry("406x406")
self.root.title("Paleta 02")
self.canvas = tk.Canvas(relief = "sunken", bd = 3)
self.canvas.pack(expand = True, fill = "both")
self.image = im
self.imTK = None
self.fade = 0
self.update()
self.root.mainloop()

# Llamamos a la función recurrentemente
def update(self):
# Creamos una paleta matizando los colores con el valor de self.fade
paleta = []
for color in COLORES:
for componente in color:
paleta.append(round(componente * self.fade))
self.image.putpalette(paleta)
self.imTK = ImageTk.PhotoImage(self.image.resize((400, 400)))
self.canvas.delete("all")
self.canvas.create_image((200,200), image = self.imTK)
# Modificamos self.fade y programamos un nuevo lanzamiento de update()
if self.fade < 1:
self.fade += 1/256
self.root.after(5,self.update)
self.root.title(f"Paleta 02 ({round(self.fade*100)}%)")
else:
self.root.title("Paleta 02 (TERMINADO)")

app = myApp()

Hemos optado por una versión orientada a objetos, que en realidad resulta tremendamente simple. Un primer bloque hasta la línea 17 se limita a importar las bibliotecas y a crear los valores para los cuatro colores elegidos, los guardamos en una tupla porque así podemos luego recorrerlos mediante un bucle. Creamos tambien la imagen, pero esta vez simplemente definimos una línea de cuatro pixels, a la hora de mostrarla ya nos encargaremos de aumentar hasta el tamaño deseado.

Definimos nuestra aplicación mediante una clase que solo tiene dos métodos:

El contructor de la clase (__init__(self)) crea la ventana y un canvas dentro de ella, luego creamos varios atributos para guardar la imagen de Pillow, la versión de la imagen para tkinter y el valor self.fade que empieza en cero y llegará hasta 1. A continuación invocamos el método update() y lanzamos el bucle principal de tkinter.

EL método update() realiza todo el trabajo. Ante todo, creamos una paleta multiplicando cada componente de los colores en la lista por el valor de self.fade Así empezamos con colores totalmente negros que progresivamente van adquiriendo brillo. Observa que para mantener valores enteros usamos la función round(). Despues empleamos .putpalette() para asignar la paleta a nuestra imagen (línea 40) y la escalamos a 400x400 pixels y la transformamos en una imagen de tkinter (línea 41).

En las líneas 42 y 43 borramos el contenido del canvas y lo reemplazamos por la nueva imagen.

A continuación viene una parte divertida, en la que te presentamos un nuevo método de tkinter. Comprobamos si hemos llegado al valor 1, en cuyo caso los colores han alcanzado el brillo total y hemos terminado. En caso contrario, incrementamos el valor de self.fade en intervalos de 1/256. Esto implica que necesitamos 256 pasos para alcanzar el matiz pleno.

En la línea 48 empleamos un práctico método de la clase <tk.Tk>, la ventana principal. El método .after(), cuyos argumentos son un tiempo en milisegundos y una función. Lo que hace este método es programar el lanzamiento de la función una vez que transcurra el tiempo. En la práctica dependiendo de lo ocupado que esté nuestro ordenador el tiempo será solo indicativo.

Para finalizar actualizamos el título de la ventana añadiendo un porcentaje de progreso. Con un poco más de esfuerzo podemos crear una barra de progreso, pero eso lo dejo como ejercicio para el lector, ya volveremos sobre tkinter exhaustivamente más adelante.

Este es el aspecto del programa
5.1.4 Empleo de sonidos en la programación de juegos

En segundo lugar tras los gráficos tenemos el ingrediente sonoro, que aporta una dimensión imprescindible a un buen juego. Al igual que en las imágenes podemos diferenciar entre el fondo y los sprites, en el sonido podemos tener un sonido de base que suele consistir en música y los efectos sonoros que potencian el efecto de los sucesos del juego.

Una primera circunstancia a considerar es que el sonido tiene una dimensión temporal, necesita el tiempo para producirse. Sin embargo un programa (y un juego más aún) no puede detenerse ni siquiera una fracción de segundo para esperar a que un sonido termine de ser reproducido. El metrónomo del los microprocesadores late en nanosegundos, y nuestros programas miden el tiempo en milisegundos. Si ya hemos insistido en la necesidad de maximizar el rendimiento para procesar los gráficos con fluidez, solo nos faltaría detenernos mientras se oyen los diversos sonidos, si fuera así nos quedaríamos escuchando la música de fondo sin más.

Esto implica que los sonidos deben reproducirse en paralelo al resto del código, y aquí entra un concepto avanzado que es la multitarea. Por suerte las librerías de juego que vamos a emplear ya incorporan este mecanismo para que una vez "lanzado" un sonido sea reproducido en segundo plano mientras nuestro programa continúa.

winsound

La biblioteca estándar posee varios módulos dedicados al sonido desde un punto de vista muy elemental. Para los que programamos en Windows una opción sencilla es winsound. Descarga este fichero en tu directorio de Python y en el intérprete interactivo prueba las siguientes líneas:

>>> import winsound
>>> winsound.PlaySound("gong.wav", winsound.SND_FILENAME)

Escucharás el sonido del gong y durante 11 segundos no recuperarás el control. Podemos hacerlo mejor, prueba de nuevo con un ligero cambio.

>>> winsound.PlaySound("gong.wav", winsound.SND_FILENAME | winsound.SND_ASYNC)
>>>

Esta vez volverás a oir el sonido, pero el prompt aparece inmediatamente y puedes continuar haciendo otras cosas.

En teoría winsound.PlaySound() admite otra serie de modificadores, pero en la práctica SND_FILENAME y SND_ASYNC son los únicos que funcionan correctamente. Vamos a hacer un pequeño programa para ver los sonidos de sistema de Windows.

# Winsound test - Prueba de las capacidades básicas

import winsound as wsnd
import tkinter as tk
import os

media= "C:\\Windows\\Media\\"

def sonido(event):
wsnd.PlaySound(media + lista.get(tk.ACTIVE) + ".wav",
wsnd.SND_FILENAME | wsnd.SND_ASYNC)

sonidos = [c[:c.rindex(".")] for c in os.listdir(media) if c.endswith(".wav")]

maxlong = max([len(s) for s in sonidos])
maxheight = min(len(sonidos), 20)

root = tk.Tk()
lista = tk.Listbox(width = maxlong, height = maxheight)
lista.pack()
lista.bind("<Double-Button-1>", sonido)
tk.Button(text = "SALIR", command = root.destroy).pack(fill = "x")

root.update()
ancho = root.winfo_width()
alto = root.winfo_height()
scrancho = root.winfo_screenwidth()
scralto = root.winfo_screenheight()

# Centrar la ventana
root.geometry(f"{ancho}x{alto}+{(scrancho-ancho)//2}+{(scralto-alto)//2}")

for sonido in sonidos:
lista.insert("end", sonido)

root.mainloop()

Usamos la biblioteca winsound para reproducir los sonidos, tkinter para crear un interfaz gráfico y os para leer los ficheros de un directorio.

La funcion sonido() se limita a reproducir un fichero de sonido, obtenemos el nombre del fichero concatenando el directorio básico almacenado en la variable media con el nombre de fichero seleccionado en el widget lista (de tipo Listbox) y la extensión ".wav". Empleamos una combinación de los flags SND_FILENAME que indica que el primer argumento debe ser interpretado como un nombre de fichero y SND_ASYNC que produce la reproducción del sonido en segundo plano sin interrumpir el programa.

En la línea 13 creamos una lista por comprensión de los nombres de ficheros WAV en el directorio C:\Windows\Media. Vamos a ver en detalle cómo hemos obtenido, filtrado y procesado los resultados:

for c in os.listdir(media) Obtenemos una lista de nombres de todas las entradas en el directorio media
if c.endswith(".wav") De las cuales solo empleamos aquellas terminadas en ".wav"
c[:c.rindex(".")] Dejamos el nombre desde el primer caracter al anterior al punto de la extensión
(el método .rindex() devuelve la posición del punto más a la derecha)

En la línea 15 obtenemos la longitud del nombre más largo de la lista, y en la 16 el valor mínimo entre el número de elementos y 20, que luego usaremos para dar dimensiones a la Listbox.

Entre las líneas 18 y 22 creamos la ventana principal, la lista, para la cual empleamos las dimensiones y a la que asignamos la función sonido() como respuesta al doble click del ratón, y un botón para terminar que ocupará la parte inferior de la ventana.

Con esto ya tenemos el interfaz completamente configurado, pero vamos a añadir un punto de sofisticación centrando la ventana en mitad de la pantalla. Para esto obtenemos el tamaño de ambas empleando los métodos .winfo_width() y similares cuyos nombres son autoexplicativos. Verás que antes de emplear esos métodos hemos usado el método .update(), esto permite que se actualize la ventana y sus estructuras de datos de forma que refleje los valores actuales reales.

Como norma general, antes de tratar de obtener información de un widget con alguno de los métodos winfo_xxxx() conviene llamar a .update() antes. Solo tienes que anular la línea 24 colocando un caracter de comentario al principio para ver la diferencia en los resultados.

Finalmente, añadimos los nombres de la lista sonidos al Listbox, y lanzamos el programa que tendrá un aspecto similar a este, dependiendo de los sonidos que haya en tu directorio C:\Windows\Media.

Sonidos del sistema

Haciendo doble click en los nombres de los sonidos podrás escucharlos. Las bibliotecas arcade y pygame poseen medios más eficaces de trabajar con sonidos, que iremos viendo más adelante, pero vamos a esbozar brevemente las características del sonido que pueden interesarnos.

Conceptos de sonido digital

El sonido está formado por oscilaciones mecánicas propagadas a través de un medio físico, normalmente el aire de la atmósfera. Los dos parámetros principales que definen un sonido dado son la amplitud y la longitud de onda.

Onda sonora

La amplitud tiene que ver con la cantidad de energía de la onda sonora, y es percibida como volumen. Como el sonido es una onda que se desplaza en cada medio a una velocidad determinada, la longitud de onda (λ) y la velocidad permiten calcular otras dos magnitudes fundamentales, que son el periodo y la frecuencia. El periodo es el tiempo entre dos puntos equivalentes de la onda y La frecuencia es el número de oscilaciones que se producen en una determinada unidad de tiempo. Se trata de dos valores que representan caras opuestas de un mismo fenómeno.

La percepción del volumen sigue una escala de tipo logarítmico y se mide en decibelios. El periodo se representa por la letra T y se mide en segundos. La frecuencia se representa mediante la letra f y se mide en ciclos por segundo o Hertzios. Añadiendo a estos valores la velocidad de propagación (v) he aquí las fórmulas que relacionan todos los valores:

λ   =   vf   =   v * T

Podemos deducir que el periodo es el inverso de la frecuencia, y viceversa.

Otro aspecto que ya hemos comentado anteriormente es la relación entre el sonido y el tiempo. El sonido tiene duración, se produce en un momento dado y se mantiene durante un tiempo hasta que cesa su producción. En la biblioteca winsound tenemos la función .Beep(frecuencia_Hz, duración_ms) que hace sonar el altavoz.

>>> import winsound as wsnd
>>> wsnd.Beep(440, 1000)
>>>

Las líneas anteriores producen la nota LA del diapasón durante un segundo. Hasta que el sonido termina no se nos devuelve el control, con lo que no es una función demasiado práctica fuera del ámbito educativo. Si no oyes nada prueba a abrir el mezclador de volumen pulsando con el botón derecho del ratón sobre el icono del altavoz en el área de notificaciones y sube el volumen de los Sonidos del sistema.

Los sonidos normalmente no se componen de una onda pura, sino de una superposición de muchas ondas diferentes. Las ondas se combinan sumando sus amplitudes (restando cuando están en momentos contrarios del movimiento oscilatorio).

Superposición de ondas

Por compleja que sea la onda resultante siempre podemos descomponerla en un conjunto de ondas sinusoidales, de hecho es lo que nuestro oído hace automáticamente al percibir un sonido, separamos las distintas frecuencias. Esto tiene aplicaciones para el estudio de los sonidos, que también podemos realizar con Python pero que no es lo que nos interesa en este momento.

Mientras que en las imágenes digitalizadas la unidad es el pixel o punto de imagen en el sonido es la muestra (sample), que se corresponde con la intensidad de la onda en un momento de tiempo. El otro factor necesario es el tamaño de la muestra en bits. Para entendernos podemos imaginar un paralelismo entre imágenes y sonidos:

Imagen Sonido
Resolución Frecuencia de muestreo
Profundidad de color Tamaño de muestra

Otro parámetro importante es el número de canales. Normalmente usaremos sonido de un solo canal (monoaural o mono) y de dos canales (estéreo). En los ficheros de cine digital solemos tener configuraciones 5.1 (cinco canales más uno de subgraves).

La biblioteca wave, que forma parte de las bibliotecas estándar, nos permite abrir ficheros en formato WAV y obtener información sobre ellos, incluída la secuencia de muestras. Prueba lo siguiente.

>>> import wave
>>> gong = wave.open("gong.wav")
>>> gong
<wave.Wave_read object at 0x00000215F1A20130>
>>> gong.getparams()
_wave_params(nchannels=2, sampwidth=3, framerate=48000, nframes=570419, comptype='NONE', compname='not compressed')
>>> gong.getnchannels()
2
>>> gong.getsampwidth()
3
>>> gong.getframerate()
48000
>>> gong.getnframes()
570419
>>>

La función wave.open() abre un fichero (por defecto para lectura, pero también podemos hacerlo para escritura) y devuelve un objeto de tipo Wave_read (o Wave_write). A partir de dicho objeto podemos obtener los parámetros en forma de objeto _wave.params con el método .getparams() o hacerlo individualmente con cada método .get****, en el que sustituiremos **** por el nombre del parámetro. El tamaño de la muestra (sampwidth) se muestra en bytes.

>>> 570419 / 48000
11.883729166666667
>>>

El número total de muestras dividido por la frecuencia de muestreo (en muestras por segundo) nos devuelve la duración del clip en segundos. Si abres el fichero con tu reproductor (te recomiendo que uses VLC) puedes comprobarlo.

>>> gong.readframes(1)
b'\xff\xff\xff\x00\xff\xff'
>>>

Podemos leer las muestras en forma de bytes con el método .readframes() al que debemos indicarle el número de muestras a leer. Hemos obtenido una sola muestra, que al ser estéreo y de 24 bits (3 bytes) consiste en una secuencia de 6 bytes.

Vamos a hacer alguna manipulación simple para explorar las posibilidades.

# Waves 01

import wave

gong  = wave.open("gong.wav")
gong2 = wave.open("gong2.wav", "wb")
gong3 = wave.open("gong3.wav", "wb")

# Copiamos el fichero gong duplicando el framerate
gong2.setparams(gong.getparams())
gong2.setframerate(gong.getframerate() * 2)
gong2.writeframes(gong.readframes(gong.getnframes()))

# Colocamos el puntero de nuevo al comienzo de los datos
gong.rewind()

# Copiamos el fichero gong reduciento el framerate a la mitad
gong3.setparams(gong.getparams())
gong3.setframerate(gong.getframerate() // 2)
gong3.writeframes(gong.readframes(gong.getnframes()))

gong3.close()
gong2.close()
gong.close()

Abrimos el fichero gong.wav y CREAMOS dos ficheros nuevos (al abrir un fichero en modo escritura si no existe se crea y se existe se trunca la longitud a cero). Luego escribimos en ambos ficheros los parámetros y los datos originales, y modificamos el framerate duplicandolo en uno y reduciéndolo a la mitad en el otro. Verás que para cada método get*** existe el contrario set*** que modifica los parámetros.

Fíjate en la línea 15. Cuando empleamos el método readframes() vamos recorriendo el fichero y modificando el puntero de posición de este, de forma que una vez leído todo en la línea 12 una llamada posterior no devuelve nada. Es por ello que tenemos que "rebobinar" el fichero, y esto es lo que hace el método .rewind().

El programa no produce ninguna salida (a no ser que se produzca algún error, como que no tengas el fichero "gong.wav" en el directorio de Python). Abre el directorio para comprobar que hay dos nuevos ficheros, "gong2.wav" y "gong3.wav". Reprodúcelos para comprobar cómo al modificar la tasa de muestreo manteniendo los datos de las muestras se modifican tanto la frecuencia del sonido como su duración.

En la programación de juegos normalmente emplearemos grabaciones guardadas en ficheros.

Formatos de audio

Al igual que ocurre en el caso de las imágenes, existe una variedad de formatos de archivo para contener sonido. Ya hemos visto el formato WAVE que es el único que la biblioteca wave puede manejar, y de hecho es el más sencillo y menos eficaz en cuanto al espacio que ocupa.

Como los datos de sonido ocupan un volumen considerable lo más conveniente es usar un método de compresión. Entre estos tenemos dos grandes grupos, aquellos que comprimen a costa de perder una parte de los datos que en general no afecta a la percepción del sonido (compresión con pérdida) o los que mantienen la integridad de cada bit (compresión sin pérdida). Entre los primeros tenemos MP3, AAC y OGG - Vorbis, y entre los segundos APE y FLAC. Este último además de tener una buena tasa de compresión sin pérdida es un formato abierto.

Observa la siguiente tabla con el tamaño de nuestro fichero "gong" en varios formatos:

gong.wav 3.26 MB
gong.mp3 464 KB
gong.mp4
(compresión AAC)
832 KB
gong.ogg 401 KB
gong.ape 539 KB
gong.flac 596 KB

EL fichero sin comprimir ocupa más de 3 MB, mientras que los demás andan alrededor de 1/2 MB. El formato MP4 es menos eficaz que el MP3 para el audio, pero el primero puede ser usado igualmente con vídeo. En este caso hemos conseguido la mayor compresión con OGG que será el que usemos de modo principal en nuestros futuros juegos. Para convertir los sonidos podríamos emplear un editor de audio como el estupendo Audacity que es de código abierto, pero puesto que lo que nos gusta es programar, hagámoslo con Python. Para ello necesitamos una nueva bibliotea.

pydub

Ante todo deberemos instalar la librería empleando la herramienta pip.

C:\>pip install pydub

Además de la biblioteca pydub hemos de instalar en nuestro ordenador la plataforma de proceso de ficheros multimedia ffmpeg. Descarga la versión ejecutable para Windows desde este enlace. Se trata de un fichero comprimido con el programa de código abierto 7Zip. Te recomiendo que descargues e instales este último como mejor alternativa al winZip o winRar.

El fichero comprimido de ffmpeg viene con un directorio ffmpeg...***..., ábrelo y te encontrarás con la siguiente estructura de directorios.

Utilidades de ffmpeg

Como no existe instalador debemos hacerlo a mano. Crea un directorio ffmpeg dentro de la carpeta C:\Program Files y una vez creado, ábrelo. Selecciona los tres directorios y los dos ficheros de la carpeta comprimida y arrástralos con el ratón hasta la carpeta. Te pedirá autorización, probablemente varias veces.

Ahora hemos de añadir la carpeta C:\Program Files\ffmpeg\bin al PATH, la variable de entorno para localizar programas ejecutables. Para eso puedes abrir cualquier carpeta del explorador y subir directorios con el botón de la flecha arriba hasta llegar al tope. Entonces pulsa con el botón derecho sobre el icono de Este Equipo y del menú emergente selecciona la opción Propiedades.

Dentro del menú de propiedades de sistema elige Configuración avanzada del sistema, en la columna de menú de la izquierda.

Se abrirá la ventana de Propiedades del sistema, y aquí pulsa el botón Variables de entorno...

La ventana que se abre ahora permite seleccionar las variables para el usuario actual o para cualquier usuario (Variables del sistema). Si el ordenador lo usas solo tú, es indiferente, de otro modo mejor modifica la variable path para todos los usuarios seleccionándola en la lista y pulsando el botón Editar.

Por fín, usa el botón Nuevo y añade la ruta C:\Program Files\ffmpeg\bin.

Ahora tenemos instaladas las utilidades ffmpeg, ffprobe y ffplay, disponibles desde cualquier ventana del símbolo del sistema. Abre este y teclea cada uno de los nombres anteriores. Es muy posible que tu antivirus impida la ejecución de los programas, en mi caso muestra una ventana de advertencia indicando que el programa ha sido puesto en cuarentena y enviado a Avast para su verificación, esto para cada uno de los tres programas. No te alarmes, en un tiempo breve Avast vuelve a desbloquear los ficheros avisando de que han sido correctamente comprobados y no revisten riesgo. Si tienes otro antivirus es posible que te pida permiso para autorizar la ejecución, dáselo y prosigue.

Una vez terminado todo el proceso de instalación, abre IDLE y prueba unas órdenes:

>>> from pydub import AudioSegment
>>> gong = AudioSegment.from_file("gong.wav")
>>> gong
<pydub.audio_segment.AudioSegment object at 0x0000025D2A7F0CA0>
>>>

pydub nos permite muchas manipulaciones del sonido, de momento vamos a recuperar la información más relevante sobre este:

>>> gong.channels
2
>>> gong.frame_rate
48000
>>> gong.sample_width
4
>>> gong.frame_count()
570419.0
>>> gong.duration_seconds
11.883729166666667
>>>

De nuevo tenemos acceso a la información relativa a canales, frecuencia de muestreo, tamaño de muestra y número de muestras. Podemos obtener también la duración sin efectuar ningún cálculo.

Una vez cargado nuestro segmento de audio podemos hacer un montón de cosas con él. Para empezar, exportarlo a numerosos formatos.

>>> gong.export("gong.ogg", format = "ogg", codec="libvorbis")
<_io.BufferedRandom name='gong.ogg'>
>>> gong.export("gong.flac", format = "flac")
<_io.BufferedRandom name='gong.flac'>
>>> gong.export("gong.mp3")
<_io.BufferedRandom name='gong.mp3'>
>>>

Ha mejorado la compresión (la tabla anterior partía de Audacity) en el caso del MP3 (186 KB) y el OGG (146 KB), y empeorado para el FLAC (1.58 MB).

pydub nos permite seccionar el audio y volver a juntar secciones o superponerlas, efectos variados e incluso sintetizar sonidos. Lamentablemente lo que no nos permite (en mi instalación de Python no lo he conseguido) es reproducir los sonidos. Vamos a anticipar acontecimientos y usaremos las capacidades de la biblioteca pygame para solventar el problema. Ante todo, instala la biblioteca con la siguiente orden en el Símbolo del sistema:

C:\>pip install pygame

Todo debería funcionar correctamente y al terminar te indicaría que pygame ha sido correctamente instalado. Ahora podemos abordar nuevos programas.

# pydub 01 - Segmentos de clip

from pydub import AudioSegment
from pygame import mixer

mixer.init()

def play(sound):
sound.export("tmp.mp3").close()
mixer.Sound("tmp.mp3").play()

gong = AudioSegment.from_file("gong.wav")

# Truncamos el sonido a 3 segundos
play(gong[:3000])

Veamos las novedades. Pygame se compone de numerosos módulos para diferentes cuestiones. Nosotros usaremos el módulo mixer que gestiona la reproducción de sonidos. Una característica de pygame es que siempre debemos inicializar la librería antes de poder usarla, si importamos un solo módulo deberemos inicializar el módulo en cuestión. Para ello invocamos mixer.init() en la línea 6.

En la línea 8 definimos una función play() que reproduce un sonido a partir de un objeto AudioSegment de pydub. Para ello guardamos el sonido en el fichero "tmp.mp3" provisionalmente y luego lo cargamos de nuevo (línea 10) con mixer.Sound(filename) y en la misma instrucción lo reproducimos con el método .play().

Una vez definida la función creamos el segmento de audio en la línea 12 cargando nuestro trillado fichero gong.wav e invocamos a la función play() con una porción del fichero. Verás que usamos la misma notación que para extraer porciones de una secuencia. Los valores de los índices corresponden a milésimas de segundo, por lo que gong[:3000] es un clip con los primeros tres segundos del sonido original.

Cortar sonidos es así de sencillo con pydub. Puedes probar a usar distintos valores de los índices de comienzo y final y escuchar los resultados, los índices negativos indican milisegundos desde el final del clip. El programa no tiene salida gráfica de interés, simplemente oiremos 3 segundos del gong. Observarás que el final del sonido resulta abrupto al haberlo cortado de esta forma. Vamos a solucionar eso.

# pydub 02 - Fundido de cierre

from pydub import AudioSegment
from pygame import mixer

mixer.init()

def play(sound):
sound.export("tmp.mp3").close()
mixer.Sound("tmp.mp3").play()

gong = AudioSegment.from_file("gong.wav")[:3000]

# Aplicamos un fundido en el último segundo
play(gong.fade_out(1000))

Esta vez recortamos el clip directamente en la línea 12 según lo leemos del disco. Luego a la hora de reproducirlo empleamos el método .fade_out(ms) con un argumento que indica el tiempo en milisegundos. Como es un fundido de cierre el tiempo cuenta desde el final del fichero. Existe también el método fade_in(ms) que realiza un fundido de apertura, y en ese caso el tiempo se cuenta desde el principio.

Escucharás que el fundido elimina la sensación abrupta que se producía antes. Por si no lo tienes claro, un fundido es una progresión entre un volumen nulo y el volumen definitivo a lo largo de un tiempo. Hay tres clases básicas, de apertura, de cierre y fundido encadenado.

Continuando con las variaciones de amplitud de la onda sonora vamos a ver más posibilidades.

# pydub 03 - Modificaciones de volumen

from pydub import AudioSegment
from pygame import mixer

mixer.init()

def play(sound):
sound.export("tmp.mp3").close()
sonido = mixer.Sound("tmp.mp3")
sonido.play()
while mixer.get_busy():
pass

gong = AudioSegment.from_file("gong.wav")[:3000]

play(gong - 10)
play(gong)
play(gong + 10)

Al ejecutar el programa escucharemos tres veces el sonido del gong con distinto volumen. Observa que hemos modificado la función play() añadiendo las líneas 12 y 13. La función mixer.get_busy() devuelve True si estamos reproduciendo sonido en ese momento. De esta forma la función no retorna hasta que se termine la reproducción. Si intentamos llamar a la función mientras un sonido aún está sonando, al querer exportar el nuevo sonido sobre el fichero "tmp.mp3" se producirá un error porque el fichero estará aún abierto y en uso. Además se superpondrían los sonidos y no podríamos observar el efecto de cambio de volumen.

Modificar el volumen es tan simple como usar los operadores + o - con un valor numérico sobre el segmento de audio. Si usamos + con dos segmentos lo que haremos será concatenarlos. El operador * con un valor entero replica el clip las veces correspondientes al valor.

>>> trigong = gong * 3
>>> trigong.duration_seconds
9
>>> play(trigong)
>>>

Esto crea un clip con tres repeticiones del sonido del gong. Como ya lo tenemos muy oído aquí tienes varios nuevos para seguir experimentando. Descarga el fichero y descomprímelo en el directorio de Python antes de seguir con los próximos programas.

# pydub 04 - Reproducción inversa del sonido

from pydub import AudioSegment
from pygame import mixer

mixer.init()

def play(sound):
sound.export("tmp.mp3").close()
sonido = mixer.Sound("tmp.mp3")
sonido.play()

sound = AudioSegment.from_file("trampa.mp3")

play(sound.reverse() + sound)

El método .reverse() hace que el clip se reproduzca desde el final hasta el principio.

Vamos a ver algunos efectos muy resultones que podemos emplear fácilmente con pydub.

# pydub 05 - Otros efectos

from pydub import AudioSegment
from pydub import effects
from pygame import mixer

mixer.init()

def play(sound):
while mixer.get_busy():
pass
sound.export("tmp.mp3").close()
sonido = mixer.Sound("tmp.mp3")
sonido.play()

trampa = AudioSegment.from_file("trampa.mp3")
fanfarria=AudioSegment.from_file("Fanfarria.wav")

play(fanfarria.pan(-0.9))
play(fanfarria.overlay(trampa, position=1000))
play(fanfarria.pan(+0.9))

for i in [1.5, 2, 3]:
play(effects.speedup(fanfarria, i))

Como vamos a hacer más de una reproducción hemos incorporado de nuevo a play() el control para esperar a que esté libre para reproducir el sonido siguiente. Al poner este control al principio de la función esta solo nos hará esperar si tratamos de oír un nuevo sonido antes de que termine el anterior.

El método .pan(level) nos permite cambiar la panoramización (panning). Esto es, en un sonido estéreo, la orientación desde la que lo percibimos. Los valores oscilan entre -1 (totalmente a la izquierda) hasta +1 (totalmente a la derecha). Si nuestro clip de sonido fuese mono sería convertido a dos canales.

El método .overlay(clip) permite superponer clips de audio. Admite los siguientes argumentos nombrados:

En cualquier caso, el clip superpuesto se interrumpe si el clip base llega al final.

El tercer método que hemos usado es effects.speedup(clip, Variación) que permite aumentar la velocidad de reproducción según el valor del argumento flotante Variación, que debe ser siempre mayor que 1.

Vamos a terminar esta visión de la biblioteca pydub viendo la posibilidad de generar sonidos completamente artificiales.

# pydub 06 - Síntesis de sonido

from pydub import AudioSegment
from pydub.generators import Sine
from pygame import mixer

mixer.init()

def play(sound):
while mixer.get_busy():
pass
sound.export("tmp.mp3").close()
sonido = mixer.Sound("tmp.mp3")
sonido.play()

LA = Sine(440).to_audio_segment()
DOs = Sine(550).to_audio_segment()
MI = Sine(660).to_audio_segment()

play(LA + DOs + MI)
play(LA.pan(-0.8).overlay(DOs).overlay(MI.pan(+0.8)))

Del módulo pydub.generators importamos Sine(), que es un generador de onda sinusoidal (es decir, como la función trigonométrica seno). Sine() crea un generador de sonido puro de la frecuencia indicada como argumento. El método .to_audio_segment() convierte el generador en un segmento de audio.

Sine() admite como argumentos la frecuencia en Hertzios, y además puedes añadir sample_rate y bit_depth para indicar la frecuencia de muestreo y el tamaño de muestra. Por defecto los valores son 44100 y 16 respectivamente. El método .to_audio_segment() admite un argumento duration con el tiempo en milisegundos, por defecto 1000.

Generamos tres clips de un segundo con las notas LA4, DO#5 y MI5, que oímos primero en secuencia como un arpegio de LA Mayor y depués a la vez en forma de acorde de LA Mayor. El efecto es bastante imperfecto, especialmente cuando superponemos las ondas, pero puede tener su aplicación.

5.1.5 Interacción: Dispositivos de entrada de usuario

Una vez que podemos presentar imágenes, incluso en secuencia formando animaciones, y sonidos, un juego necesita la intervención del jugador. Para eso hemos de emplear un dispositivo de entrada de los que se han desarrollado para los ordenadores. El teclado y el ratón están siempre disponibles en un ordenador que use un GUI, y pueden ser suficientes para muchos juegos, pero los dispositivos de entrada específicos para jugar son la palanca de mando o palanca de juego (Joystick) y los Mandos (Gamepads).

Existen otros dispositivos más exóticos que podemos emplear en juegos: volantes y pedales para juegos de conducción, alfombras con sensores para juegos controlados con el movimiento de los pies, incluso cámaras que nos permiten controlar los juegos con el movimiento del cuerpo, pero nosotros no vamos a ir tan lejos (al menos de momento ;-).

Entrada mediante eventos

Existen dos orientaciones principales para la gestión de la entrada del usuario, uno es la exploración periódica: durante el bucle del programa comprobamos la posición y estado de los botones del ratón, y el estado de las teclas. Por desgracia este enfoque suele ser muy poco fiable, no podemos controlar lo que ocurre entre exploraciones consecutivas y eso puede hacer que se pierdan algunas acciones del usuario, lo que a la postre resulta en una mala respuesta del juego. No hay nada más frustrante que en el momento crítico pulsemos el disparo y no haya respuesta. No es el modo correcto de captar usuarios.

El otro enfoque está dirigido por eventos. Toda la actividad de entrada queda registrada y cuando llega el momento oportuno podemos comprobar qué ha ocurrido mientras estábamos haciendo los cálculos del bucle de juego. En realidad, desde el momento que programamos en un GUI como Windows la elección está ya decidida.

¿Recuerdas cómo asignabamos respuestas a los widgets de tkinter?. Efectivamente, asignando funciones de respuesta a eventos. El propio Windows es un sistema mediante eventos. Hay procesos del sistema operativo que "escuchan" constantemente los dispositivos de entrada y crean mensajes para cada cambio detectado. Todo el entorno funciona mediante mensajes, cuando queremos cerrar o redimensionar una ventana, o cualquier otra acción del interfaz se generan mensajes que acaban llegando al bucle del programa y este reacciona ante ellos o a su vez los transmite al widget destinatario para que se encargue del proceso. Si tienes curiosidad por hacerte una idea de hasta qué punto los mensajes están omnipresentes puedes ver una lista de algunos de ellos aquí.

Los mensajes nos llegarán en las librerías de juegos de Python en forma de eventos, unos objetos con información sobre lo que ha ocurrido y a los que podemos reaccionar si es pertinente. Como hemos instalado pygame vamos a usarlo para crear un programa muy sencillo que se limita a mostrarnos los eventos que recibe.

# Eventos 01

import pygame

pygame.init()

root = pygame.display.set_mode((600, 400))

running = True

while running:
for event in pygame.event.get():
print(event)
if event.type == pygame.QUIT:
running = False

pygame.quit()

Este es prácticamente el esqueleto más básico de un programa con pygame. En primer lugar importamos e inicializamos la librería. La función que crea la ventana del programa es pygame.display.set_mode(), a la que indicaremos el tamaño deseado en forma de tupla.

También gestionaremos siempre el bucle principal de forma similar, mediante una variable booleana que indica que el programa está en ejecución, "running" es un nombre bastante adecuado. Dentro del bucle principal hay otro bucle que nos devuelve todos los eventos que se hayan producido y en este caso se limita a imprimirlos sin más. Prestamos especial atención al evento cuyo atributo .type se corresponde con la constante predefinida pygame.QUIT, que indica que queremos terminar el programa. En este caso cambiamos el valor de la variable running provocando la terminación del bucle principal y llegamos a la sección final en la cual abandonamos el entorno de pygame con pygame.quit().

Escribe y ejecuta el programa, no ponemos la salida porque ocupa infinidad de líneas y depende de las acciones que realizes. Prueba todo lo que se te ocurra con el ratón y el teclado y observa lo que se produce. Incluso si movemos la ventana, la minimizamos o superponemos otra ventana sobre ella se generan múltiples eventos. El propio sistema ya maneja adecuadamente la mayoría de ellos, solo tenemos que prestar atención a los que nos interesen, en este caso a los eventos de teclado, ratón y dispositivos de entrada en general.

Entrada mediante teclado

El teclado consiste, como es obvio, en una serie de teclas. Lo que ya no lo es tanto es lo que representa cada una de ellas para el ordenador. En realidad cada tecla tiene un código numérico asignado que no tiene nada que ver con la representación de un caracter y recibe el nombre de scancode. Diferentes teclados pueden tener diferentes asignaciones de códigos. Además hay códigos que no tienen nada que ver con las teclas sino que se usan para establecer y mantener la comunicación entre ordenador y teclado.

Lo primero que tienes que tener en cuenta que cada tecla puede producir dos eventos, uno al ser pulsada y otro al ser liberada. La mayoría de los teclados tienen autorrepetición, de modo que si mantienes una tecla pulsada más de un umbral de tiempo se envían nuevos códigos de pulsación periódicamente. Es el sistema operativo y los controladores del teclado los que traducen cada scancode a un caracter.

Por otra parte, hay teclas que actúan como modificadores de otras teclas. Si pulsamos la tecla A normalmente obtendremos una "a" minúscula, pero si pulsamos simultáneamente la tecla g obtendremos una "A" mayúscula. Las teclas modificadoras son g (MAYÚSC), c y b. Además existen estados del teclado que conmutamos mediante las teclas f (BLOCK MAYÚSC), p y z (BLOCK SCROLL).

En un entorno normal nos interesan los caracteres que devuelve el teclado, en un juego puede ser más útil usar directamente los scancodes, así podemos incluso diferenciar entre las teclas g o c de la derecha y de la izquierda. Además en un juego nos interesa tener en cuenta tanto la pulsación como la liberación de la tecla. Por ejemplo, si al pulsar un cursor movemos una nave, nos interesa que el movimiento persista hasta que lo liberemos. La forma de hacerlo es activar un indicador de movimiento con la pulsación y desactivarlo con la liberación.

Ya que disponemos de pygame veamos un pequeño programa que muestra con más detalle algunos de los eventos de teclado:

# Teclado 01 - Eventos del teclado

import pygame

pygame.init()

root = pygame.display.set_mode((600, 400))

running = True

while running:
for event in pygame.event.get():
if event.type in (pygame.KEYUP, pygame.KEYDOWN):
name = pygame.event.event_name(event.type)
scan = event.scancode
mod  = event.mod
key  = event.key
char = event.unicode
if char != "":
char = chr(34)+char+chr(34)
print(f"{name:>11} scan:{scan:4} key:{key:10} {char} mod:{mod:016b}")
if event.type == pygame.QUIT:
running = False

pygame.quit()

Es muy similar al anterior, pero esta vez filtramos los eventos para mostrar solo aquellos que se producen al soltar y liberar teclas, y de ellos mostramos los campos que nos pueden ser más relevantes: scancode, valores numérico y de caracter correspondientes y los modificadores. Estos últimos los mostramos en forma binaria, porque cada tecla modificadora activa un bit.

Si la tecla tiene representación en forma de caracter colocamos comillas alrededor. En caso contrario tendremos una cadena vacía.

Si necesitamos comprobar una combinación de teclas con modificadores, por ejemplo: c + S realizaremos un AND a nivel de bits con el valor del atributo event.mod y el valor del modificador.

# True si pulsamos <s> y <ctrl>
if event.key == 115 and event.mod & pygame.KMOD_CTRL:

La línea anterior funciona para cualquiera de las dos teclas de control, y no le importa si estamos también pulsando g o b.

# True si solo pulsamos <s> y <ctrl> izquierda
if event.key == 115 and event.mod == pygame.KMOD_LCTRL:

De esta otra forma, tenemos que pulsar la tecla c izquierda a la vez que la S, y no debemos tener pulsada ninguna otra tecla modificadora. Como puedes ver, tenemos un amplio grado de control sobre las combinaciones exactas que deseemos emplear.

Entrada mediante ratón

La lectura del ratón tampoco tiene mucho misterio. Aquí tenemos normalmente dos o tres botones, los cuales también informan al ser pulsados y liberados. Las pulsaciones del ratón proporcionan información del punto de la aplicación sobre el que se producen. Además tenemos movimientos, indicados mediante las componentes horizontal y vertical, y podemos arrastrar si realizamos un movimiento a la vez que mantenemos un botón pulsado.

A estas acciones básicas podemos añadir las pulsaciones dobles, que son producidas cuando pulsamos, liberamos y volvemos a pulsar dentro de un tiempo que podemos configurar mediante el panel de control de Windows. La última clase de eventos son los desplazamientos de la rueda central, cuando dispongamos de ella. Ya conocemos todos estos eventos de nuestra experiencia con tkinter. Vamos a emplearlos de nuevo.

Antes de introducir el programa, si quieres usar el mismo tipo de letra, descárgalo de este enlace. Una vez descargado, mueve el fichero a la carpeta C:\Windows\Fonts y se instalará la fuente TrueType añadiéndose a las ya disponibles.

# Mouse 01 - Eventos de ratón desde tkinter

import tkinter as tk
from tkinter import font
import time

ANCHO, ALTO, TITLE = 600, 400, "Ratón 01"
COLOR = ("black", "red", "blue", "darkgray", "green", "orange", "white", "brown",
"gold", "darkviolet", "pink")

start = True

# Obtener geometría de la ventana principal
def getgeom():
root.update()
return [root.winfo_width(), root.winfo_height(), root.winfo_x(), root.winfo_y()]

# Con la rueda cambiamos la altura de la ventana
def rueda(event):
geom = getgeom()
if event.delta < 0 and geom[1] > 100:
texto.set("Estrechando...")
delta = -10
elif event.delta > 0 and geom[1] < 600:
texto.set("Ensanchando...")
delta = 10
else:
return
root.geometry(f"{geom[0]}x{geom[1] + delta}")

# Cuando pulsamos, guardamos la posición
def start(event):
texto.set("Botón 1")
vstart.set(f"{event.x} {event.y}")

# Al mover mostramos las coordenadas
def mover(event):
texto.set(f"Moviendo\n({event.x},{event.y})")

# Al arrastrar desplazamos la ventana
def arrastrar(event):
geom = getgeom()
pos = vstart.get().split()
delta_x = event.x - int(pos[0])
delta_y = event.y - int(pos[1])
if delta_x == 0:
if delta_y > 0:
texto.set("Arrastrando...\n\u21E9")
else:
texto.set("Arrastrando...\n\u21E7")
elif delta_x > 0:
if delta_y > 0:
texto.set("Arrastrando...\n\u2B02")
elif delta_y < 0:
texto.set("Arrastrando...\n\u2B00")
else:
texto.set("Arrastrando...\n\u21E8")
else:
if delta_y > 0:
texto.set("Arrastrando...\n\u2B03")
elif delta_y < 0:
texto.set("Arrastrando...\n\u2B01")
else:
texto.set("Arrastrando...\n\u21E6")

root.geometry(f"{geom[0]}x{geom[1]}+{geom[2] + delta_x}+{geom[3] + delta_y}")

def Lclick(event):
texto.set("Doble click izquierdo")

# Cuando el puntero del ratón sale de la ventana
def salir(event):
texto.set("Te has ido")

# Cuando la ventana cambia el foco
def foco(event):
global start
if event.type.name == "FocusOut":
texto.set("¡Te has ido\ncon otra!")
elif start:
start = False
else:
texto.set("¡Has vuelto!")

# Cambiamos el color de la etiqueta recorriendo la lista COLOR
def color(event):
i = vcolor.get()
i = ((i + 1) % len(COLOR))
label["fg"] = COLOR[i]
vcolor.set(i)

######################## PROGRAMA PRINCIPAL ########################

root = tk.Tk()
root.geometry(f"{ANCHO}x{ALTO}")
root.title(TITLE)
root.resizable(0,0)
root.bind("<Motion>", mover)
root.bind("<B1-Motion>", arrastrar)
root.bind("<Button-1>", start)
root.bind("<MouseWheel>", rueda)
root.bind("<Double-Button-1>", Lclick)
root.bind("<Leave>", salir)
root.bind("<FocusOut>", foco)
root.bind("<FocusIn>", foco)
root.bind("<Button-3>", color)

vstart = tk.StringVar()
texto = tk.StringVar(value = "Prueba\ndel ratón")
vcolor = tk.IntVar(value = 0)

label = tk.Label(textvariable = texto, font = ("Humor Sans", 32))
label.pack(expand = True, fill = "both")

root.mainloop()

Vamos a comentar el listado por secciones empezando por la línea 92, en la que comienza el programa propiamente dicho. Anteriormente, al comienzo, hemos importado el módulo principal y el de fuentes de tkinter y el módulo time. También hemos definido unas variables para el tamaño de la ventana y su título, así como una tupla con colores. La variable start nos indica que es la primera ejecución del programa. La usamos en la función foco() que veremos más adelante.

Como suele ocurrir en nuestros programas de tkinter, todo consiste en definir el interfaz y una vez hecho lanzar el bucle principal. Creamos una ventana no redimensionable (línea 97) y le asignamos respuestas a una serie de eventos relacionados con el teclado.

Empleamos tres variables de tkinter, vstart nos servirá para guardar las coordenadas cuando pulsemos en botón izquierdo del ratón, por si luego hacemos un arrastre, texto es la cadena que veremos reflejada en la etiqueta que mostramos en medio de la ventana y vcolor es un índice a la tupla de colores, para irla recorriendo en función del evento correspondiente.

Terminamos creando y mostrando la etiqueta que mostrará el contenido de la variable texto, empleando la fuente que hemos descargado anteriormente. Esto define el aspecto del programa, su comportamiento depende enteramente de las funciones que se activan con cada evento, que vamos a ver a continuación.

# Obtener geometría de la ventana principal
def getgeom():
root.update()
return [root.winfo_width(), root.winfo_height(), root.winfo_x(), root.winfo_y()]

Esta función se limita a devolvernos una lista con los valores del ancho, alto y las posiciones horizontal y vertical de la ventana principal. Para asegurarnos de una respuesta válida invocamos antes el método .update().

# Con la rueda cambiamos la altura de la ventana
def rueda(event):
geom = getgeom()
if event.delta < 0 and geom[1] > 100:
texto.set("Estrechando...")
delta = -10
elif event.delta > 0 and geom[1] < 600:
texto.set("Ensanchando...")
delta = 10
else:
return
root.geometry(f"{geom[0]}x{geom[1] + delta}")

En respuesta al evento "MouseWheel", es decir, el giro de la rueda central del ratón, usamos la función rueda(). Ante todo obtenemos la geometría de la ventana, de la cual solo nos interesa su tamaño. La propiedad .delta del evento indica el giro realizado. No nos interesa su magnitud sino solo su sentido, en función del cual reduciremos el alto o lo agrandaremos a la vez que mostramos un texto adecuado mediante la variable texto. El cambio del tamaño lo produce la línea 29 y está determinado por el valor de delta.

# Cuando pulsamos, guardamos la posición
def start(event):
texto.set("Botón 1")
vstart.set(f"{event.x} {event.y}")

Aquí respondemos a la pulsación del botón izquierdo indicando el suceso producido mediante la variable texto y guardando las coordenadas de la pulsación en forma de cadena en la variable vstart. Este valor lo usaremos en caso de arrastrar el ratón, moviendo el cursor sin haber levantado el botón.

# Al mover mostramos las coordenadas
def mover(event):
texto.set(f"Moviendo\n({event.x},{event.y})")

En respuesta al movimiento del cursor sobre la ventana, cuando no pulsamos ningún botón, mostramos las coordenadas extrayéndolas directamente del objeto event.

# Al arrastrar desplazamos la ventana
def arrastrar(event):
geom = getgeom()
pos = vstart.get().split()
delta_x = event.x - int(pos[0])
delta_y = event.y - int(pos[1])
if delta_x == 0:
if delta_y > 0:
texto.set("Arrastrando...\n\u21E9")
else:
texto.set("Arrastrando...\n\u21E7")
elif delta_x > 0:
if delta_y > 0:
texto.set("Arrastrando...\n\u2B02")
elif delta_y < 0:
texto.set("Arrastrando...\n\u2B00")
else:
texto.set("Arrastrando...\n\u21E8")
else:
if delta_y > 0:
texto.set("Arrastrando...\n\u2B03")
elif delta_y < 0:
texto.set("Arrastrando...\n\u2B01")
else:
texto.set("Arrastrando...\n\u21E6")

root.geometry(f"{geom[0]}x{geom[1]}+{geom[2] + delta_x}+{geom[3] + delta_y}")

La función que responde al arrastre mientras pulsamos el boton 1 ("B1-Motion") es la más compleja del programa. Obtenemos la geometría de la ventana y la posición que grabamos en la variable vstart. Observa que para esto último primero separamos los valores mediante el método .split() en la línea 43 y luego los convertimos a valores enteros al emplearlos en las líneas 44-45. Aquí obtenemos los valores absolutos del desplazamiento del ratón.

La larga sección de condicionales de las líneas 46-64 tiene por objeto mostrar la flecha adecuada en función de hacia donde se produzca el arrastre. En total tenemos 8 posibilidades, los ejes rectos y los diagonales.

Es la línea 66 la que cambia la geometría dejando intacto el tamaño y actualizando la posición.

def Lclick(event):
texto.set("Doble click izquierdo")

El evento "Double-Button-1" se produce al hacer doble click. Nos limitamos a indicar que lo hemos recibido.

# Cuando el puntero del ratón sale de la ventana
def salir(event):
texto.set("Te has ido")

Aquí llegamos si se produce el evento "Leave", es decir, cuando movemos el puntero del ratón fuera de los límites de la ventana. Para hablar con propiedad, cuando movemos el cursor del ratón fuera del área cliente de la ventana. Si nos colocamos sobre la barra de título o sobre el borde (en este caso el borde es imperceptible porque la ventana no es redimensionable) verás que también se produce el evento. Nos limitamos a mostrarlo mediante la etiqueta. Existe el evento inverso: "Enter" pero no lo hemos considerado porque inmediatamente se produciría un movimiento del ratón y veríamos este y no aquel.

# Cuando la ventana cambia el foco
def foco(event):
global start
if event.type.name == "FocusOut":
texto.set("¡Te has ido\ncon otra!")
elif start:
start = False
else:
texto.set("¡Has vuelto!")

Algo relacionado pero diferente es el foco. Aunque saquemos de ella el cursor del ratón la ventana sigue manteniendo el foco. Si pulsamos una tecla o movemos un joystick es nuestro programa el que recibe el evento. Para cambiar el foco hemos de pulsar con el ratón sobre otra ventana o sobre el escritorio. En la función foco() procesamos los eventos "FocusOut" y "FocusIn" mostrando sendos mensajes. Para el segundo caso, la variable start que definimos al comienzo vale True para la primera ejecución del programa. Es este caso la modificamos pero no mostramos el mensaje: "¡Has vuelto!".

# Cambiamos el color de la etiqueta recorriendo la lista COLOR
def color(event):
i = vcolor.get()
i = ((i + 1) % len(COLOR))
label["fg"] = COLOR[i]
vcolor.set(i)

El último evento al que respondemos es la pulsación del botón derecho, "Button-3". Al pulsarlo obtenemos el índice guardado en vcolor y lo incrementamos (línea 88). El operador módulo garantiza que si rebasamos el final volvemos a empezar desde cero. En la línea 89 modificamos el atributo fg de la etiqueta, que corresponde al color del primer plano. Vamos recorriendo la tupla COLOR en círculos.

Y eso es todo, el aspecto del programa se refleja a continuación:

Vamos a ver brevemente cómo usar las palancas de juego.

Joystick y otros dispositivos

Los controladores de juego en general poseen dos clases de sistemas de entrada: los ejes y los botones. Los ejes suelen corresponderse, aunque no necesariamente, con las palancas, y los botones están claros. Una palanca que podemos mover en dos dimensiones comprende dos ejes, un eje izquierda-derecha y otro adelante-atrás. El controlador de GameCube que yo utilizo tiene botones con un recorrido que funcionan también como un eje.

Los botones pueden tener dos estados, pulsado o no. Los eventos que producen corresponden a la pulsación y a la liberación, lo mismo que las teclas o los botones del ratón.

Los ejes son los que no tienen equivalencia con otros sistemas de entrada. Cuando movemos un eje se produce un evento que lleva aparejado un valor correspondiente a la posición de aquel. El valor es un número float entre -1 y +1, que corresponden a ambos extremos del recorrido. El punto neutro (la posición central) devuelve 0.

Veamos un primer programa acerca de esto.

# Joystick 01

import pygame
JOY = pygame.joystick

pygame.init()

pygame.display.set_mode((800, 600))
pygame.display.set_caption("JOYSTICK")

J = [JOY.Joystick(i) for i in range(JOY.get_count())]
for j in J:
j.init()

print(f"\n{JOY.get_count()} control{'es' if JOY.get_count() != 1 else ''} " +
f"encontrado{'s' if JOY.get_count() != 1 else ''}")
print("-" * 40)

for i, j in enumerate(J):
print(i, j.get_name())
print("  Ejes:", j.get_numaxes())
print("  Botones:", j.get_numbuttons())
print()

running = True

while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.JOYAXISMOTION:
print(event)
elif event.type == pygame.JOYBUTTONDOWN:
print(f"JOYSTICK:{event.joy} BOTÓN {event.button} pulsado")
elif event.type == pygame.JOYBUTTONUP:
print(f"JOYSTICK:{event.joy} BOTÓN {event.button} levantado")

pygame.quit()

En la línea 4 creamos un alias para el submódulo pygame.joystick que contiene los elementos para gestionar las palancas de juego. Hemos de inicializar pygame para que busque los dispositivos presentes en nuestra máquina. Una vez hecho esto disponemos de información sobre el número de mandos encontrados mediante JOY.get_count().

Creamos una lista por comprensión con todos los controladores encontrados, y después inicializamos cada uno de ellos en el bucle de las líneas 12-13.

La larga sentencia print() de las líneas 15 y 16 está destinada a mantener una cierta corrección gramatical, utilizando el singular si hay un solo joystick y el plural para las demás alternativas. Se imprimen el número de mandos encontrados y una línea de separación.

Entre las líneas 19 y 23 mostramos información sobre cada mando, empleamos los métodos .get_name(), .get_numaxes() y get_numbuttons() que no necesitan explicación.

Por fín vamos al bucle principal de la forma acustumbrada. Exploramos los eventos producidos y mostramos aquellos que tienen relación con nuestro mando de juego, el movimiento de los ejes y las pulsaciones de los botones.

La salida empieza así

============= RESTART: C:/Users/User/Documents/Python/Joystick 01.py =============
pygame 2.0.1 (SDL 2.0.14, Python 3.9.0)
Hello from the pygame community. https://www.pygame.org/contribute.html

4 controles encontrados
----------------------------------------
0 MAYFLASH GameCube Controller Adapter
  Ejes:    6
  Botones: 16

1 MAYFLASH GameCube Controller Adapter
  Ejes:    6
  Botones: 16

2 MAYFLASH GameCube Controller Adapter
  Ejes:    6
  Botones: 16

3 MAYFLASH GameCube Controller Adapter
  Ejes:    6
  Botones: 16

JOYSTICK:3 BOTÓN 2 pulsado
JOYSTICK:3 BOTÓN 2 levantado
JOYSTICK:3 BOTÓN 1 pulsado
JOYSTICK:3 BOTÓN 1 levantado

No es que haya realmente cuatro mandos, pero el dispositivo adaptador de Mayflash tiene cuatro conexiones. La sorpresa surge en cuanto movemos los ejes

<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.039063692129276406})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.05468916898098697})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.06250190740684225})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.0859401226844081})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.10937833796197394})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.11719107638782922})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.13281655323953978})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.1718802453688162})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 1, 'value': 0.03970458082827235})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 1, 'value': 0.023834955900753806})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.2031311990722373})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 1, 'value': 0.007965330973235268})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.2578203680532243})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 1, 'value': -0.007812738425855281})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.2812585833307901})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.31250953703421125})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 1, 'value': -0.015625476851710562})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.35938596758934294})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.4062623981444746})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 1, 'value': -0.023438215277565844})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.4531388286996063})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.5156407361064486})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.5468916898098697})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.6015808587908567})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 1, 'value': -0.031250953703421125})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.6406445509201331})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.6875209814752647})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.7343974120303964})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 1, 'value': -0.039063692129276406})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.7734611041596728})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 1, 'value': -0.05468916898098697})>
<Event(1536-JoyAxisMotion {'joy': 3, 'instance_id': 0, 'axis': 0, 'value': -0.8281502731406598})>

Obtenemos una incesante cascada de eventos. ¿Qué es lo que ocurre?

Si observas el campo value de cada línea verás que en general se trata de valores muy pequeños, esto se debe al fenómeno llamado deriva, que provoca que la palanca no quede perfectamente centrada. La forma de resolverlo es no considerar los valores por debajo de un umbral adecuado.

# Joystick 02

import pygame
JOY = pygame.joystick

pygame.init()

pygame.display.set_caption("JOYSTICK 02")

J = [JOY.Joystick(i) for i in range(JOY.get_count())]
for j in J:
j.init()

running = True

while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.JOYAXISMOTION and abs(event.value) > 0.1:
print(event)
elif event.type == pygame.JOYHATMOTION:
print(event)
elif event.type == pygame.JOYBUTTONDOWN:
print(f"JOYSTICK:{event.joy} BOTÓN {event.button} pulsado")
elif event.type == pygame.JOYBUTTONUP:
print(f"JOYSTICK:{event.joy} BOTÓN {event.button} levantado")

pygame.quit()

Hemos simplificado el programa eliminando la salida de información inicial y concentrándonos en los eventos. También hemos añadido otro tipo de evento que corresponde al movimiento de la cruceta (pygame.JOYHATMOTION). Esta vez mantenemos bajo control la salida, que solo se produce cuando movemos una palanca o pulsamos algún botón.

Como la salida sigue siendo muy copiosa no la mostraremos. Una cosa que hay que notar es que aunque llevemos las palancas al extremo no se alcanza realmente el rango máximo teórico de -1 a +1. Debido a ello para emplear el mando conviene que dispongamos de un método de calibración que nos permita detectar los valores máximos y gestionar el mando en función del resultado. De paso, además del máximo podemos detectar también los mínimos que pueden generarse con la palanca en posición neutra.

Terminamos con la teoría y vamos a entrar en el mundo de la programación de videojuegos.

5.2 La plataforma arcade

5.2.1 Instalación de arcade

Como siempre ocurre con bibliotecas que no vienen de serie con Python, lo primero que hemos de hacer es descargarla mediante pip.

C:\>pip install arcade

Lo normal es que se instale el paquete correctamente, ahora bien, arcade depende de un buen número de otras bibliotecas, y algunas de ellas van por delante en cuanto a versiones. Puede ocurrir que si mantenemos nuestras librerías actualizadas (cosa que deberíamos hacer siempre), haya incompatibilidades. Para comprobarlo podemos usar una nueva opción de pip.

C:\> pip check
No broken requirements found.

Este sería el resultado bueno, si no hay problema. En caso contrario, pip nos notificará los conflictos detectados.

C:\> pip check
arcade 2.5.3 has requirement numpy==1.19.2, but you have numpy 1.20.0.

En tal caso, lo que debemos hacer es instalar la versión más avanzada que satisfaga la condición. En la pantalla anterior vemos que numpy debe tener la versión 1.19.2. Es sencillo:

C:\> pip install numpy==1.19.2
Collecting numpy==1.19.2
  Using cached numpy-1.19.2-cp39-cp39-win_amd64.whl
Installing collected packages: numpy
  Attempting uninstall: numpy
    Found existing installation: numpy 1.20.0
    Uninstalling numpy-1.20.0:
      Successfully uninstalled numpy-1.20.0
Successfully installed numpy-1.19.2

C:\>

Añadiendo ==versión después del nombre del paquete pip instalara la versión indicada.

Una vez instalado el paquete y solventadas las dependencias podemos empezar a practicar.

5.2.2 Introducción a la programación con arcade

Arcade es una biblioteca para la programación de juegos 2D en Python. El objeto principal del juego es la ventana, con las características ya habituales en el interfaz de ventanas: tamaño, título y color de fondo. Dentro de la ventana pintaremos un fondo y sobre él una serie de sprites, figuras gráficas que representarán a nuestro personaje, enemigos y decoración.

Arcade está muy orientado a objetos, aunque podemos realizar programas procedurales si así lo deseamos. Vamos a codificar el típico "Hola, mundo" según ambos paradigmas.

# Arcade 001 - "Hola, Mundo" procedural

# Definimos nuestras constantes
ANCHO, ALTO, TÍTULO  = 600, 400, "Hola"

# Importamos la librería arcade
import arcade

# Creamos una ventana
arcade.open_window(ANCHO, ALTO, TÍTULO)

# Asignamos un color de fondo
arcade.set_background_color(arcade.csscolor.SKY_BLUE)

# Realizamos el proceso de actualización gráfica de la ventana
arcade.start_render()

arcade.draw_text("¡Hola, mundo!",
ANCHO // 2,
ALTO // 2,
arcade.color.BLACK,
font_size = 70,
anchor_x = "center",
anchor_y = "center")

arcade.finish_render()

# Bucle principal
arcade.run()

Cuando importamos arcade se realizan todos los procesos de inicialización automáticamente. No es preciso guardar ninguna referencia a la ventana principal, puesto que el programa está diseñado para trabajar esencialmente con una única ventana, de este modo las funciones como arcade.set_background_color() que invocamos a continuación trabajan sobre la única ventana.

A continuación tienes las referencias de colores disponibles en los dos módulos que arcade nos proporciona: csscolor y color. Podemos usar las constantes predefinidas o tuplas RGB (3 valores entre 0 y 255) o RGBA (4 valores) para colores personalizados.

El proceso de actualización de la ventana hace que el contenido de esta sea visible en pantalla. Siempre tenemos que emplear las sentencias arcade.start_render() y arcade.finish_render() envolviendo las operaciones que queramos mostrar. Hemos desplegado los argumentos de arcade.draw_text() para que puedas entenderlos mejor.

Tenemos el texto que será impreso, las coordenadas de impresión (hemos usado el centro de la ventana), el color y tamaño y por último la clase de anclaje. Esto modifica la forma de usar las coordenadas. Por defecto se colocaría en ese punto la esquina superior izquierda del texto, pero al usar "center" el texto se centra sobre el punto vertical y horizontalmente.

Para terminar, la orden que pone todo en marcha y lanza el bucle principal es arcade.run(). A partir de aquí no recuperaremos el control del intérprete hasta que cerremos la ventana de arcade.

Veamos una versión idéntica pero orientada a objetos:

# Arcade 001b - "Hola, Mundo" orientada a objetos

# Definimos nuestras constantes
ANCHO, ALTO, TÍTULO  = 600, 400, "Hola"

# Importamos la librería arcade
import arcade

# Creamos una clase de ventana
class myWindow(arcade.Window):

# Inicialización de la clase, llamamos a la clase superior
def __init__(self):
super().__init__(ANCHO, ALTO, TÍTULO)
arcade.set_background_color(arcade.csscolor.SKY_BLUE)

# El método on_draw() es llamado para pintar la ventana
def on_draw(self):
arcade.start_render()

arcade.draw_text("¡Hola, mundo!",
ANCHO // 2,
ALTO // 2,
arcade.color.BLACK,
font_size = 70,
anchor_x = "center",
anchor_y = "center")

# Instanciamos nuestra clase
root = myWindow()

# Bucle principal
arcade.run()

Simplemente tenemos que derivar una clase de arcade.Window y que modificar los métodos que nos interese. En este caso el constructor __init__() y el método que "dibuja" la ventana on_draw().

SIEMPRE que definamos una clase el método __init__() será el que se invoca al crear un objeto de dicha clase, esto es, el constructor. Si derivamos nuestra clase de otra es típico invocar la inicialización de la clase superior para que todo funcione. Para eso usamos la función super() que nos remite a esa clase superior. En este caso podríamos haber escrito directamente arcade.Window puesto que esta en nuestra clase "madre", pero como norma de programación lo correcto es usar la función. Una vez inicializada la superclase realizamos los ajustes de nuestra versión personalizada.

Aparte de la gestión de eventos hay dos métodos que normalmente modificaremos a nuestro gusto, que son: on_update(), que gestiona los cambios que se producen en cada vuelta del bucle, y on_draw(), que realiza el trabajo gráfico de mostrar el contenido de la ventana. Cuando usamos este último método tenemos que empezar con arcade.start_render() pero el mismo sistema se encarga de invocar .finish_render() al terminar, por lo que no necesitamos hacerlo nosotros.

He aquí el resultado de nuestro primer programa arcade.

Una ventana con arcade
5.2.3 La clase arcade.Window

Vamos a ver las posibilidades que nos ofrece la clase principal de arcade, la ventana.

Métodos y propiedades de arcade.Window
__init__( ... ) Constructor de la clase. Podemos emplear los siguientes argumentos
(Los tres primeros son obligatorios)
width (int)
height (int)
title (str)
fullscreen (bool) = False
resizable (bool) = False
update_rate (float) = 1 / 60
antialiasing (bool) = True
gl_version (tuple[int,int]) = (3, 3)
visible (bool) = True
Anchura en pixels
Altura en pixels
Título de la ventana
Pantalla completa
Indica si la ventana es redimensionable
Frecuencia de actualización
Antialiasing
Versión de OpenGL requerida
Indica si la ventana será inicialmente visible
background_color Color de fondo, en forma de tupla
center_window() Coloca la ventana en el centro de la pantalla
clear() Limpia la ventana rellenándola con el color de fondo
close() Cierra la ventana
current_view Devuelve un objeto arcade.view con la vista activa
get_location() Devuelve las coordenadas de la ventana en forma de tupla
get_size() Devuelve el tamaño de la ventana en forma de tupla
get_system_mouse_cursor(cursor_name) Devuelve un objeto cursor de acuerdo con el argumento
get_viewport() Devuelve la ventana de visión en forma de tupla con cuatro coordenadas
(left, top, right, bottom)
maximize() Maximiza la ventana
minimize() Minimiza la ventana
on_draw() Función que dibuja el contenido de la ventana
on_key_press(symbol, modifiers) Pulsación de teclas. symbol y modifiers son valores enteros
on_key_release(symbol, modifiers) Liberación de teclas. symbol y modifiers son valores enteros
on_mouse_drag( ... ) Arrastre con el ratón
x (float)
y (float)
dx (float)
dy (float)
buttons (int)
modifiers (int)
Posición horizontal del ratón
Posición vertical del ratón
Desplazamiento horizontal
Desplazamiento vertical
Botón o botones pulsados
Modificadores
on_mouse_motion(x, y, dx, dy) Movimiento del ratón
on_mouse_press(x, y, buttons, modifiers) Pulsación de los botones del ratón
on_mouse_release(x, y, buttons, modifiers) Liberación de los botones del ratón
on_mouse_scroll(x, y, scroll_x, scroll_y) Movimiento de la rueda del ratón
x (int)
y (int)
scroll_x (float)
scroll_y (float)
Posición horizontal del ratón
Posición vertical del ratón
Desplazamiento horizontal (0.0)
Desplazamiento vertical
on_resize(width, height) Cambio de dimensiones de la ventana
En su caso, cambiar la ventana de visión (viewport)
on_update(delta_time) Actualización de la ventana
delta_time (float) Indica el tiempo transcurrido desde la última actualización
set_caption(caption) Cambia el título de la ventana
set_exclusive_mouse(exclusive) Con el argumento True captura el ratón
(Usa además set_mouse_platform_visible(True) para poder ver el cursor)
set_fullscreen(full, screen, mode, width, height) Conmuta el modo de pantalla completa
full (bool) = True

screen (<'Win32Screen'>) = None
mode
width (int) = None
height (int) = None
True pasa a pantalla completa
False a ventana
Monitor empleado
(indocumentado)
Anchura del modo de pantalla
Altura del modo de pantalla
set_location(x, y) Establece una nueva posición para la ventana dentro de la pantalla
set_max_size(width, height)
set_maximum_size(width, height)
Establece el tamaño máximo de la ventana
set_min_size(width, height)
set_minimum_size(width, height)
Establece el tamaño mínimo de la ventana
set_mouse_cursor(cursor) Selecciona el objeto cursor como cursor
Obtenemos un cursor mediante get_system_mouse_cursor()
set_mouse_platform_visible(visible) Usando True como parámetro visibiliza el ratón capturado
set_mouse_position(x, y) Cambia la posición del cursor del ratón
set_mouse_visible(visible = True) Muestra u oculta (con False) el cursor del ratón
set_size(width, height) Cambia el tamaño independientemente de si la ventana es redimensionable
set_update_rate(secs) Define el tiempo entre actualizaciones en segundos
p.ej. usa 1/60 para 60 cuadros por segundo
set_viewport(left, top, right, bottom) Define la ventana de visión
set_visible(visible = True) Muestra u oculta la ventana
set_vsync(vsync) Activa o desactiva la sincronización vertical con la velocidad del monitor
show_view() Selecciona la vista para mostrar en ventanas con diferentes vistas
update() Realiza la actualización del contenido de la ventana

Una buena lista de funciones. Vamos a plantearla en términos de grupos según su función.

El constructor es imprescindible, normalmente si derivamos una clase deberemos invocar al constructor de la clase superior y luego hacer nuestros propios trabajos de inicialización. Ancho, alto y título se requieren. Podemos emplear el argumento resizable si deseamos que nuestro programa lo sea, y visible si preferimos no mostrar la ventana hasta un momento posterior. Lo demás se puede modificar a lo largo del programa, aunque si queremos un programa a pantalla completa o una tasa de refresco determinada puede ser el momento de indicarlo.

Para usar la programación orientada a objetos además del constructor debemos personalizar además del constructor los métodos on_lo_que_sea() que responden a los eventos, en función del comportamiento que deseemos. En concreto es imprescindible crear código para on_update() y on_draw(). Si queremos interactividad deberemos interceptar el teclado (on_key_***()) y/o el ratón (on_mouse_***()).

Si la ventana es redimensionable y usamos un viewport deberemos actualizar las coordenadas visibles en el método on_resize(). Ya veremos más adelante lo que es esto.

El resto de posibilidades podemos emplearlas para fines muy específicos pero podríamos vivir sin ellas. Una característica muy valiosa son las vistas, que nos permiten que una ventana tenga diferentes aspectos y comportamiento en función de la vista elegida. En la misma ventana podemos tener una pantalla de menú, el juego o la pantalla final, por ejemplo.

Pongamos a prueba los diversos métodos que acabamos de enumerar.

# Arcade 002 - Probando los métodos de arcade.Window
# Comprobamos los eventos de teclado y ratón

import arcade

ANCHO, ALTO, TITLE = 800, 600, "Métodos de arcade.Window"

# Obtenemos la lista de teclas
KEYS = {getattr(arcade.key, k) : k for k in dir(arcade.key) if not
k.startswith("MOD_") and not k.startswith("_")}
# Los modificadores
MODS = {getattr(arcade.key, k) : k for k in dir(arcade.key) if
k.startswith("MOD_")}
# Y los botones del ratón
BUTTONS = {1:"LEFT", 2:"MIDDLE", 4:"RIGHT"}

# Función para obtener la lista de modificadores
def modis(modifiers):
_modis = []
for mod in MODS:
if modifiers & mod:
_modis.append(MODS[mod])
return "\n".join(_modis)

# Nuestra clase principal
class myWindow(arcade.Window):

def __init__(self):
super().__init__(ANCHO, ALTO, TITLE)
arcade.set_background_color((80, 120, 60))
self.texto = "ARCADE"
arcade.run()

def on_key_press(self, symbol, modifiers):
if symbol in KEYS:
self.texto = f"KEY_PRESS:\n\n{KEYS[symbol]}\n\n" + modis(modifiers)
else:
self.texto = f"KEY_PRESS:\n\n{symbol} (unknow)\n\n" + modis(modifiers)

def on_mouse_press(self, x, y, buttons, modifiers):
self.texto = f"MOUSE_PRESS:\n\nBUTTON {BUTTONS[buttons]}\n\n({x},{y})\n\n" +\
modis(modifiers)

def on_mouse_scroll(self, x, y, scroll_x, scroll_y):
self.texto = f"MOUSE_SCROLL: {scroll_y}\n\n({x},{y})"

def on_draw(self):
arcade.start_render()
#<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
arcade.draw_text(self.texto, self.width // 2, self.height //2,
arcade.color.WHITE, font_size = 40, align = "center",
anchor_x = "center", anchor_y = "center")
#>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
arcade.finish_render()


root = myWindow()

La primera novedad de este programa está en las líneas 9-13. Sabías que se pueden crear listas por comprensión, pues también podemos hacerlo con los diccionarios y crear diccionarios por comprensión. La función incorporada getattr(object, attr [,default]) nos devuelve un atributo de un objeto a partir de su nombre. Podemos añadir un valor por defecto para el caso de que el atributo no exista, de otro modo se producirá un error de tipo AttributeError.

En estas líneas creamos sendos diccionarios cuyas claves son los valores y cuyos valores son las claves del contenido de arcade.key. Filtramos los valores en cada caso para obtener solo lo que deseamos, por un lado las teclas y por otro los modificadores. En cuanto a los botones del ratón corresponden a una combinación de bits, el bit 0 (cuyo valor es 1) para el botón izquierdo, el bit 1 (cuyo valor es 2) para el central y el 2 (con un valor de 4) para el derecho. En teoría podríamos obtener una combinación de valores si pulsamos botones simultáneamente, pero yo he sido incapaz de conseguirlo.

Los modificadores también están formados por una máscara de bits. Para obtener a partir de un número los modificadores activados usamos la función modis(modifiers). Creamos una lista y añadimos las cadenas con el nombre del modificador si el bit correspondiente está activado. Una vez conseguida la lista usamos el método str_sep.join(iterable) que devuelve una cadena con cada elemento de iterable separado por str_sep.

Nuestra clase es muy sencilla, simplemente damos un color verde profundo al fondo (línea 30) y creamos una cadena self.texto. Al mostrar la ventana, en la línea 50, presentamos el contenido de esta cadena centrado en la ventana.

A partir de este esquema, cada método on_entrada_de_usuario() se limita a formatear los datos en la cadena de forma que veremos el resultado reflejado en pantalla.

Como hemos incorporado la orden arcade.run() al final del constructor, en cuanto definimos nuestra ventana en la línea 57 la aplicación arranca por sí sola.

Eventos en arcade

Puedes emplear esta simple aplicación como referencia para saber los valores recibidos cuando realizes gestión de teclado y ratón.

Vamos a emplear nuestro recién adquirido dominio para probar otros métodos de la ventana en arcade

# Arcade 003 - Probando los métodos de arcade.Window
# Comprobamos métodos diversos

import arcade
CURSORES = [getattr(arcade.Window, x) for x in dir(arcade.Window) if x.startswith("CURSOR_")]

ANCHO, ALTO, TITLE = 800, 600, "Más métodos de arcade.Window"

# Nuestra clase principal
class myWindow(arcade.Window):

def centra(self):
self.center_window()

def maximiza(self):
self.old_x, self.old_y = self.get_location()
self.ancho, self.alto = self.get_size()
self.maximize()

def restaura(self):
self.set_size(self.ancho, self.alto)
self.set_location(self.old_x, self.old_y)

def minimiza(self):
self.minimize()

def cursor(self):
self.cursor = (self.cursor + 1) % len(CURSORES)
cursor = self.get_system_mouse_cursor(CURSORES[self.cursor])
self.set_mouse_cursor(cursor)

def captura(self):
self.set_exclusive_mouse(True)
self.set_mouse_platform_visible(self.visimouse)

def libera(self):
self.set_exclusive_mouse(False)
self.set_mouse_visible(self.visimouse)

def completa(self):
self.full = not self.full
self.set_fullscreen(self.full)

def ratón(self):
self.visimouse = not self.visimouse
self.set_mouse_visible(self.visimouse)

MENU = [ (arcade.key.KEY_0, "0 - Centrar", centra),
(arcade.key.KEY_1, "1 - Maximizar", maximiza),
(arcade.key.KEY_2, "2 - Restaurar", restaura),
(arcade.key.KEY_3, "3 - Minimizar", minimiza),
(arcade.key.KEY_4, "4 - Cambiar cursor", cursor),
(arcade.key.KEY_5, "5 - Capturar ratón", captura),
(arcade.key.KEY_6, "6 - Liberar ratón", libera),
(arcade.key.KEY_7, "7 - Pantalla completa/Ventana", completa),
(arcade.key.KEY_8, "8 - Ratón invisible/visible", ratón)
]

def __init__(self):
super().__init__(ANCHO, ALTO, TITLE, resizable = True)
arcade.set_background_color((80, 120, 60))
self.setup()
self.old_x, self.old_y = self.get_location()
self.ancho, self.alto = self.get_size()
self.cursor = 1
self.full = False
self.visimouse = True
arcade.run()

# Creamos el menú
def setup(self):
self.textos = arcade.SpriteList()
for option in self.MENU:
texto = arcade.draw_text(option[1], 0, 0,
arcade.color.WHITE,
font_size = 30)
self.textos.append(texto)
self.update(0)

def on_key_press(self, symbol, modifiers):
for option in self.MENU:
if symbol == option[0]:
option[2](self)

def on_draw(self):
arcade.start_render()
#<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
self.textos.draw()
arcade.draw_text(f"Cursor = {CURSORES[self.cursor]}",
self.width // 2, 30, arcade.color.WHITE,
font_size = 16, anchor_x = "center")
#>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
arcade.finish_render()

def on_update(self, delta_time):
for i, t in enumerate(self.textos):
t.center_x = self.width // 2
t.center_y = self.height - 50 - (t.height + 10) * i

root = myWindow()

Vamos a comenzar a explorar la clase myWindow en "orden de ejecución", empezando por el constructor.

def __init__(self):
super().__init__(ANCHO, ALTO, TITLE, resizable = True)
arcade.set_background_color((80, 120, 60))
self.setup()
self.old_x, self.old_y = self.get_location()
self.ancho, self.alto = self.get_size()
self.cursor = 1
self.full = False
self.visimouse = True
arcade.run()

Inicializamos nuestra clase invocando el constructor de la clase superior que es arcade.Window, y empleando las constantes definidas al comienzo del programa para el ancho, alto y título de la ventana. Como queremos que nuestra ventana sea adaptable a los cambios la hacemos también redimensionable. Luego invocamos una función setup() en la que hacemos parte del trabajo de configuración inicial, en este caso crear el menú de opciones. Definimos varias propiedades que usaremos para guardar la posición y tamaño de la ventana para cuando la maximicemos, un índice para el cursor y dos flags (marcadores) para indicar que no estamos en modo de pantalla completa y que el cursor es visible. Por último, invocamos arcade.run() de forma que el programa es lanzado sin más.

def setup(self):
self.textos = arcade.SpriteList()
for option in self.MENU:
texto = arcade.draw_text(option[1], 0, 0,
arcade.color.WHITE,
font_size = 30)
self.textos.append(texto)
self.update(0)

El método setup() crea una serie de cadenas de texto para el menú. La función arcade.draw_text() devuelve un objeto de tipo arcade.Sprite con la imágen gráfica del texto. Además un sprite tiene otras propiedades y métodos útiles que ya veremos un poco más adelante. El objeto textos es de clase arcade.SpriteList que, como su nombre indica, nos sirve para contener una lista de sprites y gestionarlos conjuntamente de manera eficaz. A partir de la lista MENU obtenemos los textos para cada opción en forma de sprite y los añadimos a la lista. No nos preocupamos de su posición, para eso invocamos al terminar el método update()

def on_update(self, delta_time):
for i, t in enumerate(self.textos):
t.center_x = self.width // 2
t.center_y = self.height - 50 - (t.height + 10) * i

update() genera un evento de actualizazión, que es procesado por el método on_update(). Aquí lo que hacemos es colocar las cadenas del menú según el tamaño de la ventana. Como cada texto es un sprite usamos los métodos .center_x() y .center_y que colocan el centro del sprite en la coordenada indicada: la mitad de la anchura para la coordenada horizontal y el alto de la ventana (dejando un margen de 50 pixels) menos el alto del texto (con un margen de 10 pixels) multiplicado por la posición vertical del texto.

Al contrario que la mayoría de las bibliotecas gráficas, arcade establece las coordenadas verticales de abajo arriba. Siendo 0 el borde inferior y el alto de la ventana el borde superior.

def on_draw(self):
arcade.start_render()
#<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
self.textos.draw()
arcade.draw_text(f"Cursor = {CURSORES[self.cursor]}",
self.width // 2, 30, arcade.color.WHITE,
font_size = 16, anchor_x = "center")
#>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
arcade.finish_render()

Lo siguiente que ocurrirá en nuestro programa es que el sistema invoca el método .on_draw() para mostrar el contenido de la ventana. Como siempre hemos de envolver el proceso de pintado entre el "bocadillo" .start_render() y .finish_render(), que hemos puesto claramente de manifiesto. Presentamos todo el menú mediante una única orden (línea 89) que invoca el método .draw() sobre la lista de sprites. A continuación mostramos el cursor activo en la parte baja de la ventana. Para ello hemos creado anteriormente una lista por comprensión de los nombres de cursor disponibles.

CURSORES = [getattr(arcade.Window, x) for x in dir(arcade.Window) if x.startswith("CURSOR_")]

Sencillamente hemos extraído los valores de los elementos de arcade.Window cuyos nombres comienzan por "CURSOR_".

def on_key_press(self, symbol, modifiers):
for option in self.MENU:
if symbol == option[0]:
option[2](self)

A partir de aquí el programa dependerá de nuestras acciones según las teclas que pulsemos. Comprobamos si la opción elegida corresponde a las que hemos indicado en MENU y en caso afirmativo ejecutamos el método indicado también en la misma lista.

Los métodos .centra() y .minimiza() se limitan a invocar los métodos adecuados. Como nuestra clase es heredera de arcade.Window ha heredado todos los métodos de esta.

def maximiza(self):
self.old_x, self.old_y = self.get_location()
self.ancho, self.alto = self.get_size()
self.maximize()

def restaura(self):
self.set_size(self.ancho, self.alto)
self.set_location(self.old_x, self.old_y)

Para maximizar la ventana previamente guardamos el tamaño y la posición de esta, que emplearemos posteriormente para invocar el método .restaura(). Una mejora que deberíamos hacer es incluir un marcador para indicar que la ventana está maximizada, de forma que si ya lo está no hagamos nada. Actualmente, si volvemos a usar la opción de maximizar cuando está ya maximizada guardaremos de nuevo tamaño y coordenadas y al intentar restaurar no recuperaremos las condiciones previas. En cualquier caso se trata tan solo de ver cómo funcionan los diversos métodos.

def cursor(self):
self.cursor = (self.cursor + 1) % len(CURSORES)
cursor = self.get_system_mouse_cursor(CURSORES[self.cursor])
self.set_mouse_cursor(cursor)

Como ya tenemos la lista de cursores disponibles, cuando invocamos la opción Cambiar cursor actualizamos el índice self.cursor y luego establecemos el nuevo cursor. Previamente hemos de obtener un objeto cursor a partir del nombre contenido en CURSORES mediante el método .get_system_mouse_cursor(cursor_namer), y es este objeto el que usamos con el método set_mouse_cursor().

def captura(self):
self.set_exclusive_mouse(True)
self.set_mouse_platform_visible(self.visimouse)

def libera(self):
self.set_exclusive_mouse(False)
self.set_mouse_visible(self.visimouse)

En estos métodos capturamos el cursor (de forma que no podemos salir del área cliente de la ventana) o lo liberamos. En función de la configuración de visibilidad actual lo mostramos o no.

def completa(self):
self.full = not self.full
self.set_fullscreen(self.full)

def ratón(self):
self.visimouse = not self.visimouse
self.set_mouse_visible(self.visimouse)

Por último tenemos los métodos para conmutar la pantalla completa y la visibilidad del ratón. Ambos siguen el mismo esquema, cambian el valor del flag adecuado y emplean el nuevo valor para llamar al método correspondiente. En el método set_fullscreen() no usamos los demás argumentos, que nos permitirían seleccionar otra resolución de pantalla, mantenemos la configuración del escritorio.

El aspecto básico del programa es el siguiente, aunque lo interesante es que compruebes las distintas opciones.

Más opciones de arcade.Window
5.2.4 Ventanas con vistas: La clase arcade.View

Las vistas son una clase derivada de arcade.Window que nos permiten dotar a un programa de distintas apariencias y distinto comportamiento en función de la vista activa. Por ejemplo, podemos tener una pantalla inicial con el menú de juego y el juego en si mediante vistas separadas.

Las vistas tienen la propiedad .window que corresponde a la ventana principal a la cual pertenece la vista, y los métodos .on_show() que se ejecuta ser mostrada y on_hide_view() que lo hace al ocultarse.

Lo que hemos de hacer es crear cada vista igual que hemos creado una ventana normal, escribiendo sus correspondientes métodos para gestionar eventos. Vamos a hacer un programa muy simple con tres vistas: un menú para seleccionar las otras dos que corresponden una a una pelota rebotando y la otra a un efecto "sonar". Aquí tienes los gráficos y sonidos necesarios para el programa.

# Arcade 004 - Vistas

ANCHO, ALTO, TITLE = 800, 600, "Vistas en arcade"
RADIO = 250

import arcade
import random
import math

#################### Menú principal ####################
class Menu(arcade.View):

def __init__(self):
super().__init__()
self.options = arcade.SpriteList()
op1 = arcade.draw_text("1 - Juego de pelota",
ANCHO // 2,
3 * ALTO // 4,
color = arcade.color.AUREOLIN,
font_size = 40,
anchor_x = "center",
anchor_y = "center")
op2 = arcade.draw_text("2 - Sonar",
ANCHO // 2,
2 * ALTO // 4,
color = arcade.color.AUREOLIN,
font_size = 40,
anchor_x = "center",
anchor_y = "center")
op3 = arcade.draw_text("0 - TERMINAR",
ANCHO // 2,
ALTO // 4,
color = arcade.color.AUREOLIN,
font_size = 40,
anchor_x = "center",
anchor_y = "center")

self.options.append(op1)
self.options.append(op2)
self.options.append(op3)

def on_show(self):
arcade.set_background_color(arcade.color.ARMY_GREEN)

def on_draw(self):
arcade.start_render()
self.options.draw()

def on_key_press(self, symbol, modifiers):
if symbol == arcade.key.KEY_1:
self.window.show_view(self.window.pelotagame)
elif symbol == arcade.key.KEY_2:
self.window.show_view(self.window.sonargame)
elif symbol == arcade.key.KEY_0:
self.window.close()

#################### El "juego" de la pelota rebotando ####################
class PelotaGame(arcade.View):

def __init__(self):
super().__init__()
self.sprite = arcade.Sprite("Ball.png")
width = self.sprite.width
height = self.sprite.height
# Damos una posición y movimientos aleatorios a la pelota
self.sprite.center_x = random.randrange(width, ANCHO - width)
self.sprite.center_y = random.randrange(height, ANCHO - height)
self.sprite.change_x = random.randrange(5, 20)
self.sprite.change_y = random.randrange(5, 20)
self.sound = arcade.load_sound("Boing.wav")

def on_show(self):
arcade.set_background_color(arcade.color.BLACK)

def on_draw(self):
arcade.start_render()
self.sprite.draw()

def on_update(self, delta_time):
self.sprite.update()
if self.sprite.top > ALTO:
self.sprite.top = ALTO
self.sprite.change_y = -self.sprite.change_y
arcade.play_sound(self.sound)
elif self.sprite.bottom < 0:
self.sprite.bottom = 0
self.sprite.change_y = -self.sprite.change_y
arcade.play_sound(self.sound)
if self.sprite.right > ANCHO:
self.sprite.right = ANCHO
self.sprite.change_x = -self.sprite.change_x
arcade.play_sound(self.sound)
elif self.sprite.left < 0:
self.sprite.left = 0
self.sprite.change_x = -self.sprite.change_x
arcade.play_sound(self.sound)


#################### El "juego" del sonar ####################
class SonarGame(arcade.View):

def __init__(self):
super().__init__()
self.angle = math.pi / 2
self.sound = arcade.load_sound("Sonar.ogg")

def on_show(self):
arcade.set_background_color(arcade.color.DARK_BLUE)

def on_draw(self):
centro_x = ANCHO // 2
centro_y = ALTO // 2
extremo_x = centro_x + RADIO * math.cos(self.angle)
extremo_y = centro_y + RADIO * math.sin(self.angle)
arcade.start_render()
arcade.draw_circle_outline(centro_x, centro_y, RADIO + 2,
arcade.color.DARK_KHAKI, 1)
arcade.draw_line(centro_x, centro_y, extremo_x, extremo_y,
arcade.color.DANDELION, 2)

def on_update(self, delta_time):
self.angle -= 0.05
if self.angle < 0:
self.angle += 2 * math.pi
arcade.play_sound(self.sound)


#################### Ventana principal ####################
class myWindow(arcade.Window):

def __init__(self):
super().__init__(ANCHO, ALTO, TITLE)
self.menu = Menu()
self.pelotagame = PelotaGame()
self.sonargame = SonarGame()
self.show_view(self.menu)
arcade.run()

def on_key_press(self, symbol, modifiers):
if symbol == arcade.key.ESCAPE:
self.show_view(self.menu)


root = myWindow()

El código de la ventana principal es sencillo, tenemos el constructor dentro del cual creamos las tres vistas y activamos la vista del menú en la línea 136. He decidido gestionar aquí la pulsación de la tecla <ESCAPE> para volver al menú desde las demás vistas. En un juego normal podemos gestionar desde la clase principal opciones como el cambio a pantalla completa. Si definimos aquí un método .on_key_press() siempre será ejecutado, además del método equivalente de la vista activa.

La vista del menú (líneas 10-40) crea las cadenas con las opciones y las incluye en un objeto SpriteList. Empleamos el método on_show() para cambiar el color del fondo. Tenemos también métodos para mostrar el menú y para gestionar las opciones. Observa que para terminar hemos empleado el método .close() de la ventana principal.

La vista de la pelota al ser creada carga los recursos necesarios, la imagen del sprite y el sonido. Los sprites tienen numerosos métodos y propiedades para facilitarnos su gestión. Podemos gestionar su posición desde el centro o desde cualquiera de sus extremos. Además tienen unas propiedades change_x y change_y que sirven para asignar un movimiento automáticamente. De nuevo empleamos el método on_show() solamente para asignar el color de fondo. La única complejidad aparente es en el método .update(). Aquí movemos la pelota automáticamente en la línea 80, y luego comprobamos si hemos alcanzado los extremos de la ventana. Si es así, ajustamos la posición en invertimos el movimiento en el eje correspondiente y hacemos sonar el sonido.

Por su parte, el sonar se inicializa colocando en ángulo en 90° (en radianes corresponde a π / 2) y carga el sonido. Al activar la vista cambiamos el color de fondo. El método on_draw() tiene que hacer un poco de trabajo trigonométrico para trazar una línea del radio especificado en la constante RADIO desde el centro de la ventana y en el ángulo actual. Una vez obtenidos los valores trazamos una circunferencia (línea 116) ligeramente mayor y en la línea 118 la línea del sonar. Por último, el método on_update() va modificando el ángulo y cuando llegamos a la derecha (0 grados) actualiza el ángulo sumándole el círculo entero (π x 2) y hace sonar el efecto.

Observa que aunque alternemos las vistas estas conservan el estado en que se encuentren. En un juego real posiblemente deberíamos reiniciar la vista del juego a un estado de "comienzo de partida" empleando para ello el método on_show().

Aquí tienes las tres vistas de nuestro programa:

Diferentes vistas del programa
5.2.5 Los protagonistas del juego: La clase arcade.Sprite

Ya hemos tenido algún atisbo del empleo de sprites, pero vamos a ver con cierto detalle sus posibilidades.

Atributos de arcade.Sprite
alpha Grado de transparencia entre
0 (totalmente transparente) y 255 (totalmente opaco)
angle
radians
Rotación en grados sexagesimales
y en radianes
change_angle Variación angular en grados
left
right
top
bottom
Posición de los ejes del rectángulo que lo contiene
center_x
center_y
Posición del centro del sprite
position Posición en forma de vector (tupla) del centro del sprite
change_x
change_y
Variación en la posición
velocity Variación en la posición en forma de vector (tupla)
width
height
Ancho y alto del rectángulo que lo contiene
scale Escala de la imagen
color Color aplicado al sprite
texture Objeto con la textura actual
textures Lista de todas las texturas asociadas
cur_texture_index Índice de la textura actualmente mostrada
sprite_lists Todos los objetos SpriteList en los que está incluído

Hay varias propiedades que son redundantes, de modo si modificamos por ejemplo la coordenada izquierda se ajustarán automáticamente la posición del centro y de la derecha. De este modo podíamos en el programa de la pelota comprobar los extremos adecuados para ver si alcanzamos el borde de la ventana. Podemos girar el sprite y escalarlo, y podemos hacer que gire automáticamente y que se mueva del mismo modo. En el caso de imágenes monocromas podemos cambiar el color y jugar con la transparencia. Modifica el módulo de la pelota añadiendo las siguientes líneas en la clase PelotaGame:

# En el método __init__()
self.sprite.change_angle = 0.5

# En el método on_show()
self.sprite.alpha = 0

# y en el método on_update()
if self.sprite.alpha < 255:
self.sprite.alpha += 1

Añádelas al final de los tres métodos y al ejecutar el programa la pelota girará sobre sí misma y aparecerá progresivamente.

Uno de los aspectos más interesantes es que podemos dotar a un sprite de varias texturas (las texturas son objetos de la clase arcade.Texture que se crean a partir de una imagen de Pillow). De este modo podemos cambiar el aspecto del sprite o animarlo. De hecho existen clases especiales para sprites animados, y también tenemos sprites de un color sólido (rectangulares) y circulares.

Métodos de arcade.Sprite
__init__( ... ) Constructor de la clase:
filename (str) = None
scale (float) = 1
image_x (float) = 0
image_y (float) = 0
image_width (float) = 0
image_height (float) = 0
center_x (float) = 0
center_y (float) = 0
flipped_horizontally (bool) = False
flipped_vertically (bool) = False
flipped_diagonally (bool) = False
hit_box_algorithm (str) = "Simple"
hit_box_detail (float) = 4.5
Fichero con la imagen del sprite
Escalado del tamaño de la imagen
Desplazamiento horizontal dentro de la imagen
Desplazamiento vertical dentro de la imagen
Ancho del sprite
Alto del sprite
Posición horizontal del centro
Posición vertical del centro
Giro en torno al eje vertical
Giro en torno al eje horizontal
Giro en torno a un eje diagonal
Método para calcular colisiones
Precisión para calcular colisiones
append_texture(texture) Añade una textura a la lista de texturas
collides_with_list(spritelist) Devuelve una lista de los sprites en spritelist con los que hay colisión
collides_with_point(point) Devuelve True si hay colisión con el punto indicado
collides_with_sprite(sprite) Devuelve True si hay colisión con el sprite indicado
draw() Dibuja la imagen del sprite
forward(speed) Aumenta la velocidad según el valor indicado por speed
kill() Elimina el sprite de todas aquellas listas en las que esté
(Es un alias de remove_from_sprite_lists()
on_update(delta_time) Método llamado al actualizar el sprite
register_sprite_list(new_list) Crea una lista nueva y añade el sprite a ella
remove_from_sprite_lists() Elimina el sprite de todas aquellas listas en las que esté
reverse(speed) Reduce la velocidad según el valor de speed o acelera hacia atrás
set_position(center_x, center_y) Cambia la posición del sprite
set_texture(index) Establece la textura activa según el índice de la lista de texturas
stop() Detiene el movimiento del sprite
strafe(speed) Acelera perpendicularmente a la dirección actual
según el valor de speed
Valores positivos hacia la izquierda, negativos hacia la derecha
turn_left(angle) Gira a la izquierda según el ángulo
turn_right(angle) Gira a la derecha según el ángulo
update() Actualiza el estado
update_animation(delta_time) Sobreescribir este método para cambiar la imagen a mostrar
creando una animación

Podemos considerar tres grupos de métodos de nuestro interés, los relativos al movimiento del sprite, aquellos relacionados con las texturas y los que detectan colisiones. El tercer grupo nos permite detectar colisiones entre el sprite una lista de sprites (por ejemplo, para comprobar su somos alcanzados por un disparo o chocamos con un enemigo), el que detecta colisiones con un único sprite y el que lo hace con un punto.

Las texturas nos permiten cambiar el aspecto del sprite y sobre todo dotarlo de animación (por ejemplo un personaje que da pasos al desplazarse en horizontal). Por último, tenemos métodos para modificar el estado de movimiento de una forma cómoda en función de la clase de juego que estemos realizando.

El constructor nos permite varias posibilidades respecto al aspecto del sprite. Si usamos image_x e image_y combinados con image_width e image_height podemos emplear hojas de sprites, un fichero con un mosaico de diferentes imágenes del que seleccionamos una de ellas.

Como siempre, veamos la teoría en acción:

# Arcade Sprite 001

ANCHO, ALTO, TÍTULO = 800, 600, "Sprites 1"

import arcade

sprites = arcade.SpriteList()

sprites.append(arcade.Sprite("Fred_player 2.png", scale = 3))

sprites.append(arcade.Sprite("Fred_player 2.png", scale = 3,
flipped_horizontally = True))

sprites.append(arcade.Sprite("Fred_player 2.png", scale = 3,
flipped_vertically = True))

sprites.append(arcade.Sprite("Fred_player 2.png", scale = 3,
flipped_diagonally = True))

sprites.append(arcade.Sprite("Fred_player 2.png", scale = 3,
image_width = 37, image_height = 20,
image_y = 4))

for i, s in enumerate(sprites):
s.center_y = ALTO / 2
s.center_x = ANCHO / (len(sprites) + 1) * (i + 1)

arcade.open_window(ANCHO, ALTO, TÍTULO)

arcade.start_render()
sprites.draw()
sprites[0].draw_hit_box(arcade.color.RED)
arcade.finish_render()

arcade.run()

Aquí tenemos algunas de las opciones del constructor: creamos una lista de sprites y añadimos diversas figuras empleando el mismo fichero gráfico (que puedes descargar pulsando aquí) con distintas opciones. En el bucle de las líneas 24-26 colocamos todos los sprites equidistantes en una línea en el centro de la ventana y por último los mostramos. La ventaja de usar una lista de sprites es que podemos actualizarlos y mostrarlos con una sola orden. También podemos comprobar las colisiones de toda la lista de una vez.

En la línea 32 hemos usado un método que no hemos incluído en la tabla anterior, .draw_hit_box(color = (0, 0, 0), grosor = 1). Este método pinta una línea delimitando la caja de colisión del sprite. Esta depende de dos argumentos del constructor: hit_box_algorithm que puede tomar los valores "None", "Simple" (por defecto) o "Detailed" y hit_box_detail que solo se aplica si elegimos la última opción para el algoritmo. En general la opción por defecto funciona perfectamente.

Tenemos cinco versiones del sprite con la misma escala para poder compararlas mejor. La primera no emplea ningún modificador, en la segunda invertimos la imagen horizontalmente, en la tercera verticalmente, en la cuarta respecto a la diagonal ↘, y en la quinta empleamos solo una parte del gráfico. Como hemos mencionado anteriormente el último caso debería usarse con hojas de sprites, como la que te mostramos a continuación, junto con la salida del programa. Si usamos alguno de los argumentos image_x o image_y debemos especificar el tamaño mediante image_width e image_height o se producirá un error.

Veamos los métodos que afectan al movimiento del sprite:

# Arcade Sprite 002

ANCHO, ALTO, TÍTULO = 800, 600, "Sprites 2"

import arcade
import math

class myWindow(arcade.Window):

def __init__(self):
super().__init__(ANCHO, ALTO, TÍTULO)

self.sprite = arcade.Sprite("Fred_player 2.png", scale = 3)
self.sprite.center_y = ALTO / 2
self.sprite.center_x = ANCHO / 2
self.sprite.angle = 0
self.update(0)

arcade.run()

def on_draw(self):
arcade.start_render()
self.sprite.draw()
self.line.draw()
self.triangle.draw()

def on_update(self, delta_time):
self.sprite.update()

cx = self.sprite.center_x
cy = self.sprite.center_y
a  = math.radians(self.sprite.angle)
R  = 100
self.line = arcade.create_line(cx, cy, cx + R * math.cos(a), cy + R * math.sin(a),
arcade.color.RED)
self.triangle = arcade.create_polygon(((cx + R * math.cos(a + .05),
cy + R * math.sin(a + .05)),
(cx + (R + 10) * math.cos(a),
cy + (R + 10) * math.sin(a)),
(cx + R * math.cos(a - .05),
cy + R * math.sin(a - .05))),
arcade.color.RED)

def on_key_press(self, symbol, modifiers):
if symbol == arcade.key.LEFT:
self.sprite.turn_left(5)
elif symbol == arcade.key.RIGHT:
self.sprite.turn_right(5)
elif symbol == arcade.key.UP:
self.sprite.forward(1)
elif symbol == arcade.key.DOWN:
self.sprite.reverse(1)
elif symbol == arcade.key.SPACE:
self.sprite.stop()
elif symbol == arcade.key.ESCAPE:
self.sprite.set_position(ANCHO / 2, ALTO / 2)
elif symbol == arcade.key.LSHIFT:
self.sprite.strafe(0.5)
elif symbol == arcade.key.RSHIFT:
self.sprite.strafe(-0.5)

myWindow()

Aquí empleamos la misma imagen para el sprite, lo centramos en la pantalla y llamamos al método .update() para que se cree la flecha que indica el ángulo de movimiento. El método on_draw() pinta el sprite y la flecha (que consta de una línea y un triángulo para la punta).

En on_update() empleamos trigonometría elemental para definir una flecha en la dirección del atributo sprite.angle.

Empleando las teclas de cursores podemos girar y mover el sprite hacia delante y atrás. La barra espaciadora detiene el movimiento y <ESCAPE> vuelve a colocar la figura en el centro de la ventana. Las teclas <MAYUSC izquierda y derecha usan el método .strafe() para desplazar perpendicularmente nuestra figura.

Practica con el programa para entender cada método. Este es el aspecto:

Moviendo un sprite

Un nuevo ejemplo de cómo obtener diferentes sprites de una hoja de sprites. Necesitarás descargar la imagen con las diferentes actitudes de la aventurera de unas líneas arriba (pulsa con el botón derecho y selecciona "Guardar imagen como..."):

# Arcade Sprite 003

ANCHO, ALTO, TÍTULO = 800, 600, "Sprites 3"
IMX, IMY = 96, 128

import arcade

class myWindow(arcade.Window):

def __init__(self):
super().__init__(ANCHO, ALTO, TÍTULO)
self.sprites = arcade.SpriteList()

# Personaje en reposo
self.sprites.append(arcade.Sprite("Aventureras.png",
image_width = IMX, image_height = IMY,
center_x = ANCHO / 2, center_y = ALTO / 2))

for i in range(8):
self.sprites.append(arcade.Sprite("Aventureras.png",
image_width = IMX, image_height = IMY,
image_x = IMX * i, image_y = IMY * 4,
center_x = ANCHO / 9 * (i + 1),
center_y = ALTO / 4 * 3,))

self.sprites.append(arcade.Sprite("Aventureras.png",
image_width = IMX, image_height = IMY,
image_x = IMX * i, image_y = IMY * 4,
flipped_horizontally = True,
center_x = ANCHO / 9 * (8 - i),
center_y = ALTO / 4))

arcade.run()

def on_draw(self):
arcade.start_render()
self.sprites.draw()

myWindow()

Sencillamente cargamos diferentes poses de la hoja, concretamente la primera (personaje en reposo) y ocho de la última fila, que corresponden a las poses andando, creamos versiones invertidas horizontalmente para que ande hacia la izquierda. Después simplemente mostramos todos los sprites obtenidos a partir de la misma hoja.

Usando una hoja de sprites

Si lo que queremos es obtener un personaje animado no es práctico tener diferentes sprites para cada imagen, es mucho mejor cargar las diferentes imágenes como texturas de un único sprite.

# Arcade Sprite 004

ANCHO, ALTO, TÍTULO = 600, 200, "Sprites 4"
IMX, IMY = 96, 128

import arcade
from PIL import Image

class myWindow(arcade.Window):

def __init__(self):
super().__init__(ANCHO, ALTO, TÍTULO, update_rate = .07)
self.aventurera = arcade.Sprite(center_x = ANCHO / 2, center_y = ALTO / 2)

im = Image.open("Aventureras.png").convert("RGBA")

# La textura 0 corresponde al personaje en reposo
self.aventurera.append_texture(arcade.Texture("0", im.crop((0, 0, IMX, IMY))))
self.aventurera.set_texture(0)
self.walk = "stop"

# Las texturas 1-8 corresponden al personaje andando hacia la derecha
for i in range(8):
x0, y0 = IMX * i, IMY * 4
x1, y1 = IMX * (i + 1), IMY * 5
self.aventurera.append_texture(arcade.Texture(str(i + 1),
im.crop((x0, y0, x1, y1))))

# Las texturas 9-15 corresponden al personaje andando hacia la izquierda
for i in range(8):
x0, y0 = IMX * i, IMY * 4
x1, y1 = IMX * (i + 1), IMY * 5
frame = im.crop((x0, y0, x1, y1)).transpose(Image.FLIP_LEFT_RIGHT)
self.aventurera.append_texture(arcade.Texture(str(i + 9), frame))

arcade.run()

def on_draw(self):
arcade.start_render()
self.aventurera.draw()

def on_update(self, delta_time):
self.aventurera.update()
if self.aventurera.left < 0:
self.anda("right")
elif self.aventurera.right > ANCHO:
self.anda("left")

if self.walk == "right":
self.aventurera.cur_texture_index += 1
if self.aventurera.cur_texture_index > 8:
self.aventurera.cur_texture_index = 1
self.aventurera.set_texture(self.aventurera.cur_texture_index)
elif self.walk == "left":
self.aventurera.cur_texture_index += 1
if self.aventurera.cur_texture_index > 15:
self.aventurera.cur_texture_index = 9
self.aventurera.set_texture(self.aventurera.cur_texture_index)

def on_key_press(self, symbol, modifiers):
if symbol == arcade.key.SPACE:
self.para()
elif symbol == arcade.key.RIGHT:
self.anda("right")
elif symbol == arcade.key.LEFT:
self.anda("left")

def para(self):
self.walk = "stop"
self.aventurera.set_texture(0)
self.aventurera.change_x = 0

def anda(self, direction):
if direction == "right":
self.walk = "right"
self.aventurera.cur_texture_index = 1
self.aventurera.change_x = 10
elif direction == "left":
self.walk = "left"
self.aventurera.cur_texture_index = 9
self.aventurera.change_x = -10

myWindow()

Para crear texturas necesitamos imágenes de Pillow, por lo que importamos el módulo PIL.Image. Creamos la ventana, observa que hemos dado un valor de actualización relativamente bajo, unos 13 cuadros por segundo, para que la animación resulte natural. Puedes probar a eliminar el argumento update_rate y ver cómo resulta a 60 cuadros por segundo.

En la línea 13 creamos nuestro sprite, simplemente le damos una posición puesto que cargaremos las texturas después. Cargamos la imagen mediante Pillow y la convertimos a modo RGBA para que arcade pueda gestionar los choques con los bordes de la ventana, si omitimos este paso se producirá un error.

A continuación extraemos las diferentes imágenes y las cargamos como texturas del sprite, es obligatorio que cada textura tenga un nombre diferente para el gestor interno de arcade, así que les damos cadenas con el valor numérico del índice dentro de la lista de texturas.

Al tener un solo sprite el método on_draw() es muy sencillo. Solo pintamos el sprite.

En cambio en el método on_update() gestionamos dos estados diferentes, andando hacia la derecha o hacia la izquierda. La posición del sprite se actualiza sola al invocar self.aventurera.update(), luego comprobamos si hemos llegado a los límites para invertir la dirección.

En la segunda parte gestionamos el cambio de la textura activa para producir la animación.

Gestionamos los cursores a la izquierda y derecha para andar en las respectivas direcciones y el espacio para parar. Para simplificar el código hemos creado métodos para cada una de las acciones. Así no hemos necesitado repetir el código cuando hay que cambiar de dirección durante la actualización.

Este es el resultado:

Un sprite con animación

Quedan muchas cosas por aprender, pero por el momento tenemos lo más esencial. Vamos a aplicar los conocimientos expuestos para desarrollar un par de juegos reales con arcade. De momento te dejo el enlace de la documentación oficial de arcade.

5.2.6 Un juego de ejemplo con arcade: Frontón

Como se trata de un programa de cierta extensión vamos a comenzar una práctica saludable que es dividir el código en distintos ficheros. El primero de ellos será donde definamos las constantes que vamos a usar a lo largo del programa. Es conveniente realizar esto en un fichero aparte para poder compartirlo entre todos nuestros módulos, dado que el espacio de nombres de cada módulo es privado. En cada uno de los ficheros de nuestro código fuente debemos incluir la línea: from constantes import *. Asegúrate de que coincidan mayúsculas y minúsculas, dado que la sentencia import diferencia entre ellas. Yo he optado por emplear minúsculas en todos los módulos salvo en el principal.

Etapa 1: Creando el interfaz

# constantes.py

ANCHO, ALTO, TÍTULO = 800, 600, "Frontón"

ROJO= (0xFF, 0, 0)
AMARILLO   = (0xFF, 0xFF, 0)
AMARILLOSC = (0xB0, oxB0, 0)
VERDE= (0x50, 0xA0, 0x70)

Tenemos las habituales dimensiones y título de la ventana y los colores que usaremos en el juego. Ahora vamos a crear una vista para el menú. Solo vamos a tener dos opciones, pero sería fácil ampliarlo en el futuro.

# menú.py

import arcade
from constantes import *

opciones = ("Jugar", "Salir")

class Menu(arcade.View):

def __init__(self):
super().__init__()
self.base= arcade.SpriteList()
self.selected = arcade.SpriteList()
for size, lista in ((60, self.base), (100, self.selected)):
index_y = len(opciones)
for o in opciones:
opción = arcade.draw_text(o,
ANCHO / 2,
index_y * ALTO / (len(opciones) + 1),
color = AMARILLO,
font_size = size,
anchor_x = "center",
anchor_y = "center")
lista.append(opción)
index_y -= 1
self.active = 0

def on_draw(self):
arcade.start_render()
for i in range(len(self.base)):
if i == self.active:
self.selected[i].draw()
else:
self.base[i].draw()

def on_key_press(self, symbol, modifiers):
if symbol == arcade.key.UP and self.active:
self.active -= 1
elif symbol == arcade.key.DOWN and self.active < (len(self.base) - 1):
self.active += 1
elif symbol in (arcade.key.SPACE, arcade.key.ENTER):
if self.active == 0:
self.window.show_view(self.window.game)
elif self.active == 1:
self.window.close()

Solo necesitamos el constructor y los métodos on_draw() y on_key_press(). En el constructor definimos dos listas de sprites con las cadenas del menú en dos tamaños diferentes de texto. Un más grande para la opción activa y otro menor para el resto. Observa como hemos empleado dos bucles para ello. En el bucle exterior gestionamos cada lista con el tamaño de texto correspondiente y en el interior los textos de las opciones y la posición vertical de cada uno. Luego fijamos la primera opción como activa.

El método on_draw() emplea las listas de sprites como si fueran listas normales, recorremos la longitud de la lista pintando los elementos, si es el elemento activo empleamos la lista con el tamaño de texto grande, en caso contrario la otra.

El comportamiento del menú se define integramente en el método on_key_press(). Si pulsamos los cursores arriba o abajo cambiamos la opción activa, siempre que no estemos ya en el extremo. Si pulsamos <ENTRAR> o <ESCAPE> realizamos el procedimiento adecuado a la opción activa: Activamos la vista game para jugar o cerramos la ventana principal para salir.

# game.py

import arcade
from constantes import *

class Game(arcade.View):

def __init__(self):
super().__init__()

def on_draw(self):
arcade.start_render()
arcade.draw_text("JUEGO",
ANCHO / 2,
ALTO / 2,
color = AMARILLO,
font_size = 100,
anchor_x = "center",
anchor_y = "center")

def on_key_press(self, symbol, modifiers):
if symbol == arcade.key.ESCAPE:
self.window.show_view(self.window.menu)

Hemos creado una vista provisional para la partida, de momento nos limitamos a mostrar un letrero con el texto "JUEGO" en el centro de la pantalla. Cada vista gestiona el teclado por su cuenta, así que hemos de asegurarnos de que pulsando <ESCAPE> regresemos al menú.

# Frontón.py

# Bibliotecas
import arcade

# Módulos externos
from constantes import *
from menú import Menu
from game import Game

# Ventana principal
class myWindow(arcade.Window):

def __init__(self):
super().__init__(ANCHO, ALTO, TÍTULO, update_rate = .01)
arcade.set_background_color(VERDE)
self.menu = Menu()
self.game = Game()
self.show_view(self.menu)
arcade.run()

root = myWindow()

Al dividir el programa, nuestra ventana principal es extremadamente sencilla, creamos la ventana y las vistas de menú y de juego y despues activamos la primera vista. Si decidimos incorporar nuevas vistas más adelante tan solo deberíamos emplear una línea extra para cada una. Al crear la ventana ya hemos establecido una velocidad de refresco de 0.01s que representa 100 cuadros por segundo.

Una vez creados los cuatro ficheros (te recomiento que los coloques todos en un directorio aparte) puedes ejecutar el módulo Frontón.py y obtendrás la pantalla de menú con dos opciones y si eliges Jugar una pantalla similar con un letrero central. Comprueba que puedes moverte con fluidez entre ambas vistas. Ahora hemos de empezar a dotar de contenido al juego.

Antes de entrar en materia vamos a incorporar una pequeña mejora al interfaz. En estos momentos, si estamos jugando y pulsamos <ESCAPE> salimos al menú principal. Puede ser muy frustrante a mitad de una partida particularmente excitante. Vamos a diferenciar en el menú dos opciones de juego, una para comenzar una nueva partida y otra para continuar la partida en curso. Simultáneamente debemos hacer pequeños cambios en el módulo game.py para que diferenciar entre una partida nueva y la continuación de una ya iniciada. He aquí los dos modulos modificados.

Etapa 2: Detalles del menú

# menú.py

import arcade
from constantes import *

opciones = ("Comenzar", "Continuar", "Salir")

class Menu(arcade.View):

def __init__(self):
super().__init__()
self.base= arcade.SpriteList()
self.selected = arcade.SpriteList()
for size, lista in ((60, self.base), (100, self.selected)):
index_y = len(opciones)
for o in opciones:
opción = arcade.draw_text(o,
ANCHO / 2,
index_y * ALTO / (len(opciones) + 1),
color = AMARILLO,
font_size = size,
anchor_x = "center",
anchor_y = "center")
lista.append(opción)
index_y -= 1
self.active = 0
self.base[1].color = AMARILLOSC
self.new = True

def on_draw(self):
arcade.start_render()
for i in range(len(self.base)):
if i == self.active:
self.selected[i].draw()
else:
self.base[i].draw()

def on_key_press(self, symbol, modifiers):
# Movernos por las opciones
if symbol == arcade.key.UP and self.active:
self.active -= 1
if self.new and self.active == 1:
self.active = 0
elif symbol == arcade.key.DOWN and self.active < (len(self.base) - 1):
self.active += 1
if self.new and self.active == 1:
self.active = 2
# Ejecutar la opción activa
elif symbol in (arcade.key.SPACE, arcade.key.ENTER):
# COMENZAR: Activamos la opción continuar y llamamos a game.setup()
if self.active == 0:
self.new = False
self.base[1].color = AMARILLO
self.active = 1
self.window.game.setup()
self.window.show_view(self.window.game)
elif self.active == 1:
self.window.show_view(self.window.game)
elif self.active == 2:
self.window.close()

En menú.py insertamos la opción "Continuar" en la lista de opciones, además hemos reemplazado Jugar por Comenzar. Al final del constructor __init__() cambiamos el color de la segunda opción a amarillo más oscuro para indicar que se trata de una opción inactiva, y usamos un marcador booleano (self.new) para saber que no hay ninguna partida en ejecución. Las mayores novedades están en la gestión del teclado. En los movimientos arriba y abajo comprobamos si el marcador self.new está activo y en dicho caso nos saltamos la opción de continuar. Al ejecutar la opción "Comenzar" tenemos más cosas que hacer: Desactivamos el marcador de juego nuevo y damos el color normal a la opción de continuar, además activamos dicha opción para esté seleccionada por defecto si volvemos desde la partida. Llamamos a una nueva función game.setup() que debe realizar el trabajo de iniciar la partida y entonces activamos la vista del juego. La opción "Continuar" consiste tan solo en reactivar la vista del juego sin alterar nada.

# game.py

import arcade
from constantes import *

class Game(arcade.View):

def __init__(self):
super().__init__()
self.texto = arcade.draw_text("JUEGO",
ANCHO / 2,
ALTO / 2,
color = AMARILLO,
font_size = 100,
anchor_x = "center",
anchor_y = "center")

def setup(self):
self.texto.alpha = 0

def on_draw(self):
arcade.start_render()
self.texto.draw()

def on_update(self, delta_time):
if self.texto.alpha < 255:
self.texto.alpha += .5

def on_key_press(self, symbol, modifiers):
if symbol == arcade.key.ESCAPE:
self.window.show_view(self.window.menu)

Seguimos teniendo un falso juego que solo muestra un letrero. Esta vez definimos el letrero en el constructor, de este modo no tenemos que repetir esa instrucción cada vez que pintemos la ventana (100 veces por segundo) lo cual significa un ahorro de tiempo importante.

Para ver la diferencia entre "empezar el juego" y un juego en curso hemos decidido que al empezar el letrero aparezca progresivamente. Para ello el método setup() establece un valor de transparencia total para el rótulo. El método on_draw() se ha visto simplificado al limitarse a mostrar el mensaje y hemos añadido un método de actualización on_update() que se ejecutará aproximadamente 100 veces por segundo y que va aumentando la opacidad del texto hasta alcanzar el máximo. Tardará unos 2.5s en ello.

Realiza un último cambio consistente en cambiar la extensión del fichero principal Frontón.py por Frontón.pyw. Esta es una de las extensiones que maneja nuestro intérprete y que le indica que se trata de un programa GUI. De este modo no veremos la ventana del Símbolo del sistema y podemos ejecutar un script haciendo doble click desde el explorador de Windows.

El nuevo menú

Al ejecutar el programa podemos alternar entre el menú y el falso juego, si desde este volvemos al menú y elegimos Comenzar el letrero volverá a aparecer desde la nada. Ahora que tenemos esto resuelto vamos a empezar a desarrollar el verdadero juego, la mayoría del proceso lo haremos modificando el módulo game.py.

Etapa 3: Añadimos la raqueta

Vamos a empezar añadiendo unas nuevas constantes en nuestro módulo constantes.py para las dimensiones y color de la raqueta. El valor LEFTPAD corresponde a la distancia desde el borde izquierdo al eje vertical de la raqueta. Podríamos incluir estas constantes en el módulo del juego, pero de este modo está todo a mano si necesitamos modificar cualquier valor.

# Constantes

ANCHO, ALTO, TÍTULO = 800, 600, "Frontón"

ROJO= (0xFF, 0, 0)
AMARILLO= (0xFF, 0xFF, 0)
AMARILLOSC = (0xB0, 0xB0, 0)
VERDE= (0x50, 0xA0, 0x70)

LEFTPAD= 20

RAQUECOLOR = AMARILLO
RAQUETA_X  = 15
RAQUETA_Y  = 100

En el listado de game.py eliminamos el letrero y todo lo relacionado e incluimos una nueva clase para la raqueta. La raqueta aparecerá centrada en el eje horizontal de la ventana al comenzar el juego, así que la creamos desde el método setup().

# game.py

import arcade
from constantes import *

# Clase para nuestra raqueta
class Raqueta(arcade.SpriteSolidColor):

def __init__(self, parent, width, height, color):
super().__init__(width, height, color)
self.parent = parent
self.window = parent.window
# Colocamos la raqueta centrada verticalmente
self.set_position(LEFTPAD + width / 2, self.window.height / 2)

# Vista del juego
class Game(arcade.View):

def __init__(self):
super().__init__()
self.setup()

# Comienzo de una partida
def setup(self):
# Creamos la raqueta
self.raqueta = Raqueta(self, RAQUETA_X, RAQUETA_Y, RAQUECOLOR)

def on_draw(self):
arcade.start_render()
self.raqueta.draw()

def on_key_press(self, symbol, modifiers):
if symbol == arcade.key.ESCAPE:
self.window.show_view(self.window.menu)

Ya tenemos una "raqueta" inmovil en pantalla. Hemos derivado la clase de arcade.SpriteSolidColor. Esta es una clase de sprite que no tiene un gráfico asociado, solamente un recuadro con unas dimensiones y un color.

Habemus raqueta

Etapa 4: Le damos vida a la raqueta

# game.py

import arcade
from constantes import *

# Clase para nuestra raqueta
class Raqueta(arcade.SpriteSolidColor):

def __init__(self, parent, width, height, color):
super().__init__(width, height, color)
self.parent = parent
self.window = parent.window
# Colocamos la raqueta centrada verticalmente
self.set_position(LEFTPAD + width / 2, self.window.height / 2)

def update(self, y):
self.center_y = y
if self.top > ALTO:
self.top = ALTO
elif self.bottom < 0:
self.bottom = 0

En nuestra clase Raqueta añadimos un método para actualizar la posición en el eje y. Empleamos como referencia el centro de la raqueta y comprobamos que no nos salgamos por los márgenes, deteniendo el movimiento si es necesario.

# Vista del juego
class Game(arcade.View):

def __init__(self):
super().__init__()
self.setup()

# Comienzo de una partida
def setup(self):
self.run = True
self.gameover = False
# Creamos la raqueta
self.raqueta = Raqueta(self, RAQUETA_X, RAQUETA_Y, RAQUECOLOR)

En el método setup() añadimos a la clase Game unos atributos run y gameover para gestionar la pausa y el fin del juego más adelante.

# Mostrar el contenido de la ventana
def on_draw(self):
arcade.start_render()
self.raqueta.draw()

# Movemos la raqueta con el ratón
def on_mouse_motion(self, x, y, dx, dy):
if self.run:
self.raqueta.update(y)
self.window.set_mouse_position(LEFTPAD,
self.raqueta.center_y)

Añadimos un método para procesar el movimiento del ratón. Si el juego no está en pausa invocamos el método update() de la raqueta. Observa que al emplear objetos una vez diseñados estos nos resulta mucho más simple emplearlos. Además, para evitar que al mover el ratón nos vayamos desplazando y nos salgamos de la ventana con la consiguiente pérdida de control, mantenemos la posición horizontal del ratón en la línea de la raqueta y a su altura. Ten en cuenta que de momento mantenemos la visibilidad del cursor, pero muy pronto lo ocultaremos y nos saldríamos sin darnos cuenta. Por cierto, si hacemos que la ventana capture el ratón este no se sale de ella, pero siempre recibimos unas coordenadas (0, 0), con lo cual es inútil intentarlo.

def on_key_press(self, symbol, modifiers):
# ESCAPE vuelve a la pantalla de menú
if symbol == arcade.key.ESCAPE:
# Si ha terminado la partida desactivamos la opción "Continuar"
if self.gameover:
self.window.menu.base[1].color = AMARILLOSC
self.window.menu.new = True
self.window.show_view(self.window.menu)
# ESPACIO conmuta la pausa del juego
elif symbol == arcade.key.SPACE and not self.gameover:
self.run = not self.run
# Al salir de la pausa restituimos la posición del ratón
# a la altura de la pala, para que no haya un salto
if self.run:
self.window.set_mouse_position(LEFTPAD * 2,
self.raqueta.center_y)

Para terminar añadimos algunas novedades en la gestión del teclado. La tecla <ESCAPE> sigue llevándonos al menú, pero si hemos terminado la partida se desactiva de nuevo la opción de Continuar. Añadimos la tecla <ESPACIO> para activar o desactivar la pausa, en caso de que la partida no haya terminado. Cuando desactivamos la pausa nos asegurarnos de colocar el cursor del ratón en la posición de la raqueta, de otra manera esta daría un salto y posiblemente perderíamos la pelota. El siguiente paso es añadir esta.

Etapa 5: Creamos la pelota

# Constantes

ANCHO, ALTO, TÍTULO = 800, 600, "Frontón"

ROJO= (0xFF, 0, 0)
AMARILLO= (0xFF, 0xFF, 0)
AMARILLOSC = (0xB0, 0xB0, 0)
VERDE= (0x50, 0xA0, 0x70)

LEFTPAD= 20

RAQUECOLOR = AMARILLO
RAQUETA_X  = 15
RAQUETA_Y  = 100

PELOCOLOR  = AMARILLO
PELOTA_R= 10

Añadimos un par de constantes para el radio de la pelota y su color.

# game.py

import arcade
import random
from constantes import *

# Clase para nuestra raqueta
class Raqueta(arcade.SpriteSolidColor):

def __init__(self, parent, width, height, color):
super().__init__(width, height, color)
self.parent = parent
self.window = parent.window
# Colocamos la raqueta centrada verticalmente
self.set_position(LEFTPAD + width / 2, self.window.height / 2)

def update(self, y):
self.center_y = y
if self.top > ALTO:
self.top = ALTO
elif self.bottom < 0:
self.bottom = 0

Añadimos el módulo random a las importaciones. La raqueta no tiene cambios.

# Clase para la pelota
class Pelota(arcade.SpriteCircle):

def __init__(self, parent, radio, color):
super().__init__(radio, color)
self.parent = parent
self.window = parent.window
self.lanza()

# Relanzamos la pelota
def lanza(self):
# Damos una posición y velocidad aleatorias
self.set_position(random.randint(100, ANCHO - 100),
random.randint(100, ALTO - 100))
self.velocity = [3 + random.randrange(30) / 10,
1.5 + random.randrange(20) / 10]
# 50% de posibilidades de ir arriba o abajo
if random.randrange(200) < 100:
self.change_y = -self.change_y

Definimos una clase para la pelota, derivada de arcade.SpriteCircle. Esta tampoco tiene una imagen, consiste en un círculo de un radio y color determinados. El método lanza() dota a la pelota de una posición aleatoria (con un margen de separación de 100 pixels desde el borde de la ventana) y una velocidad igualmente aleatoria, entre 3 y 6 en horizontal y entre 1.5 y 2.5 en vertical. Al emplear un método podemos reutilizarlo con sencillez cuando perdamos la pelota por la izquierda.

def update(self):
raqueta = self.parent.raqueta
# Rebote con el fondo
if self.change_x > 0 and self.right >= ANCHO:
self.change_x = -self.change_x
# Impacto con la raqueta
elif (self.change_x < 0 and (raqueta.left <= self.left <= raqueta.right)) and\
(raqueta.top > self.center_y > raqueta.bottom):
self.change_x = -self.change_x
# Pérdida de la pelota
elif self.change_x < 0 and self.left <= 0:
self.lanza()
# Impactos con el suelo y techo
if (self.change_y > 0 and self.top >= ALTO) or\
(self.change_y < 0 and self.bottom <= 0):
self.change_y = -self.change_y
super().update()

Aquí tenemos el movimiento de la pelota. Si nos movemos hacia la derecha al llegar al extremo invertimos el movimiento horizontal "rebotando" contra el fondo.

Si nos movemos hacia la izquierda hemos de comprobar primero si la raqueta se interpone ante nosotros cuando llegamos a su altura. De ser así invertimos el movimiento. No utilizamos los métodos de colisión porque si lo hiciéramos podríamos alcanzar la pelota con la raqueta DESPUÉS de que hubiese rebasado la línea y rebotaría también y no queremos eso.

Por último comprobamos también el impacto vertical contra los bordes superior e inferior y si es necesario producimos el rebote adecuado. Terminamos invocando el método update() de la clase superior, que es arcade.SpriteCircle. De esta manera se actualiza la posición de la pelota en función de su velocidad.

# Vista del juego
class Game(arcade.View):

def __init__(self):
super().__init__()
self.setup()

# Comienzo de una partida
def setup(self):
self.run = True
self.gameover = False
# Creamos la raqueta y la pelota
self.raqueta = Raqueta(self, RAQUETA_X, RAQUETA_Y, RAQUECOLOR)
self.pelota  = Pelota(self, PELOTA_R, PELOCOLOR)

# Mostrar el contenido de la ventana
def on_draw(self):
arcade.start_render()
self.raqueta.draw()
self.pelota.draw()

Al iniciar el juego creamos también un objeto de clase Pelota y lo incluímos entre los objetos mostrados por el método draw().

# Al pasar a la vista del juego recolocamos el cursor del ratón
def on_show(self):
try:
self.window.set_mouse_position(LEFTPAD * 2,
self.raqueta.center_y)
except TypeError:
pass

# Actualización
def on_update(self, delta_time):
if self.run:
self.pelota.update()

# Movemos la raqueta con el ratón
def on_mouse_motion(self, x, y, dx, dy):
if self.run:
self.raqueta.update(y)
try:
self.window.set_mouse_position(LEFTPAD * 2,
self.raqueta.center_y)
except TypeError:
pass

Añadimos el método on_show() para la continuación de una partida desde el menú. Lo que hacemos es colocar el ratón a la altura de la raqueta, como se producen errores en ciertas circunstancias, los ignoramos. Añadimos también el método on_udate() en el cual gestionamos el movimiento de la pelota por el sencillo sistema de invocar su método Pelota.update(). En el proceso del ratón hemos incluído un filtro de errores, en todos los puntos en los que usamos set_mouse_position() lo haremos.

def on_key_press(self, symbol, modifiers):
# ESCAPE vuelve a la pantalla de menú
if symbol == arcade.key.ESCAPE:
# Si ha terminado la partida desactivamos la opción "Continuar"
if self.gameover:
self.window.menu.base[1].color = AMARILLOSC
self.window.menu.new = True
self.window.show_view(self.window.menu)
# SPACE conmuta la pausa del juego
elif symbol == arcade.key.SPACE and not self.gameover:
self.run = not self.run
# Al salir de la pausa restituimos la posición del ratón
# a la altura de la pala, para que no haya un salto
if self.run:
try:
self.window.set_mouse_position(LEFTPAD * 2,
self.raqueta.center_y)
except TypeError:
pass

En la gestión del teclado hemos añadido la misma gestión de errores. Ahora tenemos un pelota con la que podemos jugar. Si se pierde por la izquierda se lanza una nueva automáticamente.

Ya tenemos casi el juego. Vamos a añadir los detalles para convertirlo en un producto acabado.

Etapa 6: Puliendo el acabado

Lo primero es incorporar sonido. Tanto en un vídeo como en un juego alcanzamos otra dimensión al dotarlos de efectos sonoros. Descarga el archivo con los sonidos aquí. Extrae los sonidos en la carpeta del juego, también tienes la fuente empleada que puedes instalar copiándola en la carpeta C:\Windows\Fonts.

Como todos los sonidos se producirán a partir de las actualizaciones de la pelota podemos cargarlos en el constructor de la clase Pelota mediante arcade.load_sound(filename) y luego emplear arcade.play_sound() en el momento adecuado (los rebotes con las paredes, con la raqueta, la pérdida de una pelota y la pérdida de todas las vidas), el único sonido que gestionará la clase Game será el que indica que hemos conseguido una vida extra.

Aquí llega lo segundo, incorporar un número de pelotas disponibles que se decremente cada vez que perdamos una y al llegar a cero active la propiedad gameover. Para mostrar las vidas restantes emplearemos objetos Pelota inmóviles mediante una SpriteList.

Tanto al terminar el juego como durante la pausa mostraremos sendos mensajes informativos, resulta mucho más cortés para el jugador.

Asimismo, incorporaremos una nueva propiedad para llevar cuenta de los puntos obtenidos. Cada vez que la pelota rebote en la raqueta los incrementaremos, y los mostraremos en la pantalla dentro del método on_draw().

En relación con la puntuación, al alcanzar ciertas cantidades ganaremos vidas extra, pero la cantidad de puntos necesaria para cada vida será mayor. Para esto vamos a emplear un generador, algo parecido a un iterador que va creando y devolviendo valores a partir de un algoritmo.

Para terminar, cada vez que consigamos un rebote con la raqueta aumentaremos la velocidad de la pelota para que el juego resulte realmente interesante, y ocultaremos el cursor del ratón mientras estemos en el juego.

El último punto será incorporar el control del menú mediante la rueda y el click del ratón, lo que le dará mayor coherencia al control general de ambas vistas.

# Frontón.pyw

# Bibliotecas
import arcade

# Módulos externos
from constantes import *
from menú import Menu
from game import Game

# Ventana principal
class myWindow(arcade.Window):

def __init__(self):
super().__init__(ANCHO, ALTO, TÍTULO, update_rate = .01)
arcade.set_background_color(VERDE)
self.menu = Menu()
self.game = Game()
self.set_mouse_visible(False)
self.show_view(self.menu)
arcade.run()

root = myWindow()

En el módulo Frontón.pyw tan solo hemos añadido la línea 19 para hacer invisible el cursor del ratón en la ventana. En cuanto a menú.py el constructor y la función on_draw() están exactamente igual, solo incluimos cambios en la gestión de la entrada del usuario que mostramos a continuación:

def on_key_press(self, symbol, modifiers):
# CURSORES ARRIBA y ABAJO
if symbol == arcade.key.UP:
self.select("up")
elif symbol == arcade.key.DOWN:
self.select("down")
# Ejecutar la opción activa
elif symbol in (arcade.key.SPACE, arcade.key.ENTER):
self.launch()

# Click del ratón
def on_mouse_press(self, x, y, buttons, modifiers):
if buttons == 1:
self.launch()

# Rueda del ratón
def on_mouse_scroll(self, x, y, scroll_x, scroll_y):
if scroll_y > 0:
self.select("up")
elif scroll_y < 0:
self.select("down")

Hemos separado el proceso del teclado y del ratón del proceso sobre el menú, así no necesitamos reescribir el mismo código en los métodos on_key_press(), on_mouse_press() y on_mouse_scroll(). Ahora solo tenemos que determinar si la entrada corresponde a un movimiento a través de las opciones o a la ejecución de la opción activa.

# Cambiar la opción seleccionada
def select(self, direction):
if direction == "up" and self.active:
self.active -= 1
if self.new and self.active == 1:
self.active = 0
elif direction == "down" and self.active < (len(self.base) - 1):
self.active += 1
if self.new and self.active == 1:
self.active = 2

# Ejecutar la opción de menú seleccionada
def launch(self):
# COMENZAR: Activamos la opción continuar y llamamos a game.setup()
if self.active == 0:
self.new = False
self.base[1].color = AMARILLO
self.active = 1
self.window.game.setup()
self.window.show_view(self.window.game)
# CONTINUAR
elif self.active == 1:
self.window.show_view(self.window.game)
# SALIR
elif self.active == 2:
self.window.close()

Los nuevos métodos select() y launch() son los que ejecutan estas funciones. Aquí está el código que teníamos anteriormente en el método on_key_press(), de este modo podemos fácilmente realizar las mismas tareas con el ratón.

La mayoría de las modificaciones se han producido en la vista del juego:

# game.py

import arcade
import random
from constantes import *

# Generador para las vidas extra
# Primera con 50 puntos, para cada una se incrementa en 10 puntos
def extralife():
base = 0
step = 50
while True:
base += step
step += 10
yield base

Creamos un generador, una clase especial de función que devuelve una serie de valores en respuesta al método __next__(). Podríamos emplearlo en un bucle, pero en este caso el generador es infinito, nunca terminaría. Al ejecutar la función extralife() se crea el objeto generador, y a partir de él podemos ir obteniendo una serie de valores. La clave está en la sentencia yield, que es similar a return en que devuelve un valor, pero la función continúa en vez de terminar.

# Clase para nuestra raqueta
class Raqueta(arcade.SpriteSolidColor):

def __init__(self, parent, width, height, color):
super().__init__(width, height, color)
self.parent = parent
self.window = parent.window
# Colocamos la raqueta centrada verticalmente
self.set_position(LEFTPAD + width / 2, self.window.height / 2)

def update(self, y):
self.center_y = y
if self.top > ALTO:
self.top = ALTO
elif self.bottom < 0:
self.bottom = 0

La raqueta no necesita ningún cambio. La mayor parte están en la clase Pelota:

# Clase para la pelota
class Pelota(arcade.SpriteCircle):

def __init__(self, parent, radio, color):
super().__init__(radio, color)
self.parent = parent
self.window = parent.window
# Cargamos los efectos sonoros
self.pop1 = arcade.load_sound("pop1.ogg")
arcade.play_sound(self.pop1, volume = 0)
self.pop2 = arcade.load_sound("pop2.ogg")
arcade.play_sound(self.pop2, volume = 0)
self.endball = arcade.load_sound("BallOut.wav")
arcade.play_sound(self.endball, volume = 0)
self.endgame = arcade.load_sound("GameOver.mp3")
arcade.play_sound(self.endgame, volume = 0)
self.lanza()

El constructor carga los sonidos, y además de cargarlos los hacemos sonar sin volumen, esto es para evitar que cuando suenen por primera vez se paralize el juego durante un instante.

# Relanzamos la pelota
def lanza(self):
# Damos una posición y velocidad aleatorias
self.set_position(random.randint(100, ANCHO - 100),
random.randint(100, ALTO - 100))
self.velocity = [3 + random.randrange(30) / 10,
1.5 + random.randrange(20) / 10]
# 50% de posibilidades de ir arriba o abajo
if random.randrange(200) < 100:
self.change_y = -self.change_y

El método lanza() está igual que en la anterior versión.

def update(self):
raqueta = self.parent.raqueta
# Rebote con el fondo
if self.change_x > 0 and self.right >= ANCHO:
self.change_x = -self.change_x
arcade.play_sound(self.pop1)
# Impacto con la raqueta
elif (self.change_x < 0 and (raqueta.left <= self.left <= raqueta.right)) and\
(raqueta.top > self.center_y > raqueta.bottom):
self.change_x = -self.change_x + .2
if self.change_y > 0:
self.change_y += .2
else:
self.change_y -= .2
self.parent.puntos += 1
arcade.play_sound(self.pop2)
# Pérdida de la pelota
elif self.change_x < 0 and self.left <= 0:
# Una vida menos
if self.parent.vidas:
self.parent.vidas -= 1
self.parent.bolas.pop()
arcade.play_sound(self.endball)
self.lanza()
# Fin del juego
else:
self.parent.run = False
self.parent.gameover = True
arcade.play_sound(self.endgame)
# Impactos con el suelo y techo
if (self.change_y > 0 and self.top >= ALTO) or\
(self.change_y < 0 and self.bottom <= 0):
self.change_y = -self.change_y
arcade.play_sound(self.pop1)
super().update()

Aquí se concentran varios cambios. Al producirse los rebotes se reproducen los sonidos correspondientes, además si se trata de un impacto con la raqueta incrementamos la velocidad de la pelota (líneas 72-76) y la puntuación (línea 77). En caso de perder una pelota decrementamos el contador de vidas y si aún queda alguna hacemos sonar el efecto pertinente. Si llega a cero activamos el indicador self.gameover y desactivamos self.run. Esto último evita que se sigan moviendo la pala y la pelota, después hacemos sonar el sonido de fin de juego.

# Vista del juego
class Game(arcade.View):

def __init__(self):
super().__init__()
self.extrasound = arcade.load_sound("Vida.wav")
arcade.play_sound(self.extrasound, volume = 0)
self.setup()

# Comienzo de una partida
def setup(self):
self.run = True
self.gameover = False
self.vidas = 3
self.puntos = 0
# Creamos la raqueta y la pelota
self.raqueta = Raqueta(self, RAQUETA_X, RAQUETA_Y, RAQUECOLOR)
self.pelota  = Pelota(self, PELOTA_R, PELOCOLOR)
# Generamos las vidas extra
self.bolas = arcade.SpriteList()
for i in range(self.vidas):
bola = Pelota(self, PELOTA_R, PELOCOLOR)
bola.velocity = 0
bola.set_position(ANCHO - 80 - (30 * i), 20)
self.bolas.append(bola)
# Activamos el generador para las vidas extra
self.EXTRALIFE = extralife()
self.EXTRA = self.EXTRALIFE.__next__()

En el constructor el único añadido es que cargamos (y reproducimos en silencio) el sonido de vida extra. En cambio en el método setup() creamos e inicializamos el contador de vidas y el de puntos. Generamos objetos de la clase Pelota para cada vida extra y los colocamos adecuadamente en la esquina inferior derecha, dejando sitio para el indicador de puntuación. Gestionamos estos objetos en una SpriteList Para terminar inicializamos el generador para la puntuación de vidas extra y obtenemos el primer valor.

# Mostrar el contenido de la ventana
def on_draw(self):
arcade.start_render()
self.raqueta.draw()
self.pelota.draw()
self.bolas.draw()
arcade.draw_text(str(self.puntos), ANCHO - 50, 20, AMARILLO,
24, bold = True, anchor_y = "center")
if not self.run:
if not self.gameover:
arcade.draw_text("PAUSA", 0, ALTO / 2, ROJO, 120, ANCHO,
"center", "GILSANUB", anchor_y = "center")
else:
arcade.draw_text("GAME OVER", 0, ALTO / 2, ROJO, 80, ANCHO,
"center", "GILSANUB", anchor_y = "center")

En el método on_draw() ahora tenemos que pintar las bolas extra y la puntuación (líneas 133-134). Además comprobamos si la ejecución está detenida y en caso afirmativo mostramos el mensaje de PAUSA o GAME OVER según proceda.

# Al pasar a la vista del juego recolocamos el cursor del ratón
def on_show(self):
try:
self.window.set_mouse_position(LEFTPAD * 2,
self.raqueta.center_y)
except:
pass

# Actualización
def on_update(self, delta_time):
if self.run:
self.pelota.update()
# Añadimos las vidas extra
if self.puntos == self.EXTRA:
bola = Pelota(self, PELOTA_R, PELOCOLOR)
bola.velocity = [0, 0]
bola.set_position(ANCHO - 80 - (30 * self.vidas), 20)
self.bolas.append(bola)
self.vidas += 1
arcade.play_sound(self.extrasound)
self.EXTRA = self.EXTRALIFE.__next__()

En on_show() hemos generalizado la gestión de errores de la línea 149 eliminando el tipo de error <TypeError> (esto es aplicable a todas las ocasiones en que modificamos la posición del cursor). La actualización ahora comprueba la puntuación obtenida y si llegamos al valor necesario obtenemos una bola extra. Creamos la bola, le damos su posición, la añadimos a la lista self.bolas y hacemos sonar el sonido indicativo. Actualizamos el contador de vidas y obtenemos del generador el valor para la próxima vida extra.

# Movemos la raqueta con el ratón
def on_mouse_motion(self, x, y, dx, dy):
if self.run:
self.raqueta.update(y)
try:
self.window.set_mouse_position(LEFTPAD * 2,
self.raqueta.center_y)
except:
pass

def on_key_press(self, symbol, modifiers):
# ESCAPE vuelve a la pantalla de menú
if symbol == arcade.key.ESCAPE:
# Si ha terminado la partida desactivamos la opción "Continuar"
if self.gameover:
self.window.menu.base[1].color = AMARILLOSC
self.window.menu.new = True
self.window.show_view(self.window.menu)
# SPACE conmuta la pausa del juego
elif symbol == arcade.key.SPACE and not self.gameover:
self.run = not self.run
# Al salir de la pausa restituimos la posición del ratón
# a la altura de la pala, para que no haya un salto
if self.run:
try:
self.window.set_mouse_position(LEFTPAD * 2,
self.raqueta.center_y)
except:
pass

Por fin, en las funciones que gestionan los eventos de ratón y teclado solo hemos aplicado la misma extensión en el control de errores al mover el cursor del ratón (líneas 173 y 193).

Con esto damos por terminado este programa, aunque podríamos añadirle un sinfín de cosas, pero eso te lo dejamos como ejercicio para que practiques con Python y la librería arcade.

El juego Frontón completo
5.2.7 Otro juego de ejemplo con arcade: Marcianitos

Vamos a abordar un juego de diferente naturaleza, en este caso manejaremos sprites con imágenes y listas de sprites. La idea es emular los primitivos juegos de "marcianitos" tipo Space Invaders. Tenemos nuestra nave en la parte inferior de la pantalla, con movimiento en el eje horizontal y la posibilidad de disparar mortíferos rayos laser, y por encima los platillos alienígenas que se mueven también en líneas horizontales y tratan de destruirnos con sus disparos. Esto implica tres listas de sprites: los platillos, nuestros propios disparos y los disparos del enemigo, aparte del sprite de nuestra nave.

Estructuraremos el juego mediante dos vistas, al igual que el de frontón, una para el menú y otra para el juego propiamente dicho. Además vamos a implementar control mediante teclado y joystick en ambas vistas y además ratón en el menú. Para este juego emplearemos los recursos disponibles en el siguiente enlace. Descomprime los sonidos y gráficos en el directorio del juego, e instala la fuente copiándola al directorio C:\Windows\Fonts

Constantes y módulo principal

Empezemos por los módulos que definen las constantes y la ventana principal del juego.

# Proyecto: Marcianitos
# Fichero:  constantes.py

import arcade

ANCHO = 800
ALTO  = 600
TITLE = "Marcianitos"

FONT = "STENCIL"

# Esquema de colores
FONDO = arcade.color.BLACK
NAVE  = arcade.color.BRIGHT_GREEN
VIDAS = arcade.color.WHITE
LASER = arcade.color.AUREOLIN
ALIEN = arcade.color.AUBURN
TASER = arcade.color.RED

Igual que en el caso del frontón definimos los parámetros de la ventana, la fuente empleada y el esquema de color.

# Proyecto : Marcianitos
# Fichero  : Marcianitos.pyw

# Bibliotecas
import arcade
import pyglet

# Módulos externos
from constantes import *
from menú import Menu
from game import Game

# Ventana principal
class myWindow(arcade.Window):

def __init__(self):
super().__init__(ANCHO, ALTO, TITLE)
arcade.set_background_color(FONDO)
# Seleccionamos un icono para la ventana
icon = pyglet.image.load("Nave.ico")
arcade.get_window().set_icon(icon)
# Comprobamos si existen mandos de juego
self.joy = None
joysticks = arcade.get_joysticks()
if joysticks:
# En caso afirmativo usamos el primero
self.joy = joysticks[0]
self.joy.open()
self.menu = Menu(self.joy)
self.game = Game(self.joy)
self.show_view(self.menu)
arcade.run()

root = myWindow()

En el programa principal incorporamos una novedad. La biblioteca arcade está construída sobre otra biblioteca llamada pyglet con un montón de posibilidades para manejar gráficos, sonidos e incluso vídeos. Hay funciones que no han sido reflejadas en la primera, pero siempre podemos acudir a la base y usar los métodos de pyglet para cosas como definir un icono para la ventana. En la línea 20 usamos el método .load() del módulo pyglet.image para cargar nuestro icono. Luego combinamos en la línea 21 la función arcade.get_window() que nos devuelve el objeto ventana (en realidad el mismo root que definimos en la línea 34, que dentro del objeto corresponde al identificador de la instancia: self). Sobre dicho objeto invocamos el método .set_icon() de las ventanas de pyglet, del que nuestra clase es descendiente.

Para poder emplear un joystick buscamos los que haya conectados en ese momento mediante la sentencia de la línea 24. Si no hay ninguno tendremos el valor None almacenado en la variable joy, de haber mandos asignamos el primero a la variable y lo inicializamos mediante el método .open() (línea 28). Esto último es imprescindible o no obtendremos ninguna respuesta del mando.

Por último creamos las vistas del menú y del juego y activamos al primera.

Construcción del menú

El menú comprende un título y tres opciones: JUGAR, CONTINUAR y SALIR. La opción de continuar solo está disponible si hay una partida en marcha. Además incorporamos un efecto de deslizamiento en la aparición de los textos y un par de naves que se colocan a los lados de la opción activa. Podemos movernos por la opciones con las flechas de cursor arriba y abajo y usando la tecla a para aceptar. También podemos usar el ratón; pasando por encima de una opción se selecciona y con doble click izquierdo se activa. Si disponemos de un joystick podemos usar la palanca para movernos arriba y abajo y cualquier botón para aceptar.

Para evitar la salida accidental del programa, si activamos la opción SALIR se nos pide confirmación. La tecla S, doble click o un botón del joystick para aceptar, las teclas N o m, doble clic sobre otra zona de la ventana o un movimiento del joystick para cancelar.

# Proyecto: Marcianitos
# Fichero : menú.py

# Bibliotecas
import arcade
from constantes import *
import time

''' El menú incluye un título con el nombre del programa
Las opciones se colocan teniendo en cuenta la altura del título
Usamos una tupla con los nombres de las opciones y su método
'''

# Menú del juego
class Menu(arcade.View):

def __init__(self, joy):
super().__init__()
self.joy = joy
# Cargamos el click de selección de opción
self.sound = arcade.load_sound("Navigation.wav")
# Creamos el título y lo colocamos
self.title = arcade.draw_text(TITLE, ANCHO / 2, ALTO + 100, NAVE,
font_name = FONT, font_size = 50,
anchor_x = "center", anchor_y = "center")

Comenzamos como es habitual importando nuestras bibliotecas y módulos. Todo el menú se asienta en una clase derivada de arcade.View que definimos a continuación. Aportamos el objeto de control del joystick que obtuvimos en el módulo principal.

En el constructor comenzamos por inicializar la clase superior y guardar el joystick. Inmediatamente empezamos a definir los textos en forma de sprites empezando por el título. Observa que empleamos una coordenada vertical 100 pixels por encima del borde superior de la ventana. Así obtendremos luego el efecto de entrada desde arriba.

# Creamos el menú
opciones = {"JUGAR" : self.start,
"CONTINUAR": self.play,
"SALIR" : self.exit}

self.opción = list()
self.menu = arcade.SpriteList()
for i, o in enumerate(opciones):
texto = arcade.draw_text(o, ANCHO // 2, -100, ALIEN,
font_name = FONT, font_size = 50,
anchor_x = "center", anchor_y = "center")
texto.alpha = 100
self.menu.append(texto)
self.opción.append(opciones[o])
self.confirmtxt = arcade.draw_text("¿Salir? (S/N)",
ANCHO / 2, 0, LASER, font_name = FONT,
font_size = 30, anchor_x = "center",
anchor_y = "center")

Empleamos un diccionario para contener los textos de las opciones y los métodos correspondientes. Iteramos sobre el diccionario para crear los textos de las opciones y guardar los métodos en la lista self.opción. Esta vez colocamos los textos por debajo del borde inferior para que aparezcan deslizándose hacia arriba. En la línea 40 creamos el texto para pedirnos confirmación para salir del programa, solo lo veremos cuando activemos la opción SALIR.

# Inicializamos las variables
self.active = 0
self.joyclick = False
self.select(self.active)
self.intro = True
self.new = True
self.click = 0
self.confirm = False

Proseguimos inicializando nuestro menú. Empleamos un conjunto de variables para gestionar el comportamiento de este.

Comenzamos con la primera opción activada e invocamos el método self.select() (definido más adelante) para remarcarla. El resto de las variables indican que aún no hemos pulsado el joystick, que tenemos que ejecutar la introducción, que se trata de una partida nueva (esto controla la desactivación de la opción CONTINUAR) y que no estamos pidiendo confirmación para salir.

maxwidth = 0
for o in self.menu:
maxwidth = max(o.width, maxwidth)
self.naves = arcade.SpriteList()
nave = arcade.Sprite("nave.png", 2)
nave.set_position((ANCHO - maxwidth) / 2 - 100, -100)
nave.color = NAVE
self.naves.append(nave)
alien = arcade.Sprite("MarcianoA.png", 2)
alien.set_position(ANCHO - ((ANCHO - maxwidth) / 2 - 100), -100)
alien.color = ALIEN
self.naves.append(alien)

Aquí añadimos el efecto decorativo de dos naves a los lados de la opción seleccionada del menú. Las líneas 52 a 54 recorren la lista de sprites del menú para determinar la más larga. Creamos una lista de sprites para contener las dos naves, y a continuación creamos los sprites y los añadimos a la lista. Los colocamos a ambos lados del menú dejando un margen de 100 pixels y por debajo de la ventana.

Visualización y actualización del menú

def on_show(self):
cursor = self.window.get_system_mouse_cursor("hand")
self.window.set_mouse_cursor(cursor)
if self.joy:
self.joy.push_handlers(self)

def on_hide_view(self):
cursor = self.window.get_system_mouse_cursor(None)
self.window.set_mouse_cursor(cursor)
if self.joy:
self.joy.remove_handlers()

Estos métodos gestionan la visualización del menú y la salida de este (normalmante para ir al juego). Al entrar definimos un cursor con la imagen de una mano y activamos el control por joystick. Al salir volvemos al cursor por defecto y desactivamos el control por joystick (aunque esto no acaba de funcionar realmente y debemos comprobar al leer el joystick que estamos en la vista correcta).

def on_draw(self):
arcade.start_render()
self.title.draw()
self.menu.draw()
self.naves.draw()
if self.confirm:
self.confirmtxt.draw()

Aquí mostramos el título, las opciones de menú, las naves y en caso adecuado el texto de confirmación.

# Efecto de presentación
def on_update(self, delta_time):
if self.intro:
if self.title.center_y > ALTO - self.title.height - 10:
self.title.center_y -= 10
else:
self.title.center_y = ALTO - self.title.height - 10
for i, o in enumerate(self.menu):
y = ((self.title.bottom - 10) / (len(self.menu) + 1)) * (len(self.menu) - i)
if o.center_y < y:
o.center_y += 20
if i == self.active:
nave_y = self.naves[0].center_y
if nave_y < y:
self.naves[0].center_y = self.naves[1].center_y = nave_y + 20
break
o.center_y = y
else:
self.intro = False
self.confirmtxt.center_y = o.center_y - self.confirmtxt.height * 2
else:
nave_y = self.naves[0].center_y
if self.confirm:
active_y = self.confirmtxt.center_y
else:
active_y = self.menu[self.active].center_y
if nave_y != active_y:
self.naves[0].center_y = self.naves[1].center_y = nave_y + (active_y - nave_y) / 20

La actualización de la ventana tiene dos aspectos: El efecto inicial de aparición de los textos (líneas 87-104) y el efecto de movimiento de las naves para colocarse a los lados de la opción seleccionada (líneas 105-112).

Para la Intro, primeramente movemos hacia abajo el título hasta que llega a su posición definitiva (líneas 88-89). Una vez que esto se ha producido movemos las opciones hacia arriba por orden, hasta que una alcance su posición no comenzará a moverse la siguiente. Comenzamos el bucle en la línea 92 y empezamos calculando cual debe ser la coordenada y para la opción actual (línea 93). Si aún no hemos alcanzado dicha posición (línea 94) incrementamos la posición vertical en 20 pixels (línea 95). Si se trata de la opción seleccionada (línea 96) movemos también las naves para que se mantengan a su altura. Interrumpimos el bucle en la línea 100, de forma que hasta que terminemos de mover una opción no pasamos a las siguientes.

Empleamos una cláusula else en el bucle. Si repasas el funcionamiento de los bucles verás que la clausula else solamente se ejecuta si finalizamos el bucle normalmente, de modo que las líneas 103-104 solo se ejecutan una vez finalizado todo el proceso de introducción, cuando todos los textos han alcanzado su posición. En este momento desactivamos el marcador self.intro y fijamos la posición vertical del texto de confirmación en relación con la de la opción SALIR.

La segunda parte corresponde a la "persecución" de la opción seleccionada por parte de las naves. Empezamos obteniendo la posición vertical de estas (línea 106), y luego buscamos la posición en la que deberían estar. Si estamos solicitando confirmación para salir esta útima es la de self.confirmtext (línea 108), de otro modo es la de la opción seleccionada (línea 110).

Comparamos ambas posiciones y si son distintas reducimos la distancia en un veinteavo (línea 112). Al emplear una fracción de la diferencia obtenemos un movimiento que es más lento cuanto más nos acercamos al objetivo, y más rápido si estamos lejos.

Control del menú

# Selección mediante el teclado
def on_key_press(self, symbol, modifiers):
if symbol == arcade.key.ENTER and not self.intro:
self.opción[self.active]()
elif symbol == arcade.key.UP and self.active > 0:
self.selection("up")
elif symbol == arcade.key.DOWN and self.active < (len(self.opción) - 1):
self.selection("down")
if self.confirm:
if symbol == arcade.key.S:
self.window.close()
elif symbol == arcade.key.N or symbol == arcade.key.ESCAPE:
self.confirm = False

La gestión del teclado atañe a los cursores arriba y abajo para cambiar la selección, a para activar la opción seleccionada y si se requiere confirmación para terminar el programa la tecla S para aceptar y N o m para cancelar. Delegamos el cambio de selección y la activación en métodos adicionales para poder usarlos tanto con el teclado como con el ratón y el joystick.

# Selección mediante el raton (doble click)
def on_mouse_press(self, x, y, buttons, modifiers):
if buttons == 1:
t = time.time()
# Comprobamos el tiempo transcurrido desde el anterior click
if t - self.click > .2:
self.click = t
else:
if self.confirm and self.confirmtxt.collides_with_point((x,y)):
self.window.close()
else:
self.confirm = False
for i in range(len(self.menu)):
if i != 1 or not self.new:
if self.menu[i].collides_with_point((x, y)):
self.opción[i]()

Para activar una opción mediante el ratón empleamos un doble-click del botón izquierdo. La biblioteca arcade solo nos indica pulsaciones o liberaciones del botón del ratón, de modo que empleamos un temporizador para detectar una pulsación doble. En el constructor definimos una propiedad self.click (que inicializamos a cero) para este cometido. Obtenemos el tiempo en el momento de pulsar el ratón y comprobamos si han pasado más de 2 décimas de segundo desde la anterior pulsación (línea 133). En caso afirmativo nos limitamos a apuntar en la propiedad self.click el instante en que se ha producido la actual pulsación.

Si volvemos a pulsar antes de que transcurra el tiempo indicado la comparación de la línea 133 será negativa y ejecutaremos el bloque else. Si estamos solicitando confirmación de salida comprobamos si el doble click es sobre el mensaje de confirmación (línea 136) y en caso afirmativo cerramos la ventana. Si durante la solicitud de confirmación hacemos doble click sobre otro punto cancelamos esta. Si estamos en un estado normal, comprobamos si el click se ha producido sobre alguna opción del menú (evitando la opción CONTINUAR en caso de que se trate de una partida nueva). En caso de haber colisión entre el punto de pulsación y alguna opción del menú invocamos el método asociado a dicha opción.

def on_mouse_motion(self, x, y, dx, dy):
for i in range(len(self.menu)):
if i != 1 or not self.new:
if self.menu[i].collides_with_point((x, y)) and i != self.active:
self.deselect(self.active)
self.active = i
self.select(self.active)

Al mover el ratón realizamos una comparación similar entre la posición del ratón y las opciones del menú (evitando la de CONTINUAR si procede), y si el cursor pasa sobre una opción deseleccionamos la opción antigua y seleccionamos la nueva solamente en caso de que sean diferentes.

# Selección mediante el joystick
def on_joyaxis_motion(self, joy, axis, value):
if self.window.current_view == self.window.menu:
if axis == "y" and abs(value) > .2:
if self.confirm:
self.confirm = False
elif value < 0 and self.active > 0:
self.selection("up")
elif value > 0 and self.active < (len(self.opción) - 1):
self.selection("down")

Si existe un joystick o mando de juego producirá este evento al mover uno de los ejes. Pese a la sentencia self.joy.remove_handlers() de la línea 75 es posible que se dirijan eventos de joystick a la vista Menú aunque esté activa la vista del juego, de modo que en primer lugar comprobamos que el menú es la vista activa (línea 155)

En caso afirmativo comprobamos en la línea 156 que el movimiento es en el eje vertical y que es mayor de un umbral, como indicamos en la sección sobre entrada por joystick, para evitar que se active con la palanca en reposo.

Si todo esto es correcto comprobamos si estamos solicitando confirmación de salida, en cuyo caso mover el joystick significa denegarla. En caso contrario, empleamos el método auxiliar self.selection() del mismo modo en que lo hicimos con las teclas de cursor.

# Al pulsar el joystick activamos un marcador para evitar
# que detecte inmediatamente más pulsaciones
def on_joybutton_press(self, joy, button):
if self.window.current_view == self.window.menu:
if self.confirm and not self.joyclick:
self.window.close()
else:
self.opción[self.active]()
self.joyclick = True

# Liberamos el joystick para poder volver a pulsar
def on_joybutton_release(self, joy, button):
self.joyclick = False

La pulsación de cualquier botón del joystick o gamepad produce la ejecución de la opción de menú que se encuentre seleccionada (línea 171). Para el caso de la solicitud de confirmación comprobamos la propiedad self.joyclick que nos indica que hemos pulsado el mando y aún no hemos vuelto a levantar el botón. En caso contrario al seleccionar la opción SALIR se detectaría de nuevo el botón y se cerraría la ventana inmediatamente. Cada vez que pulsemos sobre el mando se activará el marcador self.joyclick, que solamente se volverá a desactivar al terminar la pulsación.

Métodos auxiliares del menú

En esta categoría tenemos dos grupos; aquellos métodos destinados a gestionar el movimiento a través de las opciones del menú y aquellos que corresponden a la ejecución de cada opción. Los primeros han sido añadidos para evitar tener que repetir el mismo código para los distintos dispositivos de entrada. Los segundos, además, para poder construir el menú a partir de un diccionario. Esto último no sería imprescindible, pero programando así podemos crear menús muy fáciles de actualizar o modificar.

def selection(self, direction):
if direction == "up" and self.active > 0:
self.deselect(self.active)
self.active -= 1
if self.new and self.active == 1:
self.active = 0
self.select(self.active)
elif direction == "down" and self.active < (len(self.opción) - 1):
self.deselect(self.active)
self.active += 1
if self.new and self.active == 1:
self.active = 2
self.select(self.active)

El método .selection() gestiona el movimiento arriba o abajo a través del menú. Comprobamos si estamos en el extremo del menú y si podemos incluir la opción CONTINUAR o saltamos sobre ella. Además nos encargamos de producir el efecto visual de "deselección" sobre la opción anterior y "selección" sobre la nueva-

def deselect(self, index):
self.menu[index].alpha = 100

def select(self, index):
self.menu[index].alpha = 255
arcade.play_sound(self.sound)

Estos métodos aplican un grado de transparencia parcial sobre las opciones no seleccionadas y de máxima opacidad sobre la seleccionada. Como el fondo es negro el efecto es que la opción seleccionada se ve más brillante. Además al seleccionar una opción producimos un sonido indicativo (El sonido es el que corresponde a la acción "Inicio de navegación de Windows").

def start(self):
self.new = False
self.deselect(self.active)
self.active = 1
self.select(self.active)
self.window.game.setup()
self.window.show_view(self.window.game)

def play(self):
self.window.show_view(self.window.game)

def exit(self):
# Pedimos confirmación para salir???
self.confirm = True

Para terminar con el menú, he aquí los métodos que llevan a cabo cada una de las opciones. La opción JUGAR desactiva el flag de nueva partida y marca como opción activa la de CONTINUAR. A continuación invoca el método .setup() de la vista de juego que reinicializa los valores de este para comenzar una nueva partida. Por último activa la vista del juego.

La opción CONTINUAR sencillamente restablece la vista del juego en el mismo estado en el que se encuentre.

La opción SALIR activa el flag self.confirm que hace que se muestre el mensaje de confirmación y que el control del menú responda a esta circunstancia.

Este es el aspecto del menú:

Menú de Marcianitos