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
SDL 1 tutorials (outdated)

Latest Updates

SDL2 Rogue tutorial
Wed, 29th September 2021

SDL2 Gunner tutorial
Thu, 26th August 2021

SDL2 Shooter 2 tutorial
Tue, 13th July 2021

SDL2 Widget tutorial
Fri, 18th June 2021

SDL2 Adventure tutorial
Tue, 8th June 2021

All Updates »

Tags

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

Books

« Back to tutorial listing

— Simple 2D adventure game —
Part 14: Finishing touches

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

Introduction

There are just a few more things we need to add to our game: a title screen, an ending screen, some sound effects, and some music. These things are all quite easy to do, so this final part will be simple.

Extract the archive, run make, and then use ./adventure14 to run the code. The usual controls apply. Press SPACE to start the game, then return all 4 icons to the Dungeon Mistress. Once done so, head through the door in the main room and up the stairs to see the ending screen. Close the window or press Space, Return, or Escape to finish.

Inspecting the code

We'll start from the beginning, with the title screen. The title screen is defined in title.c. There are a few functions to be found here. We'll go through them one at a time, starting with initTitle:


void initTitle(void)
{
	logo1 = getAtlasImage("gfx/misc/logo1.png", 1);
	logo2 = getAtlasImage("gfx/misc/logo2.png", 1);

	logoAlpha = 0;

	gotoDungeon = 0;

	tickVal = 0;

	gotoDungeonTimer = FPS / 2;

	app.delegate.logic = logic;
	app.delegate.draw = draw;
}

Our game logo is in two pieces, to allow it to fit onto the 512x512 texture atlas; we could've increased the size of the atlas itself, but this solution is just fine. We grab both pieces of the logo, then setup a few other variables, and set our logic and draw delegates. We'll see what all these variables do as we come to the logic and draw steps, starting with logic:


static void logic(void)
{
	if (!gotoDungeon)
	{
		logoAlpha = MIN(logoAlpha + app.deltaTime * 2, 255);

		tickVal += app.deltaTime;

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

			gotoDungeon = 1;
		}
	}
	else
	{
		gotoDungeonTimer -= app.deltaTime;

		if (gotoDungeonTimer <= 0)
		{
			initDungeon();
		}
	}
}

We're first testing if the gotoDungeon variable is 0. If so, we want to increase the value of logoAlpha. This value will be used to fade in our logo. We're limiting it to 255; if we go above this value, we'll get some odd graphical effects, such as the logo fading back in again. We're also increasing the value of tickVal by the delta time (more on this in a bit). We then also check if the Space key has been pressed. If so, we set the gotoDungeon variable to 1.

If gotoDungeon is already 1 (true) when we enter this function, we'll start to decrease our gotoDungeonTimer. The reason that we're doing this at all is because when the player presses Space, we don't want to instantly go into the dungeon. It's a bit jarring, so we if simply clear the screen for a short period (half a second, since we're using FPS / 2), we'll offer a small transition. We then call initDungeon to begin the game proper. This was once done in main.c.

Turning to the draw function, we can see it's again rather simple:


static void draw(void)
{
	int c;

	if (!gotoDungeon)
	{
		drawRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, 32, 32, 32, 255);

		drawLogo();

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

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

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

We're testing first if we're not going to the dungeon before drawing anything. This will keep our screen blank if we are. Otherwise, we'll draw the title as normal. We're using drawRect to set the background to a dull grey, then calling drawLogo to draw our logo. We're then assigning the value of c, depending on the value of tickVal, by applying the modulo of FPS and seeing if it's less than FPS / 2. Basically, we're reducing tickVal to the range of 0-59, and then checking if it's less than 30. If it is, we're setting c to 255, otherwise 192. This is then used in our "Press Space!" draw text command. In effect, the Draw Space text will flash white and grey every half a second. The copyright display follows this.

The last function we'll look at is drawLogo:


static void drawLogo(void)
{
	int x, y;

	x = (SCREEN_WIDTH - (logo1->rect.w + logo2->rect.w)) / 2;
	y = 150;

	SDL_SetTextureAlphaMod(logo1->texture, logoAlpha);

	blitAtlasImage(logo1, x, y, 0, SDL_FLIP_NONE);
	blitAtlasImage(logo2, x + logo1->rect.w, y, 0, SDL_FLIP_NONE);

	SDL_SetTextureAlphaMod(logo1->texture, 255);
}

We want to center our logo. Because it's in two parts, we need to add the widths of both parts together before we work out the value of x. After that, we set the alpha value of the logo to the value of logoAlpha. We then draw both logo1 and logo2 at the calculated x position, logo2's horizontal position being the value of x, plus the width of logo1, to set it alongside. Finally, we reset the logo's texture's alpha to 255. Because this is basically the texture atlas, we need to ensure it doesn't remain dim when we start the game; the alpha is only relevant for this function.

That's our title sequence handled. Now we can look at the ending. It's a little more complicated, and lives in ending.c. There are several functions that detail in this file, starting with initEnding:


void initEnding(void)
{
	hasEyeball = hasInventoryItem("Eyeball");
	hasRedPotion = hasInventoryItem("Red potion");

	mins = dungeon.time / (FPS * FPS);
	secs = (int)(dungeon.time / FPS) % (int) FPS;

	displayTimer = FPS / 2;

	app.delegate.logic = logic;
	app.delegate.draw = draw;
}

To begin with, we're checking to see if the player exited the dungeon with the Eyeball and Red Potion. To do this, we call hasInventoryItem; just having found the items isn't enough, we want them to have been brought out with the player. Next, we calculate how long it took the player to complete the dungeon. For the minutes (mins), we're taking the dungeon time value and dividing it by FPS * FPS (so, 60 * 60, which would be the number of frames per minute). For the seconds (secs), we're dividing the dungeon time by the frames per second, then reducing it to the range of FPS, to give it a value of between 0 and 59. We're then setting up a variable called displayTimer, to a value of FPS / 2. Just like our title screen, we want a small transition when the ending starts. Finally, we're setting up the logic and draw function pointers.

We'll look at our logic step next. It's quite simple:


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

	if (displayTimer == 0)
	{
		if (app.keyboard[SDL_SCANCODE_SPACE] || app.keyboard[SDL_SCANCODE_RETURN] || app.keyboard[SDL_SCANCODE_ESCAPE])
		{
			exit(0);
		}
	}
}

We're decreasing the value of displayTimer, limiting it to 0. If the value is 0, we'll test if Space, Return, or Escape has been pressed, and then exit the game. We want to make sure the ending text has been shown before allowing the player to exit, so that they don't press the keys too soon.

Our draw function is somewhat similar:


static void draw(void)
{
	if (displayTimer == 0)
	{
		drawCongratulations();

		drawStats();
	}
}

We want to make sure that displayTimer is 0 before drawing anything, leaving the screen blank for that half a second if not. Our drawCongratulations function coms next. It's pretty simple:


static void drawCongratulations(void)
{
	char *congratulations;

	congratulations = "Congratulations! You've escaped the dungeon and bested the Dungeon Mistress at her stupid fetch quest game! She's not exactly pleased with you right now, so you'd best stay as far away from the place as possible.\n\nIn the great tradition of games from yesteryear, this ending screen is just a load of text. But here's some stats for you:";

	drawText(congratulations, SCREEN_WIDTH / 2, 50, 255, 255, 255, TEXT_ALIGN_CENTER, 1000);
}

We're just drawing the congratulations text, centered in the screen. Nothing special. Our drawStats function is a bit more complicated:


static void drawStats(void)
{
	char message[32];
	int x, y;
	Prisoner *p;
	SDL_Color c;

	p = (Prisoner*) player->data;

	x = SCREEN_WIDTH / 2;
	y = 400;

	setStatColour(p->gold, dungeon.numGold, &c);
	drawText("Gold :", x, y, c.r, c.g, c.b, TEXT_ALIGN_RIGHT, 0);
	sprintf(message, "%d / %d", p->gold, dungeon.numGold);
	drawText(message, x + 15, y, c.r, c.g, c.b, TEXT_ALIGN_LEFT, 0);
	y += 50;

	setStatColour(p->silverFound, dungeon.numSilver, &c);
	drawText("Silver :", x, y, c.r, c.g, c.b, TEXT_ALIGN_RIGHT, 0);
	sprintf(message, "%d / %d", p->silverFound, dungeon.numSilver);
	drawText(message, x + 15, y, c.r, c.g, c.b, TEXT_ALIGN_LEFT, 0);
	y += 50;

	setStatColour(hasEyeball, 1, &c);
	drawText("Got Eyeball :", x, y, c.r, c.g, c.b, TEXT_ALIGN_RIGHT, 0);
	drawText(hasEyeball ? "Yes" : "No", x + 15, y, c.r, c.g, c.b, TEXT_ALIGN_LEFT, 0);
	y += 50;

	setStatColour(hasRedPotion, 1, &c);
	drawText("Got Red Potion :", x, y, c.r, c.g, c.b, TEXT_ALIGN_RIGHT, 0);
	drawText(hasRedPotion ? "Yes" : "No", x + 15, y, c.r, c.g, c.b, TEXT_ALIGN_LEFT, 0);
	y += 100;

	drawText("Time :", x, y, 255, 255, 255, TEXT_ALIGN_RIGHT, 0);
	sprintf(message, "%dm %02ds", mins, secs);
	drawText(message, x + 15, y, 255, 255, 255, TEXT_ALIGN_LEFT, 0);
}

We want to draw the various stats: the gold we found, the silver, whether we got the eyeball and potion, and the time we took. For the gold and silver, we want to draw the amount that was found and the total amount that was in the dungeon (we'll detail this in a bit). For the eyeball and red potion, we want to know the value of hasEyeball and hasRedPotion, as determined in initEnding.

We want to draw the text in green or red, depending on whether we found all of the item in question. We do this by calling a function called setStatColour, and passing over the actual and expected values, along with a reference to an SDL_Color object. With the colour determined, we then draw the appropriate text - the number found against the number available for gold and silver, and Yes or No for the eyeball and red potion. Our time stat is simply the minutes and seconds, always displayed in white.

Note that we're aligning the text around the middle of the screen, but aligning the left-hand side to the right, and the right-hand side to the left.

Our setStatColour function is quite straightforward:


static void setStatColour(int actual, int expected, SDL_Color *c)
{
	c->b = 0;

	if (actual == expected)
	{
		c->r = 0;
		c->g = 255;
	}
	else
	{
		c->r = 255;
		c->g = 0;
	}
}

Taking the actual, expected, and SDL_Color arguments, we merely check to see if the actual and expected values match. If so, we set the value of SDL_Color to be green. Otherwise, we set it to be red.

Before moving on, we'll quickly detail the tweaks we've made to the Dungeon and Prisoner structs, in structs.h:


typedef struct {
	SDL_Point renderOffset;
	SDL_Point camera;
	Entity entityHead, *entityTail;
	MessageBox messageBoxHead, *messageBoxTail;
	Map map;
	unsigned long entityId;
	int numGold;
	int numSilver;
	int complete;
	double time;
} Dungeon;

Dungeon now holds numGold and numSilver variables (as well as complete and time, to handle the state and play time). numGold and numSilver are determined in gold.c and silver.c. For eample, in silver.c:


void initSilver(Entity *e)
{
	e->texture = getAtlasImage("gfx/entities/silverCoin.png", 1);

	e->touch = touch;

	dungeon.numSilver++;
}

Whenever, we add silver coin, we'll increment Dungeon's numSilver by 1. For gold, we do a similar thing, but using the value of the gold.

We've also added a new variable to Prisoner - silverFound:


typedef struct {
	int gold;
	int silver;
	int silverFound;
	Entity *inventorySlots[NUM_INVENTORY_SLOTS];
	int hasLantern;
	int hasDagger;
	SDL_Color mbColor;
} Prisoner;

This is important for the ending stats, as we can't use the silver variable. Since we give the silver coins to the Blacksmith, our value decreases. It would therefore be impossible for us to find all the silver, since our silver value would always be less than the total in the dungeon. As such, when we collect a silver coin, we increment both the silver and silverFound variables, and test the silverFound variable at the end.

Something we also added in this final part is a set of stairs, that act as the exit point for the dungeon. They are defined in stairs.c. Our initStairs function merely grabs the appropriate texture and sets the touch function:


void initStairs(Entity *e)
{
	e->texture = getAtlasImage("gfx/entities/stairs.png", 1);

	e->touch = touch;
}

The touch function itself simply tests to see if the player is the thing that touched the stairs, and sets the dungeon's complete flag to 1 (true) if so:


static void touch(Entity *self, Entity *other)
{
	if (other == player)
	{
		dungeon.complete = 1;
	}
}

With that in place, we needed to only update the logic function in dungeon.c:


static void logic(void)
{
	dungeon.time += app.deltaTime;

	if (dungeon.messageBoxHead.next == NULL)
	{
		doPlayer();
	}
	else
	{
		doMessageBox();
	}

	doEntities();

	doHud();

	if (dungeon.complete)
	{
		initEnding();
	}
}

If the dungeon's complete flag is 1 (true), then we call initEnding, to finish the game. Otherwise, the main game will proceed as normal.

Before wrapping up, we'll look at how we're using sound and music. Loading sound and music was covered in the Shooter tutorial, so we'll only talk about it briefly here. For example, we've updated the movePlayer function in player.c to play a sound (playSound) when the Prisoner moves:


static void movePlayer(int dx, int dy)
{
	int x, y;
	Entity *e;

	x = player->x + dx;
	y = player->y + dy;

	x = MAX(0, MIN(x, MAP_WIDTH - 1));
	y = MAX(0, MIN(y, MAP_HEIGHT - 1));

	if (dungeon.map.data[x][y] >= TILE_GROUND && dungeon.map.data[x][y] < TILE_WALL)
	{
		e = getEntityAt(x, y);

		if (e == NULL || e->solid == SOLID_NON_SOLID || e == player)
		{
			player->x = x;
			player->y = y;

			dungeon.camera.x = x;
			dungeon.camera.x -= (MAP_RENDER_WIDTH / 2);
			dungeon.camera.x = MIN(MAX(dungeon.camera.x, 0), MAP_WIDTH - MAP_RENDER_WIDTH);

			dungeon.camera.y = y;
			dungeon.camera.y -= (MAP_RENDER_HEIGHT / 2);
			dungeon.camera.y = MIN(MAX(dungeon.camera.y, 0), MAP_HEIGHT - MAP_RENDER_HEIGHT);

			moveDelay = 5;

			if (dx != 0 || dy != 0)
			{
				playSound(SND_WALK, 0);
			}
		}

		if (e != NULL && e->touch != NULL)
		{
			e->touch(e, player);
		}

		updateFogOfWar(player, VIS_DISTANCE);
	}
}

Notice how we're only calling playSound if the dx and dy are not both 0. This is to ensure that the sound only plays if the Prison actually moves. At the start of the game, we center the camera over the prisoner by calling movePlayer and passing in 0, 0. In this case, we don't want a sound to play.

Another place we play sounds is when we pick up an item. In item.c, we've added a playSound to the touch function:


static void touch(Entity *self, Entity *other)
{
	char message[64];

	if (other == player)
	{
		if (addToInventory(self))
		{
			memset(message, 0, sizeof(message));

			sprintf(message, "Picked up %s", self->name);

			setInfoMessage(message);

			playSound(SND_ITEM, 1);
		}
		else
		{
			setInfoMessage("Can't carry anything else.");
		}
	}
}

There are many other instances of where we play sounds, but again we won't cover them all here. For loading our sound and music, we've updated our initGameSystem function in init.c:


void initGameSystem(void)
{
	initAtlas();

	initFonts();

	initEntityFactory();

	initSound();

	loadMusic("music/A tricky puzzle_1.ogg");

	playMusic(1);
}

initSound, loadMusic, and playMusic are all defined in sound.c. initSound will load all the sounds we want to use, while loadMusic will load the music. We play the music by calling loadMusic, passing in 1 to tell the music track to loop forever.

The very last thing we want to do is show the title screen when the game starts. We'll do so in main, in main.c:


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

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

	initSDL();

	initGameSystem();

	initTitle();

	atexit(cleanup);

	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;
}

Here, instead of calling initDungeon, we've replaced it with a call to initTitle. So, when the game starts, we'll load our sound and music, and show the title screen.

And there you have it - a simple dungeon adventure game, featuring a load of quirky characters and some hidden items to find! It's not a long game, just 10 minutes to finish if you know what you're doing. But what we've done here is created a basis for making a much larger game, supporting multiple dungeons and things to do. There are a few things we'd want to do better in such a case, like putting in place a scripting system for our NPCs, so that their interactions with the player aren't hardcoded. For this game, however, it'll do.

Purchase

The source code for all parts of this tutorial (including assets) is available here:

It is also available as part of the SDL2 tutorial bundle (with on-going updates):

If you do not wish to create an itch.io account, you can also purchase the tutorial bundle using PayPal. This method will be slower, however, as it will require manual verification of the transaction.

Comments

Share your comments and thoughts below. All comments are anonymous and cannot be edited.

 

Mobile site