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 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.
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:
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:
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:
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:
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!