top of page
Buscar
  • Foto del escritorFernando Sansberro

13. Máquinas de Estados de los Objetos

Actualizado: 24 dic 2021

En este capítulo se introduce el concepto de máquina de estados, que viene a ser el conjunto de estados (acciones) posibles que puede tener un objeto en el juego (por ejemplo, saltando, cayendo, pegando, disparando, etc) y cómo se pasa de un estado a otro. Este es uno de los conceptos claves y más importantes a la hora de programar videojuegos.


Por lo general, la máquina de estados es un tema que se ignora en las carreras de informática o se ve en forma superficial. Para el desarrollo de videojuegos, el concepto de máquina de estados es uno de los secretos para desarrollar videojuegos de calidad, que junto al game loop y al uso de la matemática, hace posible programar cualquier tipo de juego.


Este capítulo muestra cómo se implementan los comportamientos de los objetos del juego, lo cual se hace siempre teniendo una máquina de estados. Comenzaremos a implementar varios comportamientos en el jugador.


¿Qué es una Máquina de Estados?


Una máquina de estados finita (finite state machine, o FSM) es una máquina abstracta (sin forma) que realiza o soluciona un tipo de problema determinado. Las máquinas de estados son un programa (código) que realiza una tarea sencilla. Las vemos en todas partes. Por ejemplo, desde algoritmos simples como los de un ascensor, un semáforo, máquinas expendedoras de bebidas, etc. a cosas con lógica más avanzada como los sistemas de inteligencia artificial.


Una máquina de estados tiene dos componentes fundamentales. Primero, contiene estados. La máquina tiene una cantidad limitada de estados en los que puede estar. Segundo, contiene transiciones. Cada estado tiene ciertas condiciones que cuando se cumplen hacen pasar a la máquina de un estado a otro. A este pasaje se le denomina transición.


La máquina de estados comienza en un estado determinado, al que se le llama el estado inicial. Luego, según las condiciones que se den, se puede pasar de un estado a otro, y así hasta que termine el juego, o exista un estado final donde el objeto se destruya al alcanzarlo (por ejemplo, el estado “morir”).


¿Cómo Luce una Máquina de Estados?


Una máquina de estados se puede dibujar con un diagrama de estados y transiciones, que puede ser en forma de tabla o en forma de diagrama. En este diagrama, tendremos la lista de los estados posibles que puede tener el objeto, y las transiciones (flechas) entre un estado y otro.


Como ejemplo práctico, lo que haremos ahora es mejorar el comportamiento de la nave del jugador. Para eso, lo primero que hacemos es definir qué queremos que haga la nave del jugador. Escribimos estas notas:


- Si el jugador colisiona con un enemigo o una bala enemiga, se muere.

- Luego de un segundo, la nave aparece de nuevo (es una nueva vida).


Como vemos, tenemos dos estados. Al primer estado le llamamos NORMAL, y es el estado en el cual el jugador se controla normalmente. Cuando lo toca una bala o un enemigo, se pasa al estado DYING (muriendo). En este estado, cuando pase más de un segundo, volverá a pasar al estado NORMAL. La máquina de estados en forma de tabla (o sea, los estados y las transiciones), es la siguiente:



Estado Condición Transición

NORMAL Le pega una bala Pasa a estado DYING

NORMAL Le pega un enemigo Pasa a estado DYING

DYING Pasa más de un segundo Pasa a estado NORMAL


Figura 13-1: La máquina de estados del jugador en forma de tabla.



Esta tabla, es más sencilla de ver si la dibujamos con forma de diagrama, en la cual cada estado es un círculo y cada transición es una flecha que va de un estado a otro. Encima de la transición se pone un texto con la condición que se debe cumplir para que se cambie de estado. Veamos como queda la tabla dibujada como un diagrama:



Figura 13-2: La máquina de estados del jugador en forma de diagrama.



Implementando los Estados NORMAL y DYING


Ahora vamos a implementar en la programación la máquina de estados que definimos para el jugador. La misma va a tener los estados NORMAL y DYING como muestra la figura 13-2.

La forma más común de implementar una máquina de estados es usando una sentencia switch (o varios if anidados).


Lo que haremos es que cuando la nave choque con un enemigo muera (cuando toque una bala enemiga o una nave enemiga). Mientras muere, el jugador va a parpadear y no se va a poder controlar durante un segundo. Luego de transcurrido ese tiempo la nave volverá a estar controlable (es cuando se usa una nueva vida).


Abre el ejemplo que se encuentra en la carpeta capitulo_13\001_maquina_de_estados_jugador.


La clase CPlayer ahora contiene una máquina de estados para implementar el comportamiento según el diseño que hemos realizado (ver el diagrama anterior).

El código de la clase CPlayer es el siguiente y se explicará a continuación.


# -*- coding: utf-8 -*-

#--------------------------------------------------------------------
# Clase CPlayer.
# Nave que controla el jugador.
#
# Autor: Fernando Sansberro - Batovi Games Studio
# Proyecto: Hacete tu Videojuego.
# Licencia: Creative Commons. BY-NC-SA.
#--------------------------------------------------------------------

# Importar Pygame.
import pygame

# Importar la clase CSprite.
from api.CSprite import *

# Importar la clase CKeyboard
from api.CKeyboard import *

# Importar el manager de balas.
from api.CBulletManager import *

# Importar la clase para la bala.
from game.CPlayerBullet import *

# Importar la clase CGameConstants
from api.CGameConstants import *

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

   # Tipos de jugador.
   TYPE_PLAYER_1 = 0
   TYPE_PLAYER_2 = 1

   # Máquina de estados.
   NORMAL = 0
   DYING = 1

   # Tiempo que dura la muerte.
   TIME_DYING = 60

   # ----------------------------------------------------------------
   # Constructor. Recibe el tipo de nave (TYPE_PLAYER_1 o TYPE_PLAYER_2).
   # ----------------------------------------------------------------
   def __init__(self, aType):
       CSprite.__init__(self)

       # Segun el tipo de la nave, la imagen que se carga.
       self.mType = aType

       if self.mType == CPlayer.TYPE_PLAYER_1:
           imgFile = "assets/images/player0"
       elif self.mType == CPlayer.TYPE_PLAYER_2:
           imgFile = "assets/images/player1"

       self.mFrames = []
       i = 0
       while (i <= 2):
           tmpImg = pygame.image.load(imgFile + str(i) + ".png")
           tmpImg = tmpImg.convert_alpha()
           self.mFrames.append(tmpImg)
           i = i + 1

       self.setImage(self.mFrames[0])

       # Estado inicial.
       self.mState = CPlayer.NORMAL
       self.setState(CPlayer.NORMAL)

       # Control del tiempo cuando se muere.
       self.mTimeDying = 0

       # Variable para hacer parpadear el sprite.
       self.mCounter = 0

   # Mover el objeto.
   def update(self):

       # Invocar update() de CSprite.
       CSprite.update(self)

       # Actualizar el contador para parpadear.
       self.mCounter = self.mCounter + 1

       # Lógica del estado normal.
       if self.mState == CPlayer.NORMAL:
           if CEnemyManager.inst().collides(self):
               self.setState(CPlayer.DYING)
               return
           self.move()

       # Lógica del estado muriendo.
       elif self.mState == CPlayer.DYING:
           self.mTimeDying = self.mTimeDying + 1
           if (self.mTimeDying > CPlayer.TIME_DYING):
               self.setState(CPlayer.NORMAL)
               return

   # Dibuja el objeto en la pantalla.
   # Parámetros:
   # aScreen: La superficie de la pantalla en donde dibujar.
   def render(self, aScreen):

       # Si es estado normal, dibujar normalmente.
       if self.mState == CPlayer.NORMAL:
           CSprite.render(self, aScreen)
       # Si es estado muriendo, dibujar cuadro por medio.
       elif self.mState == CPlayer.DYING:
           if self.mCounter % 2 == 0:
               CSprite.render(self, aScreen)

   # Liberar lo que haya creado el objeto.
   def destroy(self):

       # Invocar destroy() de CSprite.
       CSprite.destroy(self)

       i = len(self.mFrames)
       while i > 0:
           self.mFrames[i - 1] = None
           self.mFrames.pop(i - 1)
           i = i - 1
       mFrames = None

   # Establece el estado actual e inicializa
   # las variables correspondientes al estado.
   def setState(self, aState):

       self.mState = aState

       if self.mState == CPlayer.NORMAL:
           pass
       elif self.mState == CPlayer.DYING:
           self.mTimeDying = 0
           self.mCounter = 0
           self.stopMove()

   # Movimiento de la nave.
   def move(self):
       # Obtener los controles.
       if self.mType == CPlayer.TYPE_PLAYER_1:
           left = CKeyboard.inst().leftPressed()
           right = CKeyboard.inst().rightPressed()
           fire = CKeyboard.inst().fire()
       else:
           left = CKeyboard.inst().APressed()
           right = CKeyboard.inst().DPressed()
           fire = CKeyboard.inst().fire2()

       # Mover la nave según las teclas.
       if (not left and not right):
           self.setVelX(0)
           self.setImage(self.mFrames[0])
       else:
           if left:
               self.setVelX(-4)
               self.setImage(self.mFrames[1])
           elif right:
               self.setVelX(4)
               self.setImage(self.mFrames[2])

       # Disparar.
       if fire:
           print("FIRE")
           b = CPlayerBullet()
           b.setXY(self.getX() + self.getWidth() / 2 - b.getWidth() / 2, self.getY())
           b.setVelX(0)
           b.setVelY(-10)
           b.setBounds(0, 0, CGameConstants.SCREEN_WIDTH, CGameConstants.SCREEN_HEIGHT)
           b.setBoundAction(CGameObject.DIE)
           CBulletManager.inst().add(b)

Como vemos en el código, la máquina de estados consiste en definir primero una constante para cada estado e inicializar el estado actual. La variable self.mState tendrá el estado actual del objeto (el jugador en este caso).


...

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

   ...

   # Máquina de estados.
   NORMAL = 0
   DYING = 1

   # Tiempo que dura la muerte.
   TIME_DYING = 60

   ...
   def __init__(self, aType):
       CSprite.__init__(self)

       ...

       # Estado inicial.
       self.mState = CPlayer.NORMAL
       self.setState(CPlayer.NORMAL)

       # Control del tiempo cuando se muere.
       self.mTimeDying = 0

       # Variable para hacer parpadear el sprite.
       self.mCounter = 0

...

Como siempre, en el constructor inicializamos todas las variables y constantes necesarias para el objeto, y usaremos la función setState() para poner al objeto en su estado inicial.

Cada vez que cambiemos de estado, invocamos a la función setState() y le pasamos el nuevo estado como parámetro. Esta función, establecerá los valores que haya que establecer para inicializar el estado. Por ejemplo, en el caso de pasar al estado CPlayer.DYING, la nave deja de moverse y se resetea el tiempo a contar antes de revivir. Este tipo de variables (denominados timers, contadores, etc.) siempre se inicializan cuando se pasa de un estado a otro.


# Establece el estado actual e inicializa
# las variables correspondientes al estado.
def setState(self, aState):

   self.mState = aState

   if self.mState == CPlayer.NORMAL:
       pass
   elif self.mState == CPlayer.DYING:
       self.mTimeDying = 0
       self.mCounter = 0
       self.stopMove()

Para detener completamente al objeto al morir, hemos hecho una función stopMove() en la clase CGameObject que lo que hace es poner la velocidad y la aceleración del objeto en cero, para que no se mueva más. Si no hacemos esto tendremos comportamientos no deseados, como por ejemplo, que el objeto si tenía una velocidad, al chocar con una bala, se seguirá moviendo mientras muere (porque en el estado en el que está muriendo no se controla con el teclado). La función stopMove() de CGameObject es la siguiente:


# Detiene el movimiento del objeto.
def stopMove(self):
   self.setVelX(0)
   self.setVelY(0)
   self.setAccelX(0)
   self.setAccelY(0)

Nota: Es fundamental inicializar bien los estados (inicializar correctamente las variables a ser usadas en el estado), para evitar tener comportamientos no deseados, producto de tener algunas variables no inicializadas, como el caso, por ejemplo, de una nave que explota pero se sigue moviendo.


Volviendo a la clase CPlayer, si vemos el listado mostrado anteriormente, veremos que todo el código que controla al jugador, ahora se encuentra en dentro de una función move(). Esto lo hemos hecho así, porque en algunos estados el jugador podrá controlarse con el teclado (por ejemplo en el estado CPlayer.NORMAL) y en otros estados no (cuando muere por ejemplo). Entonces, en los estados en los cuales se pueda mover la nave, se invoca a la función move(). Como generalmente esto ocurre en varios estados, es poner una sola línea y no queda el código duplicado.


Entonces, la función move() es una función que controla la nave con el teclado y dispara si pulsamos el disparo. Es el mismo código que había antes, pero ahora se puso en una función, porque varios estados del jugador la van a utilizar (en resumen, se pone en una función para no repetir el código entre diferentes estados que lo usen).


Por último, la magia se encuentra en la función update(). Si miramos esta función, veremos que el código queda mucho más claro de entender qué es lo que hace, dado que es parecido a la tabla o diagrama que hicimos con la máquina de estados. En el código de update() se ven los estados y las condiciones en una estructura de if:


# Mover el objeto.
def update(self):

   # Invocar update() de CSprite.
   CSprite.update(self)

   # Actualizar el contador para parpadear.
   self.mCounter = self.mCounter + 1

   # Lógica del estado normal.
   if self.mState == CPlayer.NORMAL:
       if CEnemyManager.inst().collides(self):
           self.setState(CPlayer.DYING)
           return
       self.move()

   # Lógica del estado muriendo.
   elif self.mState == CPlayer.DYING:
       self.mTimeDying = self.mTimeDying + 1
       if (self.mTimeDying > CPlayer.TIME_DYING):
           self.setState(CPlayer.NORMAL)
           return

El contador (self.mCounter) es simplemente una variable que se incrementa de a uno y se utiliza en la función render() para hacer el parpadeo. Ya veremos eso en un momento.


La función update() se puede traducir al español de la siguiente manera:


- Si la nave está en estado normal:

- Si la nave choca contra un enemigo, pasar al estado de morir.

- Sino, mover la nave con el teclado.

- Si la nave está en estado muriendo:

- Incrementar la cuenta del tiempo.

- Si ya ha pasado más de un segundo, pasar al estado normal.


Como vemos, el código queda bastante más sencillo de entender y sobre todo, de modificar. El objeto (el jugador) hace una cosa u otra, y pasa a un estado u otro según determinadas condiciones. Es muy sencillo modificar este programa para que el jugador haga cosas más complejas. Más adelante agregaremos más estados en el jugador, lo que es lo mismo que decir, que le agregaremos más comportamientos.


Por último, como queremos que en el estado muriendo el jugador haga un parpadeo, en la función render() salteamos un cuadro (no dibujamos cuadro por medio, y eso lo hace parpadear). En el estado normal, la nave se dibuja siempre, y cuando está muriendo, se dibuja un cuadro sí y uno no:


# Dibuja el objeto en la pantalla.
# Parámetros:
# aScreen: La superficie de la pantalla en donde dibujar.
def render(self, aScreen):

   # Si es estado normal, dibujar normalmente.
   if self.mState == CPlayer.NORMAL:
       CSprite.render(self, aScreen)
   # Si es estado muriendo, dibujar cuadro por medio.
   elif self.mState == CPlayer.DYING:
       if self.mCounter % 2 == 0:
           CSprite.render(self, aScreen)

Nota: Para evitar problemas durante el desarrollo del juego o para evitar bugs, es necesario asegurarse de inicializar bien las variables en cada estado y chequear bien las condiciones para las transiciones entre los estados.


Nota: Es buena práctica siempre usar setState() para cambiar de estado y las variables inicializarlas en esta función. Por otra parte asegurarse de no ejecutar código de lógica luego de invocar a setState(). Esto último se logra colocando una sentencia return luego de cada setState().


Agregando más Estados al Jugador


Una vez que tenemos la máquina de estados implementada en el objeto, agregar estados es sencillo. Lo que tenemos que hacer es agregar las constantes correspondientes, inicializar esos estados en setState(), y programar las transiciones y los estados en la función update().


Vamos a agregarle más estados al jugador, de forma tal de mejorar lo que hace al morir. Le agregaremos un estado para cuando lo toca un enemigo y luego uno para explotar. Antes de programar, hacemos los estados completos del jugador en forma de tabla o diagrama. Mira la siguiente figura:



Figura 13-3: Máquina de estados completa del jugador.



El funcionamiento es el siguiente. La nave normal se puede controlar con el teclado. Cuando una bala o enemigo la toca, parpadea durante un segundo y luego explota. Luego que termina la explosión, la nave reaparece inmune por dos segundos. En este estado la nave se puede mover y disparar, pero no puede morir. Luego de dos segundos la nave vuelve a estar en estado normal.


Veamos el ejemplo que se encuentra en la carpeta capitulo_13\002_maquina_de_estados_jugador_completa.

En la clase CPlayer, hemos agregado constantes para los nuevos estados, y se ha puesto código para cada estado en las funciones setState(), update() y render().

A continuación se muestra en negrita el código agregado o modificado:


...

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

   # Tipos de jugador.
   TYPE_PLAYER_1 = 0
   TYPE_PLAYER_2 = 1

   # Máquina de estados.
   NORMAL = 0
   DYING = 1
   EXPLODING = 2
   START = 3

   # Tiempos que duran los estados.
   TIME_DYING = 60
   TIME_EXPLODING = 60
   TIME_START = 120

   # ----------------------------------------------------------------
   # Constructor. Recibe el tipo de nave (TYPE_PLAYER_1 o TYPE_PLAYER_2).
   # ----------------------------------------------------------------
   def __init__(self, aType):
       CSprite.__init__(self)

       # Segun el tipo de la nave, la imagen que se carga.
       self.mType = aType

       if self.mType == CPlayer.TYPE_PLAYER_1:
           imgFile = "assets/images/player0"
       elif self.mType == CPlayer.TYPE_PLAYER_2:
           imgFile = "assets/images/player1"

       self.mFrames = []
       i = 0
       while (i <= 2):
           tmpImg = pygame.image.load(imgFile + str(i) + ".png")
           tmpImg = tmpImg.convert_alpha()
           self.mFrames.append(tmpImg)
           i = i + 1

       self.setImage(self.mFrames[0])

       # Estado inicial.
       self.mState = CPlayer.NORMAL
       self.setState(CPlayer.NORMAL)

       # Control del tiempo en el estado actual.
       self.mTimeState = 0

       # Variable para hacer parpadear el sprite.
       self.mCounter = 0

   # Mover el objeto.
   def update(self):

       # Invocar update() de CSprite.
       CSprite.update(self)

       # Incrementar el tiempo del estado actual.
       self.mTimeState = self.mTimeState + 1

       # Actualizar el contador para parpadear.
       self.mCounter = self.mCounter + 1

       # Lógica del estado normal.
       if self.mState == CPlayer.NORMAL:
           if CEnemyManager.inst().collides(self):
               self.setState(CPlayer.DYING)
               return
           self.move()

       # Lógica del estado muriendo.
       elif self.mState == CPlayer.DYING:
           if (self.mTimeState > CPlayer.TIME_DYING):
               self.setState(CPlayer.EXPLODING)
               return

       # Lógica del estado explotando.
       elif self.mState == CPlayer.EXPLODING:
           if (self.mTimeState > CPlayer.TIME_EXPLODING):
               self.setState(CPlayer.START)
               return

       # Lógica del estado start.
       elif self.mState == CPlayer.START:
           if (self.mTimeState > CPlayer.TIME_START):
               self.setState(CPlayer.NORMAL)
               return
           self.move()

   # Dibuja el objeto en la pantalla.
   # Parámetros:
   # aScreen: La superficie de la pantalla en donde dibujar.
   def render(self, aScreen):

       # Si es estado normal, dibujar normalmente.
       if self.mState == CPlayer.NORMAL:
           CSprite.render(self, aScreen)
       # Si es estado muriendo, dibujar cuadro por medio.
       elif self.mState == CPlayer.DYING:
           if self.mCounter % 8 == 0:
               CSprite.render(self, aScreen)
       # Si está explotando, por ahora no hacemos nada.
       elif self.mState == CPlayer.EXPLODING:
           pass
       # Si el estado es start, parpadear mas lento.
       elif self.mState == CPlayer.START:
           if self.mCounter % 16 == 0:
               CSprite.render(self, aScreen)

   # Liberar lo que haya creado el objeto.
   def destroy(self):

       # Invocar destroy() de CSprite.
       CSprite.destroy(self)

       i = len(self.mFrames)
       while i > 0:
           self.mFrames[i - 1] = None
           self.mFrames.pop(i - 1)
           i = i - 1
       mFrames = None

   # Establece el estado actual e inicializa
   # las variables correspondientes al estado.
   def setState(self, aState):

       self.mState = aState

       # Resetear el tiempo del estado actual.
       self.mTimeState = 0

       if self.mState == CPlayer.NORMAL:
           pass
       elif self.mState == CPlayer.DYING:
           self.mCounter = 0
           self.stopMove()
       elif self.mState == CPlayer.EXPLODING:
           pass
       elif self.mState == CPlayer.START:
           self.mCounter = 0

   ...

Lo primero que hacemos es agregar una constante para cada estado.


Hemos puesto una variable self.mTimeState para no tener un contador diferente por estados y así, usamos esta misma variable para llevar el tiempo que se está en un estado determinado. Para esto, en setState() esta variable se pone en cero y en update() se incrementa. Entonces, de esta forma, en cualquier estado es posible saber cuánto tiempo ha pasado en el estado actual, y esto se usa para saber si hay que pasar a otro estado.


Para agregar estados, debemos incluirlos en los if de la función update() y programar su lógica. Para agregar estados a lo que ya hacía el jugador, debemos “enganchar” los estados según el diagrama de estados. En este caso, ahora la nave pasa de estado CPlayer.DYING a CPlayer.EXPLODING (antes desde CPlayer.DYING pasaba a estado CPlayer.NORMAL y ahora enganchamos a un nuevo estado: CPlayer.EXPLODING).


En el estado CPlayer.EXPLODING, mostraremos una animación de una explosión, cosa que no estamos haciendo ahora para no hacer dos cosas a la vez. A veces es mejor programar una cosa chica a la vez, asegurarse que funcione y luego pasar a la siguiente. En este caso, una vez que tenemos el estado listo, le agregaremos la explosión. Esto es lo que haremos a continuación, aunque primero debemos mejorar la clase CAnimatedSprite para soportar varios tipos de animaciones.


Mejorando la clase AnimatedSprite: gotoAndPlay() y gotoAndStop()


Si recordamos cómo estamos manejando la animación de la nave del jugador (sino observa el código de la clase CPlayer), vemos que si nos movemos a la izquierda (en la función move()), usamos la función setImage() para establecer la imagen de la nave inclinada a la izquierda. Lo mismo para la derecha y lo mismo cuando se sueltan las teclas. Siempre usamos la función setImage() para establecer la imagen de la nave según su movimiento.


Pues bien, ahora la clase CPlayer hereda de CSprite. Si queremos hacer que la nave explote, debemos hacer que CPlayer herede de CAnimatedSprite. Esto es, porque ahora pasará a ser un sprite animado, y tendrá varias animaciones diferentes.


El problema es, que si CPlayer es un sprite animado, no podemos establecer una imagen manualmente, sino una animación. Lo que tenemos que hacer entonces, es mejorar la clase CAnimatedSprite para que soporte tanto una secuencia de animación, como poder establecer un cuadro fijo (una imagen). Esto es lo que haremos a continuación.


Implementaremos varias funciones nuevas en la clase CAnimatedSprite que nos permita tener absoluto control de las animaciones de un sprite animado. Por ejemplo, tendremos una función gotoAndStop() que irá a un cuadro determinado de la animación y quedará fijo en ese cuadro (esto es igual a establecer una imagen), y la función gotoAndPlay() que irá a un cuadro y seguirá la animación a partir de ese cuadro. Luego tendremos otras funciones que ya veremos.


Nota: Los nombres gotoAndStop() y gotoAndPlay() vienen del lenguaje Actionscript, del software Adobe Flash, en donde dada una secuencia de frames de una animación, se tienen estas funciones para ir a un cuadro determinado y detenerse, o correr la animación desde un cuadro en particular, respectivamente. Este tipo de funciones hacen muy sencilla la programación de la lógica de los sprites animados.


Lo que haremos en la clase CPlayer es cargar una animación con los tres cuadros que tiene la nave (centro, izquierda, derecha) y usamos la función gotoAndStop() para indicarle que cuadro queremos mostrar. Más adelante agregaremos una explosión, y esta animación correrá desde el inicio, usando la función gotoAndPlay().


Nota: Es bastante común que un jugador o un enemigo tenga un conjunto de animaciones y vayamos cambiando entre una y otra según lo que haga. Esto está naturalmente relacionado a la máquina de estados, dado que es muy probable que tengamos transiciones de un estado a otro cuando una animación termina.


Abre el ejemplo que se encuentra en la carpeta capitulo_13\003_goto_and_stop_y_goto_and_play.

En la clase CAnimatedSprite, hemos implementado varias funciones. En letra negrita podemos ver el código agregado:


...

class CAnimatedSprite(CSprite):

   def __init__(self):

       CSprite.__init__(self)

       # Array con los frames de la animación (las imágenes).
       self.mFrame = None

       # Frame actual de la animación.
       self.mCurrentFrame = 0

       # Control de cuando pasar de frame.
       self.mTimeFrame = 0
       # Cuantos frames deben pasar antes de pasar de imagen.
       self.mDelay = 0

       # Indica si la animación es cíclica (True) o no (False).
       self.mIsLoop = True

       # Indica si la animación no es cíclica y ha terminado.
       self.mEnded = False

   # Inicializa la animación del sprite. Establece el array de imágenes
   # de la animación, el frame actual el delay de la animación y si la
   # animación es cíclica (al terminar comienza desde el principio), o no.
   def initAnimation(self, aFramesArray, aStartFrame, aDelay, aIsLoop):
       self.mFrame = aFramesArray
       self.mCurrentFrame = aStartFrame
       self.mTimeFrame = 0
       self.mDelay = aDelay
       self.mIsLoop = aIsLoop
       self.mEnded = False
       self.setImage(self.mFrame[self.mCurrentFrame])
      
   def update(self):
      
       CSprite.update(self)

       # Ver si hay que cambiar de frame.
       self.mTimeFrame = self.mTimeFrame + 1
       if (self.mTimeFrame > self.mDelay):
           # Resetear el tiempo.
           self.mTimeFrame = 0

           # Si la animación no ha terminado, actualizar el cuadro de animación.
           if not self.mEnded:
               # Si es loop, se comienza desde el inicio. Sino queda
               # en el último cuadro.
               self.mCurrentFrame = self.mCurrentFrame + 1
               if self.mCurrentFrame >= len(self.mFrame):
                   # Si la animación es cíclica, comienza desde el inicio.
                   if self.mIsLoop:
                       self.mCurrentFrame = 0
                   # Si la animación no es cíclica, queda parado en el
                   # último cuadro y se marca que la animación ha terminado.
                   else:
                       self.mCurrentFrame = len(self.mFrame) - 1
                       self.mEnded = True

               self.setImage(self.mFrame[self.mCurrentFrame])

   def render(self, aScreen):
      
       CSprite.render(self, aScreen)

   def destroy(self):
      
       CSprite.destroy(self)
      
       i = len(self.mFrame)
       while i > 0:
           self.mFrame[i-1] = None
           self.mFrame.pop(i-1)
           i = i - 1

   # True si la animación no es cíclica y ya ha terminado. False en otro caso.
   def isEnded(self):
       return self.mEnded

   # Función para ir a un cuadro determinado de la animación
   # y quedarse en ese cuadro.
   def gotoAndStop(self, aFrame):
       if aFrame >= 0 and aFrame <= (len(self.mFrame) - 1):
           self.mCurrentFrame = aFrame
           self.setImage(self.mFrame[self.mCurrentFrame])
           self.mEnded = True

   # Función para ir a un cuadro determinado de la animación
   # y seguir con los cuadros siguientes.
   def gotoAndPlay(self, aFrame):
       if aFrame >= 0 and aFrame <= (len(self.mFrame) - 1):
           self.mCurrentFrame = aFrame
           self.setImage(self.mFrame[self.mCurrentFrame])
           self.mEnded = False
           self.mTimeFrame = 0

Lo primero que agregamos son dos variables de control: self.mIsLoop indicará si la animación es cíclica (si al terminar comienza desde el inicio nuevamente), o no, y self.mIsEnded indicará cuando la animación haya terminado, esto es, cuando haya alcanzado el último cuadro. Si la animación es cíclica, se entiende que la animación nunca termina.


Luego, la función initAnimation() ahora agrega un parámetro al final (aIsLoop), que indica si la animación es cíclica o no. Cada vez que se llama a la función initAnimation() se pone la variable self.mIsEnded en False para marcar que la animación no ha terminado.


La función update(), es quien controla la animación, como lo hacía antes, pero ahora agregamos controles sobre la animación. Una vez que la animación ha terminado, ya no se cambia de cuadro (sentencia: if not self.mIsEnded).

Luego, al llegar al final de la animación (o sea, al llegar al último elemento del array de frames), si la animación es cíclica, se vuelve al inicio (al frame cero). Si la animación no es cíclica, entonces se ha terminado (porque ha llegado al final) y se establece la variable self.mIsEnded en True. Esto hace que ya no se anime más y quede fijo en ese cuadro.


# True si la animación no es cíclica y ya ha terminado. False en otro caso.
def isEnded(self):
   return self.mEnded

La función isEnded() nos dice si la animación ha terminado o no. Como vemos, solamente retorna el valor de la variable self.mIsEnded, dado que update() se encarga de establecer su valor como vimos antes. De esta forma, podemos correr una animación en un objeto del juego usando la función initAnimation() y luego cuando isEnded() sea True, podemos pasar a otro estado. Esto es lo que vamos a usar en el jugador cuando explote. Pasaremos a un estado explotando y cuando termine la animación pasaremos a otro estado.


Las funciones gotoAndStop() y gotoAndPlay() reciben el número de cuadro al que hay que ir en la animación, y se detiene en ese cuadro o continúa la animación, respectivamente.


# Función para ir a un cuadro determinado de la animación
# y quedarse en ese cuadro.
def gotoAndStop(self, aFrame):
   if aFrame >= 0 and aFrame <= (len(self.mFrame) - 1):
       self.mCurrentFrame = aFrame
       self.setImage(self.mFrame[self.mCurrentFrame])
       self.mEnded = True

# Función para ir a un cuadro determinado de la animación
# y seguir con los cuadros siguientes.
def gotoAndPlay(self, aFrame):
   if aFrame >= 0 and aFrame <= (len(self.mFrame) - 1):
       self.mCurrentFrame = aFrame
       self.setImage(self.mFrame[self.mCurrentFrame])
       self.mEnded = False
       self.mTimeFrame = 0

Ambas funciones al inicio controlan que no le pasemos un número de cuadro que esté fuera del array de frames (daría error si pasamos un número menor a cero o mayor a la cantidad de elementos del array menos uno).


En la función gotoAndStop() la variable self.mIsEnded se pone en True (como es un cuadro solo, se considera que la animación ha terminado), y en la función gotoAndPlay() se pone en False (porque la animación recién comienza).


Con esto, ya tenemos todo pronto para tener animaciones más evolucionadas. Ahora heredamos la clase CPlayer de CAnimatedSprite, inicializamos la animación y utilizamos gotoAndStop() para ir a un cuadro determinado cuando nos movemos (en la función move()). A continuación vemos en negrita los cambios realizados en la clase CPlayer:


...

# Importar Pygame.
import pygame

# Importar la clase CAnimatedSprite.
from api.CAnimatedSprite import *

...

# La clase CPlayer hereda de CAnimatedSprite.
class CPlayer(CAnimatedSprite):

   ,,,

   def __init__(self, aType):

       # Invocar al constructor de CAnimatedSprite.
       CAnimatedSprite.__init__(self)

       # Segun el tipo de la nave, la imagen que se carga.
       self.mType = aType

       if self.mType == CPlayer.TYPE_PLAYER_1:
           imgFile = "assets/images/player0"
       elif self.mType == CPlayer.TYPE_PLAYER_2:
           imgFile = "assets/images/player1"

       self.mFrames = []
       i = 0
       while (i <= 2):
           tmpImg = pygame.image.load(imgFile + str(i) + ".png")
           tmpImg = tmpImg.convert_alpha()
           self.mFrames.append(tmpImg)
           i = i + 1

       # Control del tiempo en el estado actual.
       self.mTimeState = 0

       # Variable para hacer parpadear el sprite.
       self.mCounter = 0

       # Estado inicial.
       self.mState = CPlayer.NORMAL
       self.setState(CPlayer.NORMAL)

   # Mover el objeto.
   def update(self):

       # Invocar update() de CAnimatedSprite.
       CAnimatedSprite.update(self)

       ...

   # Dibuja el objeto en la pantalla.
   # Parámetros:
   # aScreen: La superficie de la pantalla en donde dibujar.
   def render(self, aScreen):

       # Si es estado normal, dibujar normalmente.
       if self.mState == CPlayer.NORMAL:
           CAnimatedSprite.render(self, aScreen)
       # Si es estado muriendo, dibujar cuadro por medio.
       elif self.mState == CPlayer.DYING:
           if self.mCounter % 8 == 0:
               CAnimatedSprite.render(self, aScreen)
       # Si está explotando, por ahora no hacemos nada.
       elif self.mState == CPlayer.EXPLODING:
           pass
       # Si el estado es start, parpadear mas lento.
       elif self.mState == CPlayer.START:
           if self.mCounter % 16 == 0:
               CAnimatedSprite.render(self, aScreen)

   # Liberar lo que haya creado el objeto.
   def destroy(self):

       # Invocar destroy() de CAnimatedSprite.
       CAnimatedSprite.destroy(self)

       ...

   # Establece el estado actual e inicializa
   # las variables correspondientes al estado.
   def setState(self, aState):

       self.mState = aState

       # Resetear el tiempo del estado actual.
       self.mTimeState = 0

       if self.mState == CPlayer.NORMAL:
           self.initAnimation(self.mFrames, 0, 0, False)
           self.gotoAndStop(0)

       elif self.mState == CPlayer.DYING:
           self.mCounter = 0
           self.stopMove()
           self.initAnimation(self.mFrames, 0, 0, False)
           self.gotoAndStop(0)

       elif self.mState == CPlayer.EXPLODING:
           pass

       elif self.mState == CPlayer.START:
           self.mCounter = 0
           self.initAnimation(self.mFrames, 0, 0, False)
           self.gotoAndStop(0)

   # Movimiento de la nave.
   def move(self):
       # Obtener los controles.
       if self.mType == CPlayer.TYPE_PLAYER_1:
           left = CKeyboard.inst().leftPressed()
           right = CKeyboard.inst().rightPressed()
           fire = CKeyboard.inst().fire()
       else:
           left = CKeyboard.inst().APressed()
           right = CKeyboard.inst().DPressed()
           fire = CKeyboard.inst().fire2()

       # Mover la nave según las teclas.
       if (not left and not right):
           self.setVelX(0)
           self.gotoAndStop(0)
       else:
           if left:
               self.setVelX(-4)
               self.gotoAndStop(1)
           elif right:
               self.setVelX(4)
               self.gotoAndStop(2)

       ...

Hay un cambio conceptual importante en la función setState(). Como vemos en esta función, en cada estado ahora inicializamos la animación. En lugar de hacerlo en la función init() y luego asumir que ya está inicializada, es una mejor práctica de programación inicializar todas las variables cada vez que cambiamos de estado, reduciendo de esta forma la posibilidad de errores.


Al inicializar la animación le pasamos False como último parámetro, indicando que esta animación no es cíclica y usamos gotoAndStop(0) para ir al primer cuadro.


Nota: Si no llamamos a initAnimation() nos dará error en CAnimatedSprite, dado que esta clase utiliza un array de animaciones. Para evitar errores, es que en todos los estados asignamos el array invocando a initAnimation().


En el estado CPlayer.EXPLODING no hacemos nada por ahora (ni inicializar la animación ni mostrarla en render()), pero ya tenemos todo pronto para agregar una animación cuando el jugador explota, eso es lo que haremos a continuación.


Por último, necesitamos también modificar la llamada a initAnimation() desde el constructor de CNave, dado que ahora le hemos agregado un parámetro para el loop. Como las naves enemigas tienen animación cíclica, le pasamos True como parámetro.


class CNave(CAnimatedSprite):

   ...

   def __init__(self, aType):
       CAnimatedSprite.__init__(self)

       ...

  # Inicializar la animación.
  # Se pasa el array de imágenes, el primer frame,
  # el delay de la animación y si es loop o no.
  self.initAnimation(self.mFrames, 0, 8, True)

...


Agregando la Animación de la Explosión al Morir el Jugador


Vamos ahora a implementar la explosión de la nave del jugador. Cuando el jugador muere, pasa al estado CPlayer.EXPLODING, en el cual se muestra la animación de la explosión. Cuando la animación de la explosión termina, se pasará al estado CPlayer.START.


Colocamos en la carpeta de assets, las imágenes correspondientes a la explosión. Estas imagenes se nombran explosion00.png, explosion01.png hasta explosion09.png. La explosión contiene diez cuadros en total.



Figura 13-4: Cuadros de la animación de la explosión.



Mira el ejemplo que se encuentra en la carpeta capitulo_13\004_explosion_jugador. Ve a la carpeta assets\images y observa la secuencia de imágenes de la explosión. Como siempre, copia estos archivos a tu propio proyecto.


Si ejecutamos el juego, vemos que cuando una bala toca al jugador, éste explota. Para eso, hemos agregado en la clase CPlayer un array con la secuencia de imágenes que componen la animación de la explosión.


Veamos el código de la clase CPlayer, viendo solamente las partes del código agregadas que tienen que ver con el manejo de la explosión.


...

# La clase CPlayer hereda de CAnimatedSprite.
class CPlayer(CAnimatedSprite):

   ...

   def __init__(self, aType):

       # Invocar al constructor de CAnimatedSprite.
       CAnimatedSprite.__init__(self)

       # Segun el tipo de la nave, la imagen que se carga.
       self.mType = aType

       if self.mType == CPlayer.TYPE_PLAYER_1:
           imgFile = "assets/images/player0"
       elif self.mType == CPlayer.TYPE_PLAYER_2:
           imgFile = "assets/images/player1"

       self.mFrames = []
       i = 0
       while (i <= 2):
           tmpImg = pygame.image.load(imgFile + str(i) + ".png")
           tmpImg = tmpImg.convert_alpha()
           self.mFrames.append(tmpImg)
           i = i + 1

       # Cargar la secuencia de imágenes de la explosion.
       self.mFramesExplosion = []
       i = 0
       while (i <= 9):
           num = "0" + str(i)
           tmpImg = pygame.image.load("assets/images/explosion" + num + ".png")
           tmpImg = tmpImg.convert_alpha()
           self.mFramesExplosion.append(tmpImg)
           i = i + 1

       # Control del tiempo en el estado actual.
       self.mTimeState = 0

       # Variable para hacer parpadear el sprite.
       self.mCounter = 0

       # Estado inicial.
       self.mState = CPlayer.NORMAL
       self.setState(CPlayer.NORMAL)

   # Mover el objeto.
   def update(self):

       ...

       # Lógica del estado explotando.
       elif self.mState == CPlayer.EXPLODING:
           if self.isEnded():
               self.setState(CPlayer.START)
               return

       ...

   # Dibuja el objeto en la pantalla.
   # Parámetros:
   # aScreen: La superficie de la pantalla en donde dibujar.
   def render(self, aScreen):

       ...

       # Si está explotando, dibujar la explosión.
       elif self.mState == CPlayer.EXPLODING:
           CAnimatedSprite.render(self, aScreen)
       
	 ...

   # Liberar lo que haya creado el objeto.
   def destroy(self):

       # Invocar destroy() de CAnimatedSprite.
       CAnimatedSprite.destroy(self)

       i = len(self.mFrames)
       while i > 0:
           self.mFrames[i - 1] = None
           self.mFrames.pop(i - 1)
           i = i - 1
       mFrames = None

       i = len(self.mFramesExplosion)
       while i > 0:
           self.mFramesExplosion[i - 1] = None
           self.mFramesExplosion.pop(i - 1)
           i = i - 1
       self.mFramesExplosion = None

   # Establece el estado actual e inicializa
   # las variables correspondientes al estado.
   def setState(self, aState):

       self.mState = aState

       # Resetear el tiempo del estado actual.
       self.mTimeState = 0

       if self.mState == CPlayer.NORMAL:
           self.initAnimation(self.mFrames, 0, 0, False)
           self.gotoAndStop(0)

       elif self.mState == CPlayer.DYING:
           self.mCounter = 0
           self.stopMove()
           self.initAnimation(self.mFrames, 0, 0, False)
           self.gotoAndStop(0)

       elif self.mState == CPlayer.EXPLODING:
           self.initAnimation(self.mFramesExplosion, 0, 2, False)

       elif self.mState == CPlayer.START:
           self.mCounter = 0
           self.initAnimation(self.mFrames, 0, 0, False)
           self.gotoAndStop(0)

   ...

Hemos agregado en la función de inicialización la carga de las imágenes de las explosiones en un array llamado self.mFramesExplosion. Luego, en la función setState(), cuando se pasa al estado CPlayer.EXPLODING, se carga esa animación con la función initAnimation(). A esta función se le pasa el array de imágenes, cero para arrancar desde el primer cuadro, un delay de dos cuadros para que no se muestre tan rápido y False al final porque la animación no es cíclica.


En la función render() se agrega la línea en el estado CPlayer.EXPLODING para que muestre la animación y en la función update() se agrega la condición para terminar el estado, que es cuando termina la animación de la explosión.

Notar que para esto hemos usado la función isEnded() de CAnimatedSprite, que nos dice si la animación actual ha terminado o no. Cuando termina se pasa al estado CPlayer.START para que el jugador arranque nuevamente una vida.


Nota: En general, agregar un estado a un objeto del juego implica definir la constante para el estado, inicializar el estado en setState(), programar el comportamiento en update() y dibujar en render().

Nota: No olvidar nunca al final destruir lo que se haya creado en memoria. En el código anterior, vemos que en la función destroy() eliminamos las imágenes y el array creado para la animación de la explosión.


Agregando Explosiones a los Enemigos


Lo que haremos ahora es similar a lo que hemos hecho con el jugador. Vamos a hacer que cuando una bala del jugador toque a la nave enemiga, la haga explotar. Para esto, debemos hacer una máquina de estados (nuestro enemigo aún no tiene máquina estados) con dos estados: CNave.NORMAL y CNave.EXPLODING. Luego de eso debemos cargar las imágenes de la animación de la explosión, y colocaremos como primer estado el estado CNave.NORMAL.



Como siempre, crear estados consiste en definir las constantes para cada estado, establecer un estado inicial invocando a la función setState(), inicializar el estado en setState(), y programar su comportamiento en la función update() y su dibujado en la función render().


La máquina de estados que hemos definido para la nave enemiga tiene dos estados. Del estado normal pasa al estado explotando cuando lo toca una bala. Luego que termina la animación de la explosión, el objeto se muere.

La máquina de estados de definida se muestra en la siguiente figura:



Figura 13-5: Máquina de estados de los enemigos.



Vamos el ejemplo que se encuentra en la carpeta capitulo_13\005_explosion_enemigos. Cuando acertamos un disparo, el enemigo muere con una explosión.


La explosión es la misma secuencia de imágenes que usamos para el jugador. Hemos usado los mismos assets (gráficos) temporalmente (placeholders). Más adelante los podemos cambiar.

Veamos el código de la clase CNave con los cambios que implementan los estados y la explosión.


...

# La clase CNave hereda de CAnimatedSprite.
class CNave(CAnimatedSprite):

   # Tipos de naves.
   TYPE_PLATINUM = 0
   TYPE_GOLD = 1
   TYPE_RED = 2
   TYPE_GREEN = 3
   TYPE_CYAN = 4

   # Máquina de estados.
   NORMAL = 0
   EXPLODING = 1

   # ----------------------------------------------------------------
   # Constructor. Recibe el tipo de nave (TYPE_PLATINUM o TYPE_GOLD).
   # ----------------------------------------------------------------
   def __init__(self, aType):
       CAnimatedSprite.__init__(self)

       ...

       # Cargar la secuencia de imágenes.
       self.mFramesNormal = []
       i = 0
       while (i <= 8):
           tmpImg = pygame.image.load(imgFile + str(i) + ".png")
           tmpImg = tmpImg.convert_alpha()
           self.mFramesNormal.append(tmpImg)
           i = i + 1

       # Cargar la secuencia de imágenes de la explosion.
       self.mFramesExplosion = []
       i = 0
       while (i <= 9):
           num = "0" + str(i)
           tmpImg = pygame.image.load("assets/images/explosion" + num + ".png")
           tmpImg = tmpImg.convert_alpha()
           self.mFramesExplosion.append(tmpImg)
           i = i + 1

       # Control del tiempo en el estado actual.
       self.mTimeState = 0

       # Estado inicial.
       self.mState = CNave.NORMAL
       self.setState(CNave.NORMAL)

   # Mover el objeto.
   def update(self):

       # Invocar update() de CAnimatedSprite.
       CAnimatedSprite.update(self)

       if self.mState == CNave.NORMAL:
           self.controlFire()

       elif self.mState == CNave.EXPLODING:
           if self.isEnded():
               self.die()
               return

   # Dibuja el objeto en la pantalla.
   # Parámetros:
   # aScreen: La superficie de la pantalla en donde dibujar.
   def render(self, aScreen):

       # Invocar render() de CAnimatedSprite.
       CAnimatedSprite.render(self, aScreen)

   # Liberar lo que haya creado el objeto.
   def destroy(self):

       # Invocar destroy() de CAnimatedSprite.
       CAnimatedSprite.destroy(self)

       # Eliminar todos los frames creados.
       i = len(self.mFramesNormal)
       while i > 0:
           self.mFramesNormal[i - 1] = None
           self.mFramesNormal.pop(i - 1)
           i = i - 1
       # Eliminar el array.
       self.mFramesNormal = None

       i = len(self.mFramesExplosion)
       while i > 0:
           self.mFramesExplosion[i - 1] = None
           self.mFramesExplosion.pop(i - 1)
           i = i - 1
       self.mFramesExplosion = None

   def setState(self, aState):
       self.mState = aState
       self.mTimeState = 0

       if self.mState == CNave.NORMAL:
           self.initAnimation(self.mFramesNormal, 0, 0, True)
       elif self.mState == CNave.EXPLODING:
           self.initAnimation(self.mFramesExplosion, 0, 0, False)
           self.stopMove()

   # Invocada desde Bullet cuando la Nave es alcanzada por una bala.
   def hit(self):
       self.setState(CNave.EXPLODING)
       print("NAVE EXPLOTA")

   def controlFire(self):
       # Ver si la nave dispara.
       if random.randrange(1, 500) == 1:
           b = CEnemyBullet()
           b.setXY(self.getX() + self.getWidth() / 2 - b.getWidth() / 2, self.getY() + self.getHeight())
           b.setVelX(0)
           b.setVelY(10)
           b.setBounds(0, 0, CGameConstants.SCREEN_WIDTH, CGameConstants.SCREEN_HEIGHT)
           b.setBoundAction(CGameObject.DIE)
           CEnemyManager.inst().add(b)

Si examinamos el código de la clase CNave, vemos que el código para la máquina de estados no es muy diferente a lo que escribimos en la clase CPlayer. Observa bien el código para intentar seguir su lógica. Lo que se hace para agregar estados es lo de siempre: se definen las constantes de los estados, se cargan las animaciones en el constructor, se programa la inicialización de los estados en la función setState(), se programa el comportamiento en la función update(), se dibuja en render(), y finalmente se libera toda la memoria creada en la función destroy().


Cuando la nave es alcanzada por una bala, se invoca a la función hit() en la nave. La función hit() simplemente pasa al estado CNave.EXPLODING. Si vemos en la función update(), al terminar la animación de la explosión, se mata el objeto (se destruye) invocando a self.die(). Recuerda que la función die() prende una variable indicando que está marcado para morir, lo que hace que el manager de enemigos lo destruya. Si no recuerdas esto, examina el código de CManager.


A continuación se muestra en la clase CBullet (la bala del jugador) cómo se invoca a la función hit() en el enemigo cuando éste es alcanzado. Antes se lo mataba con enemy.die() y ahora se cambió por enemy.hit() para hacer que el enemigo explote (que pase por el estado CNave.EXPLODING), antes de eliminarse del todo.


...

# 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.hit()
           self.die()

...

Entonces, es la función update() de la clase CPlayerBullet, cuando la bala choca con un enemigo, ya no va a invocar a die() porque sino el enemigo se mata instantáneamente. Lo que haremos ahora es invocar a la función hit() para informarle que le pegó una bala. Esta función hit() entonces pasa la nave al estado CNave.EXPLODING, que mostrará la explosión. Luego que la explosión termine, se invoca a la función die().


Nota: Como tanto las naves enemigas como las balas enemigas están en el mismo manager de enemigos, cuando una bala del jugador les pega, se les invoca la función hit(). Por lo tanto, debemos definir esta función en la clase CEnemyBullet, de lo contrario dará error si una bala del jugador choca contra una bala enemiga.


...

class CEnemyBullet(CSprite):

   ...

   # Invocada desde Bullet cuando la bala enemiga es alcanzada por una bala del jugador.
   def hit(self):
       self.die()
       print("COLISION BALA CONTRA BALA")

Nota: En este ejemplo hemos inicializado la explosión de los enemigos con cero de delay (la velocidad de la animación), así explota muy rápido y queda más lindo. De lo contrario la explosión permanece mucho tiempo en el mismo lugar mientras las otras naves le pasan por encima y eso no queda del todo lindo. Detalles como estos abundan al desarrollar videojuegos, y llevan bastante tiempo (a esto se le llama pulir o polishing). ¡Hay que tenerlo en cuenta!


Ignorando las Colisiones de las Balas contra las Explosiones


En el ejemplo anterior, si disparamos varias balas seguidas y le acertamos a un enemigo, vemos que el enemigo explota, pero las balas siguen chocando con la explosión y la hace explotar nuevamente (prueba dispararle muy rápido al mismo enemigo). Esto es un error muy común al desarrollar juegos, y se debe a que cada vez que choca una bala con un enemigo, se le llama a la función hit() de este último. Pero si el enemigo ya está en estado explotando, también se le llama a la función hit(), que lo vuelve a poner en el estado explotando y por lo tanto comienza la explosión nuevamente.


Esto se arregla fácil, poniendo en la clase CNave, un control en la función hit(), haciendo que explote solo si está en estado normal. De esta forma, si ya estaba explotando no lo vuelve a hacer.


# Invocada desde Bullet cuando la Nave es alcanzada por una bala.
def hit(self):
   if self.mState == CNave.NORMAL:
       self.setState(CNave.EXPLODING)
       print("NAVE EXPLOTA")

Esto soluciona el hecho de que la nave vuelve a explotar, pero aún falta hacer que la bala del jugador no se muera al chocar contra una explosión. Entonces, un control similar hay que hacer desde la bala del jugador, para no invocar a die() que mata la bala, si la colisión es con una explosión (o sea, colisiona con el enemigo pero éste está explotando, por lo cual la bala debe seguir de largo, y no morir contra en la explosión).


En la función update() de la bala del jugador (clase CPlayerBullet), chequeamos que la colisión sea contra el enemigo en estado normal para que sea una colisión válida y matar la bala. Sino, se ignora la colisión y la bala sigue de largo.


...

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:
           if enemy.isStateNormal():
               print("*****COLISION ENTRE BALA DEL JUGADOR Y ENEMIGO")
               enemy.hit()
               self.die()

...

En la clase CNave, implementamos la función isStateNormal() que retorna True si la nave se encuentra en estado normal:


# Retorna True si la nave está en estado normal.
def isStateNormal(self):
   return self.mState == Nave.NORMAL

Nota: Dado que las balas enemigas y las naves se encuentran en el mismo manager de enemigos, también tenemos que implementar esta función en esa clase. En este caso la función retorna solamente False para que las balas del jugador y las balas enemigas no choquen entre sí. Se agrega entonces esta función también en la clase CEnemyBullet:


# La bala enemigas no chocan contra las balas del jugador.
def isStateNormal(self):
   return False

Ejecuta el ejemplo que se encuentra en la carpeta capitulo_13\006_balas_ignoaran_explosiones y mira el código para ver implementados estos últimos cambios. Dispara muy seguido para ver que ahora las balas del jugador se comportan correctamente y no hacen reiniciar las explosiones ni se mueren las balas contra las explosiones.


Nota: Hemos hecho que las balas del jugador no choquen contra las balas enemigas, pero esto es una cuestión de gustos. No tiene porqué ser así. Tu puedes modificar lo que desees para cambiar el juego y hacerlo diferente, a tu gusto.


Reorganizando el Código de CSprite y CGameObject para Reutilizar


Para terminar este capítulo, haremos una breve reestructura (a esto se le denomina refactor) del código para evitar partes duplicadas. Como siempre, hacer esto siempre nos permite tener objetos más reusables y más fáciles de manejar.


Si vemos el código de CPlayer y CNave, tenemos cosas duplicadas (algunas variables y funciones). Por ejemplo, la variable self.mState, dado que ambas clases implementan una máquina de estado, es candidata a ser colocada en una clase base (la pondremos en CGameObject), para que no esté duplicada en cada clase. Recuerda que lo que se ponga en una clase base (variables y funciones), estará disponible para todas las clases que la heredan.


Otra cosa que pondremos en la clase CGameObject es el control del tiempo que el objeto está en el estado actual (variable self.mTimeState). Esto ahora lo tenemos en las dos clases (CPlayer y CNave) y lo usamos para pasar de un estado a otro cuando pasa cierto tiempo. Si este comportamiento lo quisiéramos en cualquier otra clase, deberíamos duplicarlo.

Por este motivo es que la lógica de control de tiempo de los estados también la pondremos en CGameObject.


Recuerda que el código que lleva la cuenta del tiempo del estado actual funciona así: en la función setState() se inicializa la variable self.mTimeState en cero, y en la función update() se incrementa. Todo esto ahora lo colocaremos en CGameObject.


Abre el ejemplo que se encuentra en la carpeta capitulo_13\007_reorganizando_el_codigo y mira la clase CGameObject para ver estos cambios que se han explicado arriba. Ahora cualquier game object soporta tener una máquina de estados. Miremos los cambios en la clase CGameObject:


...

class CGameObject(object):

   ...

   def __init__(self):

       ...

       # Indica si el objeto está vivo o muerto (debe morir).
       self.mIsDead = False

       # Estado actual.
       self.mState = 0

       # Control del tiempo en el estado actual.
       self.mTimeState = 0

   ...

   # Update mueve el objecto según su velocidad.
   def update(self):

       # Incrementar el tiempo del estado actual.
       self.mTimeState = self.mTimeState + 1

       # Modificar la velocidad según la aceleración.
       self.mVelX += self.mAccelX
       self.mVelY += self.mAccelY

       # Mover el objeto.
       self.mX += self.mVelX
       self.mY += self.mVelY

       # Comportamiento con el borde.
       self.checkBounds()

   ...

   # Retorna el estado actual.
   def getState(self):
      return self.mState

   # Establece el estado actual.
   def setState(self, aState):
      self.mState = aState
      # Resetear el tiempo del estado actual.
      self.mTimeState = 0

   # Retorna el tiempo en el estado actual.
   def getTimeState(self):
      return self.mTimeState

Como vemos en el código de CGameObject, hemos agregado las funciones getState() para retornar el estado actual, setState() que establece el nuevo estado actual y resetea la cuenta del tiempo y la función getTimeState() que retorna el tiempo (en frames) que el objeto está en el estado actual.


En la función update() se incrementa la variable de control del tiempo del estado actual y cada vez que se cambia de estado, se resetea la cuenta (resetear una variable significa ponerla en su valor inicial, generalmente cero).


En las clases CPlayer y CNave, se elimina la variable self.mState y se cambia por la función self.getState(). También, la función setState() de la clase CPlayer y CNave, ahora invocan a la función setState() de la clase base. Esto es lo mismo que ocurre con las funciones render() y update() que invocan a sus respectivas funciones de la clase base. Siempre que se tenga una función en la clase base y se usen en la clase superior, se deben invocar.


Veamos los cambios de CPlayer:


...

# La clase CPlayer hereda de CAnimatedSprite.
class CPlayer(CAnimatedSprite):

   ...

   # ----------------------------------------------------------------
   # Constructor. Recibe el tipo de nave (TYPE_PLAYER_1 o TYPE_PLAYER_2).
   # ----------------------------------------------------------------
   def __init__(self, aType):

       # Invocar al constructor de CAnimatedSprite.
       CAnimatedSprite.__init__(self)

       ...

       # Variable para hacer parpadear el sprite.
       self.mCounter = 0

       # Estado inicial.
       self.setState(CPlayer.NORMAL)

   # Mover el objeto.
   def update(self):

       # Invocar update() de CAnimatedSprite.
       CAnimatedSprite.update(self)

       # Actualizar el contador para parpadear.
       self.mCounter = self.mCounter + 1

       # Lógica del estado normal.
       if self.getState() == CPlayer.NORMAL:
           if CEnemyManager.inst().collides(self):
               self.setState(CPlayer.DYING)
               return
           self.move()

       # Lógica del estado muriendo.
       elif self.getState() == CPlayer.DYING:
           if (self.getTimeState() > CPlayer.TIME_DYING):
               self.setState(CPlayer.EXPLODING)
               return

       # Lógica del estado explotando.
       elif self.getState() == CPlayer.EXPLODING:
           if self.isEnded():
               self.setState(CPlayer.START)
               return

       # Lógica del estado start.
       elif self.getState() == CPlayer.START:
           if (self.getTimeState() > CPlayer.TIME_START):
               self.setState(CPlayer.NORMAL)
               return
           self.move()

   # Dibuja el objeto en la pantalla.
   # Parámetros:
   # aScreen: La superficie de la pantalla en donde dibujar.
   def render(self, aScreen):

       # Si es estado normal, dibujar normalmente.
       if self.getState() == CPlayer.NORMAL:
           CAnimatedSprite.render(self, aScreen)
       # Si es estado muriendo, dibujar cuadro por medio.
       elif self.getState() == CPlayer.DYING:
           if self.mCounter % 8 == 0:
               CAnimatedSprite.render(self, aScreen)
       # Si está explotando, dibujar la explosión.
       elif self.getState() == CPlayer.EXPLODING:
           CAnimatedSprite.render(self, aScreen)
       # Si el estado es start, parpadear mas lento.
       elif self.getState() == CPlayer.START:
           if self.mCounter % 16 == 0:
               CAnimatedSprite.render(self, aScreen)

   ...

   # Establece el estado actual e inicializa
   # las variables correspondientes al estado.
   def setState(self, aState):
       CAnimatedSprite.setState(self, aState)

       if self.getState() == CPlayer.NORMAL:
           self.initAnimation(self.mFrames, 0, 0, False)
           self.gotoAndStop(0)

       elif self.getState() == CPlayer.DYING:
           self.mCounter = 0
           self.stopMove()
           self.initAnimation(self.mFrames, 0, 0, False)
           self.gotoAndStop(0)

       elif self.getState() == CPlayer.EXPLODING:
           self.initAnimation(self.mFramesExplosion, 0, 2, False)

       elif self.getState() == CPlayer.START:
           self.mCounter = 0
           self.initAnimation(self.mFrames, 0, 0, False)
           self.gotoAndStop(0)

   ...

Los cambios en CNave son similares:


...

# La clase CNave hereda de CAnimatedSprite.
class CNave(CAnimatedSprite):

   ...

   def __init__(self, aType):
       CAnimatedSprite.__init__(self)

       ...

       # Estado inicial.
       self.setState(CNave.NORMAL)

   # Mover el objeto.
   def update(self):

       # Invocar update() de CAnimatedSprite.
       CAnimatedSprite.update(self)

       if self.getState() == CNave.NORMAL:
           self.controlFire()

       elif self.getState() == CNave.EXPLODING:
           if self.isEnded():
               self.die()
               return

   ...

   def setState(self, aState):
       CAnimatedSprite.setState(self, aState)

       if self.getState() == CNave.NORMAL:
           self.initAnimation(self.mFramesNormal, 0, 0, True)
       elif self.getState() == CNave.EXPLODING:
           self.initAnimation(self.mFramesExplosion, 0, 0, False)
           self.stopMove()

   # Invocada desde Bullet cuando la Nave es alcanzada por una bala.
   def hit(self):
       if self.getState() == CNave.NORMAL:
           self.setState(CNave.EXPLODING)
           print("NAVE EXPLOTA")

   def controlFire(self):
       # Ver si la nave dispara.
       if random.randrange(1, 500) == 1:
           b = CEnemyBullet()
           b.setXY(self.getX() + self.getWidth() / 2 - b.getWidth() / 2, self.getY() + self.getHeight())
           b.setVelX(0)
           b.setVelY(10)
           b.setBounds(0, 0, CGameConstants.SCREEN_WIDTH, CGameConstants.SCREEN_HEIGHT)
           b.setBoundAction(CGameObject.DIE)
           CEnemyManager.inst().add(b)

   # Retorna True si la nave está en estado normal.
   def isStateNormal(self):
       return self.getState() == CNave.NORMAL

A esta reorganización del código que hemos hecho, le agregaremos uno relacionado al parpadeo de los sprites, y con esto terminaremos este capítulo.


Haciendo Parpadear un Sprite (Visible / Invisible)


Cuando el jugador es alcanzado por una bala enemiga, parpadea rápido antes de explotar y luego al volver a la vida, parpadea más lento mientras es inmune.

Este parpadeo (en inglés se denomina flicker) es algo que puede ser muy común en los juegos. Sin embargo, ahora lo tenemos programado solamente en la clase CPlayer. Para hacer parpadear el sprite, lo que estamos haciendo es saltear su dibujado cada cierta cantidad de frames. Lo que haremos ahora, es mejorar cómo está hecho esto, de forma tal que pueda ser reusado por todos los objetos del juego.


Vamos a poner una función para controlar el parpadeo del sprite. La pondremos en la clase CSprite y no en CGameObject porque el parpadeo está asociado a mostrar o no una imagen (un sprite), y CGameObject no tiene imagen (es un objeto abstracto).


Haremos en la clase CSprite una función setVisible() que recibe True como parámetro si el sprite se muestra en pantalla y False si queremos que no se dibuje. Utilizaremos esto para hacer el parpadeo.


Abre el ejemplo que se encuentra en la carpeta capitulo_13\008_parpadeo_de_sprite y mira la clase CSprite, cuyo código se muestra a continuación:


...

class CSprite(CGameObject):

   # Constructor:
   def __init__(self):

       CGameObject.__init__(self)
      
       # Imagen (superficie) del sprite.
       # Usar setImage() en la clase superior para establecer una imagen.
       self.mImg = None

       # Ancho y alto de la imagen. La función setImage() los actualiza.
       self.mWidth = 0
       self.mHeight = 0

       # Indica si el sprite es visible o no.
       self.mVisible = True

   ...

   # Dibuja el objeto en la pantalla.
   # Parámetros: La superficie de la pantalla donde dibujar la imagen.
   def render(self, aScreen):

       if (self.mImg != None):
           if self.mVisible:
               aScreen.blit(self.mImg, (self.getX(), self.getY()))

   ...

   # Establece si el sprite es visible o no.
   # Parámetro: True para que se dibuje, False para que no se dibuje.
   def setVisible(self, aVisible):
       self.mVisible = aVisible

   # Retorna True si el sprite es visible y False si no lo es.
   def isVisible(self):
       return self.mVisible

En la clase CSprite, hemos agregado una variable self.mVisible que indicará si el sprite está visible o no. Esta variable se usa en la función render() para determinar si se dibuja o no el sprite. Luego, tenemos dos funciones, setVisible() y getVisible() para establecer y obtener la visibilidad del sprite respectivamente.


En la clase CPlayer, quitamos la variable self.mCounter que usábamos para hacer parpadear el sprite, y ahora usamos la variable que lleva el tiempo en el estado actual, dado que solamente es un número que crece y es lo que necesitamos. No hay necesidad de tener varias variables para lo mismo.


Ahora, en la clase CPlayer, simplemente llamamos a setVisible(True) o setVisible(False) según el contador, para hacer parpadear al sprite en los estados CPlayer.DYING y CPlayer.START. Miremos la función render() de la clase CPlayer:


def render(self, aScreen):

   # Poner visible o invisible según el caso.
   if self.getState() == CPlayer.DYING:
       if self.getTimeState() % 8 == 0:
           self.setVisible(True)
       else:
           self.setVisible(False)
   elif self.getState() == CPlayer.START:
       if self.getTimeState() % 16 == 0:
           self.setVisible(True)
       else:
           self.setVisible(False)
   else:
       self.setVisible(True)

   CAnimatedSprite.render(self, aScreen)

Por último, en la clase CPlayer, en la función setState() que se invoca cuando se pasa de estado, se pone el sprite visible. Esto es para que si al cambiar de estado justo cuando el sprite está invisible, no quede invisible. De esta forma cada vez que se pasa de estado el sprite vuelve a estar visible.


def setState(self, aState):
   CAnimatedSprite.setState(self, aState)

   # Por defecto poner el sprite visible.
   self.setVisible(True)

   ...

Nota: Es una buena práctica de programación el inicializar todas las variables cuando se pasa de estado. De esta forma se evitan potenciales problemas que se pueden dar al pasar entre estados dejando variables con valores pertenecientes a otros estados.


Con esto hemos terminado el capítulo de máquinas de estados. Como vemos, la máquina de estados es una técnica muy poderosa, y es una de las claves para hacer buenos videojuegos. Los estados se relacionan con las acciones de los objetos y con sus animaciones.


En el siguiente capítulo veremos el manejo de los sonidos y la música, otro de los aspectos fundamentales en un juego.



69 visualizaciones0 comentarios

Entradas Recientes

Ver todo
bottom of page