Cómo programar juegos en Python con Pygame

Rock Chess

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 pip. 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 invocar la función pygame.display.set_mode() que se va a encargar de crear el canvas dónde vamos a dibujar los diferentes objetos del videojuego. La función pygame.display.set_caption() se utiliza para cambiar el título de la ventana.

A continuación, definimos un bucle infinito mediante la expresión while 1:, dentro del 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.

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__': 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, ejecutemos el siguiente comando en una terminal, desde el directorio raíz del proyecto:

$ cd pong-pygame/
$ 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))

# Bucle infinito
while 1:
    ...

El fragmento carga la imagen de fondo desde el disco duro mediante la función pygame.image.load() 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 después 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 dicha conversión se va a realizar de todas formas al finalizar la ejecución de 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 final del bucle infinito:

# Bucle infinito
while 1:
    ...
    screen.blit(background, (0, 0))
    pygame.display.flip()

La función screen.blit() 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 ventana.

Esto es porque Pygame utiliza un sistema denominado double buffer. En este sistema, 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 ventana:

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 función main():

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)

# Función main()
def main():
    ...

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á ejecutada 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)

# Bucle infinito
while 1:
    ...

El fragmento posiciona cada una de las raquetas en la ventana, 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 radica en el hecho de poder 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 conciso y fácil de entender.

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

Por último, mediante la función pygame.key.set_repeat(), le indicamos a Pygame 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 por completo 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 siguiente frame, 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 funciones sprites.update() y sprites.draw().

Si ejecutamos el videojuego en este punto, podemos ver las raquetas en la ventana 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 antes de la definición de la función main():

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)

# Función main()
def main():
    ...

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.

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.

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 posicionamos la pelota justo en el centro de la ventana y además la agregamos al grupo de sprites junto con las raquetas, realizando la siguiente modificación:

# Función main()
def main():
    ...
    pad_right = Pad((5*width/6, 3*height/4))
    # Insertamos esta línea aquí
    ball = Ball((width/2, height/2))

    # Modificamos esta línea así
    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 ventana. Para esto definimos un rectángulo en la parte superior y uno en la parte inferior de la misma. El fragmento debe ubicarse justo antes del bucle infinito:

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

# Bucle infinito
while 1:
    ...

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

Para imprimirle movimiento a la pelota, que hasta ahora ha permanecido estática en el centro de la ventana, incluimos el siguiente fragmento de código al final 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 del bucle que detecta los eventos del teclado:

for event in pygame.event.get():
    ...

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, detectado por las instrucciones ball.rect.colliderect(), 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. El lector notará que si las raquetas se mueven hacia arriba o hacia abajo lo suficiente, desaparecerán de la ventana. Para remediar este inconveniente, vamos a escribir las siguientes líneas de código dentro del bucle infinito, justo después del fragmento anterior:

# Bucle infinito
while 1:
    ...
    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 tiene 10 pixeles menos 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 se mantendrán en el área visible 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 antes de la función main():

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)

# Función main()
def main():
    ...

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 un archivo de fuentes TrueType para dibujar los números en la ventana.

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 ventana el contenido de la variable self.score, es decir el puntaje.

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

pong-pygame/
  assets/
    fonts/
      wendy.ttf

Realizamos la siguiente modificación en la función main() para incluir el código responsable de cargar el archivo de fuentes desde el disco duro:

# Función main()
def main():
    ...
    ball = Ball((width/2, height/2))

    # Incluimos el fragmento aquí
    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))

    # Modificamos esta línea así
    sprites = pygame.sprite.Group(
        pad_left, pad_right, ball, left_score, right_score)

El condicional if not revisa si Pygame soporta archivos de fuentes. En caso negativo, el programa terminará y mostrará un mensaje de error.

El bloque try...except carga el archivo de fuentes y a continuación se posicionan los puntajes en la ventana usando la clase Score que acabamos de definir. Finalmente, agregamos los puntajes al grupo de sprites, de la misma forma como se hizo con las raquetas y la pelota.

Ahora debemos incrementar el puntaje cada vez que el jugador oponente permita que la pelota salga de la ventana. Para esto creamos dos rectángulos, uno en el borde izquierdo y el otro en el borde derecho de la ventana. Estos rectángulos serán igual de altos que la ventana 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)

# Bucle infinito
while 1:
    ...

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 ventana y que mantenga la pelota inmóvil.

Lo anterior es realizado por el siguiente fragmento de código. Debemos incluirlo dentro del bucle infinito justo antes de la invocación a la función sprites.update():

# Bucle infinito
while 1:
    ...

    # Insertamos el fragmento...
    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()

    # ...justo antes de esta línea
    sprites.update()

Para que el fragmento anterior funcione correctamente, debemos hacer unas pequeñas modificaciones a la clase Ball:

class Ball(pygame.sprite.Sprite):
    def __init__(self, pos=(0, 0)):
        pygame.sprite.Sprite.__init__(self)
        # Incluimos esta línea
        self.pos = pos
        self.image = pygame.Surface((10, 10)).convert()
        self.image.fill((255, 255, 255))
        # Modificamos esta línea
        self.rect = self.image.get_rect(center=self.pos)
        self.speed_x = 0
        self.speed_y = 0

    # Agregamos esta función
    def reset(self):
        self.rect = self.image.get_rect(center=self.pos)

    ...

Lo primero es agregar la variable self.pos a la función __init__(). 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__().

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

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

Jugando pong