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


The Battle for the Solar System (Complete)

The Pandoran war machine ravaged the galaxy, driving the human race to the brink of destruction. Seven men and women stood in its way. This is their story.

Click here to learn more and read an extract!

« Back to tutorial listing

— Creating a simple roguelike —
Part 20: Finishing Touches

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

Introduction

To finish off our game, we're going to add in the final touches - a title screen, some sound and music, and the option to Save and Quit our current run. We'll only be supporting one saved game at a time, so there won't be a load option. If a save game exists, it will automatically be loaded from the title screen.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./rogue20 to run the code. You will see a window open, displaying the title screen as above. Press Space (or Escape or Return to continue). If you wait a few seconds, the highscore table will be displayed for a short period, before returning to the title screen again. Play the game as usual. You can press Escape during play to bring up the pause menu, allowing you to adjust the sound and music, and also quit the game. Once you're finished, close the window to exit, or select Save and Exit from the in-game menu.

Inspecting the code

Let's start with looking at the changes to defs.h:


#define OPTIONS_FILENAME          "options.json"

We've added in a new define, to specify the name of our options save file, the file that will contain the volumes of our sound and music. We'll see this in use a bit later on.

structs.h has also been tweaked slightly:


typedef struct {
	HudMessage messages[NUM_HUD_MESSAGES];
	Entity inventoryHead, *inventoryTail;
	Entity *equipment[EQUIP_MAX];
	Highscore highscore;
	int soundVolume;
	int musicVolume;
} Game;

We've added in two new fields to Game - soundVolume and musicVolume, to hold the values of our sound and music volumes, respectively.

Next, we've added in a new file called title.c, which will be used to handle our title screen. We've only got a handful of functions to cover, and none of them are all that complicated.

Starting with initTitle:


void initTitle(void)
{
	background = loadTexture("gfx/background.jpg");

	logo = loadTexture("gfx/logo.png");

	logoAlpha = 0;

	tickVal = 0;

	timeout = FPS * 7;

	displayTimer = FPS / 2;

	loadMusic("music/Something is near.mp3");

	playMusic(1);

	app.delegate.logic = logic;

	app.delegate.draw = draw;
}

initTitle handles setting up the title screen. We're loading two textures - `background`, which will serve as the background image to our title screen and highscore, and `logo`, which is the game's title logo. Both of these are SDL_Textures, so neither are taken from our texture atlas, as they're bigger than our atlas's size (512 x 512). Next, we're setting a variable called logoAlpha to 0. This variable controls the alpha value of our logo, when it fades in. tickVal is used to control the blinking "Press Space" text colour. We're then setting a variable called `timeout` to 7 seconds. This is how long we'll display the title screen before switching over to the highscore table. displayTimer is set to half a second, and is used for the transition period when coming from the dungeon, highscore table, etc. We're then loading and playing our music, before finally setting our `logic` and `draw` delegates.

initTitleView is next:


void initTitleView(void)
{
	displayTimer = FPS / 2;

	timeout = FPS * 7;

	app.delegate.logic = logic;

	app.delegate.draw = draw;
}

This function is used when moving from the highscore table, etc. back to the title. We just need to set the displayTimer to half a second, the timeout to 7 seconds, and reset the `logic` and `draw` delegates. This basically a cutdown version of initTitle.

Our `logic` function follows. It's quite simple:


static void logic(void)
{
	displayTimer = MAX(displayTimer - app.deltaTime, 0);

	if (displayTimer == 0)
	{
		logoAlpha = MIN(logoAlpha + app.deltaTime * 2, 255);

		tickVal += app.deltaTime;

		if (app.keyboard[SDL_SCANCODE_SPACE] || app.keyboard[SDL_SCANCODE_RETURN] || app.keyboard[SDL_SCANCODE_ESCAPE])
		{
			clearInput();

			initDungeon();
		}

		timeout = MAX(timeout - app.deltaTime, 0);

		if (timeout == 0)
		{
			initHighscoreView();
		}
	}
}

We're making use of the displayTimer variable here, to control how soon we can interact with the title. When displayTimer is 0, we're increasing the value of logoAlpha, to make the logo appear, limiting it to 255. We're also increasing the value of tickVal. Next, we're checking if Space, Return, or Escape have been pressed. If so, we'll be starting the game. We'll clear the user input and then call initDungeon, to either resume the existing game or start a new one.

Lastly, we're decreasing the value of `timeout`, limiting it to 0. When it hits 0, we're calling initHighscoreView, to display the highscores table. So, as you can see, every 7 seconds we'll be moving over to the highscore table display.

The last function to look at is `draw`:


static void draw(void)
{
	int c;

	if (displayTimer == 0)
	{
		blit(background, 0, 0, 0);

		SDL_SetTextureAlphaMod(logo, logoAlpha);

		blit(logo, SCREEN_WIDTH / 2, 400, 1);

		SDL_SetTextureAlphaMod(logo, 255);

		c = ((int)tickVal % (int) FPS < FPS / 2) ? 255 : 192;

		app.fontScale = 1.5;

		drawText("Press Space!", SCREEN_WIDTH / 2, 650, c, c, c, TEXT_ALIGN_CENTER, 0);

		app.fontScale = 1;

		drawText("Copyright Parallel Realities, 2021. All Rights Reserved.", SCREEN_WIDTH / 2, SCREEN_HEIGHT - 50, 128, 128, 128, TEXT_ALIGN_CENTER, 0);
	}
}

Again, after testing if displayTimer is 0, we're rendering our graphics. We start by drawing the background, by calling `blit` and pressing over `background`. The `blit` function takes an SDL_Texture, rather than an AtlasImage. We're telling the function to draw `background` at 0,0 without being centered. That means it will cover the entire screen. Next, we're using SDL_SetTextureAlphaMod along with `logo` and logoAlpha, to change the alpha value of our logo. logoAlpha starts at 0, but as the value increases, our logo will slowly fade in. Again, we're using our `blit` function for this, since `logo` is an SDL_Texture.

We're next taking the modulo of tickVal and FPS, and then testing if the value is less than half a second. If so, we'll be setting a variable called `c` to 255. Otherwise, it will be 192. `c` is the colour of the "Press Space" text we're drawing. Effectively, we're using tickVal to make it pulse between white and light grey every half a second.

That's our title screen done. We can now look at all the other misc. changes we've made. Starting with initHighscoreView in highscores.c:


void initHighscoreView(void)
{
	displayTimer = FPS / 2;

	timeout = FPS * 7;

	app.delegate.logic = logic;

	app.delegate.draw = draw;

	clearInput();
}

Like title.c, we've added in a `timeout` variable, which we're setting to 7 seconds.

If we look the `logic` function, we can see how it's being used:


static void logic(void)
{
	displayTimer = MAX(displayTimer - app.deltaTime, 0);

	if (displayTimer == 0)
	{
		tickVal += app.deltaTime;

		if (app.keyboard[SDL_SCANCODE_SPACE] || app.keyboard[SDL_SCANCODE_RETURN] || app.keyboard[SDL_SCANCODE_ESCAPE])
		{
			clearInput();

			initDungeon();
		}

		timeout = MAX(timeout - app.deltaTime, 0);

		if (timeout == 0)
		{
			initTitleView();
		}
	}
}

Again, just like title.c, we're decreasing `timeout` and limiting it to 0. When it hits 0, we're calling initTitleView, to return to the title display.

Moving over to game.c next, we've updated initGame:


void initGame(void)
{
	char *data;
	cJSON *root;

	memset(&game, 0, sizeof(Game));

	game.soundVolume = game.musicVolume = MIX_MAX_VOLUME;

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

		root = cJSON_Parse(data);

		game.soundVolume = MIN(cJSON_GetObjectItem(root, "soundVolume")->valueint, MIX_MAX_VOLUME);
		game.musicVolume = MIN(cJSON_GetObjectItem(root, "musicVolume")->valueint, MIX_MAX_VOLUME);

		cJSON_Delete(root);

		free(data);
	}
}

We've made an update to load in our options (really just our sound and music volumes). We're first setting game's soundVolume and musicVolume to the maximums (MIX_MAX_VOLUME is 127). We're then testing to see if the options.json file exists. If so, we're going to load it, convert it into JSON, and then extract the values of soundVolume and musicVolume from it, assigning these to game's soundVolume and musicVolume, respectively. We're also using the MIN macro to ensure that our values don't exceed MIX_MAX_VOLUME.

To accompany our options loading, we've also created a saveOptions function:


void saveOptions(void)
{
	cJSON *root;
	char *out;

	root = cJSON_CreateObject();

	cJSON_AddNumberToObject(root, "soundVolume", game.soundVolume);
	cJSON_AddNumberToObject(root, "musicVolume", game.musicVolume);

	out = cJSON_Print(root);

	writeFile(OPTIONS_FILENAME, out);

	cJSON_Delete(root);

	free(out);
}

A simple function - we're storing the values of game's soundVolume and musicVolume into a JSON object and then saving that out to our options.json file.

Moving over to dungeon.c, we've made a few tweaks to support our in-game menu. Starting with initDungeon:


void initDungeon(void)
{
	memset(&dungeon, 0, sizeof(Dungeon));

	floorChangeTimer = FPS / 2;

	playerDeathAlpha = -FPS / 2;

	mouseKingDeathAlpha = -FPS / 2;

	initMap();

	initHud();

	initInventory();

	initEntities();

	setupWidgets();

	if (!loadGame())
	{
		createDungeon();
	}

	paused = 0;

	pauseMusic(0);

	app.delegate.logic = logic;

	app.delegate.draw = draw;
}

We're setting a variable called `paused` (declared as static within dungeon.c) to 0. This variable controls whether we're display our in-game menu. Also of note is a call to pauseMusic, passing over 0. This function controls whether our music is playing. Passing 1 pauses the music, while 0 resumes it (this function can be found in sound.c). We're also calling a new function named setupWidgets, to create our pause widgets.

The logic function has been tweaked to support the new `pause` variable:


static void logic(void)
{
	// snipped

	else if (floorChangeTimer == 0)
	{
		if (!paused)
		{
			doEntities();

			doHud();

			dungeon.animationTimer = MAX(dungeon.animationTimer - app.deltaTime, 0);

			if (dungeon.animationTimer <= FPS / 5)
			{
				dungeon.attackingEntity = NULL;

				if (dungeon.animationTimer == 0)
				{
					if (dungeon.currentEntity == dungeon.player)
					{
						doPlayer();
					}
					else
					{
						doMonsters();
					}
				}
			}

			doCamera();

			doSelectTile();

			if (dungeon.floor != dungeon.newFloor)
			{
				changeDungeonFloor();
			}

			if (dungeon.currentEntity == dungeon.player && app.keyboard[SDL_SCANCODE_ESCAPE])
			{
				app.keyboard[SDL_SCANCODE_ESCAPE] = 0;

				paused = 1;

				app.activeWidget = getWidget("resume", "pause");
			}
		}
		else
		{
			doWidgets("pause");

			if (app.keyboard[SDL_SCANCODE_ESCAPE])
			{
				app.keyboard[SDL_SCANCODE_ESCAPE] = 0;

				saveOptions();

				paused = 0;
			}
		}
	}
}

We've wrapped our main game processing code in an if-statement, to test if the game is not paused (`pause` is 0). If it isn't, the game plays out as normal. Otherwise, we're calling doWidgets, passing over "pause" to process our in-game widgets. We're also testing to see if Escape has been pressed, to exit the menu and return to the game (basically, unpause). If so, we're clearing the Escape key, then calling saveOptions to save our settings, and updating `paused` to 0 to continue the game.

`draw` has also been tweaked, to make use of the `paused` variable:


static void draw(void)
{
	if (floorChangeTimer == 0)
	{
		drawMap();

		drawEntities();

		if (!paused)
		{
			drawHud();
		}
	}

	if (dungeon.player->dead && playerDeathAlpha > 0)
	{
		drawRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, 255, 0, 0, playerDeathAlpha);
	}

	if (dungeon.mouseKing != NULL && dungeon.mouseKing->dead && mouseKingDeathAlpha > 0)
	{
		drawRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, 255, 255, 255, mouseKingDeathAlpha);
	}

	if (paused)
	{
		drawRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, 0, 0, 0, 160);

		drawWidgets("pause");
	}
}

When paused, we're no longer calling drawHud. This is to reduce the text noise on screen when the widgets are present, so that things don't get confusing. At the end of the functon, we're checking if `paused` is set and then drawing a transparent black rectangle across the screen, to dim it, then calling drawWidgets, passing over "pause" to draw the pause widgets.

Our widget setup is handled by setupWidgets. This is a standard widget setup function that we've seen in the past, so we'll instead focus on the two important functions that the widgets use. Starting with `resume`:


static void resume(void)
{
	saveOptions();

	paused = 0;
}

This function is called when we select "Resume" from the menu. It simply calls saveOptions and then sets `paused` to 0, so that the game continues.

The other function is `quit`:


static void quit(void)
{
	saveGame();

	exit(0);
}

This function saves our game and then quits, by calling exit. We don't return to the title screen. Having this in place now allows us to save the game at any time and not just when we change floors.

The very last change we need to make to our game is to tell it to start at the title screen, rather than jumping straight into the dungeon. So, we head over to main.c and update main:


int main(int argc, char *argv[])
{
	long then;

	memset(&app, 0, sizeof(App));

	initSDL();

	atexit(cleanup);

	initGameSystem();

	initTitle();

	nextFPS = SDL_GetTicks() + 1000;

	while (1)
	{
		then = SDL_GetTicks();

		prepareScene();

		doInput();

		logic();

		app.delegate.draw();

		presentScene();

		/* allow the CPU/GPU to breathe */
		SDL_Delay(1);

		app.deltaTime = LOGIC_RATE * (SDL_GetTicks() - then);

		doFPS();
	}

	return 0;
}

Instead of calling initDungeon, we're now calling initTitle.

And there we have it. 20 simple steps for creating a Rougelike using SDL2. It's been quite a journey, but hopefully you will have found this helpful. The nice thing about this game is that it's fairly easy to throw in some new monsters, weapons, etc. In fact, this tutorial was originally meant to be a lot longer, featuring ranged weapons, more equipment slots, and several different monsters. It could well surface one day as a Director's Cut. You'll know where it find it if so!

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