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

— Mission-based 2D shoot 'em up —
Part 25: Mission: Collect catnip

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

Introduction

It's time for our second mission, where we're going to introduce not only a new enemy, but also a bunch of new features. We'll allow access to an in-game menu, handle the player death, and also add a fancy bit where the player exits the stages in dramatic fashion after finishing the mission..!

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./shooter3-25 to run the code. You will see a window open displaying the intermission's planets screen. Select Satsuma, then enter the comms section to start the mission. Play the game as normal. You may repeat the mission as often as you like. Remember that you can change the fighter's damage, weapons, output, and health by editing game.c, if you wish to get ahead of things early. During gameplay you may also press Escape to access the settings menu. If you are killed, you will have the option to restart the game or return to the intermission section. Once you're finished, close the window to exit.

Inspecting the code

We've added the second mission in this part, which involves collection 1,000 catnip during a mission. Since we already had the code available to support this, we're going to add in a load more features, including the ability for the player to access in-game settings, and also to be killed during play. We're adding in a new enemy, too. As we've seen how to handle the setting menus (during our work on the intermission, we'll be taking a high level overview of these.

We'll start by looking at defs.h:


enum
{
	AI_WPN_NONE,
	AI_WPN_SINGLE,
	AI_WPN_DUAL
};

Our new enemy (the blue fighter) sports dual guns. We've therefore added AI_WPN_DUAL to support this.

We've added in a new MS enum next:


enum
{
	MS_INCOMPLETE,
	MS_COMPLETE,
	MS_FAILED
};

MS_FAILED will be used to denote that our mission has been failed. At this time it only happens upon the death of the player, but this will be expanded upon in later missions.

Okay, let's look at the new compilation unit, greebleDualFighter.c. This is where we'll find all the functions related to the new enemy. Many will look familiar, as they'll be quite similar to the first enemy. Starting with initGreebleDualFighter:


void initGreebleDualFighter(Entity *e)
{
	Fighter *f;

	f = malloc(sizeof(Fighter));
	memset(f, 0, sizeof(Fighter));
	f->health = f->maxHealth = 3;
	f->shield = f->maxShield = 1;
	f->ai.weaponType = AI_WPN_DUAL;
	f->ai.weaponReload = 14;
	f->ai.type = AI_NORMAL;
	f->ai.thinkTime = rand() % FPS;

	e->side = SIDE_GREEBLE;
	e->facing = FACING_RIGHT;
	e->data = f;
	e->texture = getAtlasImage("gfx/fighters/greebleDualFighter.png", 1);

	e->tick = tick;
	e->draw = draw;
	e->takeDamage = fighterTakeDamage;
	e->die = die;
	e->destroy = destroy;
}

As expected in this factory function, we're setting the fighter's attributes. Note that we're setting its ai's weaponType to AI_WPN_DUAL. Also note that it has a [weak] shield, meaning it will be a little more resilient to our attacks than its red cousin.

We'll skip the `tick`, `draw`, and `destroy` functions, as they are identical to those of greebleLightFighter, and look at the `die` function:


static void die(Entity *self)
{
	int x, y;

	playSound(SND_EXPLODE_1 + rand() % 4, CH_ANY);

	x = self->x + (self->texture->rect.w / 2);
	y = self->y + (self->texture->rect.h / 2);

	addExplosions(x, y, 25);

	addDebris(x, y, 14);

	dropCatnip(self->x, self->y, rand() % 75);

	if (rand() % 3 == 0)
	{
		dropHealth(self->x, self->y);
	}

	if (rand() % 4 == 0)
	{
		dropAmmo(self->x, self->y);
	}

	game.catnip += 10;

	self->dead = 1;

	game.stats.enemiesDestroyed++;

	updateObjective("ENEMY", 1);
	updateObjective("greebleDualFighter", 1);
}

This is mostly the same as the `die` function for greebleLightFighter, except that we're dropping a little more catnip, and are sending over "greebleDualFighter" to the updateObjective function. These two fighters make a good case for a common set of shared functions. If we were to introduce another fighter (maybe a green one, called greebleTripleFighter), we could have a compilation unit called greebleFighter.c that would provide all these functions in one place, that the sub fighters would then reference. These are the sort of things that tend to be refactored as a game evolves and opportunities such as this present themselves.

That's all for the fighter. It will behave like the original enemy, thanks to using the same AI routines. It will just be tougher to fight, thanks to its shield and weapons.

Now, let's look at the updates we've made to the existing code. Starting with scripts.c. We've updated doScript to support a couple of new commands:


void doScript(void)
{
	char		   *line, command[MAX_NAME_LENGTH], strParam[3][256];
	int             intParams[2];
	ScriptFunction *s;

	stage.scriptRunning = 0;

	updateTime();

	for (s = head.next; s != NULL; s = s->next)
	{
		if (s->running)
		{
			stage.scriptRunning = 1;

			s->delay = MAX(s->delay - app.deltaTime, 0);

			if (s->delay == 0)
			{
				// snipped

				else if (strcmp(command, "DELAY") == 0)
				{
					sscanf(line, "%*s %d", &intParams[0]);

					s->delay = intParams[0] * FPS;
				}
				else if (strcmp(command, "REMOVE_CATNIP") == 0)
				{
					sscanf(line, "%*s %d", &intParams[0]);

					game.catnip -= intParams[0];
				}
				else
				{
					SDL_LogMessage(SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_CRITICAL, "ERROR: Unrecognised script command '%s'\n", command);
					exit(1);
				}

				// snipped
			}
		}
	}
}

We're now supporting a command called "DELAY". This command is used in conjunction with the ScriptFunction's `delay` value. When reading the DELAY value, we're placing it into one of our new intParams indexes (via sscanf). intParams is an array of ints, added to allow us to read integer values. We'll then multiply it by FPS, so that our delays are resolved to the second (e.g., DELAY 3 means wait 3 seconds). The other new command is "REMOVE_CATNIP". This is called at the end of the Satsuma mission. After Leo has successfully finished the mission to collect 1,000 catnip, we're removing it from the player. It was never theirs to keep..! Once again, we'll read this into our intParams, and the remove the value from Game's catnip.

Had we only been updating the code for our new mission, we'd be stopping here. However, we've gone a step further with our presentation, that we'll now step into.

Firstly, we've updated player.c with tweaks to several of the functions. Remember that the player can now die. This needs to be supported. You might have also noticed that, upon successfully finishing the mission, the KIT-E now flies away, instead of the game simply pausing, and displaying the completed objectives. This is handled in player.c, as well as camera.c. We'll look at the changes to player.c first, starting with initPlayer:


void initPlayer(Entity *e)
{
	Fighter *f;

	// snipped

	e->takeDamage = fighterTakeDamage;
	e->die = die;
	e->destroy = destroyFighter;

	stage.player = e;

	missionCompleteBoost = 0;
}

We're setting the player's `die` pointer to the `die` function we've made (and will see in a bit). We're also setting the value of a control variable called missionCompleteBoost to 0. This controls the player leaving the stage, including the sound effect that plays, and the music jingle that sounds.

The updates to `tick` come next:


static void tick(Entity *self)
{
	Fighter *f;

	f = (Fighter *)self->data;

	if (stage.missionCompleteTimer > 0 || stage.status == MS_FAILED)
	{
		doPlayerControls(self, f);

		fighterTick(self, f);

		stage.ssx = -f->dx;
		stage.ssy = -f->dy;
	}
	else if (stage.status == MS_COMPLETE)
	{
		self->facing = FACING_RIGHT;

		if (!missionCompleteBoost && stage.player->x - stage.camera.x <= -200)
		{
			missionCompleteBoost = 1;

			playSound(SND_BOOST, CH_PLAYER);

			playSound(SND_WIN, CH_WIN);

			stopMusic();
		}

		if (missionCompleteBoost)
		{
			self->x += 20 * app.deltaTime;
		}
	}

	// snipped
}

Okay, so we've got two main pieces of logic here, contained in if-statements. The first if-stateemnt handles regular play, and will be called if the missionCompleteTimer is still counting down (it won't do so while the mission is incomplete) or if the mission is failed. The former will come into full use in later missions, when it is possible to fail a mission without being killed. In this clause, the player will control the KIT-E as normal.

If this isn't the case, and the mission is complete, we'll take control away from the player, and force the KIT-E to face right. Next, we'll test if the missionCompleteBoost flag is still 0, and if the player has moved off the left-hand side of the screen (again, we'll see in a bit how we're updating the camera tracking). If this is true, we'll update missionCompleteBoost to 1, play the boost and win jingle sounds, and stop the music playing. Finally, we'll check if missionCompleteBoost is set, and increase the player's `x` value, so they move across the screen.

So, in summary, if the mission is on-going or have been failed (and the player is still alive), we'll allow the player to control the fighter as normal. Otherwise, if the mission is now complete, we'll have the player fly away, in dramatic fashion.

Finally, we have our `die` function:


static void die(Entity *self)
{
	int x, y;

	playSound(SND_EXPLODE_1 + rand() % 4, CH_PLAYER);

	x = self->x + (self->texture->rect.w / 2);
	y = self->y + (self->texture->rect.h / 2);

	addExplosions(x, y, 25);

	addDebris(x, y, 65);

	self->dead = 1;

	stage.status = MS_FAILED;

	stopMusic();

	playSound(SND_FAILED, CH_FAILED);

	executeScriptFunction("MISSION_FAILED");
}

This looks very much like the `die` functions for the enemies, except that when the player is dead we'll stop playing the music, play a sound effect to reflect they have been killed (an ominous low hum), set Stage's `status` to MS_FAILED, and also call the "MISSION_FAILED" script function (if it exists). This allows us to react to the player's death. Maybe we want to do something story-related, like not actually kill the player, etc. Again, our scripting system makes the possibilities endless.

That's player.c done. We can now look at the changes to camera.c. There's only one function here, doCamera:


void doCamera(void)
{
	if (stage.player->dead)
	{
		stage.ssx *= 1 - (0.025 * app.deltaTime);
		stage.ssy *= 1 - (0.025 * app.deltaTime);

		stage.camera.x -= stage.ssx * app.deltaTime;
		stage.camera.y -= stage.ssy * app.deltaTime;
	}
	else if (stage.missionCompleteTimer > 0 || stage.status == MS_FAILED)
	{
		stage.camera.x = stage.player->x;
		stage.camera.y = stage.player->y;

		stage.camera.x -= SCREEN_WIDTH / 2;
		stage.camera.y -= SCREEN_HEIGHT / 2;
	}
	else if (stage.status == MS_COMPLETE)
	{
		stage.camera.x += 6 * app.deltaTime;

		stage.ssy *= 1 - (0.025 * app.deltaTime);
		stage.ssx = -4;
	}
}

The camera is no longer always centered on the player; we're now testing three different states. First, if the player is dead, we're going to make the camera slow to a halt, as though it was tracking, and suddenly lost its target. The next clause will centre the camera on the player as usual, if the mission is still in progress or has recently been failed. Finally, if the mission is complete, the camera will move to the right, while also adjusting Stage's `ssx` and `ssy`. This is seen before the player flies away from the stage (from left to right).

We can now move onto the changes made to stage.c. There have been quite a few changes here, due to us now supporting the player's death, and also the ability to access the in-game menu system, to change settings, etc. We'll be performing high level overviews of the widget stuff. So, starting with initStage:


void initStage(void)
{
	// snipped

	stopMusic();

	oldGame = game;

	// snipped

	endTransition();

	playMusic(1);
}

We're making a copy of the player's game. oldGame (static in stage.c) will contain all the data from the main Game object. We're doing this so that we can reset aspects of the player's game upon them restarting the mission (either manually or after their death). We want to preserve the amount of catnip, ammo, health, etc. they had upon starting the mission (from the intermission), so that we can reset it when they start over. If the player earned 400 catnip before then being killed, we're not going to let them keep that. Likewise, if they came into the fight with full ammo, and consumed half of it, we'll restore them to full ammo. Basically, they'll be reset to whatever they had before the mission started. We'll see this in use later.

Next up are the updates to `logic`:


static void logic(void)
{
	switch (show)
	{
		case SHOW_STAGE:
			if (stage.status == MS_INCOMPLETE)
			{
				if (app.keyboard[SDL_SCANCODE_ESCAPE])
				{
					app.keyboard[SDL_SCANCODE_ESCAPE] = 0;

					app.mouse.showCursor = 1;

					show = SHOW_MISSION_OPTIONS;
				}

				if (isControl(CONTROL_PAUSE))
				{
					clearControl(CONTROL_PAUSE);

					show = SHOW_PAUSE;
				}
			}

			if (!(stage.status == MS_FAILED && stage.missionCompleteTimer < MISSION_FAILED_STATUS_TIME))
			{
				doStage();
			}

			doStatus();
			break;

		case SHOW_PAUSE:
			if (isControl(CONTROL_PAUSE))
			{
				clearControl(CONTROL_PAUSE);

				show = SHOW_STAGE;
			}
			break;

		case SHOW_MISSION_OPTIONS:
			doStageOptions();
			break;

		case SHOW_OPTIONS:
			doOptions();
			break;

		default:
			break;
	}
}

We're now handling SHOW_STAGE, SHOW_PAUSE, SHOW_MISSION_OPTIONS, and SHOW_OPTIONS for our `show` value. When in SHOW_STAGE, the game will play as normal, allowing us to pause it or access the in-game menu. We'll be calling doStage and doStatus here, as before (some of our logic has moved into doStatus). When in SHOW_PAUSE, we'll allow the player to unpause the game. SHOW_MISSION_OPTIONS will call doStageOptions, while SHOW_OPTIONS will call doOptions (in options.c).

The updates to doStatus follow:


static void doStatus(void)
{
	switch (stage.status)
	{
		case MS_COMPLETE:
			if (stage.collectableHead.next == NULL && stage.hudMessageHead.next == NULL && !stage.scriptRunning && stage.messageBoxHead.next == NULL)
			{
				stage.missionCompleteTimer -= app.deltaTime;

				if (stage.missionCompleteTimer <= MISSION_COMPLETE_STATUS_TIME && isControl(CONTROL_PAUSE))
				{
					clearControl(CONTROL_PAUSE);

					completeStage();
				}
			}
			break;

		case MS_FAILED:
			stage.missionCompleteTimer -= app.deltaTime;

			if (stage.missionCompleteTimer <= MISSION_FAILED_STATUS_TIME)
			{
				fadeValue = MIN(fadeValue + app.deltaTime, 160);

				app.mouse.showCursor = 1;

				doWidgets("missionFailed");
			}
			break;

		default:
			break;
	}
}

We've made an update to how we're handling MS_COMPLETE. We're now waiting until we no longer have any collectables floating around, have no hud messages displaying, do not have a script running, and aren't showing a message box before we decrease the value of Stage's missionCompleteTimer. Basically, we don't want the mission to end while we're in a state where there are items of interest to the player.

The MS_FAILED case is a new one. Unlike MS_COMPLETE, we'll always decrease the value of missionCompleteTimer. Once it falls to MISSION_FAILED_STATUS_TIME or less, we'll increase the value of fadeValue, show the mouse pointer, and process our "missionFailed" widgets. Along with the updates to `logic`, this means that the game will pause and the screen darken a few moments after we fail the mission (including when the player is killed).

Okay, let's look at the rendering updates. Starting with `draw`:


static void draw(void)
{
	// snipped

	if (show == SHOW_PAUSE)
	{
		drawPause();
	}
	else if (show == SHOW_MISSION_OPTIONS)
	{
		drawStageOptions();
	}
	else if (show == SHOW_OPTIONS)
	{
		drawRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, 0, 0, 0, 128);

		drawOptions();
	}
	else if (stage.status == MS_COMPLETE && stage.missionCompleteTimer < MISSION_COMPLETE_STATUS_TIME)
	{
		drawMissionComplete();
	}
	else if (stage.status == MS_FAILED && stage.missionCompleteTimer < MISSION_FAILED_STATUS_TIME)
	{
		drawMissionFailed();
	}
}

We're now testing for various states before choosing what the draw. SHOW_PAUSE will call drawPause, SHOW_MISSION_OPTIONS will call drawStageOptions, while SHOW_OPTIONS will call drawOptions (from options.c). It will darken the screen a little before doing so, to make everything a little easier to see. We're also testing our Stage's `status`, and calling drawMissionComplete and drawMissionFailed in line with their logic timers. These will render the familiar ending screens.

drawMissionFailed is new, but very similar to those that have come before:


static void drawMissionFailed(void)
{
	app.fontScale = 3;

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

	drawText("MISSION FAILED!", SCREEN_WIDTH / 2, 80, 255, 0, 0, TEXT_ALIGN_CENTER, 0);

	drawObjectiveList();

	drawWidgets("missionFailed");
}

As we can see, we're simply drawing "MISSION FAILED!" in red, and then listing our objectives list.

While we're not going to cover all the widget functions (as they are largely similar to those in intermission), we should consider the ones most relevant to our mission. So, starting with `retry`:


static void retry(void)
{
	Mission *mission;

	mission = stage.mission;

	resetGameData();

	clearStage();

	stage.mission = mission;

	initStage();

	show = SHOW_STAGE;
}

This function is invoked whenever the player opts to restart the mission. We're keeping a copy of Stage's `mission`, since we'll be resetting Stage completely and will thus lose this pointer. Once we've cleared Stage, we're setting it back. Before that, we're calling resetGameData (we'll see this in a bit). Once all is done, we're calling initStage to start over. Notice how we're setting show to SHOW_STAGE. This is so we don't get held up by the objectives list again, which could become annoying if we fail the mission a lot. Doing this allows us to jump straight back into the action.

The `quit` function follows:


static void quit(void)
{
	resetGameData();

	clearStage();

	initIntermission();
}

Here, we're calling resetGameData, clearing the stage data, and then calling initIntermission, to return to the intermission. Unlike when we complete a mission, we're resetting the player's state details, so they don't keep anything they earned during play (or waste ammo.!).

Finally, the resetGameData function:


static void resetGameData(void)
{
	game.catnip = oldGame.catnip;
	game.kite = oldGame.kite;
}

Here, we're copying oldGame's `catnip` and `kite` data back into `game`, to reset everything to how it was prior to the mission starting. Notice that we're not resetting the stats. We want the player to keep a record of how many shots they fired, etc. even upon failing a mission or quitting.

And that's our second mission inserted. There was actually quite a bit more to this part than originally thought, but it was mostly down to adding in the in-game menu and supporting the player's death. Our next parts will be shorter as they can now focus exclusively on the missions themselves.

Our next mission will do something a bit more special - a new form of AI. We'll be introducing yet another new enemy, one that will act defensively, and aims to keep away from the player, while still attacking.

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