Cómo programar juegos en Python con Pygame

En esta oportunidad vamos a programar una versión muy simple de Pong, el famoso videojuego creado por Atari en los 70’s, usando el lenguaje de programación Python. Este es un juego para dos jugadores en el cual cada uno controla una raqueta que permite golpear una pelota. El objetivo es golpear la pelota de tal forma que el rival no lo pueda hacer de vuelta, y de esta forma sumar puntos.

Para programar el juego vamos a usar una librería de Python muy popular llamada Pygame. Según el sitio web oficial: Pygame es una colección de módulos Python diseñados para crear videojuegos.

Pygame proporciona una API en Python que permite interactuar con la librería multimedia SDL. Pygame es una librería que puede ser utilizada en los principales sistemas operativos (Windows, Mac y Linux).

Instalando Pygame

Lo primero que debemos hacer es descargar la librería e instalarla en nuestra máquina. Existen varias formas de hacerlo pero la forma más sencilla es utilizando 13. De esta forma pip se encargará de resolver todas las dependencias. Para esto, ejecutamos el siguiente comando en una terminal:

# Para instalar Pygame globalmente en el sistema (Linux, Mac)
$ sudo pip install Pygame

Es recomendable utilizar virtualenv o virtualenvwrapper para instalar cualquier librería Python.

Los usuarios Windows pueden descargar el instalador desde la página oficial de Pygame.

Pygame página de descarga

Creando el proyecto

El código fuente explicado en este artículo se puede descargar desde Github o Bitbucket.

Creamos un directorio llamado pong-pygame/ para alojar el código fuente, los gráficos y las fuentes TrueType que vamos a necesitar. Inicialmente, vamos a crear un archivo llamado src/main.py de la siguiente forma:

pong-pygame/
  src/
    main.py

Por el momento, solo vamos a crear una ventana en blanco para comprobar que tenemos Pygame instalado correctamente. Para esto, abrimos el archivo main.py y escribimos el siguiente fragmento código:

import os, sys, pygame

def main():
    pygame.init()

    size = width, height = 800, 600
    screen = pygame.display.set_mode(size)
    pygame.display.set_caption('Pong Pygame')

    while 1:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
                return

if __name__ == '__main__':
    main()

En la primera línea de código, importamos las librerías necesarias para programar el videojuego. Enseguida, definimos la función main() y dentro de ella llamamos a la función init() de Pygame la cual se encarga de inicializar algunos módulos internos.

Luego definimos el tamaño de la ventana, que en este caso va a ser de 800 x 600 pixeles, para luego incluir la expresión pygame.display.set_mode(size) que se va a encargar de crear el canvas dónde vamos a dibujar los diferentes objetos del videojuego. La expresión pygame.display.set_caption('Pong Pygame') se utiliza para cambiar el título de la ventana.

A continuación, definimos un bucle infinito mediante la expresión while 1:, en el cual vamos a programar la lógica del juego. Por ahora solo estamos escuchando cada uno de eventos de la lista de eventos de Pygame mediante el bucle for event in pygame.event.get():.

En el instante en que el evento de terminación del programa aparezca (cuando el jugador cierre la ventana), Pygame se encargará de finalizar el juego de una manera apropiada mediante la expresión pygame.quit().

La expresión if __name__ == '__main__': main() obliga al interprete de Python a invocar a la función main() solamente cuando el archivo es ejecutado como un programa. Esto quiere decir que si por alguna razón queremos importar el código en otro archivo Python, la función main() no será invocada.

Para observar el resultado de la ejecución del código que acabamos de escribir, corremos el siguiente comando, en una terminal, desde el directorio raíz del proyecto:

$ python src/main.py

Podemos ver que aparece una ventana en blanco en la pantalla:

Ejecutando Pygame por primera vez

Definiendo el área de juego

El área de juego se compone de una imagen de fondo que puede ser descargada aquí. Colocamos la imagen en el directorio assets/graphics de esta forma:

pong-pygame/
  assets/
    graphics/
      background.png

Ahora, en el archivo main.py escribimos el siguiente fragmento de código justo antes del bucle infinito:

try:
    filename = os.path.join(
        os.path.dirname(__file__),
        'assets',
        'graphics',
        'background.png')
    background = pygame.image.load(filename)
    background = background.convert()
except pygame.error as e:
    print ('Cannot load image: ', filename)
    raise SystemExit(str(e))

El fragmento carga la imagen de fondo desde el disco duro mediante la expresión pygame.image.load(filename) usando un bloque try ... except para capturar cualquier error que pueda surgir. Si algún error es detectado, el programa mostrará un mensaje en la terminal y finalizará la ejecución del videojuego.

Inmediatamente despues de cargar la imagen, realizamos una conversión del formato de los pixeles, usando la función convert(), de tal forma que sea equivalente al formato del canvas del videojuego creado anteriormente.

Si no realizamos la conversión en este punto, la velocidad de ejecución del juego se verá reducida notablemente debido a que, de todas formas, se va a realizar más adelante en cada frame. Si el videojuego corre a 60 frames por segundo, dicha conversión se realizará 60 veces cada segundo.

Para finalizar, agregamos las siguientes líneas de código al bucle infinito:

screen.blit(background, (0, 0))
pygame.display.flip()

La expresión screen.blit(background, (0, 0)) copia los pixeles contenidos en la imagen de fondo sobre el canvas que hasta ahora estaba en blanco. Sin embargo, aún no podemos ver la imagen en la pantalla.

Esto es porque Pygame utiliza un sistema denominado double buffer. En el sistema double buffer, las entidades del videojuego se dibujan en un buffer mientras que el contenido del otro buffer es mostrado en pantalla.

Para poder mostrar las entidades que se acaban de dibujar, tenemos que hacer un cambio de buffers con la expresión pygame.display.flip(). El buffer que estaba en pantalla pasa a ser el que está disponible para dibujar y el que contiene las entidades dibujadas pasa a ser el que se muestra en pantalla.

De esta manera, al volver a ejecutar el programa, podemos ver la imagen de fondo en la pantalla:

Pong área de juego

Programando las raquetas

Ahora que ya tenemos lista el área de juego, vamos a programar las raquetas para ambos jugadores. Incluimos el siguiente fragmento de código justo antes de la definición de la expresión def main(self)::

class Pad(pygame.sprite.Sprite):
    def __init__(self, pos=(0, 0)):
        pygame.sprite.Sprite.__init__(self)
        self.image = pygame.Surface((12, 30)).convert()
        self.image.fill((255, 255, 255))
        self.rect = self.image.get_rect(center=pos)
        self.max_speed = 5
        self.speed = 0

    def move_up(self):
        self.speed = self.max_speed * -1

    def move_down(self):
        self.speed = self.max_speed * 1

    def stop(self):
        self.speed = 0

    def update(self):
        self.rect.move_ip(0, self.speed)

Cada una de las raquetas van a estar definidas mediante la clase Pad, la cual hereda los atributos de la clase sprite.Sprite de Pygame. Cada raqueta va a tener una imagen de tipo Surface, la cual es un rectángulo de color blanco; un objeto de tipo Rect del mismo tamaño de la imagen, muy útil a la hora de mover las raquetas o de calcular las colisiones; una velocidad máxima y la velocidad actual.

También se definen funciones que permiten mover la raqueta hacia arriba o hacia abajo (invirtiendo el signo de la velocidad actual) y detener la raqueta. Adicionalmente se define la función update(), la cual será invocada en cada frame, y permitirá actualizar la posición de la raqueta.

En la función main(), incluimos el siguiente fragmento de código justo antes del bucle infinito:

pad_left = Pad((width/6, height/4))
pad_right = Pad((5*width/6, 3*height/4))

sprites = pygame.sprite.Group(pad_left, pad_right)

clock = pygame.time.Clock()
fps = 60

pygame.key.set_repeat(1, 1000/fps)

El fragmento posiciona cada una de las raquetas en la pantalla, usando la clase Pad que acabamos de definir. Enseguida, agrupamos las dos raquetas usando la clase sprite.Group de Pygame.

La ventaja de agrupar los sprites, que en este caso corresponden a las raquetas, es que podemos delegar la responsabilidad de actualizar y dibujar los sprites a la clase sprite.Group, sin tener que preocuparnos de hacerlo manualmente nosotros mismos. Esto también hace que el código sea más breve y por consiguiente más fácil de entender.

Posteriormente, definimos un reloj que nos va a permitir indicarle a Pygame a cuantos frames por segundo queremos que se ejecute el videojuego. En nuestro caso, el juego se ejecutará a 60 frames por segundo.

Por último, mediante la expresión pygame.key.set_repeat(1, 1000/fps), le indicamos a Pygame que queremos que continúe registrando eventos del teclado mientras mantenemos presionada una determinada tecla. Sin esta expresión, tendríamos que presionar las teclas múltiples veces para poder mover las raquetas.

Para finalizar con la programación de las raquetas, modificamos el bucle infinito de la siguiente forma:

while 1:
    clock.tick(fps)

    pad_left.stop()
    pad_right.stop()

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            return
        elif event.type == pygame.KEYDOWN and event.key == pygame.K_w:
            pad_left.move_up()
        elif event.type == pygame.KEYDOWN and event.key == pygame.K_s:
            pad_left.move_down()
        elif event.type == pygame.KEYDOWN and event.key == pygame.K_UP:
            pad_right.move_up()
        elif event.type == pygame.KEYDOWN and event.key == pygame.K_DOWN:
            pad_right.move_down()

    sprites.update()

    screen.blit(background, (0, 0))
    sprites.draw(screen)
    pygame.display.flip()

En la primera línea del bucle le indicamos a Pygame que ejecute el juego a 60 fps, usando para esto la variable clock que acabamos de definir. Luego, hacemos que las raquetas se detengan, usando para esto la función stop() de la clase Pad.

A continuación, llamamos a las funciones move_up() y move_down() de cada raqueta para moverlas dependiendo de las teclas que estemos presionando. Si el jugador presiona las teclas w o s, estará moviendo la raqueta de la izquierda. Si el jugador presiona las teclas ARRIBA o ABAJO, estará moviendo la raqueta de la derecha. De esta forma, dos jugadores pueden jugar en la misma maquina.

Enseguida, le ordenamos al grupo de sprites que actualice y dibuje en pantalla todos los sprites mediante las instrucciones sprites.update() y sprites.draw(screen).

Si ejecutamos el videojuego en este punto, podemos ver las raquetas en la pantalla y además será posible moverlas:

Pong raquetas

Creando la pelota

Para crear la pelota, vamos a definir una clase llamada Ball. Para esto, incluimos el siguiente fragmento de código justo después de la clase Pad:

class Ball(pygame.sprite.Sprite):
    def __init__(self, pos=(0, 0)):
        pygame.sprite.Sprite.__init__(self)
        self.image = pygame.Surface((10, 10)).convert()
        self.image.fill((255, 255, 255))
        self.rect = self.image.get_rect(center=pos)
        self.speed_x = 0
        self.speed_y = 0

    def change_y(self):
        self.speed_y *= -1

    def change_x(self):
        self.speed_x *= -1

    def start(self, speed_x, speed_y):
        self.speed_x = speed_x
        self.speed_y = speed_y

    def stop(self):
        self.speed_x = 0
        self.speed_y = 0

    def update(self):
        self.rect.move_ip(self.speed_x, self.speed_y)

Como en el caso de la clase Pad, la clase Ball también hereda los atributos de la clase sprite.Sprite de Pygame. La pelota se compone de una imagen (un cuadrado de color blanco) y un objeto de tipo Rect. Adicionalmente, se definen variables para la velocidad en el eje X y en el eje Y.

En el sistema de coordenadas que utiliza Pygame, el punto (0, 0) se encuentra ubicado en la esquina superior izquierda de la ventana del videojuego. Las coordenadas en el eje X se incrementan hacia la derecha. Las coordenadas en el eje Y se incrementan hacia abajo.

También se definen algunas funciones que permiten mover y detener la pelota, como es el caso de las funciones start() y stop(); así como cambiar la dirección del movimiento de la pelota, como es el caso de las funciones change_y() y change_x(). La función update() se encargará de actualizar la posición de la pelota teniendo en cuenta la velocidad en cada eje.

A continuación ubicamos la pelota justo en el centro de la pantalla y además la agregamos al grupo de sprites, junto con las raquetas:

ball = Ball((width/2, height/2))
sprites = pygame.sprite.Group(pad_left, pad_right, ball)

Ahora debemos limitar el movimiento de la pelota de tal forma que no desaparezca cuando alcance el borde superior o inferior de la pantalla. Para esto definimos un rectángulo en la parte superior y uno en la parte inferior.

top = pygame.Rect(0, 0, width, 5)
bottom = pygame.Rect(0, height-5, width, 5)

El anterior fragmento debe ubicarse justo antes del bucle infinito.

Los rectángulos van a ser igual de anchos que la pantalla y de 5 pixeles de alto. Serán invisibles. Más adelante vamos a usarlos para calcular las colisiones y hacer que la pelota rebote.

Para imprimirle movimiento a la pelota, que hasta ahora ha permanecido estática en el centro de la pantalla, incluimos el siguiente fragmento de código dentro del bucle utilizado para detectar los eventos del teclado:

for event in pygame.event.get():
    ...
    elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
        ball.start(randint(1, 3), randint(1, 3))

Invocamos la función start() de la clase Ball y le asignamos una velocidad aleatoria tanto en el eje X como en el eje Y. Para que el fragmento funcione apropiadamente, debemos incluir la siguiente línea de código al inicio del archivo para importar la librería random de Python:

from random import randint

De esta forma, cuando el jugador presione la tecla ESPACIO, la pelota comenzará a moverse.

El siguiente fragmento de código se va a encargar de detectar las colisiones entre la pelota y los rectángulos superior e inferior que creamos con anterioridad. Incluimos el código justo después de la sección que detecta los eventos del teclado, dentro del bucle infinito:

if ball.rect.colliderect(top) or ball.rect.colliderect(bottom):
    ball.change_y()
elif (ball.rect.colliderect(pad_left.rect) or
        ball.rect.colliderect(pad_right.rect)):
    ball.change_x()

Cuando la pelota haga contacto con cualquiera de los rectángulos, simplemente vamos a indicarle a Pygame que invierta la dirección del movimiento en el eje Y mediante la función change_y(). Adicionalmente, el fragmento anterior incluye la detección de colisiones de la pelota con las raquetas. En este último caso, se invierte la dirección del movimiento en el eje X mediante la función change_x().

Se debe que anotar que esta detección de colisiones es bastante simple y no cubre algunos casos extremos apropiadamente. El lector notará que cuando la pelota golpea la parte superior o inferior del rectángulo que define las raquetas, la pelota se moverá de una manera extraña e impredecible.

Finalmente, debemos limitar el movimiento de las raquetas. En este punto, si movemos las raquetas hacia arriba o hacia abajo lo suficiente, notaremos que desaparecerán de la pantalla. Para remediar este inconveniente, vamos a escribir las siguientes líneas de código dentro del bucle infinito, justo después del fragmento anterior:

screen_rect = screen.get_rect().inflate(0, -10)
pad_left.rect.clamp_ip(screen_rect)
pad_right.rect.clamp_ip(screen_rect)

El fragmento crea un rectángulo a partir del objeto screen que define la ventana del videojuego. Este rectángulo es 10 pixeles mas corto en altura que la ventana. Limitamos el movimiento de las raquetas dentro de los confines del rectángulo que acabamos de crear, usando para esto la función clamp_ip() de la clase Rect de Pygame.

Si ejecutamos el videojuego y presionamos la tecla ESPACIO, podemos ver que la pelota comenzará a moverse y las raquetas, por su parte, se mantendrán al interior de la ventana:

Pong pelota

Implementando el marcador

La última tarea que queda pendiente es implementar el marcador. El siguiente fragmento de código define la clase Score que vamos a utilizar para crear y actualizar el puntaje de cada uno de los jugadores. Lo incluimos justo después de la clase Ball.

class Score(pygame.sprite.Sprite):
    def __init__(self, font, pos=(0, 0)):
        pygame.sprite.Sprite.__init__(self)
        self.font = font
        self.pos = pos
        self.score = 0
        self.image = self.font.render(str(self.score), 0, (255, 255, 255))
        self.rect = self.image.get_rect(center=self.pos)

    def score_up(self):
        self.score += 1

    def update(self):
        self.image = self.font.render(str(self.score), 0, (255, 255, 255))
        self.rect = self.image.get_rect(center=self.pos)

Esta clase, al igual que Pad y Ball, también hereda los atributos de la clase sprite.Sprite de Pygame. Para mostrar el total de puntos, vamos a utilizar una fuente TrueType para dibujar los números en la pantalla.

La clase define una función score_up(), la cual se encarga de ir sumando puntos al marcador total, y define también la función update(), para que actualice la imagen y el rectángulo, mostrando en la pantalla el contenido de la variable self.score, es decir el puntaje.

Ahora vamos a cargar el archivo fuente, el cual se puede descargar aquí y debe colocarse en el directorio assets/ de la siguiente forma:

pong-pygame/
  assets/
    fonts/
      wendy.ttf

A continuación, incluimos el siguiente fragmento de código responsable de cargar el archivo fuente desde el disco duro:

if not pygame.font:
    raise SystemExit('Pygame does not support fonts')

try:
    filename = os.path.join(
        os.path.dirname(__file__),
        'assets',
        'fonts',
        'wendy.ttf')
    font = pygame.font.Font(filename, 90)
except pygame.error as e:
    print ('Cannot load font: ', filename)
    raise SystemExit(str(e))

left_score = Score(font, (width/3, height/8))
right_score = Score(font, (2*width/3, height/8))

Si por alguna razón estamos utilizando una versión de Pygame que no soporta archivos fuente, el programa terminará y mostrará el mensaje de error Pygame does not support fonts. Finalmente, cargamos el archivo fuente y ubicamos los puntajes en la pantalla usando la clase Score que acabamos de definir.

Agregamos los puntajes al grupo de sprites, de la misma forma como hicimos con las raquetas y la pelota.

sprites = pygame.sprite.Group(
    pad_left, pad_right, ball, left_score, right_score)

Ahora debemos incrementar el puntaje cada vez que el jugador oponente permita que la pelota salga de la pantalla. Para esto creamos dos rectángulos, uno en el borde izquierdo y el otro en el borde derecho de la pantalla. Estos rectángulos serán igual de altos que la pantalla y tendrán 5 pixeles de ancho pero serán invisibles.

left = pygame.Rect(0, 0, 5, height)
right = pygame.Rect(width-5, 0, 5, height)

Cada vez que la pelota haga contacto con estos rectángulos, le indicamos a Pygame que incremente el puntaje del jugador correspondiente en un punto, restablezca la posición de la pelota en el centro de la pantalla y que mantenga la pelota inmóvil. Todo esto es realizado por el siguiente fragmento de código. Debemos incluirlo dentro del bucle infinito justo antes de la expresión sprites.update():

if ball.rect.colliderect(left):
    right_score.score_up()
    ball.reset()
    ball.stop()
elif ball.rect.colliderect(right):
    left_score.score_up()
    ball.reset()
    ball.stop()

Para que el fragmento anterior funcione correctamente, debemos hacer unas pequeñas modificaciones a la clase Ball. Lo primero es agregar la siguiente linea en la función __init__() justo después de la expresión pygame.sprite.Sprite.__init__(self):

self.pos = pos

Esto nos permitirá usar la variable pos en las demás funciones de la clase Ball.

Luego, modificamos la línea que define el objeto de tipo Rect, también en la función __init__(), de la siguiente forma:

self.rect = self.image.get_rect(center=self.pos)

Por último, agregamos la función reset() que será la encargada de restablecer la posición de la pelota en el centro de la pantalla:

def reset(self):
    self.rect = self.image.get_rect(center=self.pos)

Si ejecutamos el programa, podemos ver los puntajes en la parte superior de la pantalla. Es más, para deleite nuestro, ¡ya será posible jugar al pong con cualquiera de nuestros familiares o amigos!

Jugando pong