• 2D shoot 'em up
SDL2 Rogue tutorial
SDL2 Gunner tutorial
SDL2 Shooter 2 tutorial
SDL2 Widget tutorial
SDL2 Adventure tutorial
— Creating a vertical shoot 'em up —
Our main game is done, meaning all we need to do is throw in the finishing touches (and make a few gameplay tweaks). In this final part, we'll be looking at what we've added to complete the experience. Note that due to adding the widget system, there have been elements of refactoring. We'll not be looking at the widgets, the music, or the sound, as these have been seen in previous tutorials and to cover them all here would mean a very long final part. Instead, we'll be focusing on the specifics of this tutorial: the title screen, the highscore table, and the save routine.
Extract the archive, run make, and then use ./shooter2-09 to run the code. You will see a window open like the one above. Use the Up and Down arrow keys with Space or Return to navigate the menu. Use the Left and Right keys to change the volume values of sound and music, when the widgets are highlighted. To change a control, highlight the control then press Return or Space, followed by the key you wish to use for the control. Pressing Escape will cancel the change. Play the game as usual, and enter a highscore if you manage to get one. Games are saved to the file save.json in the same directory as the game itself. When you're finished, close the window to exit or select Exit from the title screen.
Inspecting the code
Even with skipping over the widgets, sound, and music, there is still quite a lot to get through in this final part. Let's look at the changes to defs.h first:
We've added in a new enum to handle our controls, having one for each direction, as well as the fire control. These will be used during our configuration, as well as our game. We've also made some changes to structs.h:
Our Game struct has seen three new fields added to it: controls is an array of ints that will be used to help with control configuration, while soundVolume and musicVolume will hold the details of our sound and music volumes. We'll see all these used a bit later on.
Now onto the main code. We'll start by looking at the title screen. This is defined in title.c. There are a good number of functions to cover, so we'll start with initTitle:
This is the first function that is called in `main` (in main.c) once everything has been setup. It replaces the call to initStage from that part of the code. The first thing that happens in initTitle is we're testing a flag called wasInit. If it's false (0) we'll know we want to do some initial setup. Our title logo is split into 4 parts, so that it can fit into our texture atlas. We're grabbing all 4 parts (the word 'shooter' is divided in 2) and assigning them to various fields. We're also making a call to setupWidgets, a local function that sets up our title screen's widgets. The wasInit flag is also set to 1, to tell the code not to bother to do this next time we call initTitle.
We're then setting another flag called showScores to 0. The showScores flag is used to flip between displaying the title itself and the highscore table. This is used in conjunction with showTimer, which we're setting to SHOW_TIMEOUT (defined as 7 seconds in title.h). We're then setting our currently active widget to the "Start" widget and telling all our sound channels to stop playing. We have a sound effect that plays on a loop when the Supply Ship shows up, so this is needed in cases when the player is killed while it is active or quits the game. Finally, we're setting the app delegate's `logic` and `draw` function pointers.
We'll move straight onto the `logic` function now:
You'll notice when the game first starts that the title logo parts reveal themselves from top to bottom. This is controlled by the `reveal` variable (initially set to 0). Here, we're increasing the value of the variable and limiting it to 500, to stop is eventually becoming too high and wrapping around. Next, we're decreasing the showTimer varaiable and limiting it to 0. If the value hits 0, we're going to flip our showScores flag. As we're setting showScores to the inverse of showScores, this means it will toggle between 0 and 1. This means that after our showTimer has hit 0, we'll be swapping between showing the title and showing the highscore list. With that done, we're resetting showTimer to SHOW_TIMEOUT.
We're calling a function called doBackground next. This is part of some refactoring that was done to drive the background and stars, as the logic is shared in a few places in the code. It's effectively the doBackground and doStars calls that existed in stage.c.
We're then testing the showScores flag. If it's 0 (we're displaying the title logo itself), we're going to check if the player is pressing the Up or Down arrow keys to navigate the menu. If so, we're going to reset our showTimer to SHOW_TIMEOUT. This is important in order to stop the title screen from flipping over to the highscores while we're interacting with it. We're also telling the game to handle our title screen widgets. If showScores is 1, meaning we're displaying the highscore table, we're going to check if the player has pressed Return or Space. If so, we're going to zero those two keys, reset the showTimer, and then set showScores to 0. In effect, pressing Return or Space on the highscore display will return to the logo display. We're zeroing the key presses so as to avoid invoking the title widgets right away.
That's our logic done, so we can look at how the rendering is all handled. Our `draw` function is naturally where we're doing this:
The first call to drawBackground is part of the shared background logic and rendering refactoring that now lives in background.c. Like doBackground, this function is called in a number of places. We're then testing our showScores flag to determine what we want to draw. If showScores is 0, we're going to call drawTitle, to render our logos and widgets. If it's 1, we're going to call drawScores to render our highscore list.
drawTitle is a simple function:
We're basically drawing the logo parts that we grabbed in initTitle, as well as the widgets. Each logo part is rendered using a call to drawLogo (more on this in a bit). The "SDL2" and "2" logos are drawn centered horizontally, by subtracting their widths from the screen width and dividing by 2. For the two "Shooter" parts, we're drawing the first part at the horizontal center, less the value of the logo's width. For the second part, we're rendering at the horizontal center. This will result in the Shooter logo parts rendering alongside one another, centered around the middle of the screen.
Our drawLogo function is what handles the revealing:
We're passing in to the function the atlasImage we want to use, and the `x` and `y` coordinates we want to draw at. We're then copying the atlasImage's `rect` data (the coordinates of the image in the texture atlas) into another SDL_Rect called `src`. After that, we're updating `src`'s `h` (height) value to the smaller value between `src`'s `h` and `reveal`, using the MIN macro. In other words, if the value of `reveal` is less than the value of `src`'s `h`, we'll use that value. We're then setting up an SDL_Rect call `dest`, assigning its `x` and `y` values as the `x` and `y` we passed into the function, its `w` (width) as the atlasImage's width, and its `h` (height) as the same value as `src`'s `h`. We're then throwing this information at SDL_RenderCopyEx, to tell it to draw the image.
In short, we're clipping the image's height to the value of `reveal`. As `reveal` increases, more of the image is displayed.
That's the title logo handled, so let's look at how we're rendering our highscore table. We're doing this in drawScores:
The first thing we're doing is increasing the size of our font to 1.5, to make it a bit larger, rendering the "TOP 10 SHOOTERS" text in the middle of the screen, and then resetting the font size. We're then setting a variable called `y` to 200, which is where we'll start rendering our scores. After that, we're setting up a for-loop to draw all our scores. Note how we're using NUM_HIGHSCORES - 1. NUM_HIGHSCORES is defined as 11, but we only want to draw the first 10 scores. We're then setting three variable called `r`, `g`, `b` to 255. These are the red, green, blue (RGB) values that we'll be drawing our text in. By default, we'll be rendering all the scores in white. After this, we're testing each score structure against a variable called newHighscore (a static variable in title.c) to see if the two are equal (reference testing). If they are, we'll set the value of `b` (blue) to 0, so that the text renders in yellow (since `r` and `g` will still be 255). This is done in order to highlight the score a player just earned, so they can see it after entering their name.
We're then rendering each score line, using the set RGB values. We're first rendering the position (using sprintf with a char array called `text`), then the name associated with the highscore, then the score itself, formatted as 3 digits. We're finally increasing the value of `y` to render the next score below the current one.
That's almost it for title.c. We'll take a look at one more function quickly that's associated with the "Start" widget:
When invoked, the Start widget will call the `start` function. It will set the value of the `reveal` variable to 500, so that the logo is fully revealed, NULL the newHighscore pointer so that the recent highscore is no longer highlighted, and then call initStage to start the game.
Highscores is the next major thing that we've added to this part. We've done all the work for this in highscores.c, which before now only featured a handful of functions. We'll look at what happens when a player earns a highscore, starting with initHighscoreEntry:
When a player gets a highscore, we'll assign their score to the final entry in the highscore table (entry #11). We're making use of an input text widget for entering the player name, so we'll call getWidget to grab the widget and make it App's activeWidget. Following this, we'll set the Space key as having been pressed. Our InputWidgets rely on the user having pressed Space or Return when they are highlighted, in order to allow for text input. Manually setting the Space key as being pressed is a small hack to force the input to happen. With that done, we're checking if we need to setup our widget by testing the wasInit flag, stopping all the sound channels playing (again, to turn off the looping sound effect), and setting up our `logic` and `draw` delegates.
Our `logic` function is simple enough to understand:
We're merely updating the background by calling doBackground and also processing all our "highscore" widgets.
The `draw` function is just as simple:
We're calling drawBackground to render our background, and drawing a series of text strings at various sizes using our drawText function. Finally, we're calling drawWidgets to render our "highscore" widgets. This will consist only of the InputWidget.
Our InputWidget makes use of a function called `name` for handing its logic (when the player has pressed Return or Escape after entering text):
The first thing we're doing is grabbing the InputWidget from the app's activeWidget `data` field. We're then testing the length of the text that has been input. If it's greater than 0, we're copying the text into highscore 11's `name` field. Otherwise, we're setting the name to "ANONYMOUS" (note that we're not concerned about someone entering a name as pure spaces). With the name set, we're then making a copy of the score at positon #11. The reason we're setting the player's highscore into positon 11 is so that we can simply use `qsort` to reorder the score table. This cuts down on the micromanagement of reordering the score table manually, looking for the correct place to enter the score, and then shifting all the elements about. After calling `qsort`, the score in position 11 will then be moved into the correct place. What we want to do next is find out which highscore structure holds the score that was just entered. We can't grab a reference to it before using `qsort` and expect to then find it in the right place; our reference to element #11 will always point to element #11. We therefore walk through our array in reverse order, looking for the score that matches the one entered. When we find it, we'll assign it to a pointer called newHighscore.
Our score has now been entered into the table, has been ordered into the correct position, and we have a reference to the structure. We call saveGame to save out the scores and game configuration, and then call a function called initTitleScoresDisplay, passing in the newHighscore reference. initTitleScoresDisplay actually lives in title.c, so we'll quickly return to that file to see how that works:
We're assigning newHighscore to value of the Highscore reference (`highscore`) we've passed in (you should recall seeing newHighscore being used in the highscore display earlier). We're then calling initTitle to setup the title screen as normal, but setting showScores to 1. This will mean that we're going to show the highscore table right away, rather than the logo.
Now, let's look at game.c. Again, this is another file that was quite bare before this update. The sole function, initGame, used to call initHighscores and do nothing more. game.c now does a lot more. We'll start with looking at initGame:
We're still calling initHighscores, but now we're setting the defaults for Game's various fields. By default, our sound and music volumes will be set to maximum (MIX_MAX_VOLUME), and our game's controls will default to the arrow keys, plus the left control key to fire. With this done, we're calling a function called loadGame, then finally updating our sound and music volumes (via setSoundVolume and setMusicVolume) with the values that have been set.
The reason we're setting the defaults and then calling loadGame is that the save game file might not exist. Therefore, nothing is changed and the defaults are used. In effect, loading the game overrides the default values we've just set. Our loadGame function itself is quite simple:
We're first attempting to read a file called "save.json" (from the define SAVE_GAME_FILENAME). If the data returned from this function isn't NULL (which it will be if the file doesn't exist), we'll parse it into a JSON object using cJSON_Parse, assign it to a variable called `root`, and then set to work extracting the data we want.
We'll start by extracting a JSON object call "controls" from `root`, assigning it to a variable called `node`. This object holds our control configuration. We'll look up items in the `node` JSON object called "left", "right", "up", "down", and "fire" and assign the int value of each to the appropriate index in Game's `controls` array. The ints are equvialent to the values of SDL_SCANCODEs for the keys they represent.
Next, from the `root` object we'll grab an object called "volumes" and once again assign it to `node`. This holds the information about our sound and music volumes. For our game's soundVolume and musicVolume, we'll grab items from `node` called "sound" and "music". One thing we're doing before assigment is using the MIN and MAX macros to ensure the values don't go below 0 or above MIX_MAX_VOLUME.
We're then moving onto fetching out highscore data. We're setting a variable called `i` to 0, then grabbing a JSON array called "highscores" from the root. Using a for-loop, we're able to fetch all the child objects from the "highscores" JSON array, and copy the "name" and "score" values into our game's `highscores` array, incrementing `i` as we go, to move onto the next highscore entry. Note that we're not performing any error checking here; we're assuming that there will be no more than 11 highscores to fetch.
With our configuration and highscores loaded, we can then delete the JSON object by calling cSJON_Delete and passing in `root`, and also free the text data that was loaded.
The other function in game.c is saveGame. We've already seen this being called when a player enters a highscore. Again, this function is rather simple:
When saving the game, we'll end up creating the JSON object that we load in the loadGame step. For this, we need to create "controls", "volumes", and "highscores" objects. Let's start from the top.
We're calling cJSON_CreateObject and assigning it to a variable called `controls`. We're then calling cJSON_AddNumberToObject several times, passing in the `controls` variable, an item name, and a value. We're doing so for "left", "right", "up", "down", and "fire", setting the value of the relevant index in Game's `controls` array. In effect, we're creating an entry in our JSON object with a key-value pair for each of our controls. This can later be read by the loadGame function to extract it.
We're next creating a JSON object and assigning it to a variable called `volumes`, to which we're adding two numbers - game's soundVolume and musicVolume, with the keys "sound" and "music" respectively. This object will hold our audio volumes.
Following this, we're creating a JSON array and assigning it to the `highscores` variable. This array will hold all our highscores. We're then using a for-loop to iterate our highscores (0 to NUM_HIGHSCORES). For each one, we're creating a JSON object (as `highscore`), and populating it with the highscores's name and the score itself, using "name" and "score" as the keys. That done, we're adding the `highscore` JSON object to the `highscores` JSON array.
Now with our controls, volumes, and highscores JSON objects and arrays made, we need only add them to the root object. We create one more JSON object, assigning it to a variable named `root`, and then add the `controls`, `volumes`, and `highscores` JSON objects and array to it, using "controls", "volumes", and "highscores" as the keys. The last step is to save the JSON data. We do this by calling cJSON_Print and passing the `root` JSON object into it. This will produce a JSON string, which we assign to a variable named `out`. We then call our writeFile function, passing in the filename (SAVE_GAME_FILENAME - save.json) and the string to save. Finally, we clean everything up by calling cJSON_Delete (passing over `root`) and freeing the string data (out).
That's the core changes and updates for this final part done. Before we wrap up, we'll touch briefly on some of the gameplay changes we made and discuss why they were done. Returning to structs.h, we've added a new field to Fighter:
`charge` will be used to hold the number of powered-up shots that our fighter can fire when the player collects an (R) power-up pod. The reason for this change is because the player's rate of fire can become very high once they collect 3 tokens. It makes them largely invincible and the bosses are a joke; you just hold fire, dodge a small amount, and the battle is over in a few seconds. Faster, if you have both sidearms. `charge` will count down everytime a shot is fired. Once it hits 0, the power-up's power is exhausted and the player's rate of fire returns to normal.
We can see the power-up charge logic being applied in activatePowerUp, in powerUpPod.c:
Now, when the player receives a PP_RELOAD_RATE type pod, we'll increase the Fighter's `charge` by 100 (limited to 250). doPlayer in player.c has also been updated to make use of the `charge` field:
Now, when the player fires, we'll decrease the value of the Fighter's `charge`, limiting it to 0. Once it hits 0, we'll reset the Fighter's reloadRate to INITIAL_RELOAD_RATE (16).
So as not to leave the player in the dark about how much `charge` they have remaining, we've added it to the HUD (hud.c), in drawScoreBar:
We're rendering the Fighter's `charge`, as well as information about the wave and remaining shield, in a smaller font.
Another gameplay change that we've made is not to allow the Supply Ships to show up during boss battles. When they did so, it become too easy for the player to fully power themselves up during the encounter (especially during the first battle) and once again do away with the boss in no time. The rest of the game then becomes a cakewalk and all challenge is lost. Restricting the supply ships to the regular alien waves encourages the player to make sure they are prepared for the boss ahead of time. We've tweaked doStage in stage.c to handle this:
Now, once the nextSupplyShipTimer reaches 0, we're first checking if a boss is present by testing Stage's `boss` pointer, and only creating the supply ship if it's NULL. Otherwise, we'll skip the Supply Ship creation and just reset the nextSupplyShipTimer as usual.
The final thing to remark upon is one very important function - checking if the player has a highscore! Once the player is killed and the gameOverTimer has hit 0, we're calling a function named hasHighscore. If this returns true, we'll call initHighscoreEntry to show the highscore entry screen. Otherwise, we'll jump back to the title screen. The hasHighscore function is as simple as you might expect:
All we're doing is checking to see if the player has a highscore greater than or equal to the existing scores. However, one thing to take note of is that we're only testing the first 9 scores. We clearly don't want to test if the player's score is tied with entry #11, as that will never show up. However, we don't want to test score 10 either, as new scores that tie with existing scores will be added after the existing one, due to the sorting method. If someone therefore ties with position #10, they will never be displayed as their score will end up in position #11.
And there you have it, an upgraded sequel to our basic SDL2 Shooter game. We have enemy attack patterns, power-ups, and bosses. It's definitely a worthy sequel, I'm sure you'll agree. A third and final game to complete the trilogy will arrive one day, and will likely focus on free range movement around a space battlefield. For now, I hope that this has been useful and that it will help to answer any questions you had about how to make things like power-ups, bosses, and enemy waves.
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.
Share your comments and thoughts below. All comments are anonymous and cannot be edited.