Movimiento y colisiones en Pygame

Gran parte de los videojuegos se basan en un funcionamiento básico en el que un objeto interactúa con otro en la pantalla. En esos videojuegos, la gracia está en evitar a otros objetos, o por el contrario en buscar esos otros objetos. Piensa en Pac-Man, o el comecocos de toda la vida: el protagonista debe comer todas las fichas de un laberinto, mientras los fantasmas le persiguen. Las paredes no se pueden atravesar, y si los fantasmas tocan a nuestro héroe, éste muere. Pero sin embargo, si el comecocos se come una ficha especial, la mecánica es la inversa: se puede comer a los fantasmas enviándolos a una especie de cárcel.

De esta forma, tenemos:

  • Varios objetos moviéndose: un comecocos y cuatro fantasmas.
  • Un objeto especial (el comecocos) debe ir colisionando con cuantos objetos inmóviles pueda (fichas), pero no puede atravesar los límites que definen el laberinto (las paredes).
  • Si los fantasmas chocan con el comecocos, en función del estado de este último, ocurren eventos especiales (muerte del comecocos o captura del fantasma).

Todo en este tipo de juegos se basa en dos mecanismos: el movimiento y la detección de colisiones. Vamos a examinar el funcionamiento de estos dos mecanismos con Pygame.

Necesitarás haber leído…

Conviene también que repases la serie de creación de un videojuego con Scratch, para que puedas comparar conceptos y técnicas.

Planteamiento

Lo que vamos a hacer en este artículo es:

  1. Inicializar correctamente Pygame y definir una ventana para trabajar
  2. Dibujar dos objetos cuadrados, de diferente color. Usaremos los recursos de dibujo de Pygame, es decir: no vamos a cargar imágenes por esta vez
  3. Moveremos un cuadrado contra otro
  4. Detectaremos su colisión
  5. Cerraremos Pygame de forma limpia, tanto un tiempo después de que el primer objeto colisione con el segundo, como si el usuario hace click en el aspa de la ventana

Lo haremos con orientación a objetos, es decir: cada uno de los objetos cuadrados será una instancia de una clase en la cual definiremos su comportamiento.

Condiciones iniciales

Vamos a definir una ventana de 640×480 píxels y fondo negro. Sobre ella dibujaremos dos cuadrados, uno blanco y uno magenta. El blanco lo moveremos hacia el magenta procurando que vayan a colisionar, a una velocidad de 60 píxels por segundo, es decir, 1 píxel en cada cuadro, si el refresco de la ventana es de 60 cuadros por segundo.

La parte inicial de nuestro programa, donde definimos todos esos valores, es la siguiente:

import pygame

BLANCO = (255,255,255)
NEGRO = (0, 0, 0)
MAGENTA = (255,0,255)
FPS = 60

# inicializamos pygame
pygame.init()

#definición de la pantalla
pantalla = pygame.display.set_mode((640,480))
pantalla.fill(NEGRO)

Todavía no podemos colocar los cuadrados en la pantalla, porque no tenemos la clase Cuadrado  definida.

Clase Cuadrado

Vamos a crear la plantilla o el modelo de un cuadrado para poder crear cuantas instancias queramos. Definiremos un cuadrado móvil de la siguiente forma:

  • Tendrá un color de relleno
  • Sabrá cual es el color de fondo de la pantalla
  • Una posición inicial, formada por un par de coordenadas horizontales (x) y verticales (y).
  • Sabrá si está colisionando con otro Cuadrado que le señalaremos, es decir, podremos preguntarle al cuadrado “¿estás colisionando con este otro cuadrado?
  • Podrá dibujarse a sí mismo en la pantalla, y borrarse de ella

Recuerda: definir una clase implica definir sus datos y su comportamiento. Empecemos por los datos:

class Cuadrado:
    def __init__(self, x, y, lado, color, fondo):
        self.x = x
        self.y = y
        self.color = color
        self.fondo = fondo
        self.lado = lado

En este momento tenemos que resolver tres comportamientos: el comportamiento gráfico para pintarse a sí mismo y para borrarse, el comportamiento móvil y el que nos permite saber si está en estado de colisión con otro cuadrado. Para eso vamos a introducir el objeto pygame.Rect  y el módulo pygame.draw .

Recursos de Pygame para el comportamiento del cuadrado

El objeto pygame.Rect  es un rectángulo con ciertos métodos asociados, como es precisamente el de moverse y el de las operaciones gráficas más comunes entre formas geométricas: unión, intersección,…

La parte que nos interesa a nosotros es la del cálculo de la colisión, y la del movimiento. Para eso usaremos lo siguiente: asumamos que rect es un objeto de la clase pygame.Rect, es decir, que hemos hecho rect = pygame.Rect(...) con los argumentos que fueran necesarios. Lo mismo si veis un  rect2.

  • rect.colliderect(rect2)  devuelve True  si los dos rectángulos rect  y rect2  están en un estado de colisión, es decir, si sus áreas coinciden (se solapan) en, al menos, un píxel.
  • rect.move(avance_x, avance_y)  devuelve una copia del rectángulo desplazada lo especificado en los avances que le pasamos como argumento. El movimiento tiene lugar en un cuadro, es decir, en un sólo refresco de la pantalla. Si escribimos rect.move_ip(avance_x, avance_y)  es el propio rectángulo  rect el que se mueve, es decir: no se crea copia alguna sino que el mismo rectángulo sobre el que se invoca el método move_ip se desplaza. _ip es un sufijo que significa in place (en su lugar) y que indica esto mismo.

El módulo pygame.draw  permite dibujar muchas formas y líneas, entre las cuales se encuentra el rectángulo. La función pygame.draw.rect(pantalla, color, rect, grosor)  dibuja un rectángulo como sigue:

  • Hay que proporcionarle una superficie sobre la cual dibujar, esto es, un objeto de clase Surface, como es la pantalla de Pygame que hemos configurado.
  • Se le proporciona un color en forma de tupla (tuple de Python) para el objeto a dibujar. En dicha tupla especificamos los valores de rojo, verde y azul que codifican el color RGB del cuadrado. Los colores que hemos definido ya los hemos definido en formato de tupla, son objetos tuple de Python: NEGRO = (0, 0, 0)
  • El tercer parámetro, rect , es el objeto de clase pygame.Rect que será dibujado
  • Por último, el cuarto parámetro es el grosor, en píxels, de las líneas que forman el cuadrado. Si se le proporciona el valor 0, el cuadrado saldrá relleno del color que le hayamos indicado.

Vamos, pues, a escribir la clase Cuadrado

Código Python de la clase Cuadrado

Aquí va mi propuesta:

class Cuadrado:
    def __init__(self, x, y, lado, color, fondo):
        self.x = x
        self.y = y
        self.color = color
        self.fondo = fondo
        self.lado = lado
        self.rect = pygame.Rect(self.x,
                                self.y,
                                self.lado,
                                self.lado)

    def __pinta(self, pantalla, color):
        'Realiza el dibujo efectivo'
        pygame.draw.rect(pantalla, color, self.rect, 0)
        
    def pinta(self, pantalla):
        'Pinta el cuadrado con el color propio'
        self.__pinta(pantalla, self.color)

    def borra(self, pantalla):
        'Borra el cuadrado'
        # Realmente lo pinta con el color de fondo
        self.__pinta(pantalla, self.fondo)

    def colisiona_con(self, cuadrado2):
        'Comprueba la colisión con otro cuadrado'
        return self.rect.colliderect(cuadrado2.rect)

    def mover(self, avance_x, avance_y):
        'avance del cuadrado en un cuadro (frame)'
        self.rect.move_ip(avance_x, avance_y)

Como veis, he añadido la creación de un objeto de tipo pygame.Rect  en el constructor, pasándole la posición inicial y la dimensión de su lado. Después he implementado su comportamiento haciendo uso del objeto rect.

El método __pinta  está oculto para el programador, de forma que si intentáseis invocarla directamente, el intérprete os devolvería un error. Sólo se puede usar desde dentro del propio objeto, como en los métodos pinta y borra: es una función privada gracias al prefijo “__”, esto es, dos caracteres de subrayado (aunque realmente no está muy bien protegida, pero eso es otro tema).

Puedes probarlo en IDLE (no te hará falta ni siquiera definir una pantalla, aunque sí importar Pygame) como verás en la imagen siguiente:

Los miembros de una clase, si empiezan por __, están ocultos (o son privados)
Los miembros de una clase, si empiezan por __, están ocultos (o son privados)

Incluye el código e tu clase Cuadrado en tu programa, tras el import pygame  pero antes de la línea # inicializamos pygame

Bucle del programa

Lo que vamos a hacer ahora es el propio programa en sí:

  • Crearemos los dos objetos, alineados (para que haya colisión)
  • Los pintaremos en la pantalla
  • Moveremos uno de ellos hacia el otro hasta que colisionen. Para esto hay que tener en cuenta que no basta con mover el objeto de tipo pygame.Rect, porque no tiene una pantalla asociada: fíjate que cuando invocas a su constructor no le puedes proporcionar como parámetro una referencia a un objeto de tipo Surface (como es la ventana que hace de pantalla). Por lo tanto, tú tendrás que realizar esa relación entre objeto y pantalla. Así pues, mover un objeto implica, además, reflejar dicho movimiento en la pantalla:
    • Borrarlo de la pantalla en su posición actual
    • Moverlo a una nueva posición
    • Pintarlo en la pantalla, en su nueva posición
  • Actualizaremos la pantalla
  • Si hay colisión, escribiremos un mensaje por la consola y saldremos del programa
  • Si el usuario hace click en el botón en forma de aspa de la ventana de Pygame, saldremos del programa

Usaremos muchos de los recursos que hemos aprendido en el primer ejemplo de prueba de Pygame, que salió en el blog hace unas semanas, como es la detección de eventos en la ventana del programa, el uso del reloj de Pygame para controlar el refresco de la pantalla y las funcionalidades de refresco (o acualización de la misma).

Aquí tenéis mi propuesta, que iría al final del fichero:

cuadrado = Cuadrado(50,50, 35, BLANCO, NEGRO)
cuadrado2 = Cuadrado(540,50,35, MAGENTA, NEGRO)
cuadrado.pinta(pantalla)
cuadrado2.pinta(pantalla)
pygame.display.update()

# reloj de control de refresco
clock = pygame.time.Clock()

while not cuadrado.colisiona_con(cuadrado2):
    # pausa hasta el siguiente "tick" de reloj
    clock.tick(FPS)

    # detección de evento QUIT (aspa)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()

    # borrar el cuadrado de la pantalla
    cuadrado.borra(pantalla)
    cuadrado.mover(1, 0)
    cuadrado.pinta(pantalla)
    pygame.display.update()
    
print("Hay solape")
    
pygame.time.delay(3000)
pygame.quit()

Como veis, el código del programa, que en sucesivos artículos iremos llamando “juego”, queda sencillísimo cuando trabajamos con Python orientado a objetos: fijaos por ejemplo en la condición del bucle.

El código completo está a continuación.

import pygame

BLANCO = (255,255,255)
NEGRO = (0, 0, 0)
MAGENTA = (255,0,255)
FPS = 60

class Cuadrado:
    def __init__(self, x, y, lado, color, fondo):
        self.x = x
        self.y = y
        self.color = color
        self.fondo = fondo
        self.lado = lado
        self.rect = pygame.Rect(self.x,
                                self.y,
                                self.lado,
                                self.lado)

    def __pinta(self, pantalla, color):
        'Realiza el dibujo efectivo'
        pygame.draw.rect(pantalla, color, self.rect, 0)
        
    def pinta(self, pantalla):
        'Pinta el cuadrado con el color propio'
        self.__pinta(pantalla, self.color)

    def borra(self, pantalla):
        'Borra el cuadrado'
        # Realmente lo pinta con el color de fondo
        self.__pinta(pantalla, self.fondo)

    def colisiona_con(self, cuadrado2):
        'Comprueba la colisión con otro cuadrado'
        return self.rect.colliderect(cuadrado2.rect)

    def mover(self, avance_x, avance_y):
        'avance del cuadrado en un cuadro (frame)'
        self.rect.move_ip(avance_x, avance_y)
    


# inicializamos pygame
pygame.init()

# definición de la pantalla
pantalla = pygame.display.set_mode((640,480))
pantalla.fill(NEGRO)

cuadrado = Cuadrado(50,50, 35, BLANCO, NEGRO)
cuadrado2 = Cuadrado(540,50,35, MAGENTA, NEGRO)
cuadrado.pinta(pantalla)
cuadrado2.pinta(pantalla)
pygame.display.update()

# reloj de control de refresco
clock = pygame.time.Clock()

while not cuadrado.colisiona_con(cuadrado2):
    # pausa hasta el siguiente "tick" de reloj
    clock.tick(FPS)

    # detección de evento QUIT (aspa)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()

    # borrar el cuadrado de la pantalla
    cuadrado.borra(pantalla)
    cuadrado.mover(1, 0)
    cuadrado.pinta(pantalla)
    pygame.display.update()
    
print("Hay solape")
    
pygame.time.delay(3000)
pygame.quit()

Pulsa F5 desde el editor del IDLE, y verás que aparecen dos cuadrados y uno se mueve hasta colisionar con el otro. Cuando esto ocurre, el programa imprime un mensaje en la ventana del intérprete y, a los 3 segundos, termina:

También puedes probar a parar la ejecución con el botón en forma de aspa.

Optimización: actualización selectiva de la pantalla

En Python es muy frecuente encontrar juegos escritos con Pygame en los que la pantalla parpadea y el refresco es lento. Esto suele pasar porque los programadores no seleccionan cuidadosamente las zonas de la pantalla que deben ser actualizadas para optimizar el comportamiento de su juego: no es lo mismo actualizar dos zonas de algo más de 35 píxels de lado que toda una pantalla de 640 por 480 píxels.

La clave está en el uso de la función pygame.display.update(), la cual admite una lista de zonas rectangulares pygame.Rect para actualizar. El siguiente código que te propongo a continuación sólo actualiza la unión de los rectángulos correspondientes a la posición inicial del cuadrado blanco y la final, es decir, el trocito de pantalla que se ha visto afectado por el movimiento. Las líneas relevantes están resaltadas.

import pygame

BLANCO = (255,255,255)
NEGRO = (0, 0, 0)
MAGENTA = (255,0,255)
FPS = 60

class Cuadrado:
    def __init__(self, x, y, lado, color, fondo):
        self.x = x
        self.y = y
        self.color = color
        self.fondo = fondo
        self.lado = lado
        self.rect = pygame.Rect(self.x,
                                self.y,
                                self.lado,
                                self.lado)

    def __pinta(self, pantalla, color):
        'Realiza el dibujo efectivo'
        pygame.draw.rect(pantalla, color, self.rect, 0)
        
    def pinta(self, pantalla):
        'Pinta el cuadrado con el color propio'
        self.__pinta(pantalla, self.color)

    def borra(self, pantalla):
        'Borra el cuadrado'
        # Realmente lo pinta con el color de fondo
        self.__pinta(pantalla, self.fondo)

    def colisiona_con(self, cuadrado2):
        'Comprueba la colisión con otro cuadrado'
        return self.rect.colliderect(cuadrado2.rect)

    def mover(self, avance_x, avance_y):
        'avance del cuadrado en un cuadro (frame)'
        self.rect.move_ip(avance_x, avance_y)
    


# inicializamos pygame
pygame.init()

# definición de la pantalla
pantalla = pygame.display.set_mode((640,480))
pantalla.fill(NEGRO)

cuadrado = Cuadrado(50,50, 35, BLANCO, NEGRO)
cuadrado2 = Cuadrado(540,50,35, MAGENTA, NEGRO)
cuadrado.pinta(pantalla)
cuadrado2.pinta(pantalla)
pygame.display.update()

# reloj de control de refresco
clock = pygame.time.Clock()

while not cuadrado.colisiona_con(cuadrado2):
    # pausa hasta el siguiente "tick" de reloj
    clock.tick(FPS)

    # detección de evento QUIT (aspa)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()

    # borrar el cuadrado de la pantalla
    cuadrado.borra(pantalla)
    rect_inicial = cuadrado.rect.copy()
    cuadrado.mover(1, 0)
    rect_final = cuadrado.rect.copy()
    cuadrado.pinta(pantalla)
    pygame.display.update(rect_final.union(rect_inicial))
    
print("Hay solape")
    
pygame.time.delay(3000)
pygame.quit()

Lo que he hecho ahí ha sido sacar copias del rectángulo para calcular la unión. Si no entiendes por qué hay que sacar copias, prueba a quitar el .copy()  de las líneas 70 y 72 🙂 En breve recapitularé novedades de programación orientada a objetos para complementar la entrada de la semana pasada.

Conclusiones: algo que te llevas

Mover un cuadrado por un escenario y calcular la colisión con otro puede parecer algo trivial, pero es extremadamente útil.

Una técnica muy ocurrente y efectiva para calcular colisiones entre personajes representados mediante imágenes es construir un objeto consistente en un rectángulo ligeramente más pequeño que la imagen y usar dicho rectángulo para representar al héroe del juego en lo que a impactos y colisiones se refiere.

De esa forma, el juego representa a un comecocos moviéndose por un escenario, pero a efectos prácticos mueve también un rectángulo más pequeño que el comecocos y en su misma posición. De la misma manera, los fantasmas se mueven por partida doble: la imagen del fantasma y su rectángulo de impactos asociado.

Un comecocos con un rectángulo asociado para cálculo de colisiones
Un comecocos con un rectángulo asociado para cálculo de colisiones

Si reducimos el detectar la colisión entre el comecocos y un fantasma a detectar la colisión entre sus rectángulos asociados, programar el juego será mucho más sencillo y menos costoso computacionalmente que detectar el solapamiento entre píxels de color diferente al del fondo del juego (píxels que se suelen llamar píxels “opacos”). Imaginaos esto con 4 fantasmas, o imaginaos por un momento un juego de disparar a cientos de marcianitos esquivando sus disparos…

Espero que os haya resultado interesante y que os sirva no sólo para aprender a mover cuadrados, sino también para entrever lo conveniente que es la programación orientada a objetos cuando de lo que se trata es de modelar un objeto, un cuadrado en este caso. Poco a poco iréis viendo de una forma mucho más clara la potencia de estos conceptos, a medida que programamos más ejemplos con Pygame… hasta que los podamos llamar videojuegos 🙂

No dudéis en dejarme vuestros comentarios en este artículo o a través del formulario de contacto.

Un comentario en “Movimiento y colisiones en Pygame

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *