Explosiones
Para crear las explosiones crearemos dos nuevos archivos en donde definiremos una nueva class Explosion. Esta clase será una versión muy simple de un sistema de partículas. En concreto la clase Explosion controlara 12 partículas que se moverán de forma independiente, no va a resultar muy espectacular visualmente, pero es la base para comprender como se hacen sistemas de partículas.
Veamos el código de Explosion.h:
#ifndef _GAME_EXPLOSION_H_ #define _GAME_EXPLOSION_H_ #include <SFML/Graphics.hpp> const float max_particle_speed = 140.0f; //Contante que controla la velocidad maxima de la particula const float particle_angle_radian = (360.0f/12.0f) * (3.14159f / 180.0f); //Una 12ª parte de un circulo, para calcular la dirección de cada particula namespace game { //Estructura que conserva la posición actual y //la dirección y velocidad de movimiento de la particula struct particle { sf::Vector2f m_position; sf::Vector2f m_velocity; }; //La clase que tiene las partículas, las actualiza y las dibuja class Explosion: public sf::Drawable { public: //La explosión se crea en un punto y tendrá una vida definida en segundos Explosion( int x, int y, float max_life ); //Función que actualizara la posición de las partículas void update( float delta_time_seconds ); //Definición de la función virtual de sf::Drawable void draw ( sf::RenderTarget &target, sf::RenderStates states ) const; //Indicador que nos dirá si hay que eliminar esta explosión bool isAlive(); protected: bool m_isAlive; //El indicador de que sigue activo float m_max_life; //Conserva cual es la vida maxima de esta explosión float m_life; //Acumula cuanto tiempo a pasado desde la creación de la explosión particle m_explosion[12]; //Las 12 partículas que se usan en la explosion }; } #endif
Como podéis ver, es una clase bastante sencilla. Se puede ver ya un rasgo base de los sistemas de partículas que es la composición de, por un parte una estructura que conserva los datos propios de cada particula individual, y, por otro lado, una clase que crea, almacena, actualiza y dibuja las partículas que forman el sistema. En nuestro caso el numero de partículas es fijo (12) por lo que se crean al principio y ya permanecen hasta morir.
Veamos ahora la implementación en Explosion.cpp:
#include "Explosion.h" #include <cstdlib> namespace game { //Constructor de la explosion Explosion::Explosion( int x, int y, float max_life ) : m_life(0.0f) , m_isAlive(true) , m_max_life(max_life) { //Se inicializan las 12 partículas que forman la explosion for( int i = 0; i < 12; ++i) { //Sus posiciones iniciales son las de la explosion m_explosion[i].m_position.x = static_cast<float>(x); m_explosion[i].m_position.y = static_cast<float>(y); //La velocidad tiene una dirección dada por un Angulo que será la 12ª parte que le corresponda //y una velocidad que estará entre 0 y la velocidad maxima que pueda tener la particula m_explosion[i].m_velocity.x = std::sin( (i + 1)*particle_angle_radian ) * ((std::rand() * 1.0f) / RAND_MAX ) * max_particle_speed; m_explosion[i].m_velocity.y = std::cos( (i + 1)*particle_angle_radian ) * ((std::rand() * 1.0f) / RAND_MAX ) * max_particle_speed; } } //Función que actualizara la posición de las partículas void Explosion::update( float delta_time_seconds ) { //Actualizamos la posición de las partículas for( int i = 0; i < 12; ++i ) { //La nueva posición es igual a la vieja posición mas las velocidad por el tiempo transcurrido m_explosion[i].m_position.x += m_explosion[i].m_velocity.x * delta_time_seconds; m_explosion[i].m_position.y += m_explosion[i].m_velocity.y * delta_time_seconds; } //Acumulamos el tiempo transcurrido como vida de la explosion m_life += delta_time_seconds; //Si la vida de la explosion es mayor //que la vida maxima para esta explosion se señala como muerta if( m_life >= m_max_life ) m_isAlive = false; } //Definición de la función virtual de sf::Drawable void Explosion::draw ( sf::RenderTarget &target, sf::RenderStates states ) const { //Usaremos una estructura de SFML 2.0 //para dibujar directamente en la pantalla sf::Vertex points[12]; //Rellenamos las estructuras con la posición y el color que usaremos para dibujar for( int i = 0; i < 12; ++i ) { points[i].position = m_explosion[i].m_position; points[i].color = sf::Color::Red; } //Dibujaremos los 12 Vertex como si fueran puntos target.draw( points, 12, sf::Points ); } //Devuelve el estado de vida de la explosión bool Explosion::isAlive() { return m_isAlive; } }
Todo es bastante normal. La forma de inicializar la velocidad de las partículas en el constructor esta basada en la trigonometría. Además uso la función estándar std::rand() que devuelve un valor pseudoaleatorio entre 0 y RAND_MAX, pro eso al dividirlo entre RAND_MAX en forma de variable de punto flotante(float) obtengo un valor pseudoaleatorio entre 0.0 y 1.0, que sirve para regular la velocidad de las partículas.
Otra curiosidad es como dibujamos, usamos la misma técnica que usamos con las balas, solo que en esta ocasión en vez de dibujarlas una a una se dibujan todas las partículas a la vez.
Incluyendo Explosiones En El Juego
Toca el momento de insertar las nuevas explosiones en nuestro juego, para ello tocaremos el archivo main.cpp para añadirle unas cuantas líneas de código.
Lo primero será añadir el archivo de Explosion.h y hacer unas definiciones que nos faciliten un poco la vida:
#include "Explosion.h" //A - Inclusión de la clase explosion //Definición para mejorar la comprensión del código typedef std::list<game::Explosion*> ExplosionList; typedef std::list<game::Explosion*>::iterator ExplosionIndex;
Es igual que hicimos con las balas y los asteroides.
Las explosiones hay que mantenerlas bajo control, como hicimos con los asteroides y las balas así que necesítalos crear una nueva variable que mantenga una lista de explosiones que están activas en este momento, para posteriormente actualizarlas y dibujarlas.
//Contenedor de rodas los asteroides RockList rocks; //A - Contenedor de todas las explosiones ExplosionList explosions;
Buscaremos donde estaba la variable que mantenía la lista de rocas y añadiremos la lista de explosiones.
Necesitaremos actualizarlas y dibujarlas así que buscaremos donde se actualizaban las rocas y a continuación añadiremos la actualización de las explosiones:
//A - Actualización de las explosiones ExplosionIndex XI = explosions.begin(); ExplosionIndex XE = explosions.end(); while( XI != XE ) { game::Explosion* explosion = (*XI); if( explosion->isAlive() ) //Si esta vivo se actualiza { explosion->update( delta_time_seconds ); ++XI; } else //Si no esta vivo se borra { delete explosion; XI = explosions.erase( XI ); } };
Igualmente buscaremos la parte de código que dibuja las rocas y añadiremos el código para dibujar las explosiones:
//A - Recorremos la lista de explosiones y las dibujamos XI = explosions.begin(); XE = explosions.end(); while( XI != XE ) { game::Explosion* explosion = (*XI); window.draw( *explosion ); ++XI; }
Bien, con esto actualizamos y dibujamos las explosiones, pero todavía no las hemos creado así que tendremos que insertar nuevas explosiones cada vez que una bala choque con una roca y cuando la nave choque con una roca. Eso esta en la sección de colisiones al principio del bucle principal:
//Comprobamos la colisión if( rock->checkPoint( bullet->getPosition() ) ) { //Matamos la bala para evita que una misma bala destruya varias rocas bullet->kill(); //recicla esta roca incrementando su nivel y cambiando si dirección rock->incrementRockLevel(); rock->changeMovementAngle( std::rand()%360 ); //Si era una roca con nivel menor que 2 seguirá viva //y habrá que añadir una nueva roca del mismo nivel if( rock->isAlive() ) { game::Rock* newRock = new game::Rock( sf::Vector2f( rock->getPosition().x, rock->getPosition().y ) , std::rand()%360, rock->getRockLevel() ); //No se añade directamente, sino que se almacena para añadirse al final addedRocks.push_back( newRock ); } //A- Creamos la explosion del disparo game::Explosion* newExplosion = new game::Explosion( rock->getPosition().x, rock->getPosition().y, 0.75f ); explosions.push_back( newExplosion ); //Si esta roca colisionado ya no se prueba mas y se para a la siguiente break;
Buscamos la parte de código que comprueba la colisión de las rocas con las balas, si se produce la colisión, a parte de partir la roca o destruirla como teníamos antes añadiremos que cree una explosion en la posición de la roca con una duración de 3/4 se segundo.
//Si colisiona necesitamos por el momento reiniciar el juego //Mas adelante contaremos las vidas, los puntos y el "game over" if( rock->checkTriangle( point_a, point_b, point_c ) ) { need_reset_game = true; //A- Creamos la explosion del disparo game::Explosion* newExplosion1 = new game::Explosion( space_ship.getPosition().x, space_ship.getPosition().y, 0.5f ); explosions.push_back( newExplosion1 ); game::Explosion* newExplosion2 = new game::Explosion( space_ship.getPosition().x, space_ship.getPosition().y, 1.0f ); explosions.push_back( newExplosion2 ); break; }
También buscaremos el código donde se comprueba si la nave choca contra alguna roca y añadiremos dos explosiones en la posición de la nave con tiempo de medio y un segundo.
Con esto el núcleo del juego estaría terminado. Ahora hay que pulirlo un poco y mostrar algo de información por pantalla.
Estados del Juego
Cuando jugamos puede que no seamos conscientes de ello, pero el juego se comporta de forma diferente según estemos en el menú de inicio, estemos configurando los controles o estemos jugando propiamente dicho. Es decir que el juego tiene distintos comportamiento o estados.
Un estado seria una forma de interpretar las entradas del jugador y de mostrar los resultados por pantalla, así pulsar el botón avanzar en el menú puede hacer que el señalizador de selección cambie de posición, pero si estamos editando el sonido, la misma tecla hará que el sonido aumente de volumen.
Vamos a introducir estados a nuestro juego, nada complicado, pues es solo para ver una forma de hacerlo. De hecho lo haremos de la peor forma posible, usando lo que se denomina código espagueti, ya que usaremos una estructura if/else para controlar que parte del código se ejecuta en función del estado en el que se encuentre el juego.
También de paso vamos a introducir las variables y comprobaciones para la condición de subir de nivel, quitar vida y condición de “game over”. En nuestro juego como en casi todos en los años 80s no se puede ganar, la cosa se va haciendo cada vez mas difícil hasta que mueres, Como en la vida real ( que deprimente :( ).
Para empezar definiremos los estados de nuestro juego. Tendrá tres estados,
- MainMenu que mostrara un mensaje diciendo que pulsemos intro para comenzar a jugar
- Playing que contendrá todo lo que hemos hecho hasta ahora
- End_game que mostrara un “game over” durante unos segundos y volverá a MainMenu
Como veis el sistema es circular, de MainMenu se pasa a Playing que cuando se acaban las vidas pasa a End_game que tras unos segundos pasa a MainMenu otra vez. Si analizáis cualquier juego veréis que siempre es así, al final siempre acabáis en el menú principal antes de poder salir. A menos que salgáis a lo bestia del juego.
Necesitaremos además controlar en que nivel estamos, a través de una variable que ira aumentando su valor cada vez que acabemos con todos los asteroides en pantalla, esto a su vez generara nuevos asteroides, el numero de asteroides será de 4 en nivel 1 añadiendo uno mas por cada nivel que aumentemos.
También controlaremos el numero de vidas del jugador, empezara con 3 vidas y se le quitara una cuando choque contra una roca, cuando el numero de vidas llegue a cero se acaba la partida. Además cada vez que se le quite una vida se reinicia el nivel.
Empecemos definiendo los estados del juego. Para ello añadimos un enumerado que nos ayudara a hacer los estados mas “human friendly”.
//B - Estados del juego enum { MAIN_MENU, PLAYING, END_GAME, };
Insertaremos este enumerado antes de la función main. Los estados en realidad son números, por eso es mejor tener un enumerado que nos permita saber que el estado 0 es MainMenu por ejemplo.
Antes del bucle principal, ya dentro de la función main, añadiremos las variables de control para el nivel, la vida y los textos para mostrar los mensajes dentro del juego:
//Flag de reseteado del juego bool need_reset_game = true; float reset_timer = 0.0f; //B - Variable que nos permita retrasar un tiempo el reseteo //B - Variable que controla el nivel int level = 1; //B - Variable que controla el numero de vidas int vidas = 3; //B - Variable que contiene el estado del juego int game_state = MAIN_MENU; //B - Textos a mostrar sf::Font font; font.loadFromFile( "arial.ttf" ); sf::Text MainMenu; MainMenu.setString( "---Press ENTER to start a new game---" ); MainMenu.setFont( font ); MainMenu.setColor( sf::Color::White ); MainMenu.setPosition( 320.0f, 240.0f ); sf::FloatRect bounds = MainMenu.getLocalBounds(); MainMenu.setOrigin( bounds.width * 0.5f, bounds.height * 0.5f ); sf::Text LevelText; LevelText.setString( "Level: 1" ); LevelText.setFont( font ); LevelText.setCharacterSize( 15 ); LevelText.setColor( sf::Color::White ); LevelText.setPosition( 10.0f, 5.0f ); sf::Text LiveText; LiveText.setString( "AAA" ); LiveText.setFont( font ); LiveText.setCharacterSize( 20 ); LiveText.setColor( sf::Color::White ); LiveText.setPosition( 10.0f, 25.0f ); sf::Text EndGame; EndGame.setString( "--- GAME OVER ---" ); EndGame.setFont( font ); EndGame.setColor( sf::Color::White ); EndGame.setPosition( 320.0f, 240.0f ); bounds = EndGame.getLocalBounds(); EndGame.setOrigin( bounds.width * 0.5f, bounds.height * 0.5f );
Para mostrar textos usaremos dos clases de SFML 2.0, la clase sf::Font, que contiene la fuente que se usara para dibujar los textos. En este caso usaremos la fuente arial.ttf, esta fuente debe estar en la carpeta /bin, pues SFML no tiene acceso a las fuentes instaladas en el sistema operativo. Podéis usar cualquier fuente que queráis.
La otra clase es sf::Text, esta clase sirve para dibujar texto a partir de una fuente, es una clase derivada de sf::Transformable, por lo que tiene todas las funciones para poner, mover, girar, etc. y también es derivada de sf::Drawable, con lo cual se puede decir a la ventana de SFML 2.0 que la pinte directamente. Algunas funciones que son propias de la clase sf::Text son la función setFont para decir que fuente se utiliza, la función setString que nos permite modificas el mensaje a mostrar, y la función getLocalBounds que nos dice que rectángulo ocupa el texto que queremos mostrar y nos servirá para centrar los textos.
Como curiosidad, puedes ver que el objeto LiveText tiene como teto a mostrar “AAA”, he puesto eso porque la ‘A’ se parece a la nave y es más visual para indicar las vidas que un numero.
Reorganizando El Código
Bien, para introducir nuestros estados tendremos que reorganizar nuestro código un poco, principalmente moveremos la variable que controla el tiempo y el bucle de captura de eventos al principio del bucle principal, ya que son cosas que son comunes a los tres estados.
Después crearemos una estructura if/else con la variable game_state, se comprobara si el estado es MainMenu, o Playing o End_game y se ejecutara una parte del código u otra.
Todo lo que tenemos hasta ahora va ha estar dentro del if(game_state == Playing):
while (window.isOpen()) { //Recogemos cuanto tiempo a pasado en el frame anterior //y volvemos a poner el contador a cero float delta_time_seconds = syncronice_timer.restart().asSeconds(); //Capturamos los eventos de la ventana //Como cerrar, se ha pulsado una tecla, //se ha movido el ratón, etc. sf::Event event; while (window.pollEvent(event)) { //Si se ha pulsado cerrar ventana cerramos nuestra ventana if (event.type == sf::Event::Closed) window.close(); } if( game_state == MAIN_MENU ) { if( sf::Keyboard::isKeyPressed( sf::Keyboard::Return ) ) game_state = PLAYING; window.clear(); window.draw( MainMenu ); window.display(); } else if( game_state == PLAYING ) { //Aquí todo lo que ha sido nuestro código
La primera comprobación de la variable game_state es si estamos en el menú principal, si es así miraremos si se pulsa la tecla Return, y de ser así cambiaremos el estado a PLAYING, mientras tanto dibujaremos el mensaje del objeto texto MainMenu.
En la parte final del bucle principal insertaremos el estado den END_GAME:
else if( game_state == PLAYING ) { //Aquí todo lo que ha sido nuestro código //... //... } else if( game_state == END_GAME ) { if( reset_timer > 0.0f ) reset_timer -= delta_time_seconds; if( reset_timer <= 0.0f) game_state = MAIN_MENU; window.clear(); window.draw( EndGame ); window.display(); } //Sincronizamos para que no vaya mas rápido que 30 fps while( syncronice_timer.getElapsedTime().asSeconds() < 1.0f / 30.0f ) sf::sleep( sf::seconds( 1.0f/60.0f ) ); }
Este estado esperara un tiempo que viene dado desde el estado PLAYING cuando detecta que se han acabado las vidas y simplemente dibuja el mensaje de fin de partida, cuando se agota el tiempo vuelve al menú principal.
Controlando la Vida y Los Niveles
Ahora tendremos que hacer algunos pequeños cambios que nos permitan cambien de nivel y también descontar las vidas cuando corresponda. Para ello hemos definido las variables que tienen el nivel actual , el numero de vidas que le queda al jugador y una par de variables auxiliares, una es reset_timer que nos permitirá retrasar un tiempo la regeneración de las rocas cuando se acabe con todas las rocas o cuando se choque la nave.
Algo que hay que evitar en los juegos son los cambios bruscos en la pantalla, quedaría muy mal que nada mas chocar la nave con un asteroide todo vuelva a empezar de forma súbita, hay que dejar que el jugador asimile que ha perdido una vida y que tiene otra oportunidad.
Otra variable auxiliar es ship_visible, un binario que nos dirá si hay o no que dibujar la nave y comprobar las colisiones con esta. Mientras se espera que el juego se restablezca tras perder una vida, la nave no debe mostrarse, ni se debe calcular las colisiones con la nave.
Manos a la obra con estos cambios en el núcleo del juego. Primero en la parte que comprueba las colisiones de las rocas con la nave, insertaremos el código que nos quita una vida. La comprobación de colisión con la nave solo se harán si la nave es visible:
if( ship_visible ) { //Cogemos los puntos en pantalla que representan a la nave sf::Vector2f point_a, point_b, point_c; space_ship.getPoints( point_a, point_b, point_c ); //Nueva mente recorremos todas las rocas RI0 = rocks.begin(); RE0 = rocks.end(); while( RI0 != RE0 ) { game::Rock* rock = (*RI0); //Si colisiona necesitamos por el momento reiniciar el juego //Mas adelante contaremos las vidas, los puntos y el "game over" if( rock->checkTriangle( point_a, point_b, point_c ) ) { need_reset_game = true; reset_timer = 2.0f; //B- segundos para resetear ship_visible = false; //B- No se ve la nave --vidas; //Aquí reconstruiremos la cadena con el numero de vidas //Una 'A' por cada vida std::string liveT = ""; for( int i = 0; i < vidas; ++i ) liveT += "A"; LiveText.setString( liveT ); //A- Creamos la explosion del disparo game::Explosion* newExplosion1 = new game::Explosion( space_ship.getPosition().x, space_ship.getPosition().y, 0.5f ); explosions.push_back( newExplosion1 ); game::Explosion* newExplosion2 = new game::Explosion( space_ship.getPosition().x, space_ship.getPosition().y, 1.0f ); explosions.push_back( newExplosion2 ); break; } ++RI0; //siguiente roca }; }
Cuando la nave choca marcaremos que hay que reiniciar el sistema, también le diremos que espere 2 segundos para que se vea bien que tu nave ha sido destruida. También se actualiza el mensaje de numero de vidas, para ello cambiaremos la cadena que muestra el objeto texto LiveText poniendo tantas ‘A’ como vidas queden.
A continuación vamos a introducir el descuento del reset_timer y modificaremos la forma de resetear el estado de juego
//Actualizamos el reset_timer si este tiene algo que descontar if( reset_timer > 0.0f ) reset_timer -= delta_time_seconds; //Primer reseteado del juego if( need_reset_game && reset_timer <= 0.0f) { need_reset_game = false; if( vidas == 0 ) { game_state = END_GAME; reset_timer = 5.0f; } else { //Reseteo de la nave space_ship.reset(); ship_visible = true;
Simplemente descontaremos el tiempo transcurrido si hay algo que descontar en reset_timer, además la condición para hacer un reset tendrá en cuenta que la variable de reset_timer este a cero. Así mismo si se comprueba que se han perdido todas las vidas, no se realiza un reseteo sino que se cambia al estado de END_GAME con un tiempo de espera de 5 segundos. Si se necesita hacer el reset se pondrá la nave visible otra vez.
El reseteo tendrá que borrar cualquier explosion que pueda existir, además tendrá que generar mas asteroides en función del nivel. Por eso al final de la sección de reseteo se pone:
//Elimino todas las explosiones ExplosionIndex XI = explosions.begin(); ExplosionIndex XE = explosions.end(); while( XI != XE ) { game::Explosion* explosion = (*XI); delete explosion; ++XI; }; explosions.clear(); //B - Creo nuevos asteroides for( int i= 0; i < 3 + level; ++i ) { game::Rock* newRock = new game::Rock( sf::Vector2f( std::rand()%640, std::rand()%480 ), std::rand()%360 ); rocks.push_back( newRock ); } }
Se eliminan las explosiones tal y como se hizo con las balas y las rocas. Además en vez de añadir cuatro asteroides de forma fija se usa un bucle que añadirá 3 mas el numero de nivel ( que va de 1 a infinito ).
Tras la sección de reset insertaremos la comprobación para subir de nivel. Es decir cuando no halla mas rocas, o sea, cuando la variable que tiene una lista de las rocas este vacía, tendremos que subir un nivel y resetear el juego:
//B - Comprobación de necesitamos ir a otro nivel if( rocks.empty() && !need_reset_game ) { ++level; need_reset_game = true; reset_timer = 0.75f; std::string lText = "Level: "; //Convertimos el numero level en una cadena de texto std::stringstream ss; ss << level; lText += ss.str(); LevelText.setString( lText ); }
Para que el objeto texto que muestra el nivel pueda usar la variable numérica level tendremos que convertirla en texto. En C++ se usa el objeto std::stringstream incluida en la librería <sstream> estandar de C++.
Por ultimo solo actualizaremos la nave y la dibujaremos si es visible, comprobando la variable ship_visible y añadiremos al final del dibujado los objetos texto que muestran el nivel y las vidas que quedan.
//Actualizamos if( ship_visible ) space_ship.update( delta_time_seconds ); //Actualizamos la nave según el tiempo transcurrido //... //Dibujamos window.clear(); //Limpiar if( ship_visible ) window.draw( space_ship ); //Dibujamos la nave //... window.draw( LevelText ); window.draw( LiveText ); window.display(); //Mostrar en pantalla
GAME OVER
Con esto hemos terminado nuestro primer juego con SFML 2.0. Cierto que no es nada espectacular, pero nos muestra los fundamentos de los juegos.
Recapitulando podemos observar que:
- Cada elemento de juego es una clase
- Como se hacen las comprobaciones de colisiones
- Como es la estructura de estados de un juego
- Como se muestra información básica para el jugador
Espero que os halla resultado interesante. Os recomiendo que juguéis un poco con el código, cambiando cosas aquí y allí para saber que consecuencias tiene.
Aquí os dejo todo el proyecto con el código.
Gracias por vuestra atención. Nos vemos muy pronto.
2 comentarios:
Te has liado un cacao de código ahí increíble XDD
El tutorial esta muy bueno, pero como ya no esta el repositorio a nadie o apenas muy poca gente le sirve el tutorial.
Espero que un día lo vuelvas a subir, solo se te entiende hasta la parte que pones los archivos completos, después de eso solo pasas código y mas código y no dices donde va, si en main.cpp si roca.h XDDD
Por eso nadie comenta nada mas, seria una muy buena referencia si estuviera completo pero como yo soy novato te lo digo que "NO SE TE ENTIENDE NADA!!"jajaja XD
Suerte! =D
me da este error al compilar en la linea 178 y en la 296
Gravedad Código Descripción Proyecto Archivo Línea Estado suprimido
Error C2664 'game::Rock::Rock(game::Rock &&)': el argumento 1 no puede convertirse de 'sf::Vector2f' a 'sf::Vector2f &' asteroid c:\pruebas_sfml\asteroid\main.cpp 178
Publicar un comentario