En este capítulo implementaremos las diferentes pantallas que va a tener el juego: el menú principal, la pantalla de créditos, la pantalla de ayuda, etc.
Para esto, haremos una máquina de estados del juego. Cada estado corresponderá a cada una de las pantallas del juego, y cuando pasemos de un estado a otro, lo que haremos será pasar de una pantalla a otra. Si recordamos cuando hicimos la máquina de estados del jugador y de los enemigos, una máquina de estados se dibuja como un diagrama en donde se muestran los estados y cómo se pasa de un estado a otro (las transiciones entre estados).
Pues bien, esto nos sirve perfectamente para las pantallas del juego. Si dibujamos las pantallas del juego y cómo se pasa de una a otra, nos quedará el diagrama de la máquina de estados de las pantallas del juego. Comenzaremos con el menú y el juego.
Figura 17-1: Las pantallas del juego.
En la mayoría de las pantallas, pasamos de una a otra pantalla seleccionando los botones correspondientes o pulsando alguna tecla. Cuando estamos en el juego y termina el mismo, volvemos al menú principal. Hay diferentes condiciones para las transiciones entre pantallas, pero la máquina de estados funcionará de forma similar a las que hemos implementado antes. A continuación comenzaremos a programar la máquina de estados del juego.
La clase CGame
Para poder tener varias pantallas en el juego, y poder pasar entre una y otra, debemos tener una clase que controle eso. Esa clase tendrá el estado actual del juego y una función llamada setState() para pasar de un estado a otro. Esta clase será una clase singleton y la denominaremos CGame. La pondremos en la carpeta api, porque será una clase que pertenece al motor del juego. De hecho, la clase CGame va a sustituir a main.py como clase principal, como ya veremos.
La clase CGame se encargará de controlar el estado actual del juego, y tendrá una función setState() para cambiar entre estados del juego (cambiar entre pantallas). Al cambiar de pantalla, se va a invocar a la función destroy() del estado actual (para eliminar lo creado al momento) y se invoca a init() en el nuevo estado (para crear lo necesario para la nueva pantalla). Ya veremos el código en un momento.
La clase CGame también llevará el control del puntero del mouse, y en general de todo lo que afecte al juego como un todo (o sea, a las cosas a nivel de la aplicación).
La clase CGameState
La clase CGameState será la clase base de cualquier estado del juego. Cuando decimos “estado del juego” nos estamos refiriendo a una de las pantallas del juego. Esta clase, tendrá cuatro funciones: init(), update(), render() y destroy().
La clase CGame se encargará de que cada estado del juego llame a init() cuando se crea, luego entra en el ciclo update() / render() y al final, al salir del estado se llama a destroy() para eliminar lo creado en la pantalla.
Cualquier clase que implemente una de las pantallas del juego, será un estado del juego y esa clase heredará de CGameState. Esta clase tendrá las funciones vacías, porque implementar estas funciones es responsabilidad de las clases de estados que hagamos.
La clase CLevelState
Hasta ahora teníamos todo el código en el programa main.py, pero esto no es conveniente si vamos a tener muchas pantallas en el juego. Cuando ejecutamos el programa, ahora ya arranca en el juego, pero esto no va a ser así. El juego va a empezar en una pantalla con el menú principal. Luego si elegimos la opción de jugar, pasaremos al juego.
Quedaría muy complicado y largo el código en la clase main.py si programáramos todas las pantallas en ese programa sin usar estados y/o clases. Entonces, lo que haremos es separar cada pantalla (estado) en clases diferentes y de esta forma el código quedará más simple y fácil de modificar y en el futuro será más fácil agregar pantallas.
Entonces, lo que haremos será poner el código relacionado al juego en sí (el nivel) en la clase CLevelState. Esta clase se encarga del juego cuando el jugador está jugando, y corresponde a la clase del juego o el nivel en sí, por eso su nombre. Esto es lo que haremos a continuación.
La Máquina de Estados del Juego
Para que podamos pasar entre pantallas y tengamos diferentes estados en el juego, debemos programar el motor para que esto sea posible. Debemos implementar las clases CGame, CGameState y CLevelState. Luego tendremos un programa main.py con un código muy chico que lo que hace es inicializar la máquina de estados del juego y poner el juego en el estado inicial.
Nota: La reestructura que haremos en el siguiente ejemplo no es tan sencilla de entender sin examinar bien el código, pero nos permitirá agregar muy fácil el resto de las pantallas del juego. Como los cambios más importantes están en el motor, es posible usarlos sin entender del todo cómo funciona en detalle. Puedes construir un juego partiendo de los ejemplos, y dejar para más adelante el entendimiento completo del funcionamiento del motor.
Abre y ejecuta el ejemplo ubicado en la carpeta capitulo_17\001_estados_del_juego_level_state.
Como puedes ver, el juego es exactamente el mismo del capítulo anterior, pero se han implementado las clases que han sido descritas arriba.
Nota: Parece que hacemos un montón de cosas complicadas y el juego sigue haciendo lo mismo que antes, pero no es así. De esta forma en que componemos el código nos será muy fácil agregar nuevas pantallas. De todas formas, recuerda que mientras aprendemos cosas nuevas estamos escribiendo código de base y reestructurando las clases a medida que avanzamos, pero cuando hagamos un juego nuevo, será mucho más fácil, porque ya tendremos todas las cosas de base funcionando en un motor.
Ahora veamos el código de cada una de las nuevas clases.
La clase CGameState, es simplemente un template (una clase base) de la cual van a heredar los estados (las pantallas del juego). Esta clase va en la carpeta api, dado que es parte del motor. Tiene las funciones init(), render(), update() y destroy() vacías porque la clase que la hereda debe implementarlas. Veamos el código:
# -*- coding: utf-8 -*-
#-------------------------------------------------------------------
# Clase CCGameState.
# Clase base de todos los estados del juego. Los estados del juego heredan de
# esta clase porque se les invoca los métodos init(), update(), render() y destroy()
# desde la clase CGame que es quien maneja los estados.
#
# Autor: Fernando Sansberro - Batovi Games Studio
# Proyecto: Hacete tu Videojuego.
# Licencia: Creative Commons. BY-NC-SA.
#-------------------------------------------------------------------
class CGameState(object):
def __init__(self):
print("GameState constructor")
def init(self):
print("GameState init()")
def update(self):
print("GameState update()")
def render(self):
print("GameState render()")
def destroy(self):
print("GameState destroy()")
Ahora hemos puesto sentencias print para probar que el programa funciona correctamente e invoca a estas funciones. Luego pondremos sentencias pass en lugar de print, si la función no hace nada.
La clase CGame, contiene la lógica de la aplicación. Esta clase ahora tiene el código que antes tenía main.py, sacando todo lo relacionado al juego en sí, que eso ya no irá en el programa principal. Se mueve todo el código de main.py a la clase CGame.
La clase CGame también es parte del motor, por lo que la colocamos en la carpeta api. Veamos el código:
# -*- coding: utf-8 -*-
#--------------------------------------------------------------------
# Clase CGame.
# Clase que maneja los estados del juego a nivel de aplicación.
#
# Autor: Fernando Sansberro - Batovi Games Studio
# Proyecto: Hacete tu Videojuego.
# Licencia: Creative Commons. BY-NC-SA.
#--------------------------------------------------------------------
# Importar Pygame.
import pygame
# Importar la clase CKeyboard
from api.CKeyboard import *
# Importar la clase del mouse.
from api.CMouse import *
# Importar la clase del puntero del mouse.
from game.CMousePointer import *
# Importar el garbage collector.
import gc
# Importar la clase de constantes.
from api.CGameConstants import *
class CGame(object):
mInstance = None
mInitialized = False
mScreen = None
mClock = None
mSalir = False
mMousePointer = None
# Pantalla (estado) actual del juego.
mState = None
SCREEN_WIDTH = 0
SCREEN_HEIGHT = 0
RESOLUTION = 0
# Control del modo ventana o fullscreen.
mIsFullscreen = False
def __new__(self, *args, **kargs):
if (CGame.mInstance is None):
CGame.mInstance = object.__new__(self, *args, **kargs)
self.init(CGame.mInstance)
else:
print("Cuidado: Game(): No se debería instanciar más de una vez esta clase. Usar Game.inst().")
return self.mInstance
@classmethod
def inst(cls):
if (not cls.mInstance):
return cls()
return cls.mInstance
# Función de inicialización.
def init(self):
if (CGame.mInitialized):
return
CGame.mInitialized = True
# Definir ancho y alto de la pantalla.
CGame.SCREEN_WIDTH = CGameConstants.SCREEN_WIDTH
CGame.SCREEN_HEIGHT = CGameConstants.SCREEN_HEIGHT
CGame.RESOLUTION = (CGame.SCREEN_WIDTH, CGame.SCREEN_HEIGHT)
# Inicializar Pygame.
pygame.init()
# Inicializar el mixer de audio de Pygame.
pygame.mixer.init()
# Poner el modo de video en ventana e indicar la resolución.
CGame.mScreen = pygame.display.set_mode(CGame.RESOLUTION)
# Poner el título de la ventana.
pygame.display.set_caption("Mi Juego")
# Crear la superficie del fondo o background.
CGame.mBackground = pygame.Surface(self.mScreen.get_size())
CGame.mBackground = self.mBackground.convert()
# Inicializar el reloj.
CGame.mClock = pygame.time.Clock()
# Inicializar la variable de control del game loop.
CGame.mSalir = False
# Sprite del puntero del mouse.
CGame.mMousePointer = CMousePointer()
# Ocultar el mouse del sistema.
pygame.mouse.set_visible(False)
CGame.mState = None
# Función para cambiar entre pantallas (estados) del juego.
def setState(self, aState):
if (CGame.mState != None):
CGame.mState.destroy()
CGame.mState = None
# Liberar memoria.
print(gc.collect(), " objectos borrados.")
CGame.mState = aState
CGame.mState.init()
# Game loop del juego.
def gameLoop(self):
while not self.mSalir:
self.update()
self.render()
# Correr la lógica del juego.
def update(self):
# Timer que controla el frame rate.
CGame.mClock.tick(60)
# Llamar a update() de CKeyboard.
CKeyboard.inst().update()
# Llamar a update() de CMouse.
CMouse.inst().update()
# Actualizar el sprite del puntero del mouse.
CGame.mMousePointer.update()
# Procesar los eventos que llegan a la aplicación.
for event in pygame.event.get():
# Si se cierra la ventana se sale del programa.
if event.type == pygame.QUIT:
CGame.mSalir = True
# Si se pulsa la tecla [Esc] se sale del programa.
if event.type == pygame.KEYUP:
if (event.key == pygame.K_ESCAPE):
CGame.mSalir = True
# Si se pulsa la tecla [F], se cambia entre ventana y fullscreen.
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_f:
CGame.mIsFullscreen = not self.mIsFullscreen
if self.mIsFullscreen:
CGame.mScreen = pygame.display.set_mode(CGame.RESOLUTION, pygame.FULLSCREEN)
else:
CGame.mScreen = pygame.display.set_mode(CGame.RESOLUTION)
# Registrar cuando se apreta o suelta una tecla.
if event.type == pygame.KEYDOWN:
CKeyboard.inst().keyDown(event.key)
if event.type == pygame.KEYUP:
CKeyboard.inst().keyUp(event.key)
# Actualizar el estado del juego.
CGame.mState.update()
# Dibujar el frame y actualizar la pantalla.
def render(self):
# Dibujar el fondo.
CGame.mScreen.blit(self.mBackground, (0, 0))
# Dibujar el estado del juego.
CGame.mState.render()
# Dibujar el puntero del mouse.
CGame.mMousePointer.render(self.mScreen)
# Actualizar la pantalla.
pygame.display.flip()
def setBackground(self, aBackgroundImage):
CGame.mBackground = None
CGame.mBackground = aBackgroundImage
self.blitBackground(CGame.mBackground)
def blitBackground(self, aBackgroundImage):
CGame.mScreen.blit(aBackgroundImage, (0, 0))
# Obttener la referencia a la pantalla (usada para dibujar).
def getScreen(self):
return CGame.mScreen
def destroy(self):
if (CGame.mState != None):
CGame.mState.destroy()
CGame.mState = None
CKeyboard.inst().destroy()
CMouse.inst().destroy()
# Destruir el puntero del mouse.
CGame.mMousePointer.destroy()
CGame.mMousePointer = None
pygame.mouse.set_visible(True)
CGame.mInstance = None
# Cerrar Pygame y liberar los recursos que pidió el programa.
pygame.quit()
Dando un pantallazo de la clase CGame, vemos que es bastante del código que estaba en main.py, que seguirá existiendo porque es el punto de arranque del programa, pero podemos decir que la clase CGame es la clase principal del juego.
Hemos cambiado algunos nombres de variables, sobre todo agregando el prefijo “m” (por variable miembro) que se le pone a cada variable de una clase para indicar que es una variable miembro de esa clase. Todo lo relacionado al manejo de la aplicación (o programa, como se le quiera decir) estará en la clase CGame.
La parte que conviene notar es que tenemos una variable mState que será una referencia a una clase con el estado del juego que crearemos para cada pantalla. Usaremos la función setState() para cambiar de estado. Esta función se encarga de invocar a la función destroy() en el estado actual e invocar a la función init() en el estado siguiente. También invoca a gc.collect() para liberar la memoria usada. Más adelante hablaremos sobre esto.
La clase CGame contiene el loop principal del juego, en el cual se procesan los eventos y se invoca a las funciones update() y render() del estado actual (la pantalla actual del juego). En breve estaremos creando esta clase para el estado del juego.
Por último, tenemos una función setBackground() que establece una imagen de fondo, y en la función render(), cuando se dibuja el juego, primero se dibuja el fondo, luego se dibujan los objetos en pantalla (invocando a render() en el estado (pantalla) del juego actual), y por último se dibuja el puntero sprite del puntero del mouse para que aparezca por encima de todo.
La función setBackground() sirve para poner una imagen de fondo desde un estado. Cada estado del juego (pantalla) tendrá su propia imagen de fondo como veremos más adelante.
Lo importante de tener una clase CGame que se encarga del game loop y de la transición entre estados, es que es fácil implementar varias pantallas en el juego. Lo que haremos ahora será ver la clase del estado del juego en sí. Esta clase se llama CLevelState.
Nota: Las clases relacionadas a las pantallas del juego las hemos puesto en la carpeta game\states. Recuerda que un package es un conjunto de clases en una carpeta, y para que Python reconozca la carpeta como package (para que reconozca a las clases en esa carpeta) debe contener el archivo __init__.py, que es un archivo vacío.
Veamos el código de la clase CLevelState:
# -*- coding: utf-8 -*-
#--------------------------------------------------------------------
# Clase CLevelState.
# Nivel del juego. Es el juego en sí.
#
# Autor: Fernando Sansberro - Batovi Games Studio
# Proyecto: Hacete tu Videojuego.
# Licencia: Creative Commons. BY-NC-SA.
#--------------------------------------------------------------------
import pygame
from api.CGameState import *
from api.CGame import *
from game.CNave import *
from game.CPlayer import *
from api.CGameObject import *
from game.CGameData import *
from api.CGameConstants import *
class CLevelState(CGameState):
mImgSpace = None
mPlayer1 = None
mPlayer2 = None
def __init__(self):
CGameState.__init__(self)
print("LevelState constructor")
self.mImgSpace = None
self.mPlayer1 = None
self.mPlayer2 = None
# Función donde se inicializan los elementos necesarios del nivel.
def init(self):
CGameState.init(self)
print("LevelState init")
# Cargar la imagen del fondo. La imagen es de 640 x 360.
self.mImgSpace = pygame.image.load("assets/images/space_640x360.jpg")
self.mImgSpace = self.mImgSpace.convert()
# Dibujar la imagen cargada en la imagen de background.
CGame.inst().setBackground(self.mImgSpace)
# Crear la formación inicial.
f = 0
while f <= 4:
c = 0
while c <= 4:
n = CNave(f)
n.setXY(100 + (70 * c), 30 + (35 * f))
n.setVelX(4)
n.setVelY(0)
n.setBounds(0, 0, CGame.SCREEN_WIDTH - n.getWidth(), CGame.SCREEN_HEIGHT - n.getHeight())
n.setBoundAction(CGameObject.BOUNCE)
CEnemyManager.inst().add(n)
c = c + 1
f = f + 1
# Crear el jugador 1.
self.mPlayer1 = CPlayer(CPlayer.TYPE_PLAYER_1)
self.mPlayer1.setXY(CGame.SCREEN_WIDTH / 4 - self.mPlayer1.getWidth() / 2, CGame.SCREEN_HEIGHT - self.mPlayer1.getHeight() - 20)
self.mPlayer1.setBounds(0, 0, CGame.SCREEN_WIDTH - self.mPlayer1.getWidth(), CGame.SCREEN_HEIGHT)
# Crear el jugador 2.
self.mPlayer2 = CPlayer(CPlayer.TYPE_PLAYER_2)
self.mPlayer2.setXY(CGame.SCREEN_WIDTH / 4 * 3 - self.mPlayer2.getWidth() / 2, CGame.SCREEN_HEIGHT - self.mPlayer2.getHeight() - 20)
self.mPlayer2.setBounds(0, 0, CGame.SCREEN_WIDTH - self.mPlayer2.getWidth(), CGame.SCREEN_HEIGHT)
# Ejecutar la música de background (loop) del juego.
pygame.mixer.music.load("assets/audio/music_game.ogg")
pygame.mixer.music.play(-1)
# Poner el volumen de la música.
pygame.mixer.music.set_volume(0.5)
# Inicializar los datos del juego.
CGameData.inst().setScore1(0)
CGameData.inst().setLives1(3)
CGameData.inst().setScore2(0)
CGameData.inst().setLives2(3)
# Actualizar los objetos del nivel.
def update(self):
CGameState.update(self)
print("LevelState update()")
# Actualizar los enemigos.
CEnemyManager.inst().update()
# Mover las balas.
CBulletManager.inst().update()
# Lógica de los jugadores.
self.mPlayer1.update()
self.mPlayer2.update()
# Detectar la colision entre los dos jugadores.
if self.mPlayer1.collides(self.mPlayer2):
print("COLISION ENTRE LOS JUGADORES")
else:
print("...")
if self.mPlayer1.isGameOver() and self.mPlayer2.isGameOver():
print("AMBOS JUGADORES MUEREN - LOSE CONDITION")
if CEnemyManager.inst().getLength() == 0:
print("TODOS LOS ENEMIGOS MUEREN - WIN CONDITION")
# Dibujar el frame del nivel.
def render(self):
CGameState.render(self)
print("LevelState render()")
# Obtener la referencia a la pantalla.
screen = CGame.inst().getScreen()
# Dibujar los enemigos.
CEnemyManager.inst().render(screen)
# Dibujar las balas.
CBulletManager.inst().render(screen)
# Dibujar los jugadores.
self.mPlayer1.render(screen)
self.mPlayer2.render(screen)
# Dibujar el texto del score y las vidas.
self.drawText(screen, 5, 5, "SCORE: " + str(CGameData.inst().getScore1()), 20, (255, 255, 255))
self.drawText(screen, 5, CGame.SCREEN_HEIGHT - 20 - 5, "VIDAS: " + str(CGameData.inst().getLives1()), 20, (255, 255, 255))
self.drawText(screen, 530, 5, "SCORE: " + str(CGameData.inst().getScore2()), 20, (255, 255, 255))
self.drawText(screen, 540, CGame.SCREEN_HEIGHT - 20 - 5, "VIDAS: " + str(CGameData.inst().getLives2()), 20, (255, 255, 255))
# Destruir los objetos creados en el nivel.
def destroy(self):
CGameState.destroy(self)
print("LevelState destroy()")
# Destruir la nave de los jugadores.
self.mPlayer1.destroy()
self.mPlayer1 = None
self.mPlayer2.destroy()
self.mPlayer2 = None
self.mImgSpace = None
# Destruir las balas.
CBulletManager.inst().destroy()
# Destruir los enemigos.
CEnemyManager.inst().destroy()
CGameData.inst().destroy()
# Función para dibujar texto.
# Parámetros: La pantalla donde dibujar, coordenadas (x,y),
# texto ,tamaño de fuente y color del texto.
def drawText(self, aScreen, aX, aY, aMsg, aSize, aColor=(0,0,0)):
font = pygame.font.Font("assets/fonts/days.otf", aSize)
imgTxt = font.render(aMsg, True, aColor)
aScreen.blit(imgTxt, (aX, aY))
La clase CLevelState corresponde a la pantalla (estado del juego) del juego en sí, cuando se está jugando un nivel del juego. Como vemos en el código, es el código relacionado al juego que estaba en main.py. De esta forma, hemos pasado de tener todo junto en main.py, a mover la parte que corresponde al motor a la clase CGame y el código que corresponde al nivel del juego a la clase CLevelState.
Por último, el programa main.py ahora queda muy simple. Veamos el código:
# -*- coding: utf-8 -*-
#--------------------------------------------------------------------
# Máquina de estados del juego. Clases CGame, CGameState y CLevelState.
#
# Autor: Fernando Sansberro - Batovi Games Studio
# Proyecto: Hacete tu Videojuego.
# Licencia: Creative Commons. BY-NC-SA.
#--------------------------------------------------------------------
from api.CGame import *
from game.states.CLevelState import *
# ============= Punto de entrada del programa. =============
# Inicializar los elementos necesarios del juego.
g = CGame()
# Crear el estado para el nivel del juego.
initState = CLevelState()
g.setState(initState)
# Loop principal del juego.
g.gameLoop()
# Liberar los recursos al final.
g.destroy()
Lo único que se hace ahora el programa main.py es crear el objeto de clase CGame, que será la clase principal, luego se crea el estado inicial del juego que corresponderá al juego en sí (el nivel), se pone al juego en ese estado y se invoca al game loop. Cuando se sale del juego se invoca a la función destroy() y se termina la ejecución.
En la siguiente sección haremos el menú principal del juego, y éste será el estado inicial, dado que el juego comienza en la pantalla del menú.
Agregando la Pantalla Principal (Menú)
Vamos a hacer la pantalla del menú principal. Esta será la primera pantalla que vemos en el juego. El menú tiene el nombre del juego, una ilustración (la carátula o portada del juego), y las opciones (jugar, ver la ayuda, ver los créditos, etc). Por ahora pondremos solamente un texto indicando que con la tecla [Space] arrancamos el juego. La siguiente figura muestra el menú principal.
Figura 17-2: La pantalla del menú principal.
Con la tecla [Space] pasaremos del menú al juego, y una vez jugando, al pulsar [Esc] o cuando termine el juego (al perder todas las vidas) volveremos al menú. En principio tendremos solamente dos estados (pantallas) del juego, la pantalla principal y el juego mismo (este estado es CLevelState, la clase que hicimos en el ejemplo anterior).
Por ahora el menú tendrá el título del juego, una imagen y un texto para comenzar a jugar. Más adelante podremos mejorar el menú principal, una vez que esté implementado.
Luego implementaremos el resto de las pantallas. Como siempre, vamos haciendo el juego de a poco, agregando una funcionalidad a la vez. Una vez que tengamos dos pantallas funcionando será más fácil agregar el resto de las pantallas. Siempre conviene hacer una cosa a la vez, paso por paso, en lugar de programar muchas cosas juntas que puede ser más engorroso.
Nota: A una funcionalidad en el juego se le denomina feature (en inglés, significa una característica o algo que hace el juego).
Abre y ejecuta el proyecto de ejemplo ubicado en la carpeta capitulo_17\002_estados_del_juego_menu. Este ejemplo muestra el menú al iniciar el juego.
Lo primero que hay que decir es que si agregamos una pantalla, debemos agregar una clase para manejar ese estado. En este caso, a la clase del menú la llamaremos CMenuState, y la colocamos en la carpeta de estados del juego game\states.
En este momento, los estados del juego son CMenuState que maneja el menú principal y CLevelState que maneja el nivel del juego. Al igual que CLevelState, la clase CMenuState hereda de CGameState y tiene que implementar las funciones del ciclo de vida: init(), update(), render() y destroy().
La clase CMenuState, carga su imagen de fondo en init() e inicializa los sprites de texto para mostrar en pantalla (más adelante veremos cómo funcionan), luego en render() muestra el fondo y los textos necesarios, en update() chequea si se pulsa la tecla [Space] para pasar al juego, y por último en destroy() se destruyen los objetos creados. Veamos el código de la clase CMenuState:
# -*- coding: utf-8 -*-
#--------------------------------------------------------------------
# Clase CMenuState.
# Menú principal.
#
# Autor: Fernando Sansberro - Batovi Games Studio
# Proyecto: Hacete tu Videojuego.
# Licencia: Creative Commons. BY-NC-SA.
#--------------------------------------------------------------------
import pygame
from api.CKeyboard import *
from api.CGame import *
from api.CGameState import *
from game.states.CLevelState import *
from api.CTextSprite import *
class CMenuState(CGameState):
mImgSpace = None
mTextTitle = None
mTextPressFire = None
def __init__(self):
CGameState.__init__(self)
self.mImgSpace = None
self.mTextTitle = None
self.mTextPressFire = None
def init(self):
CGameState.init(self)
# Cargar la imagen del fondo. La imagen es de 640 x 360 al igual que la pantalla.
self.mImgSpace = pygame.image.load("assets/images/menu_640x360.jpg")
self.mImgSpace = self.mImgSpace.convert()
# Dibujar la imagen cargada en la imagen de fondo del juego.
CGame.inst().setBackground(self.mImgSpace)
self.mTextTitle = CTextSprite("INVASORES", 60, "assets/fonts/days.otf", (0xFF, 0xFF, 0xFF))
self.mTextTitle.setXY((CGame.SCREEN_WIDTH - self.mTextTitle.getWidth())/2, 20)
self.mTextPressFire = CTextSprite("Pulsa [Space] para jugar...", 20, "assets/fonts/days.otf", (0xFF, 0xFF, 0xFF))
self.mTextPressFire.setXY((CGame.SCREEN_WIDTH - self.mTextPressFire.getWidth())/2, 330)
def update(self):
CGameState.update(self)
if CKeyboard.inst().fire():
nextState = CLevelState()
CGame.inst().setState(nextState)
return
self.mTextTitle.update()
self.mTextPressFire.update()
def render(self):
CGameState.render(self)
self.mTextTitle.render(CGame.inst().getScreen())
self.mTextPressFire.render(CGame.inst().getScreen())
def destroy(self):
CGameState.destroy(self)
self.mImgSpace = None
self.mTextTitle.destroy()
self.mTextTitle = None
self.mTextPressFire.destroy()
self.mTextPressFire = None
La clase CMenuState hereda de CGameState. Entonces, las funciones init(), update(), render() y destroy() llaman a las funciones homónimas de la clase base. Es recomendable hacer esto por si algún día colocamos funcionalidad en la clase base que sirva para todas las clases que la heredan. Debemos invocar a estas funciones, dado que en Python no se invocan automáticamente.
En la función init(), cargamos la imagen del fondo del menú (que previamente hemos puesto en la carpeta assets\images) y creamos los sprites de texto que se mostrarán en pantalla. Se usa la función CGame.inst().setBackground() para establecer la imagen como fondo del juego. Esto es porque la imagen ahora la dibuja la clase CGame.
Observa cómo podemos centrar los textos fácilmente, dado que son sprites, podemos usar su función setXY() para posicionar el texto y getWidth() para saber el ancho del sprite para centrar el texto horizontalmente. Piensa cómo funciona esto.
self.mTextPressFire = CTextSprite("Pulsa [Space] para jugar...", 20,
"assets/fonts/days.otf", (0xFF, 0xFF, 0xFF))
self.mTextPressFire.setXY((CGame.SCREEN_WIDTH -
self.mTextPressFire.getWidth())/2, 330)
En las funciones update() y render() se invocan a las funciones update() y render() de los sprites como siempre. Lo que vale la pena notar es que en la función update() se está chequeando si se pulsa la tecla [Space]. Si esto ocurre, se cambia de estado al igual que se hace en main.py para llamar al menú principal. Este código es el que usaremos en cualquier estado para pasar a otro estado del juego (pasar de pantalla):
if CKeyboard.inst().fire():
nextState = CLevelState()
CGame.inst().setState(nextState)
return
Lo que hace este código es crear una instancia de la clase correspondiente al estado al que vamos a pasar, y se invoca a la función setState() de la clase CGame para establecer ese estado como el estado actual. Recuerda que la clase CGame se encarga de invocar a la función destroy() en el estado anterior y a la función init() en el estado al que vamos. Esto hace que sea sencillo cambiar de pantalla y sabemos que el motor se encarga de los detalles para que el sistema funcione correctamente.
Por último, en la función destroy() se eliminan los objetos creados por la clase. Siempre es necesario eliminar los objetos creados. Si no lo hacemos, aparecerán problemas de memoria (generalmente aparecen cuando jugamos mucho tiempo, el juego se puede poner lento).
Sentencia return Luego de Cambiar de Estado
Es muy importante mencionar que cada vez que cambiamos de estado debemos retornar inmediatamente del código que estamos ejecutando, sino, pueden haber errores. Miremos este caso: En CMenuState, en la función update(), cuando pulsamos la tecla [Space], pasamos de pantalla. Miremos el código de la función update() y veamos que hay una sentencia return (para salir de la función) luego de pasar de estado:
def update(self):
CGameState.update(self)
if CKeyboard.inst().fire():
nextState = CLevelState()
CGame.inst().setState(nextState)
return
self.mTextTitle.update() (*)
self.mTextPressFire.update() (*)
Como se explicó antes, recordemos que al cambiar de estado se invoca a la función destroy() del estado actual (esta clase). La función destroy() elimina los sprites de textos creados. Entonces, si no retornamos de la función y el código sigue ejecutando, dará error al acceder a los sprites de texto (en la parte marcada con (*)). No hay que ejecutar más nada luego de cambiar de estado, dado que es como que este estado ya no existe en realidad (las variables de la clase contienen referencias a None).
Nota: Siempre al pasar de estado, hay que colocar una sentencia return, para evitar que se pueda ejecutar código usando objetos que ya han sido eliminados en la función destroy() que fue invocada luego de cambiar de estado. Esto es tanto para los estados del juego como para los estados del jugador, de los enemigos, etc.
Estado Inicial del Juego
En el programa main.py, ahora se comienza en el menú como primera pantalla. Siempre en main.py se coloca el estado inicial del juego, creando un objeto de la clase correspondiente al estado inicial y estableciendo esa clase como estado actual. El código de main.py queda de la siguiente manera:
...
from api.CGame import *
from game.states.CMenuState import *
# ============= Punto de entrada del programa. =============
# Inicializar los elementos necesarios del juego.
g = CGame()
# Crear el estado para el menu del juego.
initState = CMenuState()
g.setState(initState)
# Loop principal del juego.
g.gameLoop()
# Liberar los recursos al final.
g.destroy()
Nos queda ver qué son los sprites de texto que hemos usado en este ejemplo. Esto lo haremos a continuación.
Sprites de Texto
Hasta ahora, cuando mostramos un texto, simplemente usamos una función drawText() que hicimos, que lo que hace es dibujar un texto en la pantalla, usando determinada fuente, tamaño de fuente y color. Existen dos problemas a solucionar en nuestro motor relacionados al manejo de texto.
El primer problema ahora es que tenemos varias pantallas, entonces, ¿dónde colocamos la función para dibujar texto para que todas las clases la usen?
El segundo problema está relacionado a la optimización. Recordemos que al mostrar texto en Pygame, se genera una imagen y Pygame tiene que armar esa imagen cuando dibujamos un texto. Si el texto no ha cambiado, no hay porqué generar la imagen nuevamente. Por supuesto siempre la debemos mostrar en pantalla en cada cuadro, pero no es necesario recrear la imagen en cada frame si el texto no ha cambiado.
Entonces, lo que haremos es crear una clase CTextSprite que será un sprite (heredará de CSprite) y se le podrá asignar un texto en lugar de una imagen. Internamente esta clase generará la imagen según el texto y la usará como la imagen del sprite.
La clase CTextSprite va en la carpeta \api porque se trata de una clase del motor, que usaremos en todos nuestros juegos. Una gran ventaja de tener un sprite de texto, es que podremos mover los textos por la pantalla como lo hacemos con cualquier sprite. Al heredar de CSprite tenemos a disposición todas las funciones que tiene un sprite, incluidas las funciones de posición, velocidad y aceleración, o las funciones getWidth() y getHeight() que serán muy útiles al colocar los textos centrados en la pantalla.
Veamos el código de la clase CTextSprite:
# -*- coding: utf-8 -*-
#--------------------------------------------------------------------
# Clase TextSprite.
# Sprite de texto. Hereda de CSprite.
#
# Autor: Fernando Sansberro - Batovi Games Studio
# Proyecto: Hacete tu Videojuego.
# Licencia: Creative Commons. BY-NC-SA.
#--------------------------------------------------------------------
import pygame
from api.CSprite import *
class CTextSprite(CSprite):
def __init__(self, aText = "", aFontSize = 10, aFontName = "", aColor = (0xFF, 0xFF, 0xFF)):
CSprite.__init__(self)
self.mText = aText
self.mFontSize = aFontSize
self.mFontName = aFontName
self.mColor = aColor
self.updateImage()
# Función genérica para dibujar un texto en una superficie.
# Parámetros: La pantalla donde dibujar, coordenadas (x,y),
# texto ,tamaño de fuente y color del texto.
@classmethod
def drawText(self, aScreen, aX, aY, aMsg, aFontName, aFontSize, aColor=(0,0,0)):
font = pygame.font.Font(aFontName, aFontSize)
imgTxt = font.render(aMsg, True, aColor)
aScreen.blit(imgTxt, (aX, aY))
def setText(self, aText):
if self.mText != aText:
self.mText = aText
self.updateImage()
def setFontName(self, aFontName):
if self.mFontName != aFontName:
self.mFontName = aFontName
self.updateImage()
def setSize(self, aFontSize):
if self.mFontSize != aFontSize:
self.mFontSize = aFontSize
self.updateImage()
def setColor(self, aColor):
if self.mColor != aColor:
self.mColor = aColor
self.updateImage()
def updateImage(self):
if (self.mFontName == ""):
font = pygame.font.SysFont("Comic Sans MS", self.mFontSize)
else:
font = pygame.font.Font(self.mFontName, self.mFontSize)
imgTxt = font.render(self.mText, True, self.mColor)
self.setImage(imgTxt)
def update(self):
CSprite.update(self)
def render(self, aScreen):
CSprite.render(self, aScreen)
def destroy(self):
CSprite.destroy(self)
La clase CTextSprite es bastante simple en su funcionamiento. En el constructor se pasan como parámetros el texto, el tamaño de la fuente, el nombre de la fuente y el color del texto. Con esos datos se arma la imagen que es la que se establece como imagen del sprite. Esto se hace en la función updateImage().
La función updateImage() es invocada cada vez que hay que armar la imagen del texto. Esto es, en el constructor cuando se crea la imagen, y luego cada vez que se cambia alguna de las propiedades del sprite de texto que altere la imagen. Cuando cambia el texto, la fuente, el tamaño o el color, hay que rehacer la imagen. Si nos fijamos en cada función para establecer una propiedad, se llama a la función updateImage() para recrear la imagen. Pero si no cambia nada no hay necesidad de andar recreando la imagen. Cada función tiene una optimización, que pregunta si no ha cambiado el dato, en cuyo caso no será necesario recrear la imagen del texto.
Por último, esta clase tiene una función estática drawText() que es la misma que antes teníamos para mostrar texto, en caso de que necesitemos dibujar un texto directamente y no queramos hacer un sprite de texto.
Mira el código de CLevelState en este ejemplo que también está usando sprites de texto para dibujar los textos del HUD, en lugar de dibujarlo directamente como lo hacía antes. En la función update(), se cambia el texto en cada frame (esto se podría optimizar, controlando que no cambie el texto si no cambió el valor del puntaje o de las vidas).
Similar a lo que se hace en la clase CMenuState, en CLevelState se inicializan las variables de los sprites de texto en init(), luego se invoca a las funciones update() y render() para establecer el texto y dibujarlo, y al final se invoca a la función destroy() para eliminar los objetos creados. Veamos marcados en negrita los lugares donde se manejan los nuevos sprites de texto en CLevelState:
...
from api.CTextSprite import *
class CLevelState(CGameState):
mImgSpace = None
mPlayer1 = None
mPlayer2 = None
mTextLives1 = None
mTextLives2 = None
mTextScore1 = None
mTextScore2 = None
def __init__(self):
CGameState.__init__(self)
print("LevelState constructor")
self.mImgSpace = None
self.mPlayer1 = None
self.mPlayer2 = None
self.mTextScore1 = None
self.mTextScore2 = None
self.mTextLives1 = None
self.mTextLives2 = None
# Función donde se inicializan los elementos necesarios del nivel.
def init(self):
...
# Inicializar los datos del juego.
CGameData.inst().setScore1(0)
CGameData.inst().setLives1(3)
CGameData.inst().setScore2(0)
CGameData.inst().setLives2(3)
self.mTextScore1 = CTextSprite("SCORE: " + str(CGameData.inst().getScore1()), 20, "assets/fonts/days.otf", (0xFF, 0xFF, 0xFF))
self.mTextScore1.setXY(5, 5)
self.mTextLives1 = CTextSprite("VIDAS: " + str(CGameData.inst().getLives1()), 20, "assets/fonts/days.otf", (0xFF, 0xFF, 0xFF))
self.mTextLives1.setXY(5, CGame.SCREEN_HEIGHT - 20 - 5)
self.mTextScore2 = CTextSprite("SCORE: " + str(CGameData.inst().getScore2()), 20, "assets/fonts/days.otf", (0xFF, 0xFF, 0xFF))
self.mTextScore2.setXY(530, 5)
self.mTextLives2 = CTextSprite("VIDAS: " + str(CGameData.inst().getLives2()), 20, "assets/fonts/days.otf", (0xFF, 0xFF, 0xFF))
self.mTextLives2.setXY(540, CGame.SCREEN_HEIGHT - 20 - 5)
# Actualizar los objetos del nivel.
def update(self):
...
if self.mPlayer1.isGameOver() and self.mPlayer2.isGameOver():
print("AMBOS JUGADORES MUEREN - LOSE CONDITION")
if CEnemyManager.inst().getLength() == 0:
print("TODOS LOS ENEMIGOS MUEREN - WIN CONDITION")
self.mTextScore1.update()
self.mTextScore2.update()
self.mTextLives1.update()
self.mTextLives2.update()
# Dibujar el frame del nivel.
def render(self):
...
# Dibujar los jugadores.
self.mPlayer1.render(screen)
self.mPlayer2.render(screen)
# Dibujar el texto del score y las vidas.
self.mTextScore1.setText("SCORE: " + str(CGameData.inst().getScore1()))
self.mTextLives1.setText("VIDAS: " + str(CGameData.inst().getLives1()))
self.mTextScore2.setText("SCORE: " + str(CGameData.inst().getScore2()))
self.mTextLives2.setText("VIDAS: " + str(CGameData.inst().getLives2()))
self.mTextScore1.render(screen)
self.mTextLives1.render(screen)
self.mTextScore2.render(screen)
self.mTextLives2.render(screen)
# Destruir los objetos creados en el nivel.
def destroy(self):
...
# Destruir las balas.
CBulletManager.inst().destroy()
# Destruir los enemigos.
CEnemyManager.inst().destroy()
self.mTextScore1.destroy()
self.mTextScore1 = None
self.mTextLives1.destroy()
self.mTextLives1 = None
self.mTextScore2.destroy()
self.mTextScore2 = None
self.mTextLives2.destroy()
self.mTextLives2 = None
CGameData.inst().destroy()
Esto es una muestra más de que agregar un objeto en el juego es crearlo, actualizarlo y borrarlo al final. O dicho de otra manera, implementar si ciclo de vida, invocando a las funciones init(), update() y render() y al final destroy().
Garbage Collector
En la clase CGame al cambiar de estado en la función setState(), se llama a la función gc.collect() para liberar la memoria que no está utilizada. Python tiene lo que se conoce como garbage collector, que es un módulo de Python que se encarga de eliminar los objetos que no están siendo más referenciados. La función gc.collect() retorna el número de objetos que ha eliminado.
# Función para cambiar entre pantallas (estados) del juego.
def setState(self, aState):
if (CGame.mState != None):
CGame.mState.destroy()
CGame.mState = None
# Liberar memoria.
print(gc.collect(), " objectos borrados.")
CGame.mState = aState
CGame.mState.init()
Cuando creamos un objeto, se asigna espacio en la memoria de la computadora para almacenar el objeto. Cuando no usamos más el objeto lo que hacemos es eliminar su referencia, esto es, asignando a la variable que lo referencia, el valor None para que no le apunte más. De esta forma, cuando un objeto ya no es referenciado, es eliminado de la memoria por el garbage collector.
No es bueno que el garbage collector corra en cualquier momento, porque es una operación que puede tomar un tiempo (ínfimo, pero suficiente como para no querer que eso pase en el medio del juego, dado que se puede notar una trancada). Entonces, un buen lugar para invocar al garbage collector es justamente al pasar de estado del juego, momento en el que acabamos de destruir objetos y creando otros, siendo el punto más crítico del motor, en cuanto a manejo de memoria. Además, al momento de cambiar de estado, no estamos dentro del juego, por lo cual es un buen momento para que se limpie la memoria utilizada.
Como siempre, este tipo de cosas a nivel del sistema lo tenemos en cuenta cuando desarrollamos el motor o engine del juego. Luego, nos queda disponible para cualquier juego que hagamos.
La Pausa del Juego
Para terminar con la programación de los estados del juego, implementaremos la pausa del juego. Ningún juego de acción puede ver la luz del día sin tener una pausa. La pausa es algo muy necesario en un juego, dado que el jugador puede tener interrupciones mientras está jugando.
Haremos que el juego se ponga en pausa al pulsar la tecla [P] o [Enter]. Cuando el juego está en pausa, mostraremos un mensaje indicando que el juego está en pausa. Presionando de nuevo la tecla [P] o [Enter] el juego continúa. Mira la siguiente figura.
Figura 17-3: El juego cuando está pausado.
Abre y ejecuta el ejemplo ubicado en la carpeta capitulo_17\003_pausa. En este ejemplo vemos que el juego se detiene si pulsamos la tecla [P] o [Enter]. En la clase CKeyboard hemos agregado las funciones necesarias para detectar cuando se pulsan estas dos nuevas teclas. Observa esta clase para ver las nuevas teclas agregadas.
Hacer la pausa es algo bastante sencillo, si sabemos qué es lo que hacer. Para que el juego se detenga, simplemente tenemos que dejar de ejecutar la función update() del estado actual. Esto lo hacemos en la clase CGame, que es la clase que se encarga de esto.
Pondremos una variable booleana (una bandera) que indicará si el juego está en pausa o no. Cuando se pulsa la tecla de pausa, esta variable cambia su valor. También creamos un sprite de texto para mostrar el mensaje de que el juego está en pausa.
Entonces, si el juego está en pausa no se ejecuta la función update() del estado del juego y se muestra el texto. Cuando el juego no está en pausa, se ejecuta la función update() normalmente y no se muestra el texto. Veamos el código de CGame con el código de la pausa:
...
# Importar la clase de texto.
from api.CTextSprite import *
class CGame(object):
...
# Pausa.
mIsPaused = False
mTextPause = None
...
# Función de inicialización.
def init(self):
...
CGame.mState = None
# Variables para la pausa.
CGame.mIsPaused = False
CGame.mTextPause = CTextSprite("JUEGO EN PAUSA", 40, "assets/fonts/days.otf", (0xFF, 0xFF, 0xFF))
CGame.mTextPause.setXY((CGame.SCREEN_WIDTH - CGame.mTextPause.getWidth()) / 2, (CGame.SCREEN_HEIGHT - CGame.mTextPause.getHeight()) / 2)
...
# Game loop del juego.
def gameLoop(self):
while not self.mSalir:
self.update()
self.render()
# Correr la lógica del juego.
def update(self):
...
if CKeyboard.inst().pauseKey():
self.togglePause()
# Cuando el juego está en pausa no se corre update().
if not CGame.mIsPaused:
# Actualizar el estado del juego.
CGame.mState.update()
# Dibujar el frame y actualizar la pantalla.
def render(self):
# Dibujar el fondo.
CGame.mScreen.blit(self.mBackground, (0, 0))
# Dibujar el estado del juego.
CGame.mState.render()
# Si el juego está en pausa se muestra el mensaje.
if CGame.mIsPaused:
CGame.mTextPause.render(self.mScreen)
# Dibujar el puntero del mouse.
CGame.mMousePointer.render(self.mScreen)
# Actualizar la pantalla.
pygame.display.flip()
...
# Pausa o continua el juego.
def togglePause(self):
CGame.mIsPaused = not CGame.mIsPaused
def destroy(self):
...
CGame.mTextPause.destroy()
CGame.mTextPause = None
...
Como vemos en el código, tenemos una variable booleana que indica si el juego está o no en pausa y según su estado se muestra o no el mensaje de pausa, o si se ejecuta o no la función update() del estado actual, como se explicó antes.
Nota: Para agregar una nueva funcionalidad al juego, generalmente no se necesita programar tanto, sino más bien saber en dónde hay que poner el código. Por este motivo, es que conviene no programar nada sin antes sentarse un momento a pensar bien qué es lo que se va a hacer y cómo.
En el caso de la pausa, la solución no es complicada. Es simplemente poner una bandera y correr o no la lógica (invocar o no a la función update()). Pero hacerlo sin pensarlo, puede llevar a errores. Sobre todo, porque se trata de la clase CGame, que es la clase de base para toda la aplicación. Debemos tener tiempo para diseñar y pensar la solución que vamos a realizar.
Imports y las Referencias Circulares en Python
Para finalizar este capítulo, vamos a hacer que al ganar el nivel (cuando se matan a todos los enemigos) o cuando se pierde el juego (cuando ambos jugadores se quedan sin vidas), que se vuelva al menú principal. Como vimos en la clase CMenuState, para pasar al nivel del juego hacemos esto:
from game.states.CLevelState import CLevelState
...
nextState = CLevelState()
CGame.inst().setState(nextState)
Pues bien, en forma similar, cuando estamos en el nivel y ocurra la condición de ganar o de perder, el código para volver al menú sería el siguiente:
from game.states.CMenuState import CMenuState
...
if self.mPlayer1.isGameOver() and self.mPlayer2.isGameOver():
print("AMBOS JUGADORES MUEREN - LOSE CONDITION")
nextState = CMenuState()
CGame.inst().setState(nextState)
if CEnemyManager.inst().getLength() == 0:
print("TODOS LOS ENEMIGOS MUEREN - WIN CONDITION")
nextState = CMenuState()
CGame.inst().setState(nextState)
Pues bien, si probamos esto veremos que nos da un error. El error no es claro porque dice que no encuentra (no está definida) la clase CMenuState, cuando claramente está importado arriba. Entonces, ¿qué es lo que sucede?
Lo que pasa aquí, es un problema que se denomina referencia circular. El módulo CMenuState necesita importar CLevelState y el módulo CLevelState necesita importar CMenuState. El problema es que un módulo (clase) no puede compilarse porque necesita al segundo, el cual necesita del primero. Si las sentencias import se encuentran al inicio de los módulos, no funcionará.
Para evitar esto, lo que hacemos es importar el nombre de clase que necesitamos antes de instanciar la clase, en la misma función update() donde cambiamos de estado. Entonces, el código de la función update() de CMenuState queda de la siguiente manera:
def update(self):
CGameState.update(self)
if CKeyboard.inst().fire():
from game.states.CLevelState import CLevelState
nextState = CLevelState()
CGame.inst().setState(nextState)
return
self.mTextTitle.update()
self.mTextPressFire.update()
Y la función update() de la clase CLevelState, ahora chequea por la condición de ganar o perder y cambia a la pantalla del menú cuando se gana o se pierde:
# Actualizar los objetos del nivel.
def update(self):
...
if self.mPlayer1.isGameOver() and self.mPlayer2.isGameOver():
print("AMBOS JUGADORES MUEREN - LOSE CONDITION")
from game.states.CMenuState import CMenuState
nextState = CMenuState()
CGame.inst().setState(nextState)
return
if CEnemyManager.inst().getLength() == 0:
print("TODOS LOS ENEMIGOS MUEREN - WIN CONDITION")
from game.states.CMenuState import CMenuState
nextState = CMenuState()
CGame.inst().setState(nextState)
return
...
Como siempre, cada vez que cambiamos de estado, lo sigue una función return para no continuar ejecutando código perteneciente a un estado que ya es obsoleto.
Si vemos el código del último ejemplo, veremos esto implementado. Al ejecutar el juego, si matamos a todos los enemigos o al perder todas las vidas, se vuelve al menú principal.
Ya tenemos el juego casi completo. De esta forma terminamos este capítulo. En el capítulo siguiente nos dedicaremos a terminar el juego, implementando los detalles finales, lo que se conoce como el pulido del juego.
Comentarios