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

Latest Updates

SDL2 turn-based strategy tutorial
Thu, 14th April 2022

Water Closet ported to PlayStation Vita
Tue, 4th January 2022

The Legend of Edgar 1.35
Sat, 1st January 2022

Achievements tutorial
Thu, 2nd December 2021

SDL2 Rogue tutorial
Thu, 30th September 2021

All Updates »

Tags

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

Books


The Red Road

For Joe Crosthwaite, surviving school was about to become more than just a case of passing his exams ...

Click here to learn more and read an extract!

« Back to tutorial listing

— A simple turn-based strategy game —
Part 21: Winning and losing

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

Introduction

Much of our game is done. Now what we need is to handle the post-game situation. Right now, one of three things can happen: the player wins, and keeps walking around the map; the ghosts win, and do likewise; or the mages and ghosts are both defeated at the same time, at which point the game will exit on it own. In this part, we're going to introduce a post-game screen, that will show the player a bunch of stats before they then exit the game.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./sdl2TBS21 to run the code. You will see a window open like the one above, showing three wizards in a maze-like map, as well as a number of ghosts. Play the game as normal. Upon all the ghosts being defeated or all the mages being killed, a game over panel will display, showing the stats from the game. At this point, clicking the left mouse button will exit the game. Otherwise, once you're finished, close the window to exit.

Inspecting the code

Adding in our win/lose screen is simple enough. There are a lot of things to touch on, however, as we add in various stat counting and other checks.

Let's go first to structs.h:


typedef struct {
	 // snipped

	struct {
		int numMages, totalMages;
		int numGhosts, totalGhosts;
		int rounds;
		int bulletsFired, bulletsHit;
		int numMoves;
		int numPancakes, totalPancakes;
		int numAmmo, totalAmmo;
		double timePlayed;
	} stats;
} Stage;

To the Stage struct, we've added in an anonymous struct called `stats`. This struct contains a number of fields: numMages is the number of mages still active in the game, while totalMages is the number starting number. numGhosts is the number of ghosts remaining, while totalGhost is the starting number. `rounds` is the number of rounds played by each team. bulletsFired is the number of shots the player has fired, while bulletsHit is the number of shots that hit their target (excluding attacking the world). numMoves is the number of squares moved by the mages. numPancakes is the number of pancakes consumed by the player, while totalPancakes is the total available at the start of the game. numAmmo is the number of magic crystals the player collected, while totalAmmo is the total number of magic crystals on the stage. Finally, timePlayed is the amount of time the game was played for, before it ended.

That's our stats done. We'll be making use of them throughout the remainder of this part. Let's now head over to stage.c, where the bulk of the update has happened. First, let's deal with initStage:


void initStage(void)
{
	// snipped

	stage.stats.rounds = 1;

	endTimer = FPS * 2;

	app.delegate.logic = logic;

	app.delegate.draw = draw;
}

We're first setting the `rounds` stat to 1, since this is the first round of the game. Next, we're setting a variable called endTimer to 2 seconds (FPS * 2). This is a countdown timer that will begin once either all the ghosts have been killed or all the mages have. Once it reaches 0, it will display the stats page. We're using this so that the end screen doesn't flash up immediately.

Now for the changes to `logic`. There have been a few tweaks here:


static void logic(void)
{
	int wasAnimating;

	wasAnimating = stage.animating;

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

		stage.animating = 1;
	}

	if (!stage.animating)
	{
		if (stage.turn == TURN_PLAYER)
		{
			doHud();

			doPlayer();
		}
		else
		{
			doAI();
		}
	}

	stage.stats.numMages = stage.stats.numGhosts = 0;

	doEntities();

	doUnits();

	doBullet();

	doEffects();

	doDamageText();

	clipCamera();

	doEndStage();

	stage.animating = stage.routeHead.next != NULL || stage.bullet.life > 0 || stage.effectHead.next != NULL || stage.damageText.life > 0;

	app.mouse.visible = !stage.animating && stage.turn == TURN_PLAYER;

	if (wasAnimating && !stage.animating)
	{
		updateUnitRanges();
	}
}

The first change is that we're now testing that there are mages and ghosts remaining in play (Stage's stat's numMages and numGhosts). If so, we'll increase the value of the timePlayed stat. Otherwise, we'll decrease the value of endTimer (and limit it to 0), and also set Stage's `animating` flag to 1. Doing this prevents any interactions from happening, either by the player or the AI.

Further down, we're setting the values of Stage's stat's numMages and numGhosts to 0. We're doing so here as we'll be taking a count of how many are active later on. In effect, we're reseting the count at the start of our gameplay loop and getting a finished count at the end of it.

Another new addition is the call to doEndStage. We'll look at this function now:


static void doEndStage(void)
{
	if (endTimer == 0 && app.mouse.buttons[SDL_BUTTON_LEFT])
	{
		exit(0);
	}
}

Not a lot happening here. We're testing whether endTimer is 0 and if the left mouse button has been pressed. If so, we're calling exit, to exit the game. If we wanted to do some extra stuff, like saving the game or the stats, we could do so in this function, to keep things tidy.

Moving onto `draw` now:


static void draw(void)
{
	// snipped

	if (stage.turn == TURN_PLAYER && endTimer > 0 && stage.stats.numMages > 0)
	{
		drawHud();
	}

	if (endTimer == 0)
	{
		drawEndStage();
	}
}

Before drawing the HUD, we're now testing whether endTimer is greater than 0 and also if there are mages still left alive in the game. This helps to ensure the HUD is not rendered along with the post-game screen, as this can look a bit messy and distracting.

We're also testing whether endTimer is 0, and calling drawEndStage if so.

The drawEndStage function is where we render our post-game screen:


static void drawEndStage(void)
{
	int y, mins, secs;
	SDL_Rect r;
	char text[MAX_DESCRIPTION_LENGTH];

	r.w = SCREEN_WIDTH * 0.4;
	r.h = SCREEN_HEIGHT * 0.75;
	r.x = (SCREEN_WIDTH - r.w) / 2;
	r.y = (SCREEN_HEIGHT - r.h) / 2;

	drawRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, 0, 0, 0, 128);

	drawRect(r.x, r.y, r.w, r.h, 0, 0, 0, 128);

	drawOutlineRect(r.x, r.y, r.w, r.h, 160, 160, 160, 255);

	app.fontScale = 2;

	if (stage.stats.numMages > 0)
	{
		drawText("VICTORY", SCREEN_WIDTH / 2, r.y + 10, 120, 255, 120, TEXT_ALIGN_CENTER, 0);
	}
	else
	{
		drawText("DEFEAT", SCREEN_WIDTH / 2, r.y + 10, 255, 120, 120, TEXT_ALIGN_CENTER, 0);
	}

	app.fontScale = 1;

	y = 110;
	sprintf(text, "%d / %d", stage.stats.totalMages - stage.stats.numMages, stage.stats.totalMages);
	drawText("Mages lost", r.x + 20, r.y + y, 255, 255, 255, TEXT_ALIGN_LEFT, 0);
	drawText(text, r.x + r.w - 20, r.y + y, 255, 255, 255, TEXT_ALIGN_RIGHT, 0);

	y += 50;
	sprintf(text, "%d / %d", stage.stats.totalGhosts - stage.stats.numGhosts, stage.stats.totalGhosts);
	drawText("Ghosts defeated", r.x + 20, r.y + y, 255, 255, 255, TEXT_ALIGN_LEFT, 0);
	drawText(text, r.x + r.w - 20, r.y + y, 255, 255, 255, TEXT_ALIGN_RIGHT, 0);

	y += 50;
	sprintf(text, "%d", stage.stats.rounds);
	drawText("Total rounds", r.x + 20, r.y + y, 255, 255, 255, TEXT_ALIGN_LEFT, 0);
	drawText(text, r.x + r.w - 20, r.y + y, 255, 255, 255, TEXT_ALIGN_RIGHT, 0);

	y += 50;
	sprintf(text, "%d", stage.stats.numMoves);
	drawText("Moves made", r.x + 20, r.y + y, 255, 255, 255, TEXT_ALIGN_LEFT, 0);
	drawText(text, r.x + r.w - 20, r.y + y, 255, 255, 255, TEXT_ALIGN_RIGHT, 0);

	y += 50;
	sprintf(text, "%d / %d", stage.stats.bulletsHit, stage.stats.bulletsFired);
	drawText("Shots hit / fired", r.x + 20, r.y + y, 255, 255, 255, TEXT_ALIGN_LEFT, 0);
	drawText(text, r.x + r.w - 20, r.y + y, 255, 255, 255, TEXT_ALIGN_RIGHT, 0);

	y += 50;
	sprintf(text, "%d / %d", stage.stats.numPancakes, stage.stats.totalPancakes);
	drawText("Pancakes eaten", r.x + 20, r.y + y, 255, 255, 255, TEXT_ALIGN_LEFT, 0);
	drawText(text, r.x + r.w - 20, r.y + y, 255, 255, 255, TEXT_ALIGN_RIGHT, 0);

	y += 50;
	sprintf(text, "%d / %d", stage.stats.numAmmo, stage.stats.totalAmmo);
	drawText("Ammo collected", r.x + 20, r.y + y, 255, 255, 255, TEXT_ALIGN_LEFT, 0);
	drawText(text, r.x + r.w - 20, r.y + y, 255, 255, 255, TEXT_ALIGN_RIGHT, 0);

	secs = ((int) stage.stats.timePlayed) / FPS;
	mins = (secs / 60);
	secs %= 60;

	y = r.h - 50;
	sprintf(text, "%02d:%02d", mins, secs);
	drawText("Time Played", r.x + 20, r.y + y, 255, 255, 255, TEXT_ALIGN_LEFT, 0);
	drawText(text, r.x + r.w - 20, r.y + y, 255, 255, 255, TEXT_ALIGN_RIGHT, 0);
}

To begin with, we're setting up an SDL_Rect called `r` to hold the dimensions of our stats panel, and are then darkening the screen with a call to drawRect, passing over the entire dimensions of the screen. Another call to rectRect follows, this time using the `x`, `y`, `w`, and `h` values of `r`. Finally, we're calling drawOutlineRect, to draw a grey outline rectangle. We now have a dark screen, with a darkened panel with a white grey outline.

Next, we're testing the value of stat's numMages. If it's greater than 0, we were victorious. We're therefore rendering some text with the word "VICTORY" in green. Otherwise, we're rendering "DEFEAT" in red.

With our header setup, we're then rendering each stat one at a time. We're first setting a variable called `y` to 110, which we'll increase by 50 before drawing each subsequent stat, it they appear on a different line. In order, we're then rendering the data for our mages, ghosts, rounds, moves made, shots fire and hit, pancakes eaten, and ammo collected. For our mages and ghosts, we're substracting the current count (numMages, numGhosts) from the total count (totalNumMages, totalNumGhosts) to get the number defeated in battle.

Lastly, we're rendering our time stat. We're taking the value of stat's timePlayed and dividing it by FPS, to get the total number of seconds elapsed, and assigning this to `secs`. Next, we're dividing `secs` by 60, to get the total number of minutes played, and assigning this to a variable called `mins`. With that done, we're taking the modulo of 60 against `secs`, to get the number of seconds according to our time calculation. We're then using `mins` and `secs` in our Time Played stat display.

Phew! That was a lot. But we're done with the majority of the work now. We just need to update one more function - endTurn:


void endTurn(void)
{
	stage.turn = !stage.turn;

	stage.showRange = SHOW_RANGE_NONE;

	resetUnits();

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

Now, when we flip the turn and it is once again the player's go (TURN_PLAYER), we're incrementing our `rounds` stat. We consider it a new round upon the start of the player's go.

That's stage.c done with. We can now turn our attention to the rest of the code. What follows will mostly be little updates and tweaks, as we incorporate the stat tracking.

Turning to bullets.c, we've updated two functions. First, 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++;
		}

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

When a bullet hits, we're testing if it's the player's turn (we're assuming the player cannot attack during the AI's turn) and also if the target is not the world (ET_WORLD), and then incrementing our bulletsHit stat.

The fireBullet function has seen a similar update:


void fireBullet(void)
{
	// snipped

	u->ap--;

	u->weapon.ammo--;

	ensureOnScreen(stage.targetEntity);

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

Once again, if it's the player's turn, we're incrementing the bulletsFired stat.

Now onto items.c. We've updated all the `init` and `touch` functions for our two item types:


void initHealth(Entity *e)
{
	initItem(e, "Pancakes", "gfx/items/health.png");

	e->touch = healthTouch;

	stage.stats.totalPancakes++;
}

In initHealth, we're incrementing totalPancakes, to get a count of how many stacks of tasty, tasty pancakes exist on the stage.

We've also updated healthTouch:


static void healthTouch(Entity *self, Entity *other)
{
	Unit *u;

	if (other->type == ET_MAGE)
	{
		u = (Unit*) other->data;

		if (u->hp < u->maxHP)
		{
			u->hp = MIN(u->hp + 10, u->maxHP);

			self->dead = 1;

			addHudMessage(160, 200, 255, "Restored 10 HP.");

			stage.stats.numPancakes++;
		}
	}
}

Whenever those lovely pancakes are eaten, we're incrementing the value of numPancakes.

Similarly, initAmmo and ammoTouch has been changed:


void initAmmo(Entity *e)
{
	initItem(e, "Magic Crystal", "gfx/items/ammo.png");

	e->touch = ammoTouch;

	stage.stats.totalAmmo++;
}

We're increasing the value of totalAmmo upon creating some ammo.


static void ammoTouch(Entity *self, Entity *other)
{
	Unit *u;

	if (other->type == ET_MAGE)
	{
		u = (Unit*) other->data;

		if (u->weapon.ammo < u->weapon.maxAmmo)
		{
			u->weapon.ammo = MIN(u->weapon.ammo + 10, u->weapon.maxAmmo);

			self->dead = 1;

			addHudMessage(160, 200, 255, "Restored 10 magic ammo.");

			stage.stats.numAmmo++;
		}
	}
}

In ammoTouch, we're increasing the value of numAmmo whenever ammo is picked up.

Over in player.c, we've made a small update to addPlayerUnits:


static void addPlayerUnits(void)
{
	Entity *e;
	int i, mx, my, x, y, ok;
	char *names[] = {"Andy", "Danny", "Izzy"};

	do
	{
		mx = rand() % MAP_WIDTH;
		my = rand() % MAP_HEIGHT;
	}
	while (!isGround(mx, my));

	stage.stats.totalMages = NUM_PLAYER_UNITS;

	// snipped
}

We're setting our totalMages stat to NUM_PLAYER_UNITS (found in player.h).

ai.c has seen a small update of its own:


static void addAIUnits(void)
{
	Entity *e;
	Unit *u;
	int i, x, y, ok, r;
	char *ghostTypes[] = {"White Ghost", "Lilac Ghost", "Blue Ghost", "Red Ghost", "Green Ghost"};

	stage.stats.totalGhosts = 3 + rand() % 10;

	for (i = 0 ; i < stage.stats.totalGhosts ; i++)
	{
		r = rand() % 5;

		e = initEntity(ghostTypes[r]);

		// snipped
	}
}

We're now randomly filling the stage with ghosts!

We're setting up a char array called ghostTypes, into which we're adding all the names of our ghost types. Next, we're setting our totalGhosts to a random between 3 and 12. This will be the total number of ghosts found on the stage. We're then using a for-loop to create that many ghosts. For each iteration of the loop, we're generating a random number between 0 and 4, and assigning it to a variable called `r`. We're then using this as the index in our ghostTypes array, that we're passing over to initEntity.

So, each time we play a level, it will have a unique layout, and a random selection of ghosts to go along with it.

Heading to units.c now, we've updated `move`:


static void move(void)
{
	Node *n;

	moveTimer -= app.deltaTime;

	if (moveTimer <= 0)
	{
		// snipped

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

Now, whenver a unit moves, we're testing to see if it's the player's turn. If so, we're incrementing the numMoves stat. There are actually two different ways we could do this - incrementing the stat upon each square moved or updating it when the movement phase has finished. I opted here to update it for each square moved, just to make the stats screen look a bit more interesting; the shots fired and hit stats might look very similar to it otherwise.

And finally, we come to the most important update - the `tick` function:


static void tick(Entity *self)
{
	Unit *u;

	u = (Unit*) self->data;

	u->shudder = MAX(u->shudder - (0.35 * app.deltaTime), 0);

	switch (self->type)
	{
		case ET_MAGE:
			stage.stats.numMages++;
			break;

		case ET_GHOST:
			stage.stats.numGhosts++;
			break;

		default:
			break;
	}
}

Upon each call, we're testing the type of entity that is calling `tick`. If it's a mage (ET_MAGE), we're increasing the numMages stat. If it's ET_GHOST, we're increasing the numGhosts stat. As you can now see, we're resetting both these stats to 0 at the start of our gameplay loop, but then incrementing them in this `tick` function. This constant recounting allows us to keep our stats perfectly in sync, without the hassle of any micromanagement. In essence, this tick function is the most important one in the game, as it governs whether the game has been won or lost..!

Hurrah! Another part down, three to go.

How are you finding the camera? It's good, right? But it could be better. The flicking back and forth when the ghosts move can be a little bit disorientating. It would be nice if we could see everything in context. In the next part, we're going to investigate making the camera move smoothly around the battlefield, so we can clearly see where everything lies, relative to each other.

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:

Directly

If you do not wish to create an itch.io account, you can also purchase the tutorial bundle using PayPal, and then download the tutorials directly from the main tutorials page.

SDL2_Tutorials.tar.gz 56.76MB 23rd April 2022

Click here to see the list of files in the archive

Mobile site