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


The Third Side (Battle for the Solar System, #2)

The White Knights have had their wings clipped. Shot down and stranded on a planet in independent space, the five pilots find themselves sitting directly in the path of the Pandoran war machine as it prepares to advance The Mission. But if they can somehow survive and find a way home, they might just discover something far more worrisome than that which destroyed an empire.

Click here to learn more and read an extract!

« Back to tutorial listing

— A simple turn-based strategy game —
Part 24: Finishing touches

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

Introduction

In this final part, we're going to look at introducing sound effects and music, as well as some misc. features.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./sdl2TBS24 to run the code. There are now a handful of command line options available:

  • -seed <n>: specify the map seed to use (as n), to generate the same map again. For example: -seed 211584942
  • -sound <n>: specify the volume of the sound effects (as n), using a value between 0 and 10. For example: -sound 6
  • -music <n>: specify the volume of the music (as n), using a value between 0 and 10. For example: -music 3
When the game starts, the logging will output the seed that was used to generate the map, so that it can be used to regenerate the same level again. Play the game as normal. Once you're finished, close the window to exit.

Inspecting the code

As you will have seen (heard?), we've added in music and sound effects to the game in this final bit. We won't linger too long on where we've added all of these, as we'll be here all day..! However, we will mention some of the more interesting places we've handled sound.

Starting with defs.h:


enum {
	CH_MOVE,
	CH_ATTACK,
	CH_HIT_1,
	CH_HIT_2,
	CH_MISS,
	CH_DIE,
	CH_ITEM,
	CH_FINISH,
	CH_UI,
	CH_MAX
};

We've created an enum for our sound channels, to play certain sound types through.

Another set of enums has also been created for each sound effect:


enum {
	SND_MOVE,
	SND_MAGIC_ATTACK,
	SND_SLIME_ATTACK,
	SND_MAGIC_HIT,
	SND_SLIME_HIT,
	SND_MAGE_DIE,
	SND_GHOST_DIE,
	SND_ATTACK_HIT,
	SND_ATTACK_MISS,
	SND_HEALTH,
	SND_AMMO,
	SND_SLIME_POOL,
	SND_SLIME_DESTORY,
	SND_VICTORY,
	SND_DEFEAT,
	SND_COMMAND_BUTTON,
	SND_MAX
};

Quite a lot like the way we've done in all the previous tutorials, so we'll press on.

Moving over to structs.h now, we've made an update to the Stage struct:


typedef struct {
	unsigned int entityId;
	unsigned long seed;
	int isCustomMap;
	MapTile map[MAP_WIDTH][MAP_HEIGHT];
	Entity entityHead, *entityTail;

	// snipped

} Stage;

We've added in two new fields: `seed` and isCustomMap. `seed` will be the value of the random seed that was used to generate the stage. isCustomMap is a flag to say whether this seed was supplied by the player. This is mainly used for debugging and information purposes.

Now, let's look at how and where we're using some of our sound effects.

Starting with bullets.c, we've update an update to applyDamage:


static void applyDamage(Bullet *b)
{
	if (stage.targetEntity->type == ET_WORLD || rand() % 100 <= getAttackAccuracy(b->accuracy))
	{
		if (stage.turn == TURN_PLAYER && stage.targetEntity->type != ET_WORLD)
		{
			stage.stats.bulletsHit++;
		}

		stage.targetEntity->takeDamage(stage.targetEntity, b->damage);

		switch (b->type)
		{
			case WT_BLUE_MAGIC:
				addHitEffect(b->x, b->y, 0, 0, 255);
				playSound(SND_MAGIC_HIT, CH_HIT_2);
				break;

			case WT_RED_MAGIC:
				addHitEffect(b->x, b->y, 255, 0, 0);
				playSound(SND_MAGIC_HIT, CH_HIT_2);
				break;

			case WT_PURPLE_MAGIC:
				addHitEffect(b->x, b->y, 255, 0, 255);
				playSound(SND_MAGIC_HIT, CH_HIT_2);
				break;

			case WT_SLIME_BALL:
			case WT_SLIME_POOL:
				addHitEffect(b->x, b->y, 0, 255, 0);
				playSound(SND_SLIME_HIT, CH_HIT_2);
				break;

			default:
				break;
		}
	}
	else
	{
		addDamageText(MAP_TO_SCREEN(stage.targetEntity->x), MAP_TO_SCREEN(stage.targetEntity->y) - (MAP_TILE_SIZE / 2), "Miss");

		playSound(SND_ATTACK_MISS, CH_MISS);
	}
}

In our switch statement, we've added a call to playSound. When it comes to successful attacks, depending on the type of weapon we'll play a different sound. Our magic-type weapons will play SND_MAGIC_HIT, while our slime-based weapons will play SND_SLIME_HIT. If the attack misses, we'll play the SND_ATTACK_MISS sound effect.

We've done something similar in fireBullet:


void fireBullet(void)
{
	// snipped

	if (stage.turn == TURN_PLAYER)
	{
		stage.stats.bulletsFired++;
	}

	switch (b->type)
	{
		case WT_BLUE_MAGIC:
		case WT_RED_MAGIC:
		case WT_PURPLE_MAGIC:
			playSound(SND_MAGIC_ATTACK, CH_ATTACK);
			break;

		case WT_SLIME_BALL:
		case WT_SLIME_POOL:
			playSound(SND_SLIME_ATTACK, CH_ATTACK);
			break;

		default:
			break;
	}
}

After the bullet is created, we're testing the type of bullet (weapon) we're using, and playing the appropriate sound effect. Once again, magic and slime attacks will use different sounds.

If we had a lot of different weapons, playing a lot of different sounds, we would want to approach this another way, by adding a field to our Weapon struct to specify the sound effects played when a bullet is fired, and the one played when it strikes a target. We have just limited weapons here, so the current approach is fine.

Units themselves play a sound when they are hit, as we can see in units.c, in the takeDamage function:


static void takeDamage(Entity *self, int damage)
{
	Unit *u;
	int r, g, b;

	if (!self->dead)
	{
		u = (Unit*) self->data;

		u->hp -= damage;

		u->shudder = 10;

		playSound(SND_ATTACK_HIT, CH_HIT_1);

		addDamageText(MAP_TO_SCREEN(self->x), MAP_TO_SCREEN(self->y) - (MAP_TILE_SIZE / 2), "%d", damage);

		// snipped
	}
}

Now, when the damage is applied, we're calling playSound to issue a punch-like sound.

We've also done the same thing in player.c, in the worldTargetTakeDamage function:


static void worldTargetTakeDamage(Entity *self, int amount)
{
	if (isSlime(self->x, self->y))
	{
		playSound(SND_SLIME_DESTORY, CH_HIT_1);

		stage.map[self->x][self->y].tile = TILE_GROUND;

		stage.targetEntity = NULL;
	}
}

Now, we're playing a sound effect whenever we destroy a slime pool.

Yet again, we're doing the same in ai.c, when the Slimers (Red Ghosts) create their pools:


static void worldTargetTakeDamage(Entity *self, int amount)
{
	if (isGround(self->x, self->y))
	{
		stage.map[self->x][self->y].tile = TILE_SLIME;

		playSound(SND_SLIME_POOL, CH_HIT_1);
	}
}

After the tile is flipped from a ground tile to a slime tile, we're playing a sound effect.

One final place to look at for our sound and music handling is in stage.c, in the `logic` function:


static void logic(void)
{
	int wasAnimating;

	doCamera();

	if (!stage.camera.scrolling)
	{
		wasAnimating = stage.animating;

		if (stage.stats.numMages > 0 && stage.stats.numGhosts > 0)
		{
			stage.stats.timePlayed += app.deltaTime;
		}
		else
		{
			if (endTimer > 0 && endTimer - app.deltaTime <= 0)
			{
				stopMusic();

				if (stage.stats.numMages > 0)
				{
					playSound(SND_VICTORY, CH_FINISH);
				}
				else
				{
					playSound(SND_DEFEAT, CH_FINISH);
				}
			}

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

			stage.animating = 1;
		}

		// snipped
	}
}

When our game finishes, whether in a win or lose state, we're playing a sound effect. Something else we're doing is stopping the music from playing, as the battle is now at an end. We only want to play our sound effects once and also stop our music when the ending screen flashes up. To do this, we simply test the value of endTimer. If it's currently greater than 0, but will be falling to 0 or less once the value is deducted (using App's deltaTime), we know we're good to go. We first call stopMusic, to stop our music from playing, then check whether we want to play a victory or lose sound effect. If we still have some mages alive (Stage's stat's numMages), the player has been victorious. We'll therefore play the victory sound. Otherwise, we'll play the defeat sound effect. Since the value is endTimer is adjusted after this check, this piece of logic will only execute once.

That's enough about our sound effects and music. Let's look at the other new feature: the command line handling. Unlike the other tutorials in this series, we're not introducing a title screen or any widgets (although these could easily be introduced if one wanted). To that end, we're going to handle some options on the command line.

Turning first to main.c, we've updated the main function:


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

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

	initSDL();

	initGameSystem();

	handleCommandLine(argc, argv);

	initStage();

	// snipped

	return 0;
}

Before initStage, we're making a call to a new function named handleCommandLine:


static void handleCommandLine(int argc, char *argv[])
{
	int i, soundVolume, musicVolume, isCustomMap;
	unsigned int seed;

	soundVolume = 127;

	musicVolume = 64;

	seed = 0;

	isCustomMap = 0;

	for (i = 0 ; i < argc ; i++)
	{
		if (strcmp(argv[i], "-seed") == 0)
		{
			isCustomMap = 1;

			seed = atol(argv[++i]);
		}

		if (strcmp(argv[i], "-sound") == 0)
		{
			soundVolume = atoi(argv[++i]) * 12.7;
		}

		if (strcmp(argv[i], "-music") == 0)
		{
			musicVolume = atoi(argv[++i]) * 12.7;
		}
	}

	setSoundVolume(soundVolume);

	setMusicVolume(musicVolume);

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

	stage.isCustomMap = isCustomMap;

	stage.seed = seed;
}

This function is responsible for handling the command line options, and takes the arguments passed to `main` as its own parameters. The first thing we do is set some defaults. We set variables called soundVolume and musicVolume to 100 and 60, respectively. These will act as the default values of our sound and music volumes. Next, we're setting the value of a variable called `seed` to 0, and a variable called isCustomMap to 0. These will be the random seed for the regenerated stage and a flag to specify that we're attempting to create a user-specified map (you'll see that these two variable names align with those new fields in Stage).

With those variables setup, we then start to loop through our command line arguments. For each argument, we're testing the value. If it's "-seed", we'll be using the argument that follows as our `seed` value. We'll also be setting isCustomMap to 1. We do the same with "-sound" and "-music", setting soundVolume and musicVolume, respectively. Note that we're multiplying the passed in value (between 0 and 10) by 12.7. This is because sound and music volumes in SDL2 can range between 0 and 127.

With our arguments handled, we're calling setSoundVolume, passing over soundVolume; setMusicVolume, passing over musicVolume; and finally we're memsetting stage, and setting its isCustomMap and `seed` with the values in this function.

This now allows us to set our sound and music volumes from the command line, as well as the random seed for the map that we want to play.

Finally, let's look at map.c, where we've updated generateMapWorker to make use of this new logic:


static int generateMapWorker(void *p)
{
	int ok;

	SDL_Delay(250);

	if (!stage.isCustomMap)
	{
		stage.seed = time(NULL);
	}

	srand(stage.seed);

	do
	{
		doCellularAutomata();

		ok = verifyCapacity();

		if (!ok)
		{
			if (stage.isCustomMap)
			{
				SDL_LogMessage(SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_WARN, "Invalid map seed: %ld", stage.seed);

				stage.isCustomMap = 0;
			}

			stage.seed = rand() % MAP_RANDOM_SEED;

			srand(stage.seed);
		}

	} while (!ok);

	growSlime();

	decorate();

	mapGenDone();

	SDL_LogMessage(SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_INFO, "Stage seed: %ld", stage.seed);

	return 0;
}

Something to keep in mind is that not all seeds will generate valid maps. If a player has specified an invalid seed, we'll print a warning, and then generate a different map. If this was incorporated into a UI, we'd want a better way to inform the player that the map couldn't be generated. Now, onto the changes.

The first thing we're now doing is testing if Stage's isCustomMap flag is set. If not, we're going to set Stage's `seed` with a call to rand(), using MAP_RANDOM_SEED (defined as 2147483647). We'll then call srand, passing over Stage's `seed`. The map generation will proceed as normal, but we're now assigning the result of verifyCapacity to a variable named `ok`. We'll test this variable next, to see if we were able to generate a map. If not, we'll first check if the player specified a map, by checking if Stage's isCustomMap was set. If so, we'll print a warning that the map seed was invalid, and then clear the isCustomMap flag. We'll then reset Stage's `seed` with a fresh call to rand, using MAP_RANDOM_SEED, and set srand once again.

Once we've successfully generated our map, we'll be logging the seed that was used to generate the stage, via SDL_LogMessage.

So, in summary, if the player has specified a map seed, we'll attempt to use it for our map generation. If it doesn't work, we'll simply create a random map and print the seed value. If the player didn't specify a seed, we'll randomly generate a map from the very start, without any warnings.

And that's a wrap! Our little turn based strategy game is done. Well, sort of. To be fair, this is just the battle portion of the game. If we wanted to expand it into a fully fledged game, what we'd want to do is allow our wizards to level up, visit different zones, and face different monsters and challenges. Sort of like X-COM. However, this tutorial should still give you a window into how one might go about creating such a game. And there is much, much more than could be introduced, including new weapons, items, environmental elements, such as lighting, to make things easier or harder to hit, etc. The list is endless and only limited by your own imagination.

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