• 2D shoot 'em up
SDL2 Gunner tutorial
SDL2 Shooter 2 tutorial
SDL2 Widget tutorial
SDL2 Adventure tutorial
— Creating a Run and Gun game —
Our game is more or less done. In this final part, we'll be focusing on updating various aspects of the gameplay, adding a title screen, and performing some final bug fixes. We've also thrown in some music and sound effects for good measure. Note - we're not going to discuss widgets or other elements that have been seen in other tutorials.
Extract the archive, run make, and then use ./gunner15 to run the code. You will see a window open, displaying the title screen. Select Start to begin the game, and play as normal. The objective is to destroy all the oil drums on the level. Once you've destroyed all the drums, the Mission Complete banner will be displayed. The game won't automatically finish, however, giving you a chance to keep playing if you so desire. Once you're finished, close the window to exit or use the menu options.
Inspecting the code
As always, we've made updates to defs.h and structs.h, to support various new features in this part. We'll look first at defs.h:
We've added in a new enum to define the status of the mission, where MISSION_INCOMPLETE, MISSION_COMPLETE, and MISSION_FAILED represents the mission being in progress, the mission being finished, and the mission being failed (such as when the player loses all their lives).
Turning now to structs.h, we've updated Stage:
We've added a few new fields here. `status` is the status of the mission, and can be MISSION_INCOMPLETE, MISSION_COMPLETE, or MISSION_FAILED. nearestOilDrum is a pointer to the nearest oil drum entity, to help us find them in our large map. numOilDrums is the number of oil drums remaining on the stage, while `time` is the amount of time that's elapsed since we started the mission.
If we turn to oilDrum.c, we can see where we're handling our oil drum counter and location. Starting with initOilDrum:
We've added a line to increment Stage's numOilDrums when we create the oil drum.
The `tick` function is where we're searching for the nearest oil drum:
We're first calculating the distance between this oil drum and the player, by calling getDistance and feeding in the oil drum's `x` and `y`, and the player's `x` and `y`. We're assigning the result to a variable called `dist`. Next, we're checking if Stage's nearestOilDrum is NULL or `dist` is less than Stage's nearestOilDrumDistance. If so, we're assigning Stage's nearestOilDrum to this oil drum (`self`) and updating Stage's nearestOilDrumDistance to `dist`. This will mean that if this oil drum is closer to the player than the current nearest oil drum (or we don't currently have a nearest drum yet), it will become the new nearest.
When it comes to decreasing the number of oil drums remaining, we're handling this in takeDamage:
Now, when an oil drum is destroyed, we're decrementing Stage's numOilDrums. We're then testing to see if numOilDrums is 0 or less and calling updateStageStatus, passing over MISSION_COMPLETE. We'll see this function in a little while.
Now that we have our oil drum count, we can look into how we're displaying this info (as well as some other pieces of data). Turning to hud.c next, we've made some updates to initHud:
To begin with, we're loading in three new textures: "gfx/hud/gunnerIcon.png", assigned to gunnerIcon; "gfx/hud/oilDrumIcon.png" assigned to oilDrumIcon; and "gfx/hud/arrow.png" assigned to `arrow`.
The draw function is the next thing we've updated:
You will have noticed that all our HUD data now lives at the top of the screen, since we've moved "Rest" from the bottom left-hand corner. To make things clearer, we're drawing a light gray transparent bar at the top of the screen, by calling drawRect, with RGB values of 48 and an alpha of 192. We're also calling three new functions: drawOilDrumArrow, drawOilDrums, and drawTime. Starting with drawOilDrums:
This function renders an oil drum icon and the number of drums. We're first calling blitAtlasImage, passing over the oilDrumIcon we loaded. Next, we're decreasing the size of our font by 25% (as 0.75) and using sprintf to create the text that will hold our oil drum count, before passing that to drawText. Finally, we're resetting the font size to normal (1.0).
drawOilDrumArrow is up next:
This is quite a simple function, as it will draw an arrow pointing towards the nearest oil drum. We start by testing that Stage's nearestOilDrum isn't NULL, before then calling blitRotated. We're passing over our `arrow`, the x and y coordinates we want to draw the arrow at, and then the angle of rotation. To get the angle of rotation, we're calling a function named getAngle and passing in the player's `x` and `y`, and the Stage's nearestOilDrum's `x` and `y`. As blitRotated renders things centered about the point passed over, our arrow will rotate nicely. Note: getAngle is a simple angle finding function, so we'll not cover it here.
Moving on to drawTime, we'll not find anything difficult to understand:
Our logic runs at 60 frame per second, so to find the number of seconds that have passed, we'll take Stage's `time` and divide by FPS (60). The result is assigned to a variable called `seconds`. To find the number of minutes, we merely divide `seconds` by 60. We're then once again reducing the size of our font to 75% and using sprintf to create our text string. We're passing over `minutes` and `seconds` (`seconds` with a modulo of 60, to correct the range). We're then calling drawText and passing over the text, before restoring our font size to normal.
That's our hud updates done. We can now look at the changes we've made to Stage. You will have noticed that when the mission starts, we're displaying a banner (and also when the player loses all their live or destroys all the oil drums). Starting with initStage:
We're pulling in three new textures: "gfx/stage/start.png" as startTexture, "gfx/stage/failed.png" as failedTexture, and "gfx/stage/complete.png" as completeTexture. We're also calling updateStageStatus, passing over MISSION_INCOMPLETE, to trigger the banner display.
doStage has been tweaked to handle all the new features we've introduced:
Not a lot of new additions, but the placement of the functions can make it look as though a lot has changed. One of the first updates we've made is to the player reset / game over logic. When testing to see if the player is dead, we're also checking that a variable called showBanner (a static variable in stage.c) is false (0). The purpose of this check is to ensure a banner isn't being displayed when we reset the player. If we don't perform this test, the "Mission Failed" banner won't be seen, due to playerRespawnTimer completing before it has a chance to appear. This is all about various timings.
We're now also setting Stage's nearestOilDrum to NULL. This will happen each time doStage is called, before we process any of our entities. This will mean that nearestOilDrum will remain NULL if no more drums exist (as it won't be set by any oil drum's `tick` function). We're also calling a new function named doBanner, and updating Stage's `time` if its `status` is MISSION_INCOMPLETE. In other words, while the player hasn't destroyed all the oil drums or hasn't lost all their lives, we'll continue to time how long they've been playing.
We can look at doBanner next:
We're testing a variable called bannerY, which is the vertical position of our banner. If it's less than the height of the screen, we'll be increasing its value, to make it move down the screen. You will have noticed that the banner stops midway down the screen for a brief period, before moving on. We achieve this by next testing if a variable called bannerTimer is greater than 0, and if bannerY has moved beyond BANNER_MID_Y (defined as SCREEN_HEIGHT divided by 2, less 50 pixels - a little above halfway down the screen). If both of these conditions hold true, we'll decrease bannerTimer's value and also set bannerY to BANNER_MID_Y. In other words, we'll not allow bannerY to increase beyond BANNER_MID_Y while bannerTimer is greater than 0.
Finally, we're updating our showBanner variable, setting it to 1 if bannerY is less than SCREEN_HEIGHT (or 0 if it's greater than that). In short, if the banner has not moved past the bottom of the screen, we'll consider that we're showing the banner.
Our `draw` function is next:
A few changes, though the only one we're interested in is drawBanner, which is called alongside drawHud (again, we're not detailing anything associated with widget handling, etc). drawBanner itself is quite simple:
Our banner is composed of two textures, one for "Mission" and the other for "Start", "Complete", or "Failed". We're first testing if the showBanner variable is set to true (1). If so, we're then performing a switch on Stage's `status`, to see which texture we want to use for the second part of our banner. We'll use startTexture for MISSION_INCOMPLETE, completeTexture for MISSION_COMPLETE, and failedTexture in another other case. The texture to use is assigned to statusTexture.
With our texture known, we want to find out the x positions of each part, to center the banner. We start by assigning a variable called `x2` the width of missionTexture ("Mission") plus 40 pixel, for spacing. Next, we take SCREEN_WIDTH less `x2` plus the width of statusTexture, all divided by 2. We assign this to a variable called `x1`. Effectively, this will tell us where the "Mission" part of the texture will be aligned on screen, when centered with the status texture part. Finally, we add `x1` to `x2`, to work out where the status portion should be drawn.
We then call blitAtlasImage for both missionTexture and statusTexture, using `x1` and `x2` as their respective x positions. We pass in bannerY as the y position, to make the banner move vertically, according to the current logic.
The final function to look at in stage is updateStageStatus. We've seen this called in a few places, so we can at last see what it does:
The sole purpose of this function is to trigger the banner display. The first thing we do is check to see if Stage's `status` is MISSION_INCOMPLETE. We do this so that we don't get a "Mission Complete" banner following a "Mission Failed" banner if the player is killed after finishing the mission (and vice versa); we only want one or the other to ever be displayed.
With that confirmed, we set Stage's `status` to the value of `status` that was passed into the function. We then perform a switch on `status`, to set the banner attributes. If `status` is MISSION_INCOMPLETE, we're going to start the banner at -100 pixels (off screen, at the top) and set bannerTimer to 1 second once it hits the middle of the screen. This means that the "Mission Start" banner will appear quickly and not remain on screen for long. For MISSION_FAILED, we're setting the banner to start much further off screen, so that it arrives a little later and also remains in the middle of the screen for 1 second. Otherwise, other banners will start at -100 and pause for 2 seconds.
Finally, we set showBanner to 1, to tell our processing and rendering functions that the banner is active.
That's it for our core game! We can now turn out attention to the title screen. Mercifully, the title screen is quite simple, so we'll be able to get through it with some relative ease. Our title screen functions all live in title.c. There's a number of functions, so we'll start with initTitle.
Our title screen requires a number of textures: the logo itself and those of the Gunner's animation frames. We start by testing if our textures need loading by checking if the first element in logoTextures is NULL. If so, we'll load "gfx/title/sdl2.png" and "gfx/title/gunner.png" into logoTextures indexes 0 and 1 respectively. Our main logo is too big to fit into our texture atlas on its own, hence the reason for splitting it in two. After that, we'll setup a for-loop to load all the Gunner's textures. This is basically the same as for when we load them during initPlayer. We then setup our widgets and set a variable named `reveal` to 0. `reveal` is used to control the fading in of our logo.
With that done, we set a variable named logoY to 275. This is the initial vertical position of the logo. We also set a variable called gunnerX to -100. This is the Gunner sprite's horizontal position. Setting it to -100 means that it will start off screen (left-hand side). We also set two other variables, gunnerAnimTimer and gunnerFrame to 0. These will hold the Gunner's animation timer and animation frame, respectively. Again, these are similar to the Gunner struct's data, as used in player.c. Following that we continue our widget, logic, and drawing preperation.
Moving onto `logic`, this is where all the magic happens:
We start by increasing the value of `reveal`, limiting it to 255. We then test to see if `reveal` is 255 (fully opaque) and begin decreasing the value of logoY. logoY itself is limited to 100. We then check to see if logoY is 100. If so, we're increasing gunnerX and limiting it to the middle of the screen (SCREEN_WIDTH divided by 2). We then decrease gunnerAnimTimer, test if it's 0 or less, and increase gunnerFrame, using the modulo of NUM_RUN_TEXTURES (defined as 6 in title.h) to enure it stays within the range we expect. Finally, we set gunnerAnimTimer back to ANIM_TIME (defined as 6 in title.h). gunnerTexture is then set to the approrpiate index in the runTextures array.
This basically means that we will first fade on our logo. Once the logo has fully appeared, we'll shift it up the screen to a certain spot. Once this location is reached, we'll allow our gunner to enter the scene, running in from left to right, halting in the middle of the screen. We'll animate the Gunner as he runs.
The `draw` function is next:
Quite straight forward. We're calling drawLogo to draw our logo, and also calling blitAtlasImage, passing in gunnerTexture and gunnerX, to draw our Gunner sprite. We're setting the 4th parameter as 1, to tell the gunner to be drawn centered around the point, so he stops nicely in the middle of the screen.
The drawLogo function is just as simple:
As we saw with the mission banner in stage.c, we're working out where we want to position the two pieces of our logo, assigning the horizontal positions to `x1` and `x2`. We're then setting the logo's alpha (really the texture atlas's alpha) to the value of `reveal`. blitAtlasImage is then called for both logoTextures, using `x1` and `x2`, and logoY for the vertical position. The alpha of the texture atlas is then restored.
The last function we'll look at is `start`:
The `start` function is connected to the Start widget's `action` and is how we begin our game. We set `reveal` to 128, so that if we return to the title screen, we'll see the logo briely perform its fade-in routine. We then call initStage, to begin the game proper.
And, of course, we're now calling initTitle from `main`, instead of initStage:
We're almost done! It's been quite a journey. Before we finish, we'll talk about some minor bugs that have been fixed. Starting with entities.c:
A bug existed that could result in health power-ups being collected twice - once for the x axis collision check and again for the y axis check. We can fix this for all entities by testing that neither `e` nor `other` is dead (`dead` field is 1), before processing further. This means that if an entity is marked as dead during a collision processing event, it will not be processed on the next round of checks.
Another bug is that returning to the title screen from the game results in a crash, due to the player's entity and data being freed twice. We can fix this in clearEntites:
Now, if we encounter the player during the entity linked list clearing, we will remove them from the list, but not delete their data. The flow of our logic means that the player will later be free explicitly, via Stage's player pointer.
The final bug fix is with bullets, to prevent entities from being killed twice:
We're checking that the entity is not dead before calling their takeDamage function. If we didn't do this, two bullets could strike an oil drum (for example) and "kill it" twice. This would result in our oil drum counter decreasing at an incorrect rate: several for one drum, instead of just 1. While we could just reset the oil drum counter at the start of the stage logic loop and have the drums increase the number using their `tick` function, this method is preferrable. If we were also scoring points, we would run the risk of scoring double, for example. This method ensures the target is valid before our execution continues.
And that's it! Our game is complete. It's not a long game and could be finished in under 10 minutes, but even so could prove an interesting distraction for a while. The addition of the time could allow for time attacks, and the arrow pointing to the oil drums is a welcome help for when struggling to find the ones remaining (and we don't have a map or radar to help..!). This game could be expanded to support more weapons and more enemies, even ones that move around. Or, to be crafty, duck and fire, to keep the player on their toes.
As for the future, the sequel to this could support multiple stages or zones, to show how to move from one stage to another. It might even be possible to persist the stage's state, with careful use of save data, allowing for players to return to zones at a later time, and find them in the same state they left them. But that's all for another day.
The source code for all parts of this tutorial (including assets) is available here:
It is also available as part of the SDL2 tutorial bundle (with on-going updates):
If you do not wish to create an itch.io account, you can also purchase the tutorial bundle using PayPal. This method will be slower, however, as it will require manual verification of the transaction.