« Back to tutorial listing

— A simple turn-based strategy game —
Part 9: Combat #4: Line of sight

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

Introduction

As it stands, we can attack and destroy ghosts by flinging magic at them, but right now we can do so from anywhere on the map, and without the hindrance of walls or other solid entities. In this part, we're going to add in range and line of sight (LOS), so that we need a clear shot at the enemies before we can attack them.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./sdl2TBS09 to run the code. You will see a window open like the one above, showing three wizards in a small room, surrounded by walls, as well as a white ghost. Notice how now when clicking on a wizard you can cycle through displaying no range, the move range, and the attack range. The attack range is shown as red squares. In order to be able to attack the ghost, it must be within one of the red squares. Moving your wizards about will update the attack range, like the move range. Play the game as normal, and defeat the ghost. Once you're finished, close the window to exit.

Inspecting the code

Adding in our attack range is fairly straightfoward. As with the accuracy and damage, our weapons will be the things that control the attack range.

We'll start with the update to defs.h:


enum {
	SHOW_RANGE_NONE,
	SHOW_RANGE_MOVE,
	SHOW_RANGE_ATTACK,
	SHOW_RANGE_MAX
};

We've added in SHOW_RANGE_ATTACK to our enums, so that when cycling through our wizard actions this is one of the possibilities.

Next, let's head over to structs.h:


typedef struct {
	int tile;
	int inMoveRange;
	int inAttackRange;
} MapTile;

We've updated MapTile, to add in a field called inAttackRange. Much like inMoveRange, this will flag whether the tile can be targetted for an attack.

Weapon has also been updated:


typedef struct {
	int type;
	int minDamage, maxDamage;
	int accuracy;
	int range;
	AtlasImage *texture;
} Weapon;

We've added in a field called `range`, that will hold the attack range of the weapon.

Moving over to units.c now, we've made a tweak to updateUnitRanges:


void updateUnitRanges(void)
{
	int x, y;
	Unit *u;

	u = (Unit*) stage.currentEntity->data;

	for (x = 0 ; x < MAP_WIDTH ; x++)
	{
		for (y = 0 ; y < MAP_HEIGHT ; y++)
		{
			stage.map[x][y].inMoveRange = 0;

			stage.map[x][y].inAttackRange = 0;
		}
	}

	if (u->ap > 0)
	{
		testMoveRange(stage.currentEntity->x, stage.currentEntity->y, u->moveRange);

		testAttackRange(u->weapon.range);
	}
}

Alongside testMoveRange, we're now calling a new function named testAttackRange:


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

	for (x = -range ; x <= range ; x++)
	{
		for (y = -range ; y <= range ; y++)
		{
			mx = stage.currentEntity->x + x;
			my = stage.currentEntity->y + y;

			if (isInsideMap(mx, my) && !isWall(mx, my) && getDistance(mx, my, stage.currentEntity->x, stage.currentEntity->y) <= range && hasLOS(stage.currentEntity->x, stage.currentEntity->y, mx, my))
			{
				stage.map[mx][my].inAttackRange = 1;
			}
		}
	}
}

The funciton itself is quite simple. It takes a single parameter: `range`, the maximum distance our attack can reach. `range` is a radius, and so we're setting up two for-loops, to test the squares in that area. Ultimately, a square that can be targetted will have its MapTile's inAttackRange set. To qualify, a square must be inside the map bounds, must not be a wall, must have a distance from the attack less than or equal to the input range (our range will be circular, not square), and finally the line of sight check (hasLOS) must pass.

Our line of sight check (hasLOS) is much like the one from SDL2 Rogue:


int hasLOS(int x1, int y1, int x2, int y2)
{
	int dx, dy, sx, sy, err, e2;

	dx = abs(x2 - x1);
	dy = abs(y2 - y1);

	sx = (x1 < x2) ? 1 : -1;
	sy = (y1 < y2) ? 1 : -1;
	err = dx - dy;

	while (1)
	{
		e2 = 2 * err;

		if (e2 > -dy)
		{
			err -= dy;
			x1 += sx;
		}

		if (e2 < dx)
		{
			err += dx;
			y1 += sy;
		}

		if (x1 == x2 && y1 == y2)
		{
			return 1;
		}

		if (isBlocked(x1, y1, 1))
		{
			return 0;
		}
	}

	return 0;
}

We've talked about this in SDL2 Rogue, so we'll not discuss it here. One thing to note is that it is calling isBlocked, a function we've updated:


int isBlocked(int x, int y, int losTest)
{
	Entity *e;

	if (!isInsideMap(x, y))
	{
		return 1;
	}
	else if (!losTest)
	{
		if (stage.currentEntity->type == ET_MAGE && !isGround(x, y))
		{
			return 1;
		}
		else if (stage.currentEntity->type == ET_GHOST && isWall(x, y))
		{
			return 1;
		}
	}
	else if (isWall(x, y))
	{
		return 1;
	}

	e = getEntityAt(x, y);

	if (e != NULL && e != stage.currentEntity && e->solid)
	{
		return 1;
	}

	return 0;
}

We're passing over a new parameter called losTest, to state whether this call is for a line of sight check. This is actually a pre-emptive update for a future part, where some features of the map will block the wizards, but not the ghosts. Otherwise, the function is more or less behaving as before (solid entities and walls will block the movement and line of sight of wizards and ghosts). We're doing this now, as it's part of the LOS feature.

So, we can now flag a load of squares on our map as being in the Unit's attack range, meaning they can be targetted and displayed as such.

Moving next to weapons.c, we've updated initWeapons:


void initWeapons(void)
{
	Weapon *w;

	w = &weapons[WT_BLUE_MAGIC];
	w->type = WT_BLUE_MAGIC;
	w->minDamage = 1;
	w->maxDamage = 7;
	w->accuracy = 60;
	w->range = 10;
	w->texture = getAtlasImage("gfx/bullets/blueMagic.png", 1);

	w = &weapons[WT_RED_MAGIC];
	w->type = WT_RED_MAGIC;
	w->minDamage = 3;
	w->maxDamage = 5;
	w->accuracy = 65;
	w->range = 9;
	w->texture = getAtlasImage("gfx/bullets/redMagic.png", 1);

	w = &weapons[WT_PURPLE_MAGIC];
	w->type = WT_PURPLE_MAGIC;
	w->minDamage = 1;
	w->maxDamage = 12;
	w->accuracy = 35;
	w->range = 15;
	w->texture = getAtlasImage("gfx/bullets/purpleMagic.png", 1);
}

Each of our weapons now has a `range` value set. Blue Magic will have a `range` of 10, Red Magic a `range` of 9, and Purple Magic a `range` of 15! Powerful stuff that Purple Magic, though its accuracy is poor compared to the other two.

Moving across to map.c, we've updated drawMap:


void drawMap(void)
{
	int x, y, n;

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

			blitAtlasImage(tiles[n], x * MAP_TILE_SIZE, y * MAP_TILE_SIZE, 0, SDL_FLIP_NONE);

			if (!stage.animating && stage.showRange != SHOW_RANGE_NONE)
			{
				if (stage.showRange == SHOW_RANGE_MOVE && stage.map[x][y].inMoveRange)
				{
					blitAtlasImage(moveTile, x * MAP_TILE_SIZE, y * MAP_TILE_SIZE, 0, SDL_FLIP_NONE);
				}
				else if (stage.showRange == SHOW_RANGE_ATTACK && stage.map[x][y].inAttackRange)
				{
					blitAtlasImage(attackTile, x * MAP_TILE_SIZE, y * MAP_TILE_SIZE, 0, SDL_FLIP_NONE);
				}
				else
				{
					blitAtlasImage(darkTile, x * MAP_TILE_SIZE, y * MAP_TILE_SIZE, 0, SDL_FLIP_NONE);
				}
			}
		}
	}
}

As we're now able to display both our move range and our attack range, we'll need to draw them as appropriate. As before, if Stage's showRange is not SHOW_RANGE_NONE we'll be drawing a range. We're now testing to see if showRange is SHOW_RANGE_MOVE and then testing if the current MapTile's inMoveRange flag is set before drawing moveTile. Similarly, we're checking if showRange is SHOW_RANGE_ATTACK and the MapTile's inAttackRange flag is set before drawing attackTile. Otherwise, we'll be drawing darkTile, so that squares outside of the range will be dark.

loadTiles has been updated, too:


static void loadTiles(void)
{
	int i;
	char filename[MAX_FILENAME_LENGTH];

	for (i = 0 ; i < MAX_TILES ; i++)
	{
		sprintf(filename, "gfx/tiles/%d.png", i);

		tiles[i] = getAtlasImage(filename, 0);
	}

	darkTile = getAtlasImage("gfx/tiles/dark.png", 1);

	moveTile = getAtlasImage("gfx/tiles/move.png", 1);

	attackTile = getAtlasImage("gfx/tiles/attack.png", 1);

	routeTile = getAtlasImage("gfx/tiles/route.png", 1);
}

We're now loading attackTile, alongside the others.

Heading next over to player.c, we've updated attackTarget with the final, all important check:


static void attackTarget(Unit *u)
{
	if (u->ap > 0 && stage.map[stage.targetEntity->x][stage.targetEntity->y].inAttackRange)
	{
		fireBullet();
	}
}

Now, as well as requiring AP to attack, the target entity must reside in a MapTile that has the inAttackRange flag set. Otherwise, nothing will happen.

We're almost done! We need just make one final little tweak, before we wrap things up. Heading over to hud.c, we've made a change to drawTopBar:


static void drawTopBar(void)
{
	Unit *u;
	int x, accuracy;
	char text[MAX_DESCRIPTION_LENGTH];

	drawRect(0, 0, SCREEN_WIDTH, 45, 0, 0, 0, 192);

	u = (Unit*) stage.currentEntity->data;

	x = 10;

	drawText(stage.currentEntity->name, x, 0, 255, 255, 255, TEXT_ALIGN_LEFT, 0);

	x += 200;
	sprintf(text, "HP: %d / %d", u->hp, u->maxHP);
	drawText(text, x, 0, 200, 200, 200, TEXT_ALIGN_LEFT, 0);

	x += 250;
	sprintf(text, "AP: %d / %d", u->ap, u->maxAP);
	drawText(text, x, 0, 200, 200, 200, TEXT_ALIGN_LEFT, 0);

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

		if (stage.map[stage.targetEntity->x][stage.targetEntity->y].inAttackRange)
		{
			accuracy = 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 rendering the accuracy of the attack to the target, we're testing if it is possible to hit the target. If not, our accuracy will be displayed as 0. We're defaulting accuracy to 0, and then testing to see if the MapTile the target resides at has its inAttackRange flag set. If so, we'll update accuracy with a call to getAttackAccuracy. The reason for this is so that things don't look odd when we are unable to hit the target, but still see an accuracy reading.

That was easy, wasn't it? Once again, we're seeing how simple it is to slot things into our game thanks to all the work we did in part 1.

I think it's about time that our ghost was able to fight back, don't you? In the next part, we'll look into telling our ghost to attack the wizards whenever it spots them.

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