— Mission-based 2D shoot 'em up — Note: this tutorial assumes knowledge of C, as well as prior tutorials.
Introduction We've reached the the point where we're ready to implement loading and saving into our game. Eventually, we'll allow loading from the title screen, and also the ability to "Continue ..." based on the most recent save. Right now, however, we're offering it in the Intermission screen. Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./shooter3-20 to run the code. You will see a window open like the one above. Use the mouse to control the cursor. Click on an empty slot to enable the Save button. Clicking on a used save slot will enable the Load, Save, and Delete buttons. Clicking Load will load the game data, and restart the Intermission section. Clicking Delete on a used save slot will delete the associated data. Games are saved to files called save01.json, save02.json, etc. Once you're finished, close the window to exit. Inspecting the code Well, this part is a bit larger than one might expect, but mostly comes down to all the JSON work that goes into producing the save data (and also reading it back in again). We've added a few extras to this part, to help make it more complete (such as the transition.c file). Let's go ahead and see what's been added and changed, starting with defs.h:
We've created two new defines here, NUM_SAVE_SLOTS is the number of save slots we'll support, while SAVE_SLOT_EMPTY is a special value to mark as a save slot as not being in use. Over to structs.h now, where we've made some additions and updates:
We've added a struct called SaveSlot, that will represent meta data for a saved game. `slot` is the save slot number, dateTimeStr is a string that will hold the formatted time and date the game was saved, `time` is actual time in milliseconds from the epoch that the game was saved, and `rect` is the rectangular position of the save slot on screen, used with mouse interactions. Next, we've updated Mission:
We've added a variable here called `complete`, to mark whether we've completed this mission. This is a pre-empt for later parts, but something our save game data will support. Finally, we've updated Game:
We've added a variable called saveSlot. This is both to help mark the current save slot the player is using, and, later, to help support autosaving. Now for loadSave.c, which is the new compilation unit that we've created to show our save screen. There's quite a number of functions here, but a lot of them are easy to understand. Starting with initLoadSave:
This is the setup function, that prepares the screen. The first thing we do is call a function named populateSaveSlots, passing over an array of SaveSlots (of NUM_SAVE_SLOTS in size) called saveSlots (static in loadSave.c). Next, we NULL a variable called selectedSaveSlot that will be used to track the save slot we've selected, and then we setup each SaveSlot's `rect`, to position it on screen. We setup our widgets, and then finally set a variable called messageTimer to 0. This variable is used to control displaying messages to the player. Note that our Load, Save, and Delete widgets are all disabled by default, until we select a slot. A standard setup function. Next, we have doLoadSave:
This is the main function that drives our saving screen. As you can see, it's very similar to some other functions that have come before. We're decreasing the value of messageTimer, and also testing if we're hover over any of our save slots, or have clicked on them. If we click on any, we set selectedSaveSlot to the one we clicked on, and then call a function named updateButtons. The last thing we do is process our widgets. The updateButtons function comes next:
Here, we're disabling or enabling the Load and Delete widgets, based on whether the selected save slot is empty. This prevents us from attempting to load a non-existent game (including one that we might have just deleted!). We're enabling the Save button, though, since once we've selected a slot, we're always allowed to save. That's our main logic done. We'll come to the widget action functions in a moment. For now, let's look at our rendering code. The drawLoadSave function handles this:
This function is actually less complicated than it might at first appear. In short, what is it doing is looping through all the save slots and drawing a coloured rectangle (using the SaveSlot's `rect`), based on whether the save slot is empty, in use, selected, being hovered over, etc. This is very much like our intermission icons and our shop. With the rectangle and outline drawn, we're then drawing the save slot's dateTimeStr. Notice we're rendering it twice, the first time in black, and slightly offset. This is to create a drop shadow, to aid with readability. Lastly, we're testing the value of messageTimer, and drawing our message (static in loadSave.c). We're also processing our widgets. Pretty simple so far, especially since we've seen this sort of thing done a few times before. Now for all our widget functions. First up, we have the `load` function:
Here, we're calling a function named loadGame (done in game.c - we'll see this later on), passing over the currently selected save slot. With the load complete, we're then calling initIntermission, to reset the intermission screen. Next up is the `save` function:
A little more going on here. When we save a game, we're setting Game's saveSlot to the value of the selected save slot's `slot`, before then saving the game, via a call to saveGame (done in game.c). With that done, we'll repopulate our save slots, to reflect the new save data, prepare a message to inform the player about what's happened, and then update our buttons. A series of logical steps, to keep everything in sync! The `delete` function follows a similar path:
We're calling deleteGame (done in game.c), once again passing over the slot number of the selected slot, and then updating our save screen as with the save function. That's it for loadSave.c. What we need to do now is look at our actual loading and saving function. As previously stated, this is being done in game.c. We'll start with initGame:
We've updated this function to reset all our planets missions to be incomplete. Since this is a new game, we'll want to ensure no missions that had been completed previously remain so. Next up is loadGame, a new function:
Once more, this looks like a meaty function, but all that's really going on is that we're loading JSON and then using the data to populate our Game object. We call initGame first, to reset it into a pristine state. Most of what's happening should be clear, but one thing worth mentioning is that we're loading an array of missions that have been compelted. For this purpose, we're looping through all our planets, finding any that have a mission attached, comparing the name of the planet against the value of the JSON node, and marking the planet's mission as complete. SAVE_GAME_FILENAME is a define in game.c, that specifies the name and format of our saved game, so that we can be consistent amongst all our functions. Turning now to saveGame:
Again, this is just standard JSON object building and saving. Worth mentioning is how we're looping through all our planets, looking for those with a mission that has been completed, and adding the name of the planet to a JSON array. This allows it to be reloaded later on in the loadGame function. One thing we're doing extra here is that we're adding a field called "date", using the system time (in milliseconds) to log the time the save was created, so that we can display it to the player. Doing this is better than relying on the file time, that can change if the files are copied, etc. Otherwise, there's nothing out of the ordinary here, there's just a lot of it..! The deleteGame function follows:
As expected, this function deletes the save file identified by `slot`, calling unlink to actually remove the file. Finally, we have the populateSaveSlots function:
We've seen this called a few times in loadSave.c. What it does is look for all available save files (calling fileExists in util.c) to check for their existence. When one is found, it will load the JSON data in the file, and read the "date" value. We then use that value to set the saveSlot's dateTimeStr value. We're doing this with the help of strftime, found in the time.h header. If there is no save game available, we'll set the save slot's time to SAVE_SLOT_EMPTY (-1), and set the dateTimeStr as "(Empty)", to show it is not in use. That's it for loadSave.c and game.c. Now we just need to incorporate this into our intermission screen. So, over to intermission.c, where we've updated initIntermission:
We're calling three new function here - startTransition, initLoadSave, and endTransition. We'll come to startTransition and endTransition at the end. For now, we'll move on to the update to `logic`:
We've added in the IS_LOAD_SAVE case statement, to call doLoadSave. We've then also updated `draw`:
We've added in the IS_LOAD_SAVE case statement, to call drawLoadSave. Before we finish, let's look at the startTransition and endTransition functions. These functions live in transition.c, and are responsible for briefly clearing our screen before drawing things again. This will be added to a few places in our game, so that going from the intermission to the mission, for example, isn't an instant change, and gives the player a chance to appreciate something different is happening. So, over to transition.c. startTransition comes first:
To begin with, we're storing the state of the mouse's cursor, before then turning it off. Next, we're keeping a track of the time the transition started. We're then calling functions to clear the screen and the input. We're then restoring the mouse's showCursor to its previous state (although, nothing will be drawn yet). The endTransition function looks like this:
We're testing to see how long it has been between the calls to startTransition and the call to endTransition, deducting this from 500, and assigning the result to a variable called `elasped`. If it's been less than 500 milliseconds (half a second), we'll call SDL_Delay, feeding in the remaining time to wait (elasped). We'll then reset app's deltaTime so stuff doesn't suddenly play catch up, and everything will resume. Taking initIntermission as the working example - we call startTransition at the top of the function and endTransition at the bottom. In between, we're doing some setup. Most of the time, the setup will take much less than 500 milliseconds, and so we'll only wait that long. However, in a much larger game, with more going on, endTransition could end immediately. And that's it for loading and saving. We've got just one section left to create, and that's our options screen. With our options done, we'll be able to redefine controls, adjust the sound and music, and also specify if we want to automatically save our game. This will then mark the end of the intermission section, and we'll be able to start on the main game loop. Purchase The source code for all parts of this tutorial (including assets) is available for purchase: From itch.io It is also available as part of the SDL2 tutorial bundle: | |
Desktop site |