« Back to tutorial listing

— A simple turn-based strategy game —
Part 16: Slime pools

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

Introduction

We're going to introduce something new here - an obstacle in the world. These will be slime pools, that will inhibit the movement of the wizards, but not the ghosts. The ghosts will be able to move through (over) slime pools, while the mages can't. The mages will be able to target and destroy them, however, to clear the path (see screenshot).

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./sdl2TBS16 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. When encountering slime pools, notice how the A* pathfinding walks around them (if possible). You you click on slime pools to target them, just like ghosts, and click again to attack. So long as you have a clear line of sight, the slime pools will always be destroyed by your magic attacks. Once you're finished, close the window to exit.

Inspecting the code

Adding in the pools is a very simple thing, as we'll see. Fun fact: when this tutorial was first being prototyped, it involved tanks and jets. The tanks could move over the land, but were blocked by water. The jets were unaffected by this. I changed it to wizards and ghosts, just because..!

Let's start by looking at the update to structs.h:


typedef struct {
	unsigned int entityId;
	MapTile map[MAP_WIDTH][MAP_HEIGHT];
	Entity entityHead, *entityTail;
	Entity deadEntityHead, *deadEntityTail;
	Entity *currentEntity, *targetEntity, *hoverEntity;
	Entity worldTarget;
	Effect effectHead, *effectTail;
	DamageText damageText;
	Message messageHead, *messageTail;
	Node routeHead;
	int turn;
	int animating;
	int showRange;
	Bullet bullet;
	struct {
		int x;
		int y;
	} selectedTile;
	struct {
		double x;
		double y;
	} camera;
} Stage;

We've added in a new field to Stage. worldTarget is an Entity that will represent the thing in the world that we're targetting (such as the slime pool). Think of this as a meta entity, that can be moved around. The slime pools are actually MapTiles, so we'll be leveraging this worldTarget to attack them.

Moving across to mapGen.c, we've updated generateMap:


void generateMap(void)
{
	do
	{
		doCellularAutomata();
	} while (!verifyCapacity());

	growSlime();

	decorate();
}

We've added in a call to growSlime. We'll come to this in a bit. For now, we've made a change to verifyCapacity:


static int verifyCapacity(void)
{
	int x, y, fillCount, attempts;
	double fillPercent;

	// snipped

	for (x = 0 ; x < MAP_WIDTH ; x++)
	{
		for (y = 0 ; y < MAP_HEIGHT ; y++)
		{
			if (stage.map[x][y].tile == 2)
			{
				stage.map[x][y].tile = TILE_GROUND;

				if (rand() % 256 == 0)
				{
					stage.map[x][y].tile = TILE_SLIME;
				}
			}
			else
			{
				stage.map[x][y].tile = TILE_WALL;
			}
		}
	}

	return 1;
}

Once we've found a suitable map to use (via the flood fill validation), we're setting our ground and wall tiles. When setting a ground tile (TILE_GROUND), there is a one in 256 chance that the tile will actually be slime (TILE_SLIME). We can get more slime on the map by lowering this value, and less of it by raising the value. 256 is a good value, I've found.

Now we come to the growSlime function:


static void growSlime(void)
{
	int x, y, i;

	for (i = 0 ; i < 15 ; i++)
	{
		for (x = 0 ; x < MAP_WIDTH ; x++)
		{
			for (y = 0 ; y < MAP_HEIGHT ; y++)
			{
				if (stage.map[x][y].tile == TILE_SLIME)
				{
					growSlimePoint(x, y);
				}
			}
		}
	}
}

What this function will do is loop through all our MapTiles, looking for a slime tile. Upon finding one, we'll call a function named growSlimePoint, passing across the `x` and `y` coordinates of the slime tile. We're going to go over our map 15 times. Once again, increasing this value will result in larger slime pools, while lowering it will create smaller ones.

growSlimePoint follows:


static void growSlimePoint(int x, int y)
{
	int mx, my;

	mx = x;
	my = y;

	switch (rand() % 3)
	{
		case 0:
			mx += rand() % 2 - rand() % 2;
			break;

		case 1:
			my += rand() % 2 - rand() % 2;
			break;

		default:
			break;
	}

	if ((mx != x || my != y) && isInsideMap(mx, my) && stage.map[mx][my].tile == TILE_GROUND)
	{
		stage.map[mx][my].tile = TILE_SLIME;
	}
}

The idea behind this function is to randomly expand a slime tile into a neighbouring ground tile. We start by setting two variables `mx` and `my` to the same values as the `x` and `y` passed into the function. Next, we select a random of 3. If it's 0, we'll update `mx` by between -1 and 1, meaning we'll expand the slime on the horizontal. If our random is 1, we'll randomly update `dy` by between -1 and 1, meaning we'll expand the slime on the vertical. By default, we'll do nothing.

Next, we'll test if we're actually expanding our slime point by checking that `mx` is not the same as `x`, or that `my` is not the same as `y`, ensure the point of expansion is inside the map, and then test that the new point is a ground tile. If all of these checks are true, we'll flip the ground tile to be a slime tile.

That's all we need to do to create our slime pools! Easy as pie!

Now, let's look at how we can go about attacking and destroying them. Turning to player.c, we've updated doSelecteUnit:


static void doSelectUnit(void)
{
	Entity *e;
	Unit *u;

	if (app.mouse.buttons[SDL_BUTTON_LEFT])
	{
		if (e != NULL && e->solid)
		{
			// snipped
		}
		else
		{
			if (isSlime(stage.selectedTile.x, stage.selectedTile.y))
			{
				stage.worldTarget.type = ET_WORLD;
				STRCPY(stage.worldTarget.name, "Slime");
				stage.worldTarget.takeDamage = worldTargetTakeDamage;

				if (stage.worldTarget.x != stage.selectedTile.x || stage.worldTarget.y != stage.selectedTile.y)
				{
					stage.worldTarget.x = stage.selectedTile.x;
					stage.worldTarget.y = stage.selectedTile.y;

					stage.targetEntity = &stage.worldTarget;
				}
				else
				{
					attackTarget(u);
				}
			}
			else
			{
				stage.targetEntity = NULL;
			}
		}
	}

	// snipped
}

Once we've checked to to see if we're not targetting a solid entity, we're testing if the MapTile we've selected is a slime tile (with a call to a new function called isSlime). If so, we'll target it. We're setting Stage's worldTarget's (our meta entity) `type` to ET_WORLD, its `name` to "Slime", and then assigning its takeDamage function pointer to a function called worldTargetTakeDamage. We'll come to this in a bit. Next, we're testing if we've not previously selected this tile. If we've not, we'll set Stage's worldTarget's `x` and `y` to the selectedTile's `x` and `y`, and then assign Stage's targetEntity as Stage's worldTarget.

So, in short, we're creating a fake entity that we can attack, and setting some attributes. If we've previously selected this tile as our target, we'll simply call attackTarget, as usual.

Finally, if we assess that the map tile we've clicked on isn't an entity or a slime tile, we'll NULL Stage's targetEntity to deselect our target.

So, again, as you can see, when attacking a slime tile we're preparing a fake target to act as the entity.

Next, we come to worldTargetTakeDamage:


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

		stage.targetEntity = NULL;
	}
}

A simple function this, working just like the other takeDamage functions. We first ensure that the MapTile being occupied is a slime tile, and we're then reverting it to being a ground tile. We're then clearing Stage's targetEntity, so that the tile doesn't remain targetted.

Let's take a look the isSlime function now, found in map.c:

int isSlime(int x, int y) { return isInsideMap(x, y) && stage.map[x][y].tile >= TILE_SLIME; }

Easy enough. We're test if the map point at x and y is inside the map and also if the value of the MapTile is TILE_SMILE or greater. Testing this way both ensures we don't index outside of our array, and gives us scope to have different slime images, if we so desire.

Jumping over next to bullets.c, we've updated applyDamage:


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

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

When targetting slime tiles (entities with a type of ET_WORLD, as assigned in player.c), a bullet will never miss. We therefore test to see if Stage's targetEntity `type` is ET_WORLD, and always call the targetEntity's takeDamge function if so.

And since we can always hit the world, we should reflect this on the HUD. Moving over to hud.c, we've updated drawTopBar:


static void drawTopBar(void)
{
	// snipped

	if (stage.targetEntity != NULL && (stage.targetEntity->type == ET_GHOST || stage.targetEntity->type == ET_WORLD))
	{
		accuracy = 0;

		if (stage.map[stage.targetEntity->x][stage.targetEntity->y].inAttackRange)
		{
			accuracy = (stage.targetEntity->type == ET_WORLD) ? 100 : getAttackAccuracy(u->weapon.accuracy);
		}

		sprintf(text, "%s (%d%%, %d - %d)", stage.targetEntity->name, accuracy, u->weapon.minDamage, u->weapon.maxDamage);

		drawText(text, SCREEN_WIDTH - 10, 0, 120, 160, 255, TEXT_ALIGN_RIGHT, 0);
	}
}

Now, when working out the accuracy to display, we're testing if the targetEntity's `type` is ET_WORLD. If so, `accuracy` will be 100 (so long as it is inside our attack range). Otherwise, we'll be calling getAttackAccuracy as normal, to work out the real accuracy value.

Almost done! Just one thing left to update and our slime is all done.

Heading over to units.c, we've made a tweak to resetUnits:


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

	stage.currentEntity = stage.targetEntity = NULL;

	memset(&stage.worldTarget, 0, sizeof(Entity));

	// snipped
}

Now, alongside the NULLing of Stage's currentEntity and targetEntity, we're memsetting Stage's worldTarget. We're doing so to ensure that the `name` and takeDamage function pointer are cleared, as the AI will also be making use of this entity in a later part!

There we go! Our map now has slime pools that can hinder the wizards in their quest to do away with the rogue ghosts.

Speaking of the ghosts, it's about time we expanded to roster a bit. So, in the coming parts we'll be adding in all the various other AI types, including ghosts that run away from the mages and ones that blow up when destroyed..!

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