« Back to tutorial listing

— A simple turn-based strategy game —
Part 19: Red Ghost

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

Introduction

It's time to introduce an enemy with some different capabilities. Our Red Ghost will be able to create slime pools, by targetting the map itself with its weapon. These pools will be just like the ones that are placed when we create the map, although we won't be growing them any larger than the single square they occupy.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./sdl2TBS19 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. The Red Ghosts will attack and retreat from the player, much like the Blue Ghosts, but will also target the map around the mages, to create slime pools. Once you're finished, close the window to exit.

Inspecting the code

Once again, adding in our Red Ghosts won't be too difficult, as we have a lot of things already in place to support them. Their behaviour will also be quite a lot like the Blue Ghosts, except they will be a little more aggressive.

Starting with defs.h:


enum {
	AI_PASSIVE,
	AI_COWARD,
	AI_NORMAL,
	AI_SLIMER
};

We've added a new AI enum: AI_SLIMER will be used to tell our ai system that this ghost is a slimer type.

We've also added in a new weapon:


enum {
	WT_BLUE_MAGIC,
	WT_RED_MAGIC,
	WT_PURPLE_MAGIC,
	WT_SLIME_BALL,
	WT_SLIME_POOL,
	WT_MAX
};

WT_SLIME_POOL is the weapon that the Red Ghosts will use to create slime pools. Just like regular weapons, it can also hurt the mages.

Let's define this weapon now. We just need to head over to weapons.c and update initWeapons:


void initWeapons(void)
{
	Weapon *w;

	// snipped

	w = &weapons[WT_SLIME_POOL];
	w->type = WT_SLIME_POOL;
	w->ammo = w->maxAmmo = 9999999;
	w->minDamage = 3;
	w->maxDamage = 10;
	w->accuracy = 35;
	w->range = 20;
	w->texture = getAtlasImage("gfx/bullets/slimeBall.png", 1);
}

Like everything else, we're just defining the weapon's stats. It uses the same texture image as the slime ball, but has a much larger range and can cause a little more damage. It's less accurate, though, as is the case for most powerful weapons in games!

Moving over to ai.c now, we'll add in our new AI profile. Starting with doAI:


void doAI(void)
{
	Unit *u;

	if (stage.currentEntity != NULL)
	{
		u = (Unit*) stage.currentEntity->data;

		if (u->ap != 0)
		{
			switch (u->ai.type)
			{
				// snipped

				case AI_SLIMER:
					doSlimer(u);
					break;

				default:
					u->ap = 0;
					break;
			}
		}
		else
		{
			nextUnit();
		}
	}
	else
	{
		endTurn();
	}
}

We've added AI_SLIMER to our switch statement, where we'll call a new function named doSlimer, passing over our Unit data for the current ghost.

The doSlimer function looks quite a bit like the doNormal function we updated for the Blue Ghost:


static void doSlimer(Unit *u)
{
	int doFallback, doAttack;

	lookForEnemies(u, &doFallback, &doAttack);

	if (doFallback || doAttack)
	{
		if (doFallback && doAttack && rand() % 2 == 0)
		{
			if (rand() % 3 > 0)
			{
				fireBullet();
			}
			else
			{
				throwSlimePool();
			}
		}
		else if (doAttack && rand() % 3 > 0)
		{
			if (rand() % 3 > 0)
			{
				fireBullet();
			}
			else
			{
				throwSlimePool();
			}
		}
		else
		{
			fallback();
		}
	}
	else
	{
		moveRandom();
	}
}

In much the same way as doNormal, we're making a call to lookForEnemies, and deciding how to respond, based on whether the doFallback and doAttack flags are set. However, when it comes to attacking, there is now a 1 in 3 chance that instead of calling fireBullet, we'll call throwSlimePool. Overall, there is also a much higher chance that the Slimer (Red Ghost) will attack a target, rather than falling back, compared to doNormal.

We'll move onto the throwSlimePool function now:


static void throwSlimePool(void)
{
	int ok, attempts, x, y;

	attempts = 12;

	do
	{
		x = stage.targetEntity->x + rand() % 3 - rand() % 3;
		y = stage.targetEntity->y + rand() % 3 - rand() % 3;

		ok = isGround(x, y) && stage.map[x][y].inAttackRange && getEntityAt(x, y) == NULL;

		if (ok)
		{
			stage.worldTarget.x = x;
			stage.worldTarget.y = y;
			stage.worldTarget.takeDamage = worldTargetTakeDamage;

			stage.targetEntity = &stage.worldTarget;
		}

		attempts--;
	}
	while (!ok && attempts > 0);

	fireBullet();
}

This function is responsible for creating a slime pool. It will randomly select a location on the map near to its selected target, and attempt to fire at it.

We start by setting a variable called `attempts` to 12. Again, this is a control variable, to ensure our function gives up trying to target a square within a certain time and not get stuck in an endless loop. Next, we'll enter a while-loop, and take the `x` and `y` coordinates of our currently targeted entity and adjust them by between -3 and 3. These `x` and `y` values will be where we'll attempt to create our slime pool. We test that this new location (x / y) is a ground tile, is within the ghost's attack range, and that there is nothing already occupying the tile, with a call to getEntityAt. We'll assign the result to a variable called `ok`.

If `ok` is 1, we'll prepare to attack the tile. We'll be making use of Stage's worldTarget entity, just as we did with the mages. We set worldTarget's `x` and `y` to the `x` and `y` values we picked, and also assign worldTarget's takeDamage function pointer to a function called worldTargetTakeDamage (we'll see this in a moment). Next, we'll update Stage's targetEntity to be the worldTarget. Ultimately, this will mean that we will be attacking the map, at the specified x and y coordinates.

If we exhaust all our `attempts` (or `ok` is set), we'll exit the loop. Regardless of whether we were able to target the world, we'll call fireBullet, meaning we'll either attack the ghost's original target or throw a slime pool.

That's all we need to do for our slimer logic. We now just need to make a few adjustments elsewhere, to support the action further. First, let's finish up ai.c, by tweaking addAIUnits:


static void addAIUnits(void)
{
	Entity *e;
	Unit *u;
	int i, x, y, ok;

	for (i = 0 ; i < 3 ; i++)
	{
		e = initEntity("Red Ghost");

		// snipped
	}
}

We'll be creating Red Ghosts for this part, so we want to call initEntity and pass over "Red Ghost" (once again, with the appropriate additionals to entityFactory.c).

The last function we want to investigate is worldTargetTakeDamage:


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

A very simple function - it simply converts a ground tile into a slime tile. We're calling isGround here, just to sanity check Stage's worldTarget's `x` and `y` values, in case we attempt to set memory outside of the bounds of our map array.

Now, let's move over to bullets.c, where we've made one small change to applyDamage:


static void applyDamage(Bullet *b)
{
	if (stage.targetEntity->type == ET_WORLD || rand() % 100 <= getAttackAccuracy(b->accuracy))
	{
		stage.targetEntity->takeDamage(stage.targetEntity, b->damage);

		switch (b->type)
		{
			// snipped

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

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

WT_SLIME_POOL will cause the same hit effect as WT_SLIME_BALL, so we simply add that case to our bullet type switch statement.

Almost done! Just one very important thing left to do, and that's to define the ghost itself. Moving over to ghosts.c, we've added a new function named initRedGhost:


void initRedGhost(Entity *e)
{
	Unit *u;

	STRCPY(e->name, "Red Ghost");

	u = initGhost(e, "gfx/units/redGhost.png");
	u->hp = u->maxHP = 10;
	u->ap = u->maxAP = 2;
	u->moveRange = 10;
	u->weapon = getWeapon(WT_SLIME_POOL);

	u->ai.type = AI_SLIMER;
}

We're defining our Red Ghost, giving it its `name`, `texture`, and all other attributes. For the `weapon`, we're calling getWeapon with WT_SLIME_POOL, and for its AI type we're setting AI_SLIMER.

Finished! We now have a ghost that attacks with a little more aggression and can create slime pools, to hinder our mages. 4 ghosts down, 1 more to go. Our final ghost will be a very special type and will have some gameplay implications - the Green Ghost will explode when it is killed, splattering the surrounding area with slime pools, and also injurying anything standing nearby..! We'll see how we create this guy next.

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:

Desktop site