top of page
Buscar
  • Foto del escritorFernando Sansberro

11. Detectando las Colisiones

Actualizado: 24 dic 2021

Las colisiones son un aspecto muy importante en los juegos, sobre todo en los juegos arcade, porque las cosas interesantes suceden cuando dos objetos chocan. En este capítulo vamos a ver cómo detectar colisiones entre dos sprites y luego veremos cómo detectar colisiones entre un sprite y todos los enemigos o todas las balas.


Colisiones entre Dos Sprites


En el juego que estamos construyendo, debemos detectar cuando una bala del jugador choca con un enemigo para que éste explote. De la misma manera, las balas de los enemigos deben chequear colisiones con el jugador para saber si le pegan, y si esto ocurre, el jugador deberá morir. También, dependiendo del juego del que se trate, será necesario chequear las colisiones entre el jugador y los enemigos, entre los enemigos entre sí, o entre las balas entre sí, etc.


Comenzaremos con las colisiones más fáciles de detectar, y luego, más adelante avanzaremos a colisiones más complicadas. Por ahora lo que necesitamos saber básicamente es cuando un sprite colisiona con otro.


Sprites Rectangulares


Los sprites son imágenes, y siempre hay que tener en cuenta que los sprites son rectangulares. Al menos la imagen que se muestra en la pantalla es rectangular, aunque la forma real del sprite (el dibujo) no lo sea, porque tiene áreas con transparencia alrededor. Por ejemplo, veamos el sprite de la nave enemiga:



Figura 11-1: Imagen del sprite con áreas en blanco (transparente).



Al rectángulo que utilizamos para chequear la colisión de un sprite, lo denominamos bounding box, y es un rectángulo que encierra el área colisionable de un sprite. El bounding box es el rectángulo más chico que se pueda dibujar alrededor del sprite, lo que representa el área colisionable. Puede quedar algo del dibujo fuera del bounding box, pero no debería ser mucho porque en esa parte no se detecta la colisión.


Por ahora, utilizaremos como bounding box la misma área de la imagen, por lo cual no debería haber grandes áreas transparentes en el sprite. Más adelante, en otro capítulo, programaremos en la clase CSprite la posibilidad de tener un bounding box diferente al rectángulo de la imagen. El bounding box, entonces, es el rectángulo que usaremos para detectar la colisión y por ahora será del mismo tamaño que la imagen.


Lo ideal sería chequear si los píxeles de dos sprites se tocan, lo cual se llama perfect pixel collision, pero rara vez se usa, debido a que es muy lento determinar si los píxeles se tocan realmente. Esto haría que el juego funcione más lento. Por esta razón es que para detectar colisiones, se utiliza la comparación entre rectángulos, porque es muy rápida en comparación con cualquier otro método.


Al chequear las colisiones entre rectángulos, debemos tener cuidado con los casos donde las imágenes son irregulares y no corresponden a rectángulos, lo cual puede tener efectos como los que se muestran a continuación.



Figura 11-2: Las naves rotadas tienen áreas de colisión con partes en blanco.



En esta imagen, las naves de la primera posición no se están tocando, y esto se ve claramente en el dibujo. Los rectángulos de colisión (bounding box) no se tocan. Sin embargo, al rotar las naves (ver la segunda imagen), las naves no se tocan en la imagen, pero sus rectángulos de colisión sí se tocan.


Más adelante implementaremos el bounding box en los sprites para solucionar estos problemas. Por ahora tendremos que utilizar sprites cuyas imágenes sean aproximadamente más rectangulares en su forma, para que las colisiones funcionen mejor.


También es posible hacer colisiones entre círculos si los sprites son de aspecto más circulares que rectangulares. Este tipo de colisión es muy sencillo de implementar y ésto lo veremos más adelante.


Colisiones entre Rectángulos


Comenzaremos el trabajo de colisiones detectando si los dos jugadores se chocan. Para saber si dos sprites chocan, debemos revisar si sus rectángulos se están tocando. La siguiente imagen muestra esto:



Figura 11-3: Colisión entre dos rectángulos.



Cada sprite en nuestro programa, tiene una función getX(), getY(), getWidth() y getHeight() que retorna la posición x e y, el ancho y el alto del sprite respectivamente. Con estas funciones, podemos calcular si los rectángulos colisionan (determinar si se intersectan).


Para detectar colisiones, haremos una función en la clase CSprite que reciba otro sprite como parámetro y se fije si los rectángulos de las imágenes colisionan. La función collides() de la clase CSprite es la siguiente:


# Función para detectar colisión contra otro sprite.
def collides(self, aSprite):
   x1 = self.getX()
   y1 = self.getY()
   w1 = self.getWidth()
   h1 = self.getHeight()
   x2 = aSprite.getX()
   y2 = aSprite.getY()
   w2 = aSprite.getWidth()
   h2 = aSprite.getHeight()

   if ((((x1 + w1) > x2) and (x1 < (x2 + w2))) and 
      (((y1 + h1) > y2) and (y1 < (y2 + h2)))):
       return True
   else:
       return False

Veamos el ejemplo ubicado en la carpeta capitulo_11\001_colisiones_entre_rectangulos. En la clase CSprite se puede ver implementada esta función. Conviene hacer un dibujo en papel para entender lo que pregunta la función para saber si dos rectángulos colisionan (se pueden buscar demos en Internet, este algoritmo se denomina AABB collision detection). La ventaja es que una vez que se entiende esta función, se puede reutilizar en cualquier juego.


En este ejemplo movemos a las dos naves, cada una con sus teclas, y en la consola podemos ver que sale un mensaje en cada frame indicando si las naves están colisionando o no. Para esto, se ha puesto un chequeo en el programa main.py, en update():


    # Detectar la colision entre los dos jugadores.
    if player1.collides(player2):
        print("COLISION ENTRE LOS JUGADORES")
    else:
        print("...")

De esta forma, ahora podremos detectar colisiones entre dos sprites del juego. Lo siguiente será reaccionar a esas colisiones (explosiones, dañar una nave o matarla, etc), pero primero, debemos aprender cómo implementar colisiones contra un manager, o dicho de otra forma, detectar la colisión de un sprite contra todos los sprites de una lista de sprites (los sprites del manager).


Colisiones de un Sprite contra una Lista de Sprites (Manager)


En los juegos arcade o de acción como el que estamos haciendo, necesitamos saber si un sprite choca contra alguno de los sprites de una lista. Esto lo usaremos en varios casos. Por ejemplo, cuando queremos saber si una bala del jugador toca a alguno de los enemigos, o cuando el jugador mismo es quien toca a alguno de los enemigos. Ahora vamos a detectar cuando el jugador está chocando contra un enemigo (una nave o una bala, recuerda que tanto las naves como las balas enemigas se encuentran en el manager de enemigos).


Para chequear la colisión del jugador contra los sprites de la lista (manager) de enemigos, debemos chequear la colisión del rectángulo del jugador contra cada uno de los enemigos que exista en la lista de enemigos. La lista de enemigos se encuentra en la clase CEnemyManager. Hay que tener en cuenta que en esta lista también se encuentran las balas enemigas, las cuales se cuentan como un enemigo más (en realidad las balas enemigas son un tipo de enemigo).


Veamos el ejemplo que se encuentra en la carpeta capitulo_11\002_colisiones_sprite_contra_lista.

En este ejemplo, se muestra un texto por consola cuando uno de los jugadores choca contra alguno de los enemigos. Para esto, agregamos en la clase CManager una función que recibe un sprite como parámetro (el jugador en este caso) y recorre la lista chequeando la colisión con cada sprite de la lista. Si el sprite pasado como parámetro colisiona con algún sprite de la lista, la función retorna True y si al final de recorrer la lista no ha colisionado con ninguno retorna False. La función recorre la lista y usa la función collides() de CSprite escrita anteriormente para chequear la colisión. Veamos la función en la clase CManager:


# Determina si el sprite que se recibe como parámetro choca
# con alguno de los sprites de la lista.
# Retorna True si colisiona con alguno y False si no colisiona
# con ninguno.
def collides(self, aSprite):
   i = 0
   while i < len(self.mArray):
       if aSprite.collides(self.mArray[i]):
           return True
       i = i + 1
   return False

En main.py chequeamos la colisión entre el jugador y todos los sprites enemigos:


# Detectar la colisión entre los jugadores y los enemigos.
if CEnemyManager.inst().collides(player1):
   print(“COLISION ENTRE PLAYER1 Y ENEMIGO”)
if CEnemyManager.inst().collides(player2):
   print(“COLISION ENTRE PLAYER2 Y ENEMIGO”)

Por ahora, solo mostraremos en la consola si un jugador choca o no contra un enemigo, pero no importa cual. Esto lo hacemos así para comprobar que todo funciona bien. Más adelante, necesitaremos que la función collides() del manager retorne el sprite con el cual choca, para aplicarle alguna acción (por ejemplo, que explote o salte, o que tenga algún efecto).


Ahora que sabemos cómo chequear colisiones entre un sprite y una lista de sprites, seguiremos con la detección de colisiones que son necesarias para este juego.


Colisiones de las Balas del Jugador contra los Enemigos


Ahora vamos a detectar colisiones entre todas las balas del jugador contra todos los enemigos. Cuando la bala del jugador choque contra un enemigo, éste último morirá (y la bala también). Por ahora haremos que se muera el enemigo en forma inmediata, por lo cual desaparecerá instantáneamente. Más adelante haremos animaciones de una explosión cuando veamos cómo hacer animaciones y cuando sepamos cómo trabajar con los estados de los objetos (para hacerlo explotar).


Para ver si una bala choca a algún enemigo, debemos determinar si la bala choca con cada uno de los enemigos que hay en la lista de enemigos, preguntando uno a uno. Para eso, en la bala del jugador (en la clase CPlayerBullet), debemos recorrer la lista de enemigos y uno a uno ver si hay colisión o no. Esto es lo que hace la función collides() del manager, que hicimos en la sección anterior, así que la usaremos aquí. El código de CPlayerBullet queda de la siguiente manera:


...

# Importar el manager de enemigos para detectar colisiones.
from api.CEnemyManager import *

# La clase CPlayerBullet hereda de CSprite.
class CPlayerBullet(CSprite):

   ...

   # Mover el objeto.
   def update(self):

       CSprite.update(self)

       # Detectar choque contra los enemigos.
       if CEnemyManager.inst().collides(self):
           print("*****COLISION ENTRE BALA DEL JUGADOR Y ENEMIGO")

   ... 

Abre el proyecto que se encuentra en la carpeta capitulo_11\003_colisiones_balas_contra_enemigos para ver este código en la clase CPlayerBullet. Ahora podemos detectar las colisiones de las balas del jugador y ya tenemos casi todo pronto para que las balas hagan algo.


Al chequear cada bala que se dispara contra todos los enemigos, vemos que el chequeo de colisiones crece exponencialmente con la cantidad de sprites que haya en el juego. Esta es un área que siempre deberemos optimizar.


Por ejemplo, si el juego tiene cinco sprites, y cada uno puede colisionar con los restantes (cuatro), la cantidad de colisiones que hay que detectar es 5*4 = 20 chequeos de colisión. Si N es la cantidad de sprites en el juego, la cantidad de colisiones es N * (N-1). Esto es si cada sprite chequea colisiones contra todos los demás.


Como la acción de realizar cada chequeo de colisión lleva tiempo, en el futuro esto se debe optimizar para que el juego no se torne lento con los cálculos si hay muchas cosas en pantalla. Realizar muchas colisiones es algo que puede enlentecer el juego. Para evitar esto, por ejemplo, podemos no detectar las colisiones entre los enemigos entre sí. O no chequear la colisión entre dos sprites dos veces. Dependiendo del juego del que se trate por supuesto, esto ayuda a reducir la cantidad de colisiones a detectar.


Reaccionando a las Colisiones


Ahora que sabemos detectar colisiones, y que ya tenemos colocado el chequeo de colisiones en el código, vamos a hacer que cuando dos objetos se toquen, mueran. Más adelante los haremos explotar, pero por ahora haremos que mueran (en realidad en algún momento van a morir, luego de explotar, así que esto hay que hacerlo igual).


Para hacer que un objeto muera, haremos una función die() en la clase CGameObject que lo que hace es establecer la variable mIsDead en True.

Como hemos visto antes, y ya está programado en el motor, cuando esta variable se vuelve True, el manager destruye el objeto y lo elimina de la lista. Entonces, en el momento que detectamos colisiones, invocamos a la función die() en los objetos que queremos y los mismos serán eliminados. Más adelante en este punto del programa dispararemos sonidos, partículas, incrementaremos el puntaje, etc.


Lo primero que hay que hacer es cambiar la función de chequeo de colisiones que se encuentra en la clase CManager, para que en lugar de retornar sólo True o False como está ahora, que retorne el sprite con el cual se colisiona, o None si no se colisiona con ninguno. De esta forma podemos hacer algo con la referencia al sprite.


Veamos como queda la función collides() en la clase CManager, en el ejemplo que se encuentra en la carpeta capitulo_11\004_matando_enemigos:


# Determina si el sprite que se recibe como parámetro choca
# con alguno de los sprites de la lista.
# Retorna True si colisiona con alguno y False si no colisiona
# con ninguno.
def collides(self, aSprite):
   i = 0
   while i < len(self.mArray):
       if aSprite.collides(self.mArray[i]):
           return self.mArray[i]
       i = i + 1
   return None

En la clase CPlayerBullet, chequeamos la colisión de la bala contra los enemigos. Para eso guardamos en una variable enemy la referencia al primer sprite con el que choca la bala, o None si no choca con ninguno.


...

# La clase CPlayerBullet hereda de CSprite.
class CPlayerBullet(CSprite):

   ...

   # Mover el objeto.
   def update(self):

       CSprite.update(self)

       # Detectar choque contra los enemigos.
       # Si la bala choca un enemigo, mueren ambos.
       enemy = CEnemyManager.inst().collides(self)
       if enemy != None:
           print("*****COLISION ENTRE BALA DEL JUGADOR Y ENEMIGO")
           enemy.die()
           self.die()

...

Cuando la bala toca a un enemigo, tanto la bala como el enemigo mueren. Luego en este punto le agregaremos explosiones, sonidos, partículas, etc. Por ahora invocamos a la función die() para que mueran ambos objetos. En la clase CGameObject, implementamos esta función:


# Marcar al objeto para morir. El manager lo elimina.
def die(self):
   self.mIsDead = True

Al llamar a esta función, simplemente se marca el objeto para morir, y luego de ejecutarse su función update(), el manager se encarga de eliminarlo. Este comportamiento ya lo tenemos programado desde que hicimos el manager.



Figura 11-4: Ahora podemos disparar y eliminar enemigos.


Al correr el ejemplo, si le disparamos a todos los enemigos, nos quedaremos con la pantalla vacía. Más adelante programaremos la condición para ganar el juego, que es cuando no queden más enemigos en el manager. Ahora pasaremos a implementar los sprites con animaciones, efectos de sonidos, etc. y luego trabajaremos en el armado completo del juego.


En el siguiente capítulo veremos cómo implementar animaciones de sprites.


45 visualizaciones0 comentarios

Entradas recientes

Ver todo
bottom of page