Desarrollo de un Juego 2D en OpenGL, parte 2

En el número de Enero-Febrero 2007, publicamos un artículo donde establecíamos las bases teóricas y de configuración para desarrollar un videojuego 2D con OpenGL. En esta ocasión continuamos con dicho artículo, desarrollando las clases que le darán vida a nuestro juego.Lo primero que definiremos será precisamente de qué se tratará nuestro juego y posteriormente veremos aspectos como el diseño de algunas clases para representar los objetos dentro del juego y también como construir el mundo del juego e implementar diferentes aspectos de éste. Los juegos 2D son sencillos porque generalmente están basados en bloques (también conocidos como ‘Tiles’ o ‘Sprites’), es decir, a diferencia de los juegos 3D, la geometría de los juegos 2D son simplemente cuadrados texturizados.

A lo largo de este artículo mencionaré diferentes funciones de OpenGL, pero no iré a detalle en cuanto a cómo utilizar cada comando por razones de espacio en la revista, pero trataré de que todo quede bien comentado en el código, el cual estará disponible en el sitio web de SG, así como el archivo ejecutable para la plataforma Windows, los datos necesarios para el juego, y un pequeño manual de usuario.

Definición del Juego
“Crystal Forest Revenge” es un juego del tipo “side-scroller” (estilo Mario Bros. ó Megaman). El juego se centra en Bob, un extraño habitante del bosque que, al ver a sus amigos ser tomados prisioneros, se ve obligado a detener a Woody, un robot de otra galaxia que ha tomado el control del bosque y planea pronto tener el control de todo el planeta. Bob, con la ayuda de algunos elementos del bosque, deberá luchar a través de los niveles contra los ayudantes de Woody que poseen armas más poderosas.

Creación de Clases de C++
Para un mejor manejo de la estructura, separaremos el código en Clases de C++. Como en muchos casos, hay varias formas en que se podría definir la estructura de un juego, pero la utilizada aquí será lo más sencilla posible. Utilizaremos una clase para representar el juego, y sus miembros serán objetos para representar los elementos principales, tales como un nivel del juego, el jugador principal, los enemigos y la cámara virtual. A continuación se muestran los elementos más importantes de esta clase.

/// En Juego.h
class Juego {
private:
bool m_Inicializado, m_MostrandoMenu, m_Teclado[ 256 ];
int m_NivelActual, m_UltimoNivel, m_NumFrames;

Camara2D m_Camara;
GLFont m_Fuente;
Jugador m_Jugador;
VecEnemigos m_Enemigos;
Quadtree m_Quadtree;
Nivel m_Nivel;
//... mas atributos

public:
Juego();
~Juego();

bool Inicializa( void ); // Inicializa los diferentes objetos del juego
void Termina( void ); // Libera los recursos antes de salir del juego
void Frame( float tiempo_transcurrido ); // Metodo principal de procesamiento
void MuestraMenu( void ); // Muestra el menu cuando el juego esta pausado
bool Input( unsigned char key, int x, int y ); // Procesa el input del usuario
//... mas metodos

private:
void IniciaFrame( void ); // Limpia la pantalla, actualiza la camara virtual, etc.
void TerminaFrame( void ); // Procesamiento final del frame
void Actualiza( float tiempo_transcurrido ); // Actualiza los objetos del juego
bool CargaNivel( char *archivo );
void Render( void );
//... mas metodos
};

El método de entrada de procesamiento de esta clase es el método Frame, ya que crearemos un objeto de tipo Juego en la función principal (main) y llamaremos al método Frame en las funciones asignadas a GLUT para hacer todo el procesamiento correspondiente cuando se necesita redibujar la pantalla.

/// En main.cpp
void SG_DisplayFunction()
{

float tiempoActual = (float)timeGetTime();
if( g_pJuego != NULL )

{
// Llama al metodo Frame, pasando el tiempo transcurrido
// desde la ultima vez que se mando llamar al metodo Frame
g_pJuego->Frame(( tiempoActual - g_ultimoTiempoDisplay ) / 1000.0f );
g_ultimoTiempoDisplay = tiempoActual;
}

glutSwapBuffers(); // Intercambia el front y back buffer
}

/// En Juego.cpp
void Juego::Frame( float tiempo_transcurrido )
{
IniciaFrame();

// Actualiza los objetos, detecta colisiones, detecta si el jugador
// esta muerto, detecta si se completo un nivel, etc.
Actualiza( tiempo_transcurrido );

if( m_Inicializado && m_NivelActual > 0 )
{
if( m_MostrandoMenu || m_JugadorMuerto ||
( m_NivelTerminado && ( m_NivelActual == m_UltimoNivel )))
{
MuestraMenu();
}
else if ( m_NivelTerminado && ( m_NivelActual < m_UltimoNivel ))
{
CargaSiguienteNivel();
}
else
{
Render();
m_NumFrames++;
}
}

TerminaFrame();
}

La cámara virtual esta representada en la clase Camara2D, y su función principal es seguir al personaje dentro del juego y establecer la posición a donde se está viendo al hacer el Render.

/// En Camara.h
class Camara2D
{
private:
vector2 m_Posicion;
bool m_Moviendo;
// ... otros atributos

public:
Camara2D();
~Camara2D();

void Actualiza( vector2 pos );
void MueveCamara( bool mueveX, int direccionX, bool mueveY, int direccionY );
void GetPosicion( float *x, float *y );
// ... mas metodos
};

/// En Juego.cpp
void Juego::IniciaFrame()
{
float camx = 0.0f, camy = 0.0f;
// Obtener la posicion actual de la camara
m_Camara.GetPosicion( &camx, &camy );
// Limpia el bufer de color y el Z-buffer
glClear( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT );
// Cambiar a la ModelView Matrix
glMatrixMode( GL_MODELVIEW );
// Establece la ModelView Matrix = Matriz Identidad
glLoadIdentity();

// Establecer la posicion a donde ve la camara
gluLookAt(
camx, camy, 30.0f, // posicion de la camara
camx, camy, -30.0f, // posicion a la que la camara esta viendo
0.0f, 1.0f, 0.0f // vector “up” de la camara

);
}

Los niveles del juego
Podríamos definir internamente toda la parte gráfica del juego, pero esto tiene el inconveniente de que necesitaríamos recompilar el juego cada que se necesite una modificación y el crear diferentes niveles para el juego sería muy complicado. En su lugar, utilizaremos un editor creado en C# (disponible también en el archivo .zip) para crear nuestros niveles para el juego. En el editor, usaremos la opción para crear un nuevo mapa y en ésta definiremos el número de capas, así como el número de renglones y columnas que definirán que tanto espacio horizontal y vertical habrá en el juego. Las capas las utilizaremos para crear la ilusión de profundidad, las capas con valores menores en el eje Z representarán elementos que se encuentran más lejos.

Para asignar un elemento gráfico a una zona del nivel, simplemente seleccionaremos con el mouse el área del nivel, luego una imagen de la ventana de recursos y entonces damos click al botón “Asignar Textura”; esto creará un billboard que se usará dentro del juego. Para facilitar el movimiento de los personajes dentro del juego, separaremos las gráficas de la detección de colisiones, es decir, los billboards creados no se usarán para la detección de colisiones. En su lugar, el editor nos permite crear zonas o bloques en las que los personajes pueden caminar.

En juegos complejos, se utilizan niveles en formato binario, entre otras razones, para acelerar el tiempo de cargado. En nuestro caso no utilizaremos un formato binario, sino un archivo XML que nos permite tener cierto control para editarlo directamente si se necesita. Para cargar los archivos XML en el juego utilizaremos TinyXML, disponible en www.grinninglizard.com/tinyxml. TinyXML es una
librería escrita en C++ que nos permite leer los diferentes elementos de un archivo XML. El código correspondiente a cargar el nivel se encuentra en el método CargaNivel( ) de la clase Juego.

Texturas
Para demostrar la creación de texturas, usaremos dos tipos de archivos de imagen, uno es el .TGA y otro es el .PNG, que tiene un muy buen esquema de compresión y también soporta el canal alfa en las imágenes. Para leer archivos .TGA utilizaremos nuestras propias funciones y para los archivos .PNG utilizaremos la librería gratuita libpng (libpng.sourceforge.net).

El proceso para utilizar una imagen como textura dentro de un juego es el siguiente:
1. Leer el archivo que contiene la imagen y extraer los datos a un búfer.
2. Crear un “Texture Object” con la función glGenTextures( ), “enlazar” ese objeto como el objeto actual con la función glBindTexture( ),
asignar los parámetros necesarios con las funciones glTexEnvf( ) y glTexParameteri( ) y finalmente, asignar el búfer leído como la fuente de datos del objeto con la función glTexImage2D( ). Esta última función manda los bytes de la imagen a la tarjeta gráfica y nos regresa un identificador para poder utilizar esa textura creada durante el juego, por lo que esta función, y el cargado de las texturas en general, debe hacerse al inicio del juego, o mejor aún, al inicio de cada nivel.
3. Durante el juego, habilitar el mapeado de texturas utilizando glEnable(GL_TEXTURE_2D), enlazar el texture object con la función glBindTexture( ), y asignar coordenadas de textura a los vértices de los objetos con glTexCoord2f( ).

Para facilitar la creación de texturas, usaremos un objeto global de la clase CTextureManager que tiene métodos para cargar texturas desde archivos, cada identificador de la textura que carga es almacenado en un vector y nos regresa el índice donde fue almacenado.

Otros recursos, texto y sonido
No existe soporte para el texto como tal dentro de OpenGL, lo que haremos es crear una imagen con los caracteres de la fuente que queremos utilizar (existen programas gratis para hacer esto, como www.angelcode.com/products/bmfont), cargarlo como textura, y acceder las diferentes regiones de la textura dependiendo de los caracteres a mostrar en pantalla. Para esto utilizaremos Display Lists, que es una característica de OpenGL que nos permite “compilar” un grupo de comandos de Render para no tener que ejecutar todos esos comandos de Render cada Frame. La funcionalidad del manejo de texto dentro de nuestro juego está en la clase GLFont (glfont.h, glfont.cpp).

Para el sonido y reproducción utilizaremos FMOD, una librería multiplataforma gratis para productos no comerciales, disponible en www.fmod.org. Esta librería tiene muchas características interesantes, como soporte para sonido 3D, soporte para varios canales y soporte para cargar diferentes formatos, tales como asf, ogg, flac, wma, wav y mp3.

Movimiento y detección de colisiones
Para el movimiento de los personajes, utilizaremos diferentes billboards que representan cada frame de la animación, y en el método Render del juego determinaremos que billboard usar dependiendo del tiempo transcurrido desde que se inició la animación del personaje.

La clase base que representa a un caracter tiene un atributo que representa su posición en pantalla (X, Y), este nos servirá en cada Frame para poder calcular la posición correspondiente en la pantalla respecto a los bloques en los que se puede caminar. Esta comparación no se puede hacer solo tomando el valor en el eje ‘Y’ de la posición del personaje porque los bloques pueden estar inclinados. El método utilizado será el siguiente:
•En cada bloque o área en la que se pueda caminar, tendremos un punto A (inicio) y un punto B (fin), lo que matemáticamente lo convierte en un segmento de recta dirigido.
•El hecho de que el segmento de recta sea dirigido es importante porque esto nos permite tomar un punto C (posición del personaje) y calcular la siguiente fórmula.

int Area2Int( int aX, int aY, int bX, int bY, int cX, int cY )
{
int area2 = ((( bX - aX ) * ( cY - aY )) -
(( cX - aX ) * ( bY - aY )));
return area2;
}
Sin entrar en detalles matemáticos, lo interesante de esta fórmula es que nos regresará un valor mayor a cero si el punto C se encuentra a la “izquierda” del segmento AB, y un valor menor a cero si se encuentra a la “derecha” (y un valor igual a cero si el punto se encuentra en el segmento). Para utilizar esto, es importante que todos nuestros segmentos de recta sean dirigidos hacia la misma dirección.

Sistemas de partículas
Los sistemas de partículas son usados en los juegos para representar cosas como lluvia, humo, nieve, fuego, disparos, sangre, etc. En el juego usaremos sistemas de partículas para representar las explosiones cuando se destruyen a los enemigos.

Un sistema de partículas es una colección precisamente de partículas. Cada partícula es independiente y tiene atributos individuales tales como: posición, velocidad, tiempo de vida, tamaño, y color. Los sistemas de partículas tienen ciertos atributos que comparten todas las partículas generadas por el sistema, como el tipo de representación (líneas, puntos, o cuadrados texturizados), tasa de emisión (intervalo de tiempo que debe pasar para que se generen nuevas partículas), posición del sistema, fuerzas externas (gravedad, viento), entre otros.

Hay diferentes enfoques en cuanto a los sistemas de partículas, para el juego usaremos una solución sencilla, definiremos una clase que representa un sistema de partículas genérico y una estructura que representa una partícula. No definiremos una clase separada para representar una partícula porque el sistema de partículas se encargará de inicializar, actualizar y renderizar las partículas.

/// En SistemaParticulas.h
struct Particula
{
vector2 m_Posicion, m_PosPrevia, m_Velocidad;
float m_TiempoVida, m_Tamanio, m_Color[4];
};

class SistemaParticulas
{
protected:
Particula *m_ListaParticulas;
int m_MaxParticulas, m_NumParticulas;
vector2 m_Posicion;
float m_UltimaEmision;
//... más atributos

public:
SistemaParticulas( int max_particulas, vector2 posicion );
~SistemaParticulas();

virtual void Actualiza( float tiempo_transcurido ) = 0;
virtual void Render() = 0;
virtual int Emite();
virtual void Inicializa();
//... más métodos
};

Visibilidad
Durante el juego, solo una pequeña parte del nivel es visible en todo momento, y para tener un buen rendimiento, solo aquellas partes que son visibles deben ser renderizadas. Existen estructuras de datos especializadas utilizadas para el particionamiento espacial, tales como los Quadtrees para el caso en 2D, pero no podemos utilizar un Quadtree porque las dimensiones (ancho, alto) de nuestro nivel son muy diferentes. En su lugar, el editor de niveles separará cada capa en pequeños sectores. Cada sector almacenará índices a los billboards que contiene. El método Render( ) de la clase Juego llama al método Render( ) de la clase Nivel, pasando como parámetro la posición de la cámara virtual, este método se encargará de renderizar solo aquellos sectores que sean visibles.

Conclusión
En este artículo concluimos el desarrollo de nuestro juego, espero que les haya sido interesante. Cualquier duda respecto al código me pueden contactar en joel.villagrana@gmail.com y trataré de responder lo más pronto posible. Solo me resta agradecer a Nell (nellfallcard@gmail.com) por su ayuda con algunas texturas para el juego y hacerles la invitación a participar en el concurso de videojuegos de Creanimax 2007! Las bases estarán disponibles pronto en www.creanimax.com

Acerca del autor
Joel Villagrana es egresado de la Universidad de Guadalajara como Ingeniero en Computación, y estudió una maestría en Ambientes Virtuales en Inglaterra. Actualmente trabaja para IBM, y en su tiempo libre sigue aprendiendo las nuevas técnicas de gráficas 3D. Participó en el libro “More OpenGL Game Programming” publicado en 2005. La información de estos artículos representa su punto de vista y no necesariamente el de IBM Corporation.