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


Firmware

HF-Tech's chips have changed the world. Embedded into the heads of over 90% of the world's population, they have cured autism, dementia, provided intelligence boosts, and helped to ease some of the more mundane tasks in life. Daniel Blair, hacker and Workshop member, however is not convinced that everything is as rosy as it seems. But is he looking in all the wrong places..?

Click here to learn more and read an extract!

« Back to tutorial listing

— Mission-based 2D shoot 'em up —
Part 27: Mission: Rescue POWs

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

Introduction

In our next mission, Leo has been charged with rescuing some fellow resistance fighters from Greeble POW ships. The player will need to chase down the POW ships (that will run away from them), destroy them to release the POWs themselves, and then collect the POW pods. In all, the player will need to break open at least 5 POW ships to finish the mission (and also not lose any POWs in the process).

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./shooter3-27 to run the code. You will see a window open displaying the intermission's planets screen. Select Pea, then enter the comms section to start the mission. Play the game as normal, chasing down the POW ships, destroying them, and collecting the POW pods that are dropped. Failure to collect any POW pods will result in the mission being failed. 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. Once you're finished, close the window to exit.

Inspecting the code

When we first began this tutorial series, we implemented the player's main gun in such a way that the bullets couldn't harm anything that wasn't on screen. As you may now be able to see, this was a good move, as it prevents us from shattering the POW ships and spilling the contents when we're unaware of it happening. This could cause us to constantly fail the mission simply due to stray fire!

We've made a number of updates here to support our new AI type and objective processing, but it's no major undertaking, as we'll see. So, let's jump into the code.

Starting with defs.h:


enum
{
	AI_NORMAL,
	AI_DEFENSIVE,
	AI_EVADE
};

We've added in a new AI_ enum. AI_EVADE will specify that our AI will use the evade strategy, to keep away from enemies.

Next up, we've added a new CT_ enum:


enum
{
	CT_CATNIP,
	CT_HEALTH,
	CT_AMMO,
	CT_POW
};

CT_POW will identify our POWs amongst the collectables. Like its siblings, the enum will be used to handle how we react to the player picking it up (or not picking it up, as we'll see later!).

Finally, we've added a new objective type:


enum
{
	OT_NORMAL,
	OT_DEFEAT_ALL_ENEMIES,
	OT_CONDITIONAL
};

OT_CONDITIONAL will be used to describe an objective where the target value must NOT be met. If we do so, the mission will fail.

Now for our new enemy type. greeblePOWShip.c contains all the functions for handling this new enemy, and will be very familiar to you. Starting with initGreeblePOWShip:


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

	f = malloc(sizeof(Fighter));
	memset(f, 0, sizeof(Fighter));
	f->health = f->maxHealth = 10;
	f->ai.weaponType = AI_WPN_NONE;
	f->ai.type = AI_EVADE;
	f->ai.thinkTime = rand() % FPS;

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

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

Again, very standard. Of note here is that the weaponType is set to AI_WPN_NONE, as our POW ships don't have any weapons. The AI `type` is to AI_EVADE.

The `tick` function follows:


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

	f = (Fighter *)self->data;

	f->dx = MIN(MAX(f->dx, -MAX_SPEED), MAX_SPEED);
	f->dy = MIN(MAX(f->dy, -MAX_SPEED), MAX_SPEED);

	fighterTick(self, f);

	doFighterAI(self, f);

	if (stage.engineEffectTimer <= 0)
	{
		addEngineEffect(self->x + (self->facing == FACING_LEFT ? self->texture->rect.w : 0), self->y + 2);
	}

	stage.numActiveEnemies++;
}

Once more, the only major difference between this craft and its siblings is the engine position (call to the addEngineEffect function). Once more, we see a case for refactoring, to have a common tick function.

As with the bombers, we're going to skip in the `draw` and `destroy` functions, as they are identical to the other fighters, and finish with 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);

	dropPOWs(self->x, self->y, 2 + rand() % 4);

	addDebris(x, y, 20);

	game.catnip += 10;

	self->dead = 1;

	game.stats.enemiesDestroyed++;

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

Here, we're calling a new function named dropPOWs. This, as we will see, will spawn between 2 and 5 POW collectables that the player must pick up, to avoid failing the mission. Our POW craft don't drop catnip, ammo, or health..!

Now, over to collectables.c, where we've made a bunch of updates to support our new POW collectable. Starting with initCollectables:


void initCollectables(void)
{
	memset(&stage.collectableHead, 0, sizeof(Collectable));

	stage.collectableTail = &stage.collectableHead;

	if (healthTexture == NULL)
	{
		healthTexture = getAtlasImage("gfx/collectables/health.png", 1);
		catnipTexture = getAtlasImage("gfx/collectables/catnip.png", 1);
		ammoTexture = getAtlasImage("gfx/collectables/ammo.png", 1);
		powTexture = getAtlasImage("gfx/collectables/pow.png", 1);
	}
}

We're loading the texture used for the POW collectable (powTexture). Next, we've updated doCollectables:


static void doCollectable(Collectable *c)
{
	Fighter *f;
	char     text[MAX_DESCRIPTION_LENGTH];

	c->x += c->dx * app.deltaTime;
	c->y += c->dy * app.deltaTime;

	c->health -= app.deltaTime;

	if (c->health <= 0)
	{
		if (c->type == CT_POW)
		{
			updateObjective("powLost", 1);
		}
	}

	if (collision(c->x, c->y, c->texture->rect.w, c->texture->rect.h, stage.player->x, stage.player->y, stage.player->texture->rect.w, stage.player->texture->rect.h))
	{
		switch (c->type)
		{
			// snipped

			case CT_POW:
				addHudMessage("Rescued a POW", 255, 255, 255);
				playSound(SND_ITEM_MISC, CH_ITEM);
				break;

			default:
				break;
		}

		c->health = 0;
	}
}

Here, we've added in all the major logic for handling our new POW collectables. First, after reducing the collectable's `health`, we're testing if it's 0 or less. If so, and it's `type` is CT_POW, we're going to call updateObjective, passing over "powLost". Our mission has a conditional objective tied to the target name "powLost", meaning that if this collectable's `health` hits 0 before we have a chance to collect it, we'll fail the mission..!

For the remainder of the function, we're testing for having collected the POW, and are adding a HUD message, and playing a sound. As you can see, it's quite easy to add in new collectables, and tie them to our objectives system. It's extremely flexible!

Lastly, we've added in a new function called dropPOWs:


void dropPOWs(int x, int y, int amount)
{
	Collectable *c;
	int          i;

	for (i = 0; i < amount; i++)
	{
		c = initCollectable(CT_POW, x, y, powTexture);

		c->health = (FPS * 7) + rand() % FPS;
	}
}

This function is merely responsible for creating a bunch of collectables, of CT_POW `type`, and using the powTexture. Our POWs will existing for between 7 and 8 seconds.

Now, let's turn to ai.c, where we've added in the new functions to handle our evade AI type. Starting with doFighterAI:


void doFighterAI(Entity *self, Fighter *f)
{
	if (!stage.player->dead)
	{
		if (f->ai.thinkTime <= 0)
		{
			if (f->ai.target == NULL || rand() % 4 == 0)
			{
				f->ai.target = findNearestEnemy(self);
			}

			switch (f->ai.type)
			{
				// snipped

				case AI_EVADE:
					doEvadeAI(self, f);
					break;

				default:
					break;
			}
		}

		// snipped
	}
}

We've added in the AI_EVADE case to our ai `type` switch statement, to call doEvadeAI. This a new function that we'll detail below:


static void doEvadeAI(Entity *self, Fighter *f)
{
	int     speed, distance, moveToAlly;
	Entity *ally;

	f->dx = f->dy = 0;

	moveToAlly = 1;

	if (f->ai.target != NULL)
	{
		distance = getDistance(self->x, self->y, f->ai.target->x, f->ai.target->y);

		if (distance < SCREEN_WIDTH)
		{
			calcSlope(self->x, self->y, f->ai.target->x, f->ai.target->y, &f->dx, &f->dy);

			moveToAlly = 0;
		}
	}

	if (moveToAlly)
	{
		ally = findNearestAlly(self);

		if (ally != NULL)
		{
			calcSlope(ally->x, ally->y, self->x, self->y, &f->dx, &f->dy);
		}
	}

	speed = 5 + rand() % 8;

	f->dx *= speed;
	f->dy *= speed;

	self->facing = f->dx < 0 ? FACING_LEFT : FACING_RIGHT;

	f->ai.thinkTime = FPS + (FPS * rand() % 3);
}

This function is pretty simple, despite how it might appear. To begin with, we're setting the fighter's `dx` and `dy` to 0, to bring them to the stop, and then setting a flag called moveToAlly to 1. This, as we'll see shortly, will be used to control whether our AI should seek out a buddy to move towards. We next test if our AI has a target. If so, and the target is less than a screen's width away, we'll perform the calculations to move the fighter away from the target. We'll then update the moveToAlly flag to 0, so it doesn't attempt to move towards a buddy with enemies nearby. Next, we'll test the value of moveToAlly. If it's set, we'll call a new function named findNearestAlly. If we find one, we'll move towards them. With all that done, we'll set our speeds (`dx` and `dy`), `facing`, and update our thinkTime.

So, in summary - if near an enemy, run away from them. If not, move towards a buddy (safety in numbers!). Due to the nature of our game, you might not see this buddy thing happening too often.

The findNearestAlly function follows:


static Entity *findNearestAlly(Entity *self)
{
	Entity *e, *rtn;
	int     distance, bestDistance;

	rtn = NULL;
	bestDistance = -1;

	for (e = stage.entityHead.next; e != NULL; e = e->next)
	{
		if (e != self && e->side == self->side)
		{
			distance = getDistance(self->x, self->y, e->x, e->y);

			if (bestDistance == -1 || distance < bestDistance)
			{
				rtn = e;

				bestDistance = distance;
			}
		}
	}

	return rtn;
}

Very much like the code we use for finding an enemy target (in fact, it's basically the inverse). We're simply looking through our list of entities, to find an entity that is on the same side as us, take note of the closest, and return it (we're assuming that all our entities are fighters).

That's our AI sorted out. Now for another important update - the changes to our objectives. Of course, it's possible now to fail the mission by scoring points in an OT_CONDITIONAL type. Let's turn to objectives.c, and see how this is done.

First up, we have changes to doObjectives:


void doObjectives(void)
{
	Objective *o;
	char       text[MAX_LINE_LENGTH];
	int        allComplete, isDefeatAllEnemies, numIncomplete;

	// snipped

	if (objectiveTimer <= 0)
	{
		// snipped

		for (o = stage.objectiveHead.next; o != NULL; o = o->next)
		{
			if (o->type != OT_CONDITIONAL && o->currentValue < o->targetValue)
			{
				allComplete = 0;

				numIncomplete++;
			}

			// snipped
		}

		if (isDefeatAllEnemies && numIncomplete == 1 && stage.totalEnemies == -1)
		{
			stage.totalEnemies = stage.enemyLimit = 0;
		}

		if (allComplete)
		{
			// complete all conditional
			for (o = stage.objectiveHead.next; o != NULL; o = o->next)
			{
				o->currentValue = o->targetValue;
			}

			addHudMessage("Mission Complete!", 0, 255, 0);

			stage.status = MS_COMPLETE;

			game.stats.missionsCompleted++;

			executeScriptFunction("MISSION_COMPLETE");
		}
	}
}

First, when looping through all our objectives to test how many are not yet complete (numIncomplete) we're ignoring any of `type` OT_CONDITIONAL. If we were to include these in our incomplete count, we'd never be able to finish the mission. Remember: we want to avoid "completing" these objectives..!

Next up, when testing the allComplete flag, we're looping through all our objectives and setting their currentValue to their targetValue. This is just so that when we finish the mission, things don't look weird on our objectives list summary. Seeing the conditional listed as incomplete is basically incorrect.

Now for the changes to updateObjective:


void updateObjective(char *targetName, int value)
{
	Objective *o;
	char       text[MAX_LINE_LENGTH];
	double     oldVal, newVal;

	for (o = stage.objectiveHead.next; o != NULL; o = o->next)
	{
		if (strcmp(o->targetName, targetName) == 0 && o->currentValue < o->targetValue)
		{
			oldVal = o->currentValue;

			o->currentValue = MIN(o->currentValue + value, o->targetValue);

			if (o->currentValue == o->targetValue)
			{
				if (o->type == OT_NORMAL)
				{
					sprintf(text, "%s - Objective Complete!", o->description);

					addHudMessage(text, 0, 255, 0);

					objectiveTimer = FPS;
				}
				else if (o->type == OT_CONDITIONAL)
				{
					sprintf(text, "%s - Objective Failed!", o->description);

					addHudMessage(text, 255, 0, 0);

					stage.status = MS_FAILED;

					executeScriptFunction("MISSION_FAILED");

					stopMusic();

					playSound(SND_FAILED, CH_FAILED);
				}
			}
			else
			{
				// snipped
			}
		}
	}
}

We've updated the response to our objective's currentValue meeting its targetValue. We're now testing if the `type` is OT_NORMAL, and handling it in the standard way if so. If not, we're testing if this is a conditional objective. If so, we're going to inform the player that they have failed the objective, via a HUD message. We'll set Stage's `status` to MS_FAILED, execute a script function named "MISSION_FAILED", stop the music, and play our ominous failure sound effect, as we do when the player is killed..! Yep, we're not wasting any time here - if we "complete" an OT_CONDITIONAL we'll end the game swiftly!

That's almost it. One more change we're going to make is to stage.c, where we display our objectives list via drawObjectiveList:


static void drawObjectiveList(void)
{
	Objective *o;
	SDL_Color  c;
	int        y;
	char       text[16];

	app.fontScale = 2;

	y = 280;

	for (o = stage.objectiveHead.next; o != NULL; o = o->next)
	{
		c.r = c.g = c.b = 0;

		if (o->currentValue == o->targetValue)
		{
			if (o->type == OT_CONDITIONAL && stage.status == MS_FAILED)
			{
				c.r = 255;
			}
			else
			{
				c.g = 255;
			}
		}
		else
		{
			c.r = c.g = c.b = 255;
		}

		sprintf(text, "%d / %d", o->currentValue, o->targetValue);

		drawText(o->description, 300, y, c.r, c.g, c.b, TEXT_ALIGN_LEFT, 0);

		drawText(text, SCREEN_WIDTH - 300, y, c.r, c.g, c.b, TEXT_ALIGN_RIGHT, 0);

		y += 80;
	}

	app.fontScale = 1;
}

As we've seen before, this function draws all our objectives to the screen. We're now testing if an objective is of `type` OT_CONDITIONAL and if the mission has been failed, and rendering that objective in red, rather than green. This will further emphasise to the player how and when they failed the mission, so they will know for next time.

Another mission done! And at this point we've more or less added in all the gameplay features that we need. We have our core AI behaviours, and we're able to process three different types of objectives.

We have one more main mission to add now, and it's of a type that every gamer enjoys - protecting an ally from harm! The SS Goodboy is in danger, and needs protecting while her crew fixes the engines.

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