— Mission-based 2D shoot 'em up — Note: this tutorial assumes knowledge of C, as well as prior tutorials.
Introduction It's time to start putting in our proper missions. The first two of these missions will be the easiest to do, as they feature objectives and requirements that already exist in our code. All we need to do is add in the mission data, and make some additional tweaks. Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./shooter3-24 to run the code. You will see a window open displaying the intermission's planets screen. Select Radish, then enter the comms section to start the mission. Play the game as normal. You may repeat the mission as often as you like. Remember that you can change the fighter's damage, weapons, output, and health by editing game.c, if you wish to get ahead of things early. Once you're finished, close the window to exit. Inspecting the code Before we get started, you're likely wondering something - what exactly is this game all about? Why are cat flying starfighters? Why do the planets have such silly names? Well, the plot of our game is that sometime in the year 24XX, the human race has been wiped out by an evil alien race known as The Greebles.
Indeed, cats have been aware of these things for centuries, which is why they stare into space at things we cannot see. Some believe they are ghosts, but we humans found out too late that it was aliens all along. And then they blew us to bits. It is now up to cats, dogs, and mice to fight back against the Greeble race, who seek to control the multiverse, and bring disorder to everything. Leo, our player character, is a pilot in the Cat Navy, who is piloting the prototype E variant of the reliable KIT-class fighter. He is assisted by Mittens, a comms expert and his best friend. Also present is The Admiral, who contact him from time to time with vital missions and information. Hopefully, that'll answer any questions you have about why our furry friends are the protagonists. Basically, I wanted to have an incredibly stupid plot. Anyway, moving along ... We've had a lot of hardcoded stuff up until this point. So, as well as hooking up a proper mission to play, we're going to make changes to our code to read data file, and integrate everything properly into our game system. Let's first look at our mission file, found in missions/radish.json:
This is the structure of all our mission files. We'll be reading and expecting the music filename (musicFilename); a list of `entities` (even if empty), that will specify the entities and their positions on the stage (we'll see this come into play in later missions); an `enemies` definition, that will be used to specify which enemies appear on the stage, and how many of them there are; our lists of `objectives`; and our `script`. We'll be expanding on some of this later, but otherwise the mission data file should be easy to understand. Now, let's look at the code updates we've made. Starting with defs.h:
We've added a new enum here to OT_DEFEAT_ALL_ENEMIES. This is a special objective that will be completed once all the enemies on the stage are defeated. In most stages, an unlimited number of enemies will spawn while the mission is active, and so we can use this as a "clean up" objective, once all the other ones are finished. Now for the updates to structs.h:
We've added in an `id` field to Objective, so that it can be uniquely identified by things such as our scripting system. All objectives loaded must have an id field in our JSON, even if it's blank (not null). Next, we've updated Mission:
We've added in three new fields here: `filename` is the Mission's filename (such as data/missions/radish.json). `requires` is a field that will act as a dependency list. You might remember that when loading and saving, we were logging the missions that had been completed. This is the field that will specify those dependencies. Again, this is expected by our loading step. `available` is a flag to say whether the mission is available to play. While we're developing our missions we won't implement these; we'll leave them to the Finishing Touches phase. Finally, we've updated Stage:
We've added in a few new fields here. enemyTypes is an array of strings that will hold the names of the types of enemies that can appear on the stage (such as "greebleLightFighter"). numEnemyTypes is used to tell us the length of the array. enemyLimit will be used to control just how many enemies we are allowed to exist on the stage at any one time. totalEnemies specifies how many enemies in total exist in the mission. Used in conjunction with enemyLimit, we can create missions where we have 10 enemies in total, but only add 3 to the stage at a time. As we'll see later, setting totalEnemies to -1 will mean our enemy count is unlimited. Finally, we've added in a pointer to our currently active Mission (`mission`). Now to a new compilation unit - mission.c. This is where we'll be loading all our mission data, and doing other pieces of logic. Most of the functions here right now are to do with data loading, so they will be quick to consider. Starting with loadMission:
We're passing over the `filename` of the mission to load, loading up the JSON, and passing it to other functions that require it. Notice that we're loading the music here, and not in stage.c. Since our music filename resides in the mission data, we're loading it during this step, rather than storing it elsewhere, and then loading it in initStage. The loadEntities function follows:
While our first mission doesn't define any entities, one can see how it works. Our "entities" node in JSON is an array of objects, containing a "type", and "x" and "y" values for the position. The entity is then created, via initEntity, and placed at the location (the player always starts at 0,0 and is automatically added during initStage, so there's no need to add them here). loadEnemies comes next:
We're loading up the enemy data here, assigning the values to those found in Stage. For our enemyTypes, we're calling a function named toArray (in util.c) to that will break our "types" string into an array (enemyTypes is comma separated), and places the array length into a variable we pass over as the second argument (Stage's numEnemyTypes, as a pointer). loadObjectives is next:
This function is as expected - we're loading up our objectives list. This function replaces the hardcoded objectives list we've had in the past few parts. Finally, we have the loadScript:
Once again, this just loads our script, and replaces the hardcoded data we had in the previous part. All the relevant fields here are being set (including creating our list of lines, per function). It will be correctly deleted in the clearScript function call. That's it for mission.c, for now. So, let's move on to the rest of the code, and see what extra tweaks we've had to make. Starting with comms.c, we've updated the startMission function:
Now, before calling initStage, we're assigning Stage's `mission` to the mission of the currently selected planet in the intermission. We'll now know which mission we're working with when we start the game. Now over to objectives.c. We've made an update to doObjectives, to account for our new OT_DEFEAT_ALL_ENEMIES type:
We've introduced two new control variables here: isDefeatAllEnemies and numIncomplete. We're setting both these flag to 0 (false) before processing our objectives list. If we encounter an objective that's not yet complete, we're increasing the value of numIncomplete. We're also testing if the objective we're processing is OT_DEFEAT_ALL_ENEMIES. If so, we're setting the isDefeatAllEnemies flag to 1 (true). Next, we're testing if we've actually completed this objective. The OT_DEFEAT_ALL_ENEMIES objective is considered to be complete if there are no more active enemies on a stage, and the the totalEnemies value is 0. In other words, we've defeated all the enemies on the stage, and there are no more to come! The last bit of logic we've added in is testing whether the isDefeatAllEnemies flag is set (meaning that objective is present and active). With that known, we check how many outstanding objectives there are, and whether we still have unlimited enemies on the stage. If there is only one objective outstanding (which will be the OT_DEFEAT_ALL_ENEMIES objective), we'll set both the totalEnemies and enemyLimit to 0. What this logic is doing is checking if the defeat all enemies objective is the only outstanding objective remaining, and then preventing any more enemies from being created by the game, so that the objective can be completed. If we don't do this, it will be impossible to finish the mission..! Now over to stage.c, where we've made a few final tweaks. Starting with initStage:
We've added in a call to loadMission, passing over the mission filename to load. We've also added in a loop to call addEnemy, up to the value of Stage's enemyLimit. This pre-populates our stage with enemies before we begin. Our completeStage function now looks a little different:
First, we're calling executeScriptFunction and passing over "POST_MISSION" to have the script run any functions named POST_MISSION. Next, we're explictly calling doScript to force the script function to run. Our script processing normally only happens during our stage's logic step, so we need to force it to happen here. The rest of the function remains the same. Lastly, we've made a much larger update to addEnemy:
Before, our game would add in an unlimited number of enemies, next to the player. We've now made a bigger change to this logic. First, we're testing if we're allowed to add an enemy. We can add an enemy if we have an enemy list defined (numEnemyTypes), if we're not already at our active enemy limit (enemyLimit), and whether we still have enemies remaining to add from our count (totalEnemies). Should we be able to do so, we'll randomly choose an enemy from Stage's enemyTypes, create it, and then prepare to randomly place it. Before, we were adding the enemies almost directly on top of the player. Now, we're picking a location off-screen to add them, either at the top, bottom, left or right. Finally, we're decreasing Stage's totalEnemies count (if it's greater than 0), so that our enemy counter shrinks. Of course, we should clean up Stage's enemyTypes array in clearStage, to prevent a memory leak:
Our first real mission is complete! Amazing! As you can see, having put all the pieces together earlier meant that we were able to slot the new mission in with ease. Our next mission will be even easier to add in, so we're going to take the opportunity to insert some extra features at the same time. It's a bit boring that once we finish the mission everything just stops, so we'll make the end of the mission more interesting. And speaking of interesting, it's about time we added in a new enemy to face. Want even more interesting? The player will no longer be immortal ... 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 |