martes, 12 de febrero de 2013

El primer juego en SFML 2.0 (III): Disparos & Asteroides

En la anterior entrada, programamos la nave espacial usando las herramientas de dibujo que nos proporciona SFML2, así no tuvimos que usar ningún gráfico. En está tercera parte  vamos a programar los disparos de la nave. Al pulsar la tecla espacio las bala saldrán desde el vértice delantero de la nave. También crearemos los asteroides, estos serán círculos para simplificar posterior mente las colisiones.

Las Balas

Las balas van a ser representadas por puntos en la pantalla, estos saldrán desde el vértice delantero de la nave y tendrán una velocidad y una vida, lo cual definirá cuanto espacio recorren. Como no tienen aceleración la actualización de las balas será muy sencillo.
Lo primero que vamos a hacer es crear dos nuevos archivos, un archivo de cabecera en la carpeta include de nuestro proyecto y otro archivo fuente en la carpeta source, tan como hicimos en la entrada anterior con los archivos de la nave. Yo los he llamado Bullet.h y Bullet.cpp
En principio la clase Bullet es similar a la clase Ship, pero el hecho que sean representados por puntos implica que no podemos usar las clases tipo shape de SFML2. En vez de eso usaremos una de las funciones de la ventana que nos permite definir vértices y dibujarlos interpretando los vértices en función de la primitiva que le pasemos.

Nota:

Cuando dibujamos en un entorno 3D, aun simulando dibujar 2D como hace SFML2, en realidad todo son dibujos hecho por polígonos, así un sprite (un objeto dibujado a partir de una textura) no es mas que un cuadrado dibujado frente a la cámara 3D.
Un circulo o una figura convexa de SFML2 no es mas que un objeto que a la hora de dibujar genera una serie de cuadrados o polígonos para que al dibujarlos tengan la forma mas aproximada a la deseada.
OpenGL (SFML2) y DirectX tiene lo que se denominan primitivas de dibujado, que no es mas que dando a la tarjeta gráfica una serie de puntos (vértices) como tiene que dibujarlos, hay principalmente las siguiente primitivas de dibujado: puntos, líneas, líneas conectadas, triángulos, triángulos conectados y abanico de triángulos, OpenGL además tiene cuadrados, cuadrados conectados y polígonos.
fig1_4
En esta imagen puedes ver como se relaciona los puntos dentro de una lista con como se dibujan según la primitiva que le indiquemos. SFML2 solo tiene points, line, line strip, triangle, triangle strip, triangle fan y quad.
Así pues, la bala estará representada por un punto, que se dibujara en pantalla como una primitiva point. Veamos el código de cabecera.
#ifndef _GAME_BULLET_H_
#define _GAME_BULLET_H_

#include <SFML/Graphics.hpp>
#include <SFML/System.hpp>

//Algunas constantes que nos ayudaran a regular como se comportan las balas
const float bullet_live_seconds = 4.0f;   //Vida maxima de una bala es segundos
const float bullet_speed = 70.0f;    //Velocidad de la bala en pixel / segundo
const float bullet_shoot_speed_seconds = 0.5f; //frecuencia de disparo es segundos, cuanto tiempo tiene que pasar entre dos disparos seguidos

namespace game
{
 //La clase Bullet 
 class Bullet: public sf::Transformable, public sf::Drawable
 {
 public:
  //La bala se creara a partir de una posición inicial
  //y una dirección en la que se mueve
  Bullet( sf::Vector2f& initial_position, float movement_angle );

  //Actualizar la posición de la bala
  void update( float delta_time_seconds );

  //Dibujar la posición de la bala
  void draw ( sf::RenderTarget &target, sf::RenderStates states ) const;

  //Esto nos indicara si hay que destruir el objeto bala
  bool isAlive();

 protected:
  //Vector dirección de la bala
  sf::Vector2f m_direction_of_movement;
  //Los segundos que le quedan a la bala antes de ser destruida
  float m_remaining_live;
  //Indicador de si la bala sigue viva
  bool m_is_alive;
 };
}

#endif //_GAME_BULLET_H_

Como podéis ver es muy similar a la clase Ship. El constructor indica que para crear la bala tendremos que decirle donde empieza, y que Angulo tiene. Luego hay una variable m_is_alive que nos servirá para determinas cuando hay que destruir un objeto Bullet.
#include "Bullet.h"

//Constante para convertir grados a radianes para las funciones matemáticas
const float degree2radian = (3.14159f / 180.0f);

namespace game
{
 Bullet::Bullet( sf::Vector2f& initial_position, float movement_angle )
  : m_direction_of_movement( std::cosf( movement_angle * degree2radian), std::sinf( movement_angle * degree2radian ) ) //Usamos la trigonometría para saber cual es el vector de movimiento de la bala
  , m_remaining_live( bullet_live_seconds ) //Almacenamos cuanta vida le queda a la bala
  , m_is_alive( true ) //Marcamos como viva
 {
  setPosition( initial_position ); //Actualizamos la posición del padre transformable
 }

 void Bullet::update( float delta_time_seconds )
 {
  if( !m_is_alive ) return; //Si la bala no esta viva no se actualiza

  //Realizamos la actualización de la vida de la bala
  m_remaining_live -= delta_time_seconds;
  //Si la vida resultante es menor que cero, se indica que la bala esta muerta
  if( m_remaining_live < 0 ) m_is_alive = false;

  //Actualizamos la posición de la bala S = V * T
  sf::Vector2f velocity = m_direction_of_movement * bullet_speed * delta_time_seconds;
  move( velocity );

  //Hacemos que la bala este siempre en pantalla
  //Si se sale por un lado, entra por el otro.
  //Como hicimos con la nave
  sf::Vector2f position = getPosition();
  if( position.x <= -1.0f ) position.x = 640.0f + 1.0f;
  else if( position.x >= 641.0f ) position.x = - 1.0f;

  if( position.y <= -1.0f ) position.y = 480.0f + 1.0f;
  else if( position.y >= 481.0f ) position.y = - 1.0f;

  setPosition( position );
 }

 void Bullet::draw( sf::RenderTarget &target, sf::RenderStates states ) const
 {
  //Como es un punto lo dibujaremos con la función que permite
  //Dibujar figuras geométricas usando la primitiva punto.
  sf::Vertex v( getPosition(), sf::Color::White );
  target.draw( &v, 1, sf::Points );
 }

 bool Bullet::isAlive()
 {
  return m_is_alive;
 }
}

En los comentarios del archivo fuente están indicados lo pasos que se dan. En la función update, básicamente se le resta vida a la bala y se marca como muerta si es necesario, se actualiza la posición según el vector de dirección de movimiento, la constante velocidad y el tiempo transcurrido desde la ultima actualización, y se asegura que la bala este siempre en pantalla.

El dibujado es simple, se crea un vertex con la posición de la bala y se dibuja un solo punto.

NOTA:

Seria mucho mas eficiente generar los puntos de todas las balas y dibujarlas todas a la vez, pero como estamos empezando quiero primar la sencillez a la eficiencia.

 

Los asteroides


Los asteroides van a ser círculos, esto simplificara el calculo de las colisiones en la siguiente entrada. Por ello al igual que hicimos con la nave, yaceremos un sf::CirlceShape como representación gráfica de los asteroides.

En realidad no hay nada nuevo en la clase Rock. Tiene un indicador como la bala para saber cuando hay que destruir el objeto, tienen una serie de características como nivel que puede ser 0, 1, 2 y que tendrá influencia en como se comporta y se dibuja. Por lo demás, se crea con un origen y una dirección y se mueve en esa dirección.

Igual que con la nave y las balas crearemos dos nuevos archivos, uno de cabecera y otro fuente. Yo los he llamado Rock.h y Rock.cpp. Veamos el archivo de cabecera:
#ifndef _GAME_ROCK_H_
#define _GAME_ROCK_H_

#include <SFML/Graphics.hpp>

//Algunas constantes para retocar el comportamiento de los asteroides
//Hay tres niveles de asteroides Grande(0), mediano(1) y pequeño(2)
//Por eso uso arrays para las constantes de control
const float rock_speed[3] = { 40.0f, 60.0f, 80.0f };
const float rock_radius[3] = { 30.0f, 15.0f, 7.0f };

namespace game
{
 //Como la nave este objeto tendrá posición y se podrá dibujar directamente
 //He elegido que sea un circulo para simplificar las colisiones posteriormente
 //Pero como en la nave podríamos haber creado un convexshape con una forma irregular
 class Rock:public sf::Transformable, public sf::Drawable
 {
 public:
  //Se crea en una posición, con una dirección de movimiento y un nivel que por defecto es Grande(0)
  Rock( sf::Vector2f& initial_position, float movement_angle, sf::Uint8 rock_level = 0 );

  //Actualizar el asteroide
  void update( float delta_time_seconds );
  
  //Dibujar el asteroide
  void draw ( sf::RenderTarget &target, sf::RenderStates states ) const;

  //Indicar si sigue vivo o hay que destruirlo
  bool isAlive();

 protected:
  //Variable que indica que nivel tiene
  sf::Uint8 m_rock_level;

  //Objeto que será la representación gráfica
  sf::CircleShape m_rockShape;

  //Vector dirección del movimiento
  sf::Vector2f m_direction_of_movement;

  //Indicador de si esta vivo
  bool m_is_alive;
 };
}

#endif //_GAME_ROCK_H_

Lo mas interesante es el uso de array de constantes para contener la velocidad y tamaño de los asteroides según su nivel.

Ahora veamos el código fuente:
#include "Rock.h"

//Variable para convertir de grados a radianes en las funciones matemáticas
const float degree2radian = (3.14159f / 180.0f);

namespace game
{
 //Creación a partir de un origen y una dirección con un nivel
 Rock::Rock( sf::Vector2f& initial_position, float movement_angle, sf::Uint8 rock_level )
  : m_direction_of_movement( std::cosf( movement_angle * degree2radian), std::sinf( movement_angle * degree2radian ) )
  , m_is_alive( true )
  , m_rock_level( rock_level )
 {
  setPosition( initial_position );

  //Creamos la figura circulo y la centramos según el radio de la roca
  m_rockShape.setRadius( rock_radius[ m_rock_level ] );
  m_rockShape.setFillColor( sf::Color( 0, 0, 0, 0xFF) );
  m_rockShape.setOutlineColor( sf::Color::White );
  m_rockShape.setOutlineThickness(2);

  //Por defecto el origen de es la esquina superior izquierda
  //pero nosotros queremos que este centrada, así que
  //movemos el origen según el radio que tiene el asteroide
  m_rockShape.setOrigin( rock_radius[ m_rock_level ], rock_radius[ m_rock_level ] );
 }

 void Rock::update( float delta_time_seconds )
 {
  if( !m_is_alive ) return; //Si el asteroide no esta vivo no se actualiza

  //Actualizamos la  posición
  sf::Vector2f velocity = m_direction_of_movement * rock_speed[ m_rock_level ] * delta_time_seconds;
  move( velocity );

  //Hacemos que siempre estén en pantalla
  //Usamos el radio de la roca para determinar cuando se hace el salto
  //de forma que el salto nunca sea visible al usuario
  sf::Vector2f position = getPosition();
  if( position.x <= -rock_radius[ m_rock_level ] ) position.x = 640.0f + rock_radius[ m_rock_level ];
  else if( position.x >= 640.0f + rock_radius[ m_rock_level ] ) position.x = - rock_radius[ m_rock_level ];

  if( position.y <= -rock_radius[ m_rock_level ] ) position.y = 480.0f + rock_radius[ m_rock_level ];
  else if( position.y >= 480.0f + rock_radius[ m_rock_level ] ) position.y = - rock_radius[ m_rock_level ];

  setPosition( position );
 }
    
 void Rock::draw( sf::RenderTarget &target, sf::RenderStates states ) const
 {
  //Dibujamos la figura en función de la transformación del objeto Rock
  states.transform *= getTransform();
  target.draw( m_rockShape, states );
 }

 bool Rock::isAlive()
 {
  return m_is_alive;
 }
}

No hay nada de especial en el código que no hallamos comentado antes.

Empezando a disparar


Hasta ahora hemos creado la clase Bullet y la clase Rock, es el momento de integrarlas para que los veamos en acción en nuestro juego.

Lo primero que hay que tener en cuenta es que no sabemos con antelación cuantas balas, ni cuantos asteroides van a haber en pantalla durante el juego, así que no podemos crear una array estático. Para ello acudiremos a las librerías estadas de C++, las STL. Dentro de estas librerías hay dos contenedores que tienen la características que necesitamos, el contenedor std::vector y el contenedor std::list. Ambos pueden añadir y eliminar elementos de forma dinámica, de modo que no necesitamos saber cuanto espacio necesitamos, simplemente añadimos y eliminamos según necesitemos.

Es mejor usar la std::list por que es mas eficiente a la hora de eliminar objetos por el medio de la lista y como no sábenos a priori que bala abra que destruir o que asteroide es mejor usar la lista para mantener todos las balas y asteroides.
#include "Ship.h"
#include "Bullet.h" //A- Inclusión de la clase Bullet
#include "Rock.h" //B - Inclusión de la clase Rock

//A - Definición para mejorar la comprensión del código
typedef std::list<game::Bullet*> BulletList;
typedef std::list<game::Bullet*>::iterator BulletIndex;

//B - Definición para mejorar la comprensión del código
typedef std::list<game::Rock*> RockList;
typedef std::list<game::Rock*>::iterator RockIndex;

Lo primero que haremos será incluir los archivos de cabecera de la bala y de los asteroides. Luego , para simplificar el código vamos a definir la lista de punteros a balas como BulletList y el iterador a ese tipo de lista como BulletIndex. Lo mismo se hará con la lista de punteros a asteroides como se ve en el código.
//El objeto nave de nuestro juego
 game::Ship space_ship;

 //A - Contenedor de todas las balas disparadas
 BulletList bullets;
 float time_to_next_bullet = 0.0f; //A - Control de la frecuencia de disparo

 //B - Contenedor de rodas los asteroides
 RockList rocks;



A continuación vamos a crear dentro de la función main una lista de balas y una lista de asteroides, también se crea una variable time_to_next_bullet que nos servirá para controlar la frecuencia de disparo.
  //A - Rutina de disparo cuando se pulsa espacio & se puede disparar
  time_to_next_bullet -= delta_time_seconds;
  if( sf::Keyboard::isKeyPressed( sf::Keyboard::Space ) && time_to_next_bullet < 0.0f)
  {
   time_to_next_bullet = bullet_shoot_speed_seconds; //Reset del control de frecuencia de disparo

   //Calculo dela posición de la bala en función de la posición y dirección de la nave
   sf::Vector2f position = space_ship.getPosition();
   float movement_angle = space_ship.getRotation();

   //Movemos la posición de la bala hasta el morro de la nave
   position += 10.0f * sf::Vector2f( std::cosf( movement_angle * degree2radian ), std::sinf( movement_angle * degree2radian ) );

   //Creamos una nueva bala y la almacenamos
   game::Bullet* newBullet = new game::Bullet( position, movement_angle );
   if( newBullet ) bullets.push_back( newBullet );
  }

La rutina de disparo se hará después de comprobar los eventos del sistema, aquí lo que haremos será quitar tiempo a time_to_next_bullet  Luego comprobaremos que se ha pulsado la tecla espacio y si se puede disparar otra bala. Si se puede disparar reseteamos la variable que controla la frecuencia de disparo, calculamos la posición del vértice delantero de la nave usando la dirección de la nave y la longitud de la misma ( 10 pixel ). Finalmente creamos la nave en la posición del morro de la nave y con la dirección que tiene la nave en ese momento y lo insertamos al final de la lista de balas.
  //A - Actualización de las balas
  BulletIndex I = bullets.begin();
  BulletIndex E = bullets.end();
  while( I != E )
  {
   game::Bullet* bullet = (*I);

   if( bullet->isAlive() ) //Si la bala sigue viva se actualiza y se pasa a la siguiente
   {
    bullet->update( delta_time_seconds );
    ++I;
   }
   else //Si la bala ha muerto se elimina y se pasa a la siguiente
   {
    delete bullet;
    I = bullets.erase( I );
   }
  };

Después de actualizar la nave, actualizaremos las balas. Para ello recorreremos toda la lista de balas comprobando si están vivas, si están vivas se actualiza según el tiempo desde la ultima actualización y se pasa al siguiente, sino esta viva se borra el objeto y se elimina de la lista.


NOTA:

Hay que asegurarse siempre que por cada new que se hace en el código hay un delete del objeto.Si no empieza a quedar agujeros de memoria (memory leaks).
//A - Recorremos la lista de balas y las dibujamos
  I = bullets.begin();
  E = bullets.end();
  while( I != E )
  {
   game::Bullet* bullet = (*I);

   window.draw( *bullet );
   ++I;
  }

Después de dibujar la nave, se dibujan las balas, recorriendo la lista y dibujando en la pantalla.

Se hará algo parecido con los asteroides.
 //C - Flag de reseteado del juego
 bool need_reset_game = true;

 //Bucle principal de la aplicación
 //Mientras nuestra ventana sigua abierta
    while (window.isOpen())
    {
  //C - Primer reseteado del juego
  if( need_reset_game )
  {
   need_reset_game = false;

   //Reseteo de la nave
   space_ship.reset();

   //Elimino todos los disparos
   BulletIndex I = bullets.begin();
   BulletIndex E = bullets.end();
   while( I != E )
   {
    game::Bullet* bullet = (*I);
    delete bullet;
    ++I;
   };
   bullets.clear();

   //Elimino todos los asteroides
   RockIndex RI = rocks.begin();
   RockIndex RE = rocks.end();
   while( RI != RE )
   {
    game::Rock* rock = (*RI);
    delete rock;
    ++RI;
   };
   rocks.clear();

   //Crea nuevos asteroides
   game::Rock* newRock = new game::Rock( sf::Vector2f( std::rand()%640, std::rand()%480 ), std::rand()%360 );
   game::Rock* newRock2 = new game::Rock( sf::Vector2f( std::rand()%640, std::rand()%480 ), std::rand()%360 );
   game::Rock* newRock3 = new game::Rock( sf::Vector2f( std::rand()%640, std::rand()%480 ), std::rand()%360 );
   game::Rock* newRock4 = new game::Rock( sf::Vector2f( std::rand()%640, std::rand()%480 ), std::rand()%360 );

   rocks.push_back( newRock );
   rocks.push_back( newRock2 );
   rocks.push_back( newRock3 );
   rocks.push_back( newRock4 );
  }

Vamos a usar la variable need_reset_game para controlar la creación de asteroides, esta nos servirá para cuando tengamos que reiniciar el juego mas adelante. Cuando hay que reiniciar el juego se reseteare la posición de la nave, se eliminaran todas las Balas y todos los asteroides que existan en ese momento y se crearan por el momento cuatro asteroides en puntos aleatorios y con direcciones aleatorias.
  //B - Actualización de los asteroides
  RockIndex RI = rocks.begin();
  RockIndex RE = rocks.end();
  while( RI != RE )
  {
   game::Rock* rock = (*RI);

   if( rock->isAlive() ) //Si esta vivo se actualiza
   {
    rock->update( delta_time_seconds );
    ++RI;
   }
   else //Si no esta vivo se borra
   {
    delete rock;
    RI = rocks.erase( RI );
   }
  };

Luego solo hay que actualizar como se hizo con las balas y posteriormente dibujar:
//B - Recorremos la lista de asteroides y los dibujamos
  RI = rocks.begin();
  RE = rocks.end();
  while( RI != RE )
  {
   game::Rock* rock = (*RI);
   window.draw( *rock );
   ++RI;
  }

Exactamente igual que las balas. Tenéis mi proyecto con el código fuente de la que hay hasta ahora aquí.

Con esto tenemos ya algo que se parece al juego de asteroides, aunque aun no se puede dispara ni chocar con las rocas. Eso será en mi próxima entrada, donde calcularemos las colisiones, haremos que se rompan los asteroides y crearemos unas pequeñas explosiones.

Gracias por vuestra atención, nos vemos pronto.

7 comentarios:

Francisco dijo...

De nuevo, excelente tutorial y me esta siendo muy util para iniciarme en el desarrollo de videojuegos, a parte de ir acostumbrandome a la sintaxis y peculiaridades de C++, nunca he programado en C++, pero si en Java y algo de C.



dan-elo dijo...

Hola! Muy buenos tus tutoriales, son de mucha ayuda. Pero tengo un problema, no me reconoce las listas!

std::list

eso me da error...¿Qué librería tengo que añadir? ¿Como lo hago?

Un saludo.

Unknown dijo...

Hola, gracias por tu interes.
Si ha copiado el codigo de arriba puede que te de problemas por que no esta todo el codigo completo (si te descargas el 7z de la leccion si funciona)
Faltan algunas cabeceras por incluir. Concretamente faltan

#include
#include

#include
#include
#include

en main.cpp, por eso puede que te diga que no sabe que es std::list.

Un saludo

dan-elo dijo...

Exacto, me faltaba uno de los includes.

#include

Ahora funciona perfecto. Muchas gracias.

Se agradecen tus tutoriales, y más en algo tan complejo como la creación de juegos.

Saludos.

Unknown dijo...

El repositorio no existe mas =(((

Unknown dijo...

El link te lleva a box, hay te dice que no hay acceso directo al archivo (intentare mirar porque). Aparece otro link desde donde se puede descargar el archivo 7z con el codigo y el proyecto de vs2010.
Gracias por tu interes.

Unknown dijo...

Bueno compañero, lo he descargado como dices pero no lo conseguí compilar. Uso el codeblocks y tira muchos errores.
Aun así gracias.
Me quedo con las explicaciones ;)
Tu Blog esta muy bien, pena que no hagas mas tutoriales como este. :)
Saludos (y)