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

— Simple 2D adventure game —
Part 11: The Cursed Maze

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

Introduction

We can now add the second magical Icon to find. This one will be located in the so-called Cursed Maze, an area of the game that doesn't light up normally and will require a lantern. Two new characters have been added to the start area: the Merchant and the Dungeon Mistress. The Merchant will sell the player the lantern, in exchange for another item, while the haughty Dungeon Mistress will be the one to whom the player returns the Icons they've found.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./adventure11 to run the code. The usual controls apply. The goal is to fetch the Envelope Icon from the Cursed Maze. Two keys now exist in the maze, along with two chests. One of the chests contains the item the Merchant desires. Fetch the item and take it to the Merchant, then make your way to the Cursed Maze. Navigate the maze to find the Icon, and then return it to the Dungeon Mistress. Close the window to exit.

Inspecting the code

As already stated, we've added into two new characters, plus an area where our normal fog of war rules don't apply. We'll start by looking at the Merchant. He operates much the same way as the Goblin does, and he has his own definition in structs.h:


typedef struct {
	int state;
	int itemId;
	Entity *item;
} Merchant;

Just like the Goblin, we've got a variable to hold the Merchant's state, which will be used to control how he responds to the player. We've also added in an itemId and item entity pointer. You'll likely recognise this as the same way the Chest works. The Merchant will hold the item he will exchange with you. The Merchant himself is defined in merchant.c. We'll start, as always, with the init function:


void initMerchant(Entity *e)
{
	Merchant *merchant;
	merchant = malloc(sizeof(Merchant));
	memset(merchant, 0, sizeof(Merchant));

	STRCPY(e->name, "Merchant");
	e->texture = getAtlasImage("gfx/entities/merchant.png", 1);
	e->solid = SOLID_SOLID;
	e->data = merchant;

	e->touch = touch;
	e->load = load;

	mbColor.r = 16;
	mbColor.g = 32;
	mbColor.b = 64;
}

Nothing we've not seen before. We're mallocing the Merchant data, setting the name, texture, solid state, and data, and the touch and load function pointers. We're also setting an SDL_Color variable called mbColor (message box colour) to a light blue. This will be the colour of the merchant's message box.

The touch function is where the Merchant's logic lives. If you've read the Goblin tutorial, it should look quite familiar:


static void touch(Entity *self, Entity *other)
{
	Merchant *m;

	if (other == player)
	{
		self->facing = (other->x > self->x) ? FACING_RIGHT : FACING_LEFT;

		m = (Merchant*) self->data;

		switch (m->state)
		{
			case STATE_INIT:
				addMessageBox("Merchant", "Hey, I hear you've been tasked with finding all those magical icons. I might have something that will help you.", mbColor.r, mbColor.g, mbColor.b);
				addMessageBox("Prisoner", "A map?", 64, 64, 64);
				addMessageBox("Merchant", "Noooooo, don't be silly. A lantern, to help you find your way through the crushing darkness of The Cursed Maze, over in east.", mbColor.r, mbColor.g, mbColor.b);
				addMessageBox("Prisoner", "How much gold will it cost me?", 64, 64, 64);
				addMessageBox("Merchant", "Gold? I don't want gold, I've got plenty of that already. What I want is a copy of Fleetwood Mac's Rumours album, on vinyl. I heard there's a copy somewhere in this dungeon.", mbColor.r, mbColor.g, mbColor.b);
				addMessageBox("Prisoner", "Would a 192kbs MP3 do?", 64, 64, 64);
				addMessageBox("Merchant", "...", mbColor.r, mbColor.g, mbColor.b);
				addMessageBox("Prisoner", "Sorry, that was in poor taste.", 64, 64, 64);

				m->state = STATE_WANT_ALBUM;
				break;

			case STATE_WANT_ALBUM:
				if (hasInventoryItem("Rumours"))
				{
					addMessageBox("Prisoner", "I found this in the store room.", 64, 64, 64);
					addMessageBox("Merchant", "No way! That's awesome. I've got the album on CD, but vinyl just sounds better, you know.", mbColor.r, mbColor.g, mbColor.b);
					addMessageBox("Prisoner", "That's not actually true, it's ...", 64, 64, 64);
					addMessageBox("Merchant", "VINYL. SOUNDS. BETTER. Nick Cage said so, in The Rock.", mbColor.r, mbColor.g, mbColor.b);
					addMessageBox("Prisoner", "Actually a decent Michael Bay film, that.", 64, 64, 64);
					addMessageBox("Merchant", "Agreed. Anyway, here's the lantern I promised you.", mbColor.r, mbColor.g, mbColor.b);
					addMessageBox("Prisoner", "What the heck?!", 64, 64, 64);
					addMessageBox("Merchant", "Okay, it's a Jack-o'-lantern, but it still works.", mbColor.r, mbColor.g, mbColor.b);
					addMessageBox("Prisoner", "Right ...", 64, 64, 64);

					removeInventoryItem("Rumours");

					addToInventory(m->item);

					m->item = NULL;

					m->state = STATE_HAS_ALBUM;
				}
				else
				{
					addMessageBox("Merchant", "Let me know if you find that Rumours record, will you?", mbColor.r, mbColor.g, mbColor.b);
				}
				break;

			case STATE_HAS_ALBUM:
				addMessageBox("Merchant", "Can't wait to get home tonight and whack this bad boy on my turntable.", mbColor.r, mbColor.g, mbColor.b);
				break;

			default:
				break;
		}
	}
}

When the player touches the Merchant, the Merchant will face the character, and then react according to his state. In STATE_INIT, a conversation will be had, and the Merchant will then move into STATE_WANT_ALBUM. In STATE_WANT_ALBUM, we'll check to see if the player has Rumours item in their inventory. If so, another conversation will be had, Rumours will be removed from the player's inventory, and the Merchant's item will be placed in the player's inventory. We'll null the Merchant's item and then set the Merchant's state to STATE_HAS_ALBUM. Nulling the Merchant's item has no real effect, as it is never checked in our logic. It's good practice. If Rumours doesn't exist in the player's inventory, the Merchant will prompt the player to find it. Finally, in the STATE_HAS_ALBUM state, the Merchant will respond with a single message.

A lot of talking, and some item exchange logic. Pretty simple. The Merchant's load function is equally simple:


static void load(Entity *e, cJSON *root)
{
	Merchant *merchant;

	merchant = (Merchant*) e->data;

	merchant->itemId = cJSON_GetObjectItem(root, "itemId")->valueint;
}

It's basically the same as the Chest, except that we're using a Merchant instead of a Chest.

We'll stick with the game flow around the Merchant for now, and look at the updates made to the Prison and the fog of war. In order to work with darkness of the Cursed Maze, we need to make some logic changes. Starting with the Prisoner struct:


typedef struct {
	int gold;
	Entity *inventorySlots[NUM_INVENTORY_SLOTS];
	int hasLantern;
} Prisoner;

We've added in a variable called hasLantern. By default, this will be 0, indicating that we don't have a lantern in our inventory. The reason we're doing this will become clear in a moment. For now, let's look at how we set this variable. Turning to inventory.c, we've made a change to addToInventory:


int addToInventory(Entity *e)
{
	Prisoner *p;
	int i;

	p = (Prisoner*) player->data;

	for (i = 0 ; i < NUM_INVENTORY_SLOTS ; i++)
	{
		if (p->inventorySlots[i] == NULL)
		{
			p->inventorySlots[i] = e;

			removeEntityFromDungeon(e);

			updatePrisonerAttributes(p);

			return 1;
		}
	}

	return 0;
}

We've added a call to a new function called updatePrisonerAttributes whenever we pick up an item, defined before:


static void updatePrisonerAttributes(Prisoner *p)
{
	int i;

	p->hasLantern = 0;

	for (i = 0 ; i < NUM_INVENTORY_SLOTS ; i++)
	{
		if (p->inventorySlots[i] != NULL)
		{
			if (strcmp(p->inventorySlots[i]->name, "Lantern") == 0)
			{
				p->hasLantern = 1;
			}
		}
	}
}

The idea behind this function is to test the items that exist in our inventory. By default, we're setting the Prisoner's hasLantern variable to 0. We're then looping through all the items in the inventory to see if the Lantern exists. If so, we're setting the hasLantern variable to 1. This same function is called by dropSelectedItem:


static void dropSelectedItem(void)
{
	Prisoner *p;
	Entity *e;

	p = (Prisoner*) player->data;

	if (p->inventorySlots[selectedSlot] != NULL)
	{
		e = p->inventorySlots[selectedSlot];

		e->x = player->x;
		e->y = player->y;

		addEntityToDungeon(e);

		p->inventorySlots[selectedSlot] = NULL;
	}

	updatePrisonerAttributes(p);
}

If we drop the Lantern, we want to make sure that we can no longer navigate the Cursed Maze, as we don't have the required item in our inventory. The reason we're setting a variable like this is so that each time we move, we don't loop through our inventory and test each object to see if it's the Lantern. Basically, we're just saving some execution time.

Now let's look at how we're dealing with the Cursed Maze. If you've entered it without the Lantern, you'll notice that it remains completely dark. The first thing we've done is update defs.h:


#define TILE_HOLE              0
#define TILE_GROUND            1
#define TILE_DARK              35
#define TILE_WALL              40

TILE_DARK is a new define. What we're saying here is that any tile index between 35 and 39 (inclusive) will be a dark tile. Our map data for the Cursed Maze has all its tiles (other than the walls) declared as index 35.

If we now look at fogOfWar.c, we can see how this is being applied in the hasLOS function:


static int hasLOS(Entity *src, int x2, int y2)
{
	int x1, y1, dx, dy, sx, sy, err, e2;
	Prisoner *prisoner;

	x1 = src->x;
	y1 = src->y;

	dx = abs(x2 - x1);
	dy = abs(y2 - y1);

	sx = (x1 < x2) ? 1 : -1;
	sy = (y1 < y2) ? 1 : -1;
	err = dx - dy;

	if (src == player)
	{
		prisoner = (Prisoner*) src->data;
	}

	if (dungeon.map.data[x1][y1] >= TILE_DARK && dungeon.map.data[x1][y1] < TILE_WALL)
	{
		if (src != player || !prisoner->hasLantern)
		{
			return 0;
		}
	}

	while (1)
	{
		e2 = 2 * err;

		if (e2 > -dy)
		{
			err -= dy;
			x1 += sx;
		}

		if (e2 < dx)
		{
			err += dx;
			y1 += sy;
		}

		if (dungeon.map.data[x1][y1] >= TILE_DARK && dungeon.map.data[x1][y1] < TILE_WALL)
		{
			if (src != player || !prisoner->hasLantern)
			{
				return 0;
			}
		}

		if (x1 == x2 && y1 == y2)
		{
			return 1;
		}

		if (dungeon.map.data[x1][y1] >= TILE_WALL || visData[x1][y1].hasSolidEntity)
		{
			return 0;
		}
	}

	return 0;
}

The first thing to note is that we've changed the function parameters. Before, it took x1, y1, x2, y2 as it's arguments. Now it's taking the source entity, x2, y2. The purpose of this is so that we can test is the entity in question is the Prisoner, to test the presence of the Lantern. Before entering our while loop, we're now testing to see if the tile at x1, y1 falls within the dark tile range, if so, and the entity passed into the function is not the player, or it is the player and they don't have the lantern, we immediately return 0, to say that there is no line of sight. What this means is that our line of sight is blocked instantly if standing on a dark tile.

We repeat this check during our while loop, for each tile we come to during our line of sight test. This is important to ensure that we correctly block the line of sight, and that some of tiles are lit by mistake (such as the entrance to the maze).

That's all we need to do to block the line of sight - use a different set of tile indexes and test them during our fog of war update. We've also decorated the map's dark tiles, in the same way we do with the regular ground tiles:


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

	srand(144893);

	for (x = 0 ; x < MAP_WIDTH ; x++)
	{
		for (y = 0 ; y < MAP_HEIGHT ; y++)
		{
			if ((dungeon.map.data[x][y] == TILE_GROUND || dungeon.map.data[x][y] == TILE_DARK) && rand() % 5 == 0)
			{
				dungeon.map.data[x][y] += (1 + rand() % 4);
			}

			if (dungeon.map.data[x][y] == TILE_WALL)
			{
				dungeon.map.data[x][y] += rand() % 6;
			}
		}
	}
}

We're nearly done with this part of the update. Let's now look at the Dungeon Mistress. Starting with structs.h, we can see she's quite simple:


typedef struct {
	int iconsFound;
} DungeonMistress;

She merely has a variable to hold the number if icons that the player has found. We can see this in use in dungeonMistress.c. Before we get to that, we'll quickly look at the init function:


void initDungeonMistress(Entity *e)
{
	DungeonMistress *dungeonMistress;
	dungeonMistress = malloc(sizeof(DungeonMistress));
	memset(dungeonMistress, 0, sizeof(DungeonMistress));

	e->texture = getAtlasImage("gfx/entities/dungeonMistress.png", 1);
	e->solid = SOLID_SOLID;
	e->facing = FACING_RIGHT;
	e->data = dungeonMistress;

	e->touch = touch;

	mbColor.r = 64;
	mbColor.g = 0;
	mbColor.b = 64;
}

Just like the Goblin and Merchant, we're setting up the Dungeon Mistress's various pieces of entity data, touch, and message box. And once again, the touch function is where the main logic happens:


static void touch(Entity *self, Entity *other)
{
	DungeonMistress *d;

	if (other == player)
	{
		self->facing = (other->x > self->x) ? FACING_RIGHT : FACING_LEFT;

		d = (DungeonMistress*) self->data;

		if (hasInventoryItem("Icon"))
		{
			removeInventoryItem("Icon");

			d->iconsFound++;

			switch (d->iconsFound)
			{
				case 1:
					addMessageBox("Prisoner", "I got one of the icons!", 64, 64, 64);
					addMessageBox("Dungeon Mistress", "You found one? Beginner's luck, I guess. Well, don't expect the others to come so easily. I'll just take that from you ...", mbColor.r, mbColor.g, mbColor.b);
					break;

				case 2:
					addMessageBox("Prisoner", "Here you go ...", 64, 64, 64);
					addMessageBox("Dungeon Mistress", "Another one? No, you're cheating. This has got to be a fake. I'll have it checked ...", mbColor.r, mbColor.g, mbColor.b);
					break;

				default:
					break;
			}
		}
		else
		{
			switch (d->iconsFound)
			{
				case 0:
					addMessageBox("Dungeon Mistress", "Not found any yet? Aw, poor baby. Going to be here at while, aren't you? Heh heh heh!", mbColor.r, mbColor.g, mbColor.b);
					break;

				case 1:
					addMessageBox("Dungeon Mistress", "Don't get excited, hon. You've only found one icon so far.", mbColor.r, mbColor.g, mbColor.b);
					break;

				case 2:
					addMessageBox("Dungeon Mistress", "Halfway there, but you'll never find the rest. You'll starve to death down here. Ha ha ha!", mbColor.r, mbColor.g, mbColor.b);
					break;

				default:
					break;
			}
		}
	}
}

When the player walks into the Dungeon Mistress, she will turn to face them, and then check to see if the player is carrying an Icon. If so, the Icon is removed from the player's inventory and the Dungeon Mistress's iconsFound variable is incremented by 1. A conversation will then be displayed, based on the number of icons that have been found so far. If the player isn't carrying any Icons, the Dungeon Mistress will taunt the player with various other messages. Returning all the icons to the Dungeon Mistress is what will ultimately allow the prisoner to escape the dungeon.

We've now completed half of our little adventure game! Hurrah! There are just two more icons left to find, and two other challenges remaining to create. In the next part, we'll look at dealing with Vampire Bats, and introduce the Blacksmith character, as well as some other items to find and use.

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