PC Games

Orb
Lasagne Monsters
Three Guys Apocalypse
Water Closet
Blob Wars : Attrition
The Legend of Edgar
TBFTSS: The Pandoran War
Three Guys
Blob Wars : Blob and Conquer
Blob Wars : Metal Blob Solid
Project: Starfighter
TANX Squadron

Tutorials

2D shoot 'em up
2D top-down shooter
2D platform game
Sprite atlas tutorial
Working with TTF fonts
2D adventure game
Widget tutorial
2D shoot 'em up sequel
2D run and gun
Roguelike
Medals (Achievements)
2D turn-based strategy game
2D isometric game
2D map editor
2D mission-based shoot 'em up
2D Santa game
2D split screen game
SDL 1 tutorials (outdated)

Latest Updates

SDL2 Versus game tutorial
Wed, 20th March 2024

Download keys for SDL2 tutorials on itch.io
Sat, 16th March 2024

The Legend of Edgar 1.37
Mon, 1st January 2024

SDL2 Santa game tutorial 🎅
Thu, 23rd November 2023

SDL2 Shooter 3 tutorial
Wed, 15th February 2023

All Updates »

Tags

android (3)
battle-for-the-solar-system (10)
blob-wars (10)
brexit (1)
code (6)
edgar (9)
games (43)
lasagne-monsters (1)
making-of (5)
match3 (1)
numberblocksonline (1)
orb (2)
site (1)
tanx (4)
three-guys (3)
three-guys-apocalypse (3)
tutorials (17)
water-closet (4)

Books


H1NZ

Arriving on the back of a meteorite, an alien pathogen has spread rapidly around the world, infecting all living humans and animals, and killing off all insect life. Only a handful are immune, and these survivors cling desperately to life, searching for food, fresh water, and a means of escape, find rescue, and discover a way to rebuild.

Click here to learn more and read an extract!

« Back to tutorial listing

— Mission-based 2D shoot 'em up —
Part 23: Scripting

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:


struct MessageBox
{
	char        speaker[MAX_NAME_LENGTH];
	AtlasImage *portraitTexture;
	char        message[MAX_DESCRIPTION_LENGTH];
	int         h;
	int         received;
	double      timer;
	MessageBox *next;
};

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:


struct ScriptFunction
{
	char            name[MAX_NAME_LENGTH];
	int             numLines;
	char		  **lines;
	int             lineNum;
	int             running;
	double          delay;
	ScriptFunction *next;
};

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:


typedef struct
{
	// snipped
	HudMessage  hudMessageHead, *hudMessageTail;
	Objective   objectiveHead, *objectiveTail;
	MessageBox  messageBoxHead, *messageBoxTail;
	double      engineEffectTimer, rocketEffectTimer;
	int         numActiveEnemies;
	Entity     *player;
	PointF      camera;
	int         scriptRunning;
} 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:


void initScript(void)
{
	ScriptFunction *s;

	memset(&head, 0, sizeof(ScriptFunction));
	tail = &head;

	time = 0;

	s = malloc(sizeof(ScriptFunction));
	memset(s, 0, sizeof(ScriptFunction));
	tail->next = s;
	tail = s;

	STRCPY(s->name, "TIME 1");
	s->numLines = 2;
	s->lines = malloc(sizeof(char *) * s->numLines);
	s->lines[0] = malloc(256);
	STRNCPY(s->lines[0], "MSG_BOX Leo;gfx/avatars/leo.png;Testing, testing, 1, 2, 3. Is this thing on?", 256);
	s->lines[1] = malloc(256);
	STRNCPY(s->lines[1], "MSG_BOX Mittens;gfx/avatars/mittens.png;It's working, Leo. Please don't start singing like you did last time.", 256);

	s = malloc(sizeof(ScriptFunction));
	memset(s, 0, sizeof(ScriptFunction));
	tail->next = s;
	tail = s;

	STRCPY(s->name, "TIME 60");
	s->numLines = 2;
	s->lines = malloc(sizeof(char *) * s->numLines);
	s->lines[0] = malloc(256);
	STRNCPY(s->lines[0], "MSG_BOX Mittens;gfx/avatars/mittens.png;Are you still fighting those Greebles?", 256);
	s->lines[1] = malloc(256);
	STRNCPY(s->lines[1], "MSG_BOX Leo;gfx/avatars/leo.png;Just playing around with them!", 256);
}

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:


void doScript(void)
{
	char		   *line, command[MAX_NAME_LENGTH], strParam[3][256];
	ScriptFunction *s;

	stage.scriptRunning = 0;

	updateTime();

	for (s = head.next; s != NULL; s = s->next)
	{
		if (s->running)
		{
			stage.scriptRunning = 1;

			s->delay = MAX(s->delay - app.deltaTime, 0);

			if (s->delay == 0)
			{
				line = s->lines[s->lineNum];

				sscanf(line, "%s", command);

				if (strcmp(command, "MSG_BOX") == 0)
				{
					sscanf(line, "%*s %255[^;]%*c%255[^;]%*c%255[^\n]", strParam[0], strParam[1], strParam[2]);

					addMessageBox(strParam[0], strParam[1], strParam[2]);
				}
				else
				{
					SDL_LogMessage(SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_CRITICAL, "ERROR: Unrecognised script command '%s'\n", command);
					exit(1);
				}

				s->lineNum++;

				s->running = s->lineNum < s->numLines;
			}
		}
	}
}

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:


static void updateTime(void)
{
	int  then, now;
	char command[16];

	if (stage.status == MS_INCOMPLETE)
	{
		then = (int)time / FPS;

		time += app.deltaTime;

		now = (int)time / FPS;

		if (then != now)
		{
			sprintf(command, "TIME %d", now);

			executeScriptFunction(command);
		}
	}
}

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:


void executeScriptFunction(char *name)
{
	ScriptFunction *s;

	for (s = head.next; s != NULL; s = s->next)
	{
		if (!s->running && s->lineNum < s->numLines && strcmp(s->name, name) == 0)
		{
			SDL_LogMessage(SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_INFO, "Executing script function '%s' ...", s->name);

			s->running = 1;
		}
	}
}

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:


void clearScript(void)
{
	ScriptFunction *s;
	int             i;

	while (head.next)
	{
		s = head.next;

		if (s->lines != NULL)
		{
			for (i = 0; i < s->numLines; i++)
			{
				free(s->lines[i]);
			}

			free(s->lines);
		}

		head.next = s->next;

		free(s);
	}
}

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:


void initHUD(void)
{
	stage.hudMessageTail = &stage.hudMessageHead;
	stage.messageBoxTail = &stage.messageBoxHead;

	// snipped
}

We first setup the MessageBox linked list (Stage's messageBoxHead and messageBoxTail). Next, we update doHUD:


void doHUD(void)
{
	// snipped

	doMessages();

	doMessageBox();
}

We're just calling doMessageBox here. Next up is the actual addMessageBox, that we saw being called by doScript:


void addMessageBox(char *speaker, char *portraitTexture, char *message)
{
	MessageBox *m;

	m = malloc(sizeof(MessageBox));
	memset(m, 0, sizeof(MessageBox));
	stage.messageBoxTail->next = m;
	stage.messageBoxTail = m;

	STRCPY(m->speaker, speaker);
	m->portraitTexture = getAtlasImage(portraitTexture, 1);
	STRCPY(m->message, message);
	m->timer = MAX(strlen(message) * 5, FPS * 2);

	m->h = getWrappedTextHeight(message, MESSAGE_BOX_WIDTH) + 70;
}

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:


static void doMessageBox(void)
{
	MessageBox *m;

	m = stage.messageBoxHead.next;

	if (m != NULL)
	{
		if (!m->received)
		{
			playSound(SND_RADIO, CH_GUI);

			m->received = 1;
		}

		m->timer -= app.deltaTime;

		if (m->timer <= -FPS)
		{
			if (m == stage.messageBoxTail)
			{
				stage.messageBoxTail = &stage.messageBoxHead;
			}

			stage.messageBoxHead.next = m->next;

			free(m);
		}
	}
}

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:


void drawHUD(void)
{
	// snipped

	drawHudMessages();

	drawMessageBox();
}

We're calling drawMessageBox:


static void drawMessageBox(void)
{
	MessageBox *m;
	SDL_Rect    r;

	m = stage.messageBoxHead.next;

	if (m != NULL && m->timer > 0)
	{
		r.w = MESSAGE_BOX_WIDTH;
		r.h = m->h;
		r.x = (SCREEN_WIDTH - r.w) / 2;
		r.y = 50;

		drawRect(r.x, r.y, r.w, r.h, 0, 160, 0, 64);
		drawOutlineRect(r.x, r.y, r.w, r.h, 0, 255, 0, 128);

		drawText(m->speaker, r.x + 100, r.y, 0, 255, 0, TEXT_ALIGN_LEFT, 0);
		drawText(m->message, r.x + 100, r.y + 32, 255, 255, 255, TEXT_ALIGN_LEFT, r.w - 110);

		blitAtlasImageScaled(m->portraitTexture, r.x + 8, r.y + 8, 72, 72, 0);
	}
}

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:


void initStage(void)
{
	// snipped

	initObjectives();

	initScript();

	// snipped
}

We're calling initScript, to get everything prepared. Next up, we've updated doStage:


static void doStage(void)
{
	// snipped

	doObjectives();

	doScript();

	doHUD();

	// snipped
}

We're calling doScript. We've also made a change to doStatus:


static void doStatus(void)
{
	switch (stage.status)
	{
		case MS_COMPLETE:
			if (!stage.scriptRunning && stage.messageBoxHead.next == NULL)
			{
				stage.missionCompleteTimer -= app.deltaTime;
			}
			break;

		default:
			break;
	}
}

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:


static void clearStage(void)
{
	// snipped

	clearEffects();

	clearScript();

	memset(&stage, 0, sizeof(Stage));
}

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:

Mobile site