- Hands-On Game Development with WebAssembly
- Rick Battagline
- 1728字
- 2021-06-24 13:41:13
Destroying a spaceship on collision
Now that we are detecting collisions between the projectiles and the spaceships, it would be nice to do something more interesting than printing a line to the console. It would be nice to have a little explosion animation for our projectiles and our ships when they hit something. We can add an animation associated with each of these objects as they are destroyed.
Instead of loading multiple sprites for each frame of the animation as we did in a previous chapter, I'm going to introduce the concept of sprite sheets. Instead of loading a single projectile frame and a single ship frame for each of our spaceships, we will load a sprite sheet for each that includes not only the undamaged version of each but a destruction sequence that we will animate through when any of these objects are destroyed.
Having three different sprite sheets in this example is done for convenience only. When you decide how to pack your sprite sheets for production, there are several considerations that you must take into account. You will most likely want to break out your sprite sheets based on when you will need them. You may have a series of sprites you need that are common to all levels of the game. You may choose to break out the sprites based on the level. You also need to take into consideration that, for performance reasons WebGL requires power-of-2 sized sprite files. That may impact your decisions concerning what sprites to pack into what sprite sheets. You may also consider purchasing a tool such as Texture Packer to pack sprites for you more quickly than you could do by hand.
We have created three sprite sheets to replace the three sprites we were using. These Sprites are FranchiseExp.png to replace Franchise.png, BirdOfAngerExp.png to replace BirdOfAnger.png, and ProjectileExp.png to replace Projectile.png. We are going to need to make some tweaks to the Projectile class, Ship class, EnemyShip class, PlayerShip, and the ProjectilePool class, as well as the game_loop function.
We are going to start by modifying the game loop to keep track of the game's timing data. We must remove some code from the PlayerShip::Move function inside the player_ship.cpp file. This code existed from Chapter 4, Sprite Animations in WebAssembly with SDL, where we discussed the basics of animating a sprite by animating PlayerShip. We must delete the following code from the first several lines of PlayerShip::Move:
current_time = SDL_GetTicks();
diff_time = current_time - last_time;
delta_time = (double)diff_time / 1000.0;
last_time = current_time;
This code gets the current time and calculates all of our time-related information we use for speed adjustments and animation timing. We probably should have moved this code to the game loop a few chapters ago, but better late than never. The following is the code for the new game_loop function in main.cpp:
void game_loop() {
current_time = SDL_GetTicks();
diff_time = current_time - last_time;
delta_time = (double)diff_time / 1000.0;
last_time = current_time;
input();
move();
render();
}
Strictly speaking, we did not have to make this change, but it makes more sense to have the game timing code within the game loop. Now that we have changed our game loop, we are going to modify the Projectile class. Here are the changes to the class definition we must make from within the game.hpp file:
class Projectile: public Collider {
public:
const char* c_SpriteFile = "sprites/ProjectileExp.png";
const int c_Width = 16;
const int c_Height = 16;
const double velocity = 6.0;
const double alive_time = 2000;
SDL_Texture *m_SpriteTexture;
SDL_Rect src = {.x = 0, .y = 0, .w = 16, .h = 16 };
Uint32 m_CurrentFrame = 0;
int m_NextFrameTime;
bool m_Active;
float m_TTL;
float m_VX;
float m_VY;
Projectile();
void Move();
void Render();
void Launch(float x, float y, float dx, float dy);
};
We need to modify the c_SpriteFile variable to point to the new sprite sheet PNG file instead of the single sprite file. We need to increase the size of its width and height. To make space for the explosion, we will make all frames in the sprite sheet 16 x 16 instead of 8 x 8. We also need a source rectangle. When each sprite has used an entire file, we could pass in null to SDL_RenderCopy, and the function would render the entire contents of the sprite file. Now we only want to render one frame, so we need a rectangle that will start at 0,0 and render the width and height of 16. The sprite sheets we have created are horizontal strip sprite sheets, meaning that every frame is laid out in order and placed horizontally. To render a different frame of our animation, we will only need to modify the .x value inside our source rectangle. The final attribute we added is to the public section and is the m_CurrentFrame attribute. That tracks which frame in the animation we are currently on. We will keep our current frame at 0 when we are not rendering the explosion animation.
Next, we will need to modify a few functions on the Projectile class. These functions are the Projectile::Move function and the Projectile::Render function inside of the projectile.cpp file. Here is the new version of the Projectile::Move function:
void Projectile::Move() {
if( m_CurrentFrame > 0 ) {
m_NextFrameTime -= diff_time;
if( m_NextFrameTime <= 0 ) {
++m_CurrentFrame;
m_NextFrameTime = ms_per_frame;
if( m_CurrentFrame >= 4 ) {
m_Active = false;
m_CurrentFrame = 0;
return;
}
}
return;
}
m_X += m_VX;
m_Y += m_VY;
m_TTL -= diff_time;
if( m_TTL < 0 ) {
m_Active = false;
m_TTL = 0;
}
}
The top section of the Move function is all new. If the current frame is not 0, we will run through the animation until it ends and then deactivate our projectile, sending it back to the projectile pool. We do this by subtracting the time since the app last ran the game loop. That is the value stored in the diff_time global variable. The m_NextFrameTime attribute variable stores the number of milliseconds until we switch to the next frame in our series. Once the values are below 0, we increment our current frame and reset m_NextFrameTime to the number of milliseconds we want between each new frame of our animation. Now that we have incremented the current animation frame, we can check to see whether it is greater than or equal to the frame number of the last frame in this animation (in this case, 4). If so, we need to deactivate the projectile and reset the current frame to 0.
Now, that we have made the changes we need to make to the Move() function, here are the changes we must make to the Projectile::Render() function:
void Projectile::Render() {
dest.x = m_X + 8;
dest.y = m_Y + 8;
dest.w = c_Width;
dest.h = c_Height;
src.x = 16 * m_CurrentFrame;
int return_val = SDL_RenderCopy( renderer, m_SpriteTexture,
&src, &dest );
if( return_val != 0 ) {
printf("SDL_Init failed: %s\n", SDL_GetError());
}
}
The first change to the Render function is the addition of the src rectangle to the SDL_RenderCopy call, as well as setting its x value immediately above that call. Each frame in our sprite sheet is 16 pixels wide, so setting the x value to 16 * m_CurrentFrame will select a different 16 x 16 sprite from the sprite sheet. The width and height of that rectangle will always be 16, and the y value will always be 0 because we placed the sprites into this sprite sheet as a horizontal strip.
Now we are going to make some modifications to the Ship class definitions inside the game.hpp file:
class Ship: public Collider {
public:
Uint32 m_LastLaunchTime;
const int c_Width = 32;
const int c_Height = 32;
SDL_Texture *m_SpriteTexture;
SDL_Rect src = {.x = 0, .y = 0, .w = 32, .h = 32 };
bool m_Alive = true;
Uint32 m_CurrentFrame = 0;
int m_NextFrameTime;
float m_Rotation;
float m_DX;
float m_DY;
float m_VX;
float m_VY;
void RotateLeft();
void RotateRight();
void Accelerate();
void Decelerate();
void CapVelocity();
virtual void Move() = 0;
Ship();
void Render();
};
We modified the width and height constants to reflect the new sprite size of 32 x 32 pixels as it appears in our sprite sheet. We also must add a source rectangle to the Projectile class. Inside our public attributes section, we have added a few variables to track the alive or dead status of the ship, (m_Alive); the current frame the game is rendering, (m_CurrentFrame); and the time in milliseconds until we render the next frame, (m_NextFrameTime). Next, we will make the necessary modifications to the ship.cpp file. We need to modify the Ship::Render function:
void Ship::Render() {
if( m_Alive == false ) {
return;
}
dest.x = (int)m_X;
dest.y = (int)m_Y;
dest.w = c_Width;
dest.h = c_Height;
src.x = 32 * m_CurrentFrame;
float degrees = (m_Rotation / PI) * 180.0;
int return_code = SDL_RenderCopyEx( renderer, m_SpriteTexture,
&src, &dest,
degrees, NULL, SDL_FLIP_NONE );
if( return_code != 0 ) {
printf("failed to render image: %s\n", IMG_GetError() );
}
}
At the top of the function, we have added code to check to see whether the ship is currently alive. If it is not, we do not want to render the ship, so we return. Later on, we set the source rectangle x value to 32 times the current frame with the line: src.x = 32 * m_CurrentFrame;. That changes our render to render a different 32 x 32 block of pixels from our sprite sheet based on the frame we want to render. Lastly, we must pass that src rectangle into the call to SDL_RenderCopyEx.
Now that we have modified the Ship class, we will change the EnemyShip class definition and the PlayerShip class definition to use our sprite sheet PNG files instead of the old single sprite files. Here are the modifications to those two class definitions inside the game.hpp file:
class PlayerShip: public Ship {
public:
const char* c_SpriteFile = "sprites/FranchiseExp.png";
const Uint32 c_MinLaunchTime = 300;
PlayerShip();
void Move();
};
class EnemyShip: public Ship {
public:
const char* c_SpriteFile = "sprites/BirdOfAngerExp.png";
const Uint32 c_MinLaunchTime = 300;
const int c_AIStateTime = 2000;
FSM_STUB m_AIState;
int m_AIStateTTL;
EnemyShip();
void AIStub();
void Move();
};
The only changes made to these class definitions are to the values of the c_SpriteFile constant in each class. The c_SpriteFile constant in the PlayerShip class was modified from "sprites/Franchise.png" to "sprites/FranchiseExp.png", and the c_SpriteFile constant in EnemyShip was modified from "sprites/BirdOfAnger.png" to "sprites/BirdOfAngerExp.png". Now that we have made that change, these classes will use the sprite sheet .png files instead of the original sprite files.
Now that we have modified the definitions for these classes, we must change the Move functions for each of them. First, we will revise the EnemyShip::Move function inside the enemy_ship.cpp file:
void EnemyShip::Move() {
if( m_Alive == false ) {
return;
}
AIStub();
if( m_AIState == TURN_LEFT ) {
RotateLeft();
}
if( m_AIState == TURN_RIGHT ) {
RotateRight();
}
if( m_AIState == ACCELERATE ) {
Accelerate();
}
if( m_AIState == DECELERATE ) {
Decelerate();
}
if( m_CurrentFrame > 0 ) {
m_NextFrameTime -= diff_time;
if( m_NextFrameTime <= 0 ) {
m_NextFrameTime = ms_per_frame;
if( ++m_CurrentFrame >= 8 ) {
m_Alive = false;
return;
}
}
}
CapVelocity();
m_X += m_VX;
if( m_X > 320 ) {
m_X = -16;
}
else if( m_X < -16 ) {
m_X = 320;
}
m_Y += m_VY;
if( m_Y > 200 ) {
m_Y = -16;
}
else if( m_Y < -16 ) {
m_Y = 200;
}
if( m_AIState == SHOOT ) {
Projectile* projectile;
if( current_time - m_LastLaunchTime >= c_MinLaunchTime ) {
m_LastLaunchTime = current_time;
projectile = projectile_pool->GetFreeProjectile();
if( projectile != NULL ) {
projectile->Launch( m_X, m_Y, m_DX, m_DY );
}
}
}
}
There are two places where the code must be changed. First, we do not want to do any of the Move function's work if the enemy ship is not alive, so we added this check at the beginning of the function to return if the ship is not alive:
if( m_Alive == false ) {
return;
}
Next, we needed to add the code to check whether we needed to run the death animation. We do this if the current frame is greater than 0. The code in this section is similar to what we did for the projectile to run its death animation. We subtract the time between frames, (diff_time), from the next frame time, (m_NextFrameTime), to determine whether we need to increment the frame. When this value drops below 0, the frame is ready to change by incrementing m_CurrentFrame, and we reset the m_NextFrameTime countdown timer by setting it to the number of milliseconds we want between each frame, (ms_per_frame). If our current frame hits the end of our frame sprite sheet, (++m_CurrentFrame >= 8), then we set the enemy ship to no longer be alive, (m_Alive = false). This is shown here:
if( m_CurrentFrame > 0 ) {
m_NextFrameTime -= diff_time;
if( m_NextFrameTime <= 0 ) {
m_NextFrameTime = ms_per_frame;
if( ++m_CurrentFrame >= 8 ) {
m_Alive = false;
return;
}
}
}
Now, we will make the same changes to the PlayerShip::Move function within the player_ship.cpp file:
void PlayerShip::Move() {
if( m_Alive == false ) {
return;
}
if( left_key_down ) {
RotateLeft();
}
if( right_key_down ) {
RotateRight();
}
if( up_key_down ) {
Accelerate();
}
if( down_key_down ) {
Decelerate();
}
if( m_CurrentFrame > 0 ) {
m_NextFrameTime -= diff_time;
if( m_NextFrameTime <= 0 ) {
m_NextFrameTime = ms_per_frame;
if( ++m_CurrentFrame >= 8 ) {
m_Alive = false;
return;
}
}
}
CapVelocity();
m_X += m_VX;
if( m_X > 320 ) {
m_X = -16;
}
else if( m_X < -16 ) {
m_X = 320;
}
m_Y += m_VY;
if( m_Y > 200 ) {
m_Y = -16;
}
else if( m_Y < -16 ) {
m_Y = 200;
}
if( space_key_down ) {
Projectile* projectile;
if( current_time - m_LastLaunchTime >= c_MinLaunchTime ) {
m_LastLaunchTime = current_time;
projectile = projectile_pool->GetFreeProjectile();
if( projectile != NULL ) {
projectile->Launch( m_X, m_Y, m_DX, m_DY );
}
}
}
}
Just like in our EnemyShip::Move functions, we add a check to see whether the player is alive with the following code:
if( m_Alive == false ) {
return;
}
And we also add some code to run the death animation if our current frame is greater than 0:
if( m_CurrentFrame > 0 ) {
m_NextFrameTime -= diff_time;
if( m_NextFrameTime <= 0 ) {
m_NextFrameTime = ms_per_frame;
if( ++m_CurrentFrame >= 8 ) {
m_Alive = false;
return;
}
}
}
The last thing we need to do is modify the collision detection code we added earlier to the ProjectilePool::MoveProjectiles function to run the death animation for a ship and a projectile if the two collide. Here is the new version of ProjectilePool::MoveProjectiles inside of the projectile_pool.cpp file:
void ProjectilePool::MoveProjectiles() {
Projectile* projectile;
std::vector<Projectile*>::iterator it;
for( it = m_ProjectileList.begin(); it != m_ProjectileList.end(); it++ ) {
projectile = *it;
if( projectile->m_Active ) {
projectile->Move();
if( projectile->m_CurrentFrame == 0 &&
player->m_CurrentFrame == 0 &&
projectile->HitTest( player ) ) {
player->m_CurrentFrame = 1;
player->m_NextFrameTime = ms_per_frame;
projectile->m_CurrentFrame = 1;
projectile->m_NextFrameTime = ms_per_frame;
}
if( projectile->m_CurrentFrame == 0 &&
enemy->m_CurrentFrame == 0 &&
projectile->HitTest( enemy ) ) {
enemy->m_CurrentFrame = 1;
enemy->m_NextFrameTime = ms_per_frame;
projectile->m_CurrentFrame = 1;
projectile->m_NextFrameTime = ms_per_frame;
}
}
}
}
Inside of this code, every time we move a projectile, we do a hit test against that projectile and the player as well as a hit test between that projectile and the enemy. If either the ship or the projectile is running its death animation (m_CurrentFrame == 0 is false), then we do not need to run the hit test because the ship or the projectile has already been destroyed. If the hit test returns true, then we need to set the current frame of both the projectile and the ship to 1 to begin the destruction animation. We also need to set the next frame time to the number of milliseconds until the frame changes.
Now that we have added all of this new code, the ship and the enemy ship will run an explosion animation that destroys the ship when hit. The projectiles will also explode instead of just disappearing. The circle colliders are fast but not very precise. In the Implementing compound circle colliders section, we will learn the modifications we need to make to use multiple circle colliders on a single ship. That will give us collisions that look more accurate than simple circles.