— Mission-based 2D shoot 'em up — Note: this tutorial assumes knowledge of C, as well as prior tutorials.
Introduction Before we get into developing our missions properly, we're going to throw in a small scripting system, to liven things up a bit, and also help to make our missions richer in nature. Something to keep in mind with this system is that this is a very simple implementation; it doesn't perform any logic, and simply execute commands in conjunction with C calls. If you're after something deeper, a mature scripting language such as Lua would be more appropriate. However, what we have here suits our needs perfectly. Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./shooter3-23 to run the code. You will see a window open like the one above. Play the game as normal, using the regular controls. After a second, a message box will appear, featuring Leo and Mittens having a chat. Another pair of message boxes will appear 60 seconds into the mission (if it is still on-going). Once you're finished, close the window to exit. Inspecting the code Our scripting system is actually rather easy to implement and run, since it's just a series of keys and values. There are a number of changes we've had to make to integrate this into our code, but it won't be anything complicated. Starting with structs.h:
First up, we've created a struct called MessageBox to represent the message boxes that pop up. `speaker` is the name of the speaker (such as Leo); portraitTexture is the texture to show for the face of the speaker; `message` is the message text; `h` is the height of the message box; `received` is a control variable to say whether the message box has just been displayed; and `timer` is how long the message box will display for. The next struct we've created is called ScriptFunction:
This struct is used to hold the information about a "function" in our script. `name` is the name of the function; numLines is the number of lines in our function; `lines` is the line data itself; lineNum is a control variable to hold which line the script is currently processing; `running` is a variable to flag whether this script is running; and `delay` is a variable used to control the execution flow, halting the script for a period of time. Finally, we've updated Stage:
We've added messageBoxHead and messageBoxTail, as our MessageBox list, and a variable called scriptRunning, that will hold the state of our scripting system. This variable will come in useful during gameplay (we'll see more on this in a bit). Now for script.c. This is a new compilation unit that holds all the code for handling our scripting system. This won't be too difficult to understand, as our script is all text-based (no op codes to be found here). Starting with initScript:
This function starts by setting up our script linked list (`head` and `tail`), sets a variable called `time` (static in script.c) to 0, and then adds in some script functions. In our real missions we'll be reading this from JSON, but for now we're hardcoding them. We've made two script functions here, one called "TIME 1" and the other called "TIME 60". Both of these feature 2 lines (numLines), with each line containing a command to show a message box. We're mallocing the strings here (`line`[0] and `line`[1]) as we'll be clearing the script down when the mission ends, and so we need to have the memory malloc'd in this way. Our script function lines take the form of <COMMAND> <argument1> <argument2> .... When it comes to parsing our script lines, this is the format that we will expect. The most important part of the line is the initial COMMAND, as we'll read this first, and then decide how to handle the rest of the line. With our setup down, we come to the next function - doScript:
doScript is responsible for processing our script. We'll be calling this function all the time as our mission runs. We first set Stage's scriptRunning to be 0 (false), and then call updateTime (we'll see this in a moment). Next, we'll loop through all our script functions (our link list is called `head`). We'll check if the script is `running`, and if so we'll update Stage's scriptRunning to 1. Next, we'll decrease the current ScriptFunction's (`s`) `delay`. Should `delay` be 0 (and usually it is), we'll begin processing our script lines. We grab a pointer to the current line, indexing `lines` by lineNum, and reading the command at the start of the line, into a variable called `command`. We then test the value of command, to see what to do. We only support "MSG_BOX" right now, so we process this. Our MSG_BOX actually has one argument, that is delimited by semicolons. The reason for this is to allow for spaces in the variable parameters. We therefore delimit by the semicolons, reading the values into variables called strParam[0] (speaker), strParam[1] (avatar), and strParam[2] (text), and then feed them into a function called addMessageBox. If we don't recognise the command that we've parsed, we'll print an error, and exit. Yep, my heavy-handed approach to problems. I personally find it helpful to immediately see the problem, rather than allow it to disappear into logging, and wonder why things aren't working (especially in the case of a minor typo). Of course, this won't be to everyone's liking. In fact, it'll not be to most. With our command handled, we increment our line number (lineNum), and then update the script's `running` flag. Our script will be considered to still be running if lineNum is less than numLines (in other words, we still have lines left to process). That's it the doScript function. We're currently not processing a "DELAY" command. That's reserved for future use. Now for updateTime:
In a nutshell, this function is responsible for processing script events that are handled by time changes. This function will only run when the mission is in progress. We'll assign a variable called `then` the value of `time`, increment `time`, and then assign the current time to `now` (both converted to seconds). We then compare the times, and if they are different, we're calling a function named executeScriptFunction, passing over a string that reflects the new time (TIME 1, TIME 2, etc). In other words, once a second we'll attempt to run a script named "TIME #". A basic function, then. Let's look at executeScriptFunction now:
This is a major function in our script handling and processing. It takes the name of the script function to execute as an argument, and then loops through all the known ScriptFunctions, looking for a match. As well as a `name` match, the script is expected to not already be running and not have completed running (lineNum being less than numLines). Once a match is found, we set the ScriptFunction's `running` flag to 1, so that it is processed in doScript. One function remaining in script.c, which is clearScript:
We're clearing down the memory and resources that we created for our script, to ensure we don't leak any memory (this is why we're mallocing our test strings). That's all we need to do for our script..! As you can see, it's just a series of lines that are parsed to extract a command. Let's now look at the MessageBox functionality that we added in. This is done in hud.c. So, starting with initHUD:
We first setup the MessageBox linked list (Stage's messageBoxHead and messageBoxTail). Next, we update doHUD:
We're just calling doMessageBox here. Next up is the actual addMessageBox, that we saw being called by doScript:
We're passing in the `speaker`, portraitTexture (avatar), and `message` to use. We're creating a MessageBox, and then copying in all the data required (including fetching the portrait from our texture atlas). For the `timer` value (how long to display our MessageBox), we'll set a minimum time of 2 seconds, but also attempt to calculate how long to display it, based on the length of the `message` text (by multiplying the number of characters in the message by 5). Lastly, we're setting the height of the MessageBox (h) according to the calculated height of the wrapped message text, plus 70 (to account for padding and the speaker name). Again, nothing overly complex. Now on to doMessageBox:
Unlike our HudMessages, our MessageBoxes will behave like a queue (FIFO); only one will be displayed at a time. We check to see if there is a MessageBox at the head of our list, and process it if so. We'll start by checking if the `received` flag is set, and if not we'll play a sound, and update the flag to 1. Next, we'll decrease the MessageBox's `timer`, and remove the MessageBox if it falls to -FPS. We're not removing it when it hits 0 because we want to introduce a small delay between MessageBoxes that are queued up for display. When it comes to drawing, we'll only render the MessageBox if the timer is greater than 0. That's all for our logic, so we can move on to the rendering phase. First, we've updated drawHUD:
We're calling drawMessageBox:
We're testing if we have a MessageBox is available to draw, and also if its `timer` is greater than 0, and then rendering a rectangle to contain the text. Next, we're drawing the name of the speaker and their message, and finally the speaker's portrait. We're using blitAtlasImageScaled here, to fit it into a certain bounds (of 72 x 72). We're almost done! We just need to update stage.c to integrate our script, and this part is finished. First up, let's update initStage:
We're calling initScript, to get everything prepared. Next up, we've updated doStage:
We're calling doScript. We've also made a change to doStatus:
Now, before decreasing our missionCompleteTimer when our mission is complete, we're testing that we're not currently running a script (scriptRunning), and also that a messageBox is not being displayed. This is so that the mission doesn't end while chats are happening. Finally, we've updated clearStage:
We're calling clearScript here, to clear down our script, along with everything else. There, our script system is in place, and can be used to display messages, and also extended to support extra functionality. As you can no doubt see, being able to display message boxes, and do other things has enhanced our potential to create exciting, unique missions. I used this extensively in TBFTSS: The Pandoran War, where objectives could be enabled, enemy groups could be created, and the flow of the mission could be radically altered (an example of this is an optional mission where once-friendly craft suddenly become hostile). We've finally erected all the pillars of our game. We have our game loop, and can add scripts to our missions. So, for the next 6 parts we're going to create the missions for the player to undertake. There is still work to do be done to support some of the required features, but these updates will be smaller compared to everything else that has come before. 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 |