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

Android Games

DDDDD
Number Blocks
Match 3 Warriors

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


A North-South Divide

For over a hundred years, messenger Duncan has wandered the world, searching for the missing pieces of an amulet that will rid him of his curse; a curse that has burdened him with an extreme intolerance of the cold, an unnaturally long life, and the despair of watching all he knew and loved become lost to the ravages of time. But now, Duncan is close to the end of his long quest.

Click here to learn more and read an extract!

« Back to tutorial listing

— Mission-based 2D shoot 'em up —
Part 21: Intermission: Options

Note: this tutorial assumes knowledge of C, as well as prior tutorials.

Introduction

The final part of our intermission is creating the options screen. Here, we'll be able to adjust our sound, music, controls, and other things. It's basically a standard options menu..!

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./shooter3-21 to run the code. You will see a window open like the one above. Use the mouse to control the cursor. Click and drag the mouse on the sound and music volume bars to change the values. Click on the checkboxes to toggle the autosave and show FPS options. The quit button does nothing right now. To adjust the controls (in the Configure Controls ... section), click on the control value to change, then press the new key or keypad button to wish to use (pressing Backspace will clear the value, and Escape will cancel the change). The configuration is saved to a file called config.json when navigating away from the options screen. Once you're finished, close the window to exit.

Inspecting the code

We've seen options configuration before, in SDL2 Gunner, so if you've been following this tutorial series this won't come as anything new. We'll therefore speed through this final bit. One thing you'll immediately be aware of is that we have sound and music! I thought I would leave this until we were able to control the volumes (although keep in mind that your configuration settings won't carry over to other parts, as they live in separate directories..!)

Turning to the code, we've updated structs.h:


typedef struct
{
	// snipped

	struct
	{
		int keyControls[CONTROL_MAX];
		int joypadControls[CONTROL_MAX];
		int soundVolume;
		int musicVolume;
		int autosave;
		int deadzone;
	} config;
	struct

	// snipped

} App;

We've added a new field here - `autosave`. This is to support the automatic saving of our games, and is tied to the options screen we're building.

With that added, we can turn to the options screen proper. options.c has a number of functions, but nothing too difficult to understand. Starting with initOptions:


void initOptions(void)
{
	Widget *w;

	show = SHOW_GENERAL;

	soundVolumeWidget = getWidget("soundVolume", "options");
	soundVolumeWidget->x = 700;
	soundVolumeWidget->y = 100;
	soundVolumeWidget->action = soundVolume;
	((SliderWidget *)soundVolumeWidget->data)->value = (1.0 * app.config.soundVolume) / 128;

	musicVolumeWidget = getWidget("musicVolume", "options");
	musicVolumeWidget->x = 700;
	musicVolumeWidget->y = 200;
	musicVolumeWidget->action = musicVolume;
	((SliderWidget *)musicVolumeWidget->data)->value = (1.0 * app.config.musicVolume) / 128;

	autosaveWidget = getWidget("autosave", "options");
	autosaveWidget->x = 700;
	autosaveWidget->y = 300;
	autosaveWidget->action = autosave;
	((ToggleWidget *)autosaveWidget->data)->on = app.config.autosave;

	showFPSWidget = getWidget("showFPS", "options");
	showFPSWidget->x = 700;
	showFPSWidget->y = 400;
	showFPSWidget->action = showFPS;
	((ToggleWidget *)autosaveWidget->data)->on = app.dev.showFPS;

	w = getWidget("controls", "options");
	w->x = (SCREEN_WIDTH - w->w) / 2;
	w->y = 500;
	w->action = controls;

	w = getWidget("quit", "options");
	w->x = (SCREEN_WIDTH - w->w) / 2;
	w->y = 650;
	w->action = quit;
	w->hidden = 0;

	w = getWidget("back", "options");
	w->x = (SCREEN_WIDTH - w->w) / 2;
	w->y = 650;
	w->hidden = 1;

	w = getWidget("back", "controls");
	w->x = (SCREEN_WIDTH - w->w) / 2;
	w->y = 650;
	w->action = returnToOptions;
}

Of course, we're setting up a number of widgets here, since this screen is all about the widgets. Notice we're setting a variable called `show` (static in options.c) to SHOW_GENERAL. This is to help us know whether we want to process our options widgets or our control widgets.

Next up is doOptions:


void doOptions(void)
{
	switch (show)
	{
		case SHOW_GENERAL:
			doWidgets("options");
			break;

		case SHOW_CONTROLS:
			doWidgets("controls");
			break;

		default:
			break;
	}
}

Here, we can see we're testing the value of `show`, and deciding to either call doWidgets with "options" or "control", depending on the value being SHOW_GENERAL or SHOW_CONTROLS.

The drawOptions function follows, and has near identical logic:


void drawOptions(void)
{
	switch (show)
	{
		case SHOW_GENERAL:
			drawWidgets("options");
			break;

		case SHOW_CONTROLS:
			drawWidgets("controls");
			break;

		default:
			break;
	}
}

We either want to call drawWidgets with "options" or "controls", depending on the value of `show`.

Next up is loadConfig:


void loadConfig(void)
{
	char  *data;
	cJSON *root, *node;

	if (fileExists(CONFIG_FILENAME))
	{
		data = readFile(CONFIG_FILENAME);

		root = cJSON_Parse(data);

		for (node = cJSON_GetObjectItem(root, "keyControls")->child; node != NULL; node = node->next)
		{
			app.config.keyControls[lookup(node->child->string)] = node->child->valueint;
		}

		for (node = cJSON_GetObjectItem(root, "joypadControls")->child; node != NULL; node = node->next)
		{
			app.config.joypadControls[lookup(node->child->string)] = node->child->valueint;
		}

		app.config.soundVolume = cJSON_GetObjectItem(root, "soundVolume")->valueint;
		app.config.musicVolume = cJSON_GetObjectItem(root, "musicVolume")->valueint;
		app.config.autosave = cJSON_GetObjectItem(root, "autosave")->valueint;
		app.config.deadzone = cJSON_GetObjectItem(root, "deadzone")->valueint;

		cJSON_Delete(root);

		free(data);
	}

	setSoundVolume(app.config.soundVolume);

	setMusicVolume(app.config.musicVolume);
}

Okay, just some JSON loading going on here. We're checking if the config file exists (CONFIG_FILENAME is a define set to "config.json"), and then setting App's config's various values with those from our config file. With all that done, we're calling setSoundVolume and setMusicVolume (in sound.c) to set our volumes with the values of App's config's soundVolume and musicVolume.

saveConfig comes next:


void saveConfig(void)
{
	char  *out;
	cJSON *root, *node, *array;
	int    i;

	root = cJSON_CreateObject();

	cJSON_AddNumberToObject(root, "soundVolume", app.config.soundVolume);
	cJSON_AddNumberToObject(root, "musicVolume", app.config.musicVolume);
	cJSON_AddNumberToObject(root, "autosave", app.config.autosave);
	cJSON_AddNumberToObject(root, "deadzone", app.config.deadzone);

	array = cJSON_CreateArray();
	for (i = 0; i < CONTROL_MAX; i++)
	{
		node = cJSON_CreateObject();
		cJSON_AddNumberToObject(node, getLookupName("CONTROL_", i), app.config.keyControls[i]);
		cJSON_AddItemToArray(array, node);
	}
	cJSON_AddItemToObject(root, "keyControls", array);

	array = cJSON_CreateArray();
	for (i = 0; i < CONTROL_MAX; i++)
	{
		node = cJSON_CreateObject();
		cJSON_AddNumberToObject(node, getLookupName("CONTROL_", i), app.config.joypadControls[i]);
		cJSON_AddItemToArray(array, node);
	}
	cJSON_AddItemToObject(root, "joypadControls", array);

	out = cJSON_Print(root);

	writeFile(CONFIG_FILENAME, out);

	cJSON_Delete(root);

	free(out);
}

We're just outputting the configuration data to config.json here. Nothing more that needs to be said..!

Now onto the widget action functions. Again, we'll speed through these, as it will be quite clear what they are doing. Starting with soundVolume:


static void soundVolume(void)
{
	SliderWidget *s;

	s = (SliderWidget *)soundVolumeWidget->data;

	app.config.soundVolume = (int)(MIX_MAX_VOLUME * s->value);

	setSoundVolume(app.config.soundVolume);
}

We're setting the sound volume with the value of the soundVolumeWidget (note: our slider holds a value between 0.0 and 1.0, so we just multiply MIX_MAX_VOLUME, the maximum sound volume supported by SDL2, by this amount).

musicVolume follows:


static void musicVolume(void)
{
	SliderWidget *s;

	s = (SliderWidget *)musicVolumeWidget->data;

	app.config.musicVolume = (int)(MIX_MAX_VOLUME * s->value);

	setMusicVolume(app.config.musicVolume);
}

We're using similar logic to soundVolumne. The `autosave` function is next:


static void autosave(void)
{
	app.config.autosave = ((ToggleWidget *)autosaveWidget->data)->on;
}

We're setting App's Config's `autosave` value to the autosaveWidget's `on` value, that will either be 0 or 1. The showFPS function comes next:


static void showFPS(void)
{
	app.dev.showFPS = ((ToggleWidget *)showFPSWidget->data)->on;
}

As expected, we're setting App's Config's showFPS to the showFPSWidget's `on` value, that will either be 0 or 1. Next up is the `controls` function:


static void controls(void)
{
	show = SHOW_CONTROLS;
}

When the "Configure Controls ..." button is pressed, we're setting the value of `show` to SHOW_CONTROLS, to display and process our control widgets. The final function is returnToOptions:


static void returnToOptions(void)
{
	show = SHOW_GENERAL;
}

This function is merely setting show to SHOW_GENERAL. This function is tied to the "back" button in the "controls" widget group. We're setting the action we want to use in initOptions, so that we can return to the main options configuration after leaving the controls screen (our controls are handled in controls.c). As we've just mentioned controls.c, let's head over there now, and take a look.

We've added a number of functions to this compilation unit, all of which involve widget processing and handling..! So, we'll be speeding through this part, too. Starting with initControls:


void initControls(void)
{
	Widget *w;

	setupControlWidgets();

	deadzoneWidget = getWidget("deadzone", "controls");
	deadzoneWidget->x = (SCREEN_WIDTH - deadzoneWidget->w) / 2;
	deadzoneWidget->y = 540;
	deadzoneWidget->action = deadzone;
	((SliderWidget *)deadzoneWidget->data)->value = (1.0 * app.config.deadzone) / DEADZONE_MAX;

	w = getWidget("back", "controls");
	w->x = (SCREEN_WIDTH - w->w) / 2;
	w->y = SCREEN_HEIGHT - 250;

	updateControls();
}

This is just some widget setup. Nothing more to add! doControls follows:


void doControls(void)
{
	doWidgets("controls");
}

We're just processing the "controls" widgets. drawControls is similar in nature:


void drawControls(void)
{
	drawWidgets("controls");
}

We're just drawing our "controls" widgets. Next up is setupControlWidgets:


static void setupControlWidgets(void)
{
	Widget        *w;
	ControlWidget *cw;
	int y, i;

	y = 100;

	for (i = 0; i < CONTROL_MAX; i++)
	{
		w = getWidget(widgetNames[i], "controls");
		w->w = 700;
		w->x = (SCREEN_WIDTH - w->w) / 2;
		w->y = y;
		w->h = 45;
		w->action = updateControls;

		cw = (ControlWidget *)w->data;
		cw->keyboard = app.config.keyControls[i];
		cw->joypad = app.config.joypadControls[i];

		y += 60;
	}
}

Again, this is related to our widget configuration. We're looping through all our control widgets, setting their positions, and applying the current keyboard and joypad button from App config.

The updateControls function comes next:


static void updateControls(void)
{
	Widget        *w;
	ControlWidget *cw;
	int            i;

	for (i = 0; i < CONTROL_MAX; i++)
	{
		w = getWidget(widgetNames[i], "controls");
		cw = (ControlWidget *)w->data;
		app.config.keyControls[i] = cw->keyboard;
		app.config.joypadControls[i] = cw->joypad;
	}
}

This function just updates all our control widgets with the values from App's config. This is called whenever we change a control, to keep everything in sync. Finally, we have the `deadzone` function:


static void deadzone(void)
{
	SliderWidget *s;

	s = (SliderWidget *)deadzoneWidget->data;

	app.config.deadzone = (int)(DEADZONE_MAX * s->value);
}

Yet again, we're just setting Apps' Config's `deadzone` value to the value of the deadzoneWidget's value (as a percentage of DEADZONE_MAX, defined as 32000).

Okay, that's options.c and controls.c handled. As you can see, it's mostly composed of widget handling, so nothing worth lingering on.

Let's head over to init.c, where we've updated initGameOptions:


static void initGameOptions(void)
{
	// snipped

	app.config.soundVolume = MIX_MAX_VOLUME;
	app.config.musicVolume = MIX_MAX_VOLUME;

	loadConfig();
}

initGameOptions has been with us since the first part of this tutorial, but now we're calling loadConfig, to actually load our saved configuration (if it exists). initGameOptions sets all our defaults before doing so (such as the WASD control scheme).

Finally, let's move over to intermission.c, where we're incorporating our options screen. First up, we've updated initIntermission:


void initIntermission(void)
{
	int i, x;

	startTransition();

	if (app.config.autosave)
	{
		saveGame();
	}

	// snipped

	initOptions();

	// snipped

	section = IS_OPTIONS;

	starfieldTimer = 0;

	app.delegate.logic = logic;
	app.delegate.draw = draw;
	app.mouse.showCursor = 1;

	loadMusic("music/Map.ogg");

	endTransition();

	playMusic(1);
}

We're now testing if the `autosave` options is set, and calling saveGame in response. We're also calling initOptions, and setting section to IS_OPTIONS, to jump straight to it (for this demonstration). Also note how we're loading and playing music!

Next up, the adjustment to `logic`:


static void logic(void)
{
	// snipped

	switch (section)
	{
		// snipped

		case IS_LOAD_SAVE:
			doLoadSave();
			break;

		case IS_OPTIONS:
			doOptions();
			break;

		default:
			break;
	}
}

We've added the case statement for IS_OPTIONS, and are calling doOptions. Similarly, we've updated `draw`:


static void draw(void)
{
	// snipped

	switch (section)
	{
		// snipped

		case IS_LOAD_SAVE:
			drawLoadSave();
			break;

		case IS_OPTIONS:
			drawOptions();
			break;

		default:
			break;
	}

	drawSectionIcons();
}

We've added the IS_OPTIONS case, to call drawOptions.

Finally, we've made a tweak to doSectionIcons:


static void doSectionIcons(void)
{
	int i;

	hoverSectionIcon = -1;

	for (i = 0; i < IS_MAX; i++)
	{
		if (i == IS_COMMS && !hasSelectedMission)
		{
			continue;
		}

		if (collision(app.mouse.x, app.mouse.y, 1, 1, sectionIcons[i].x, sectionIcons[i].y, sectionIconTexture->rect.w, sectionIconTexture->rect.h))
		{
			hoverSectionIcon = i;

			if (app.mouse.buttons[SDL_BUTTON_LEFT])
			{
				playSound(SND_GUI, CH_GUI);

				app.mouse.buttons[SDL_BUTTON_LEFT] = 0;

				if (i != section)
				{
					if (section == IS_OPTIONS && i != IS_OPTIONS)
					{
						saveConfig();
					}

					section = i;

					if (section == IS_COMMS)
					{
						openComms();
					}
				}
			}
		}
	}
}

We've added in some logic to see if the current section is IS_OPTIONS and the new section is a different one (in other words, we've navigated away from it). If so, we're calling saveConfig.

And there we have it, our intermission section is mostly complete. Now, what were we making again? Oh, yes - a mission and objective based 2D shooter! It feels that while we've been putting together the intermission screens, we've been neglecting the rest of the game. Well, don't be fooled. What we've done for the past several parts is get everything ready to commence with our main game loop. So, in the next part we're going to look at how to connect up missions with our intermission, to getting everything going!

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