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
SDL 1 tutorials (outdated)

Latest Updates

SDL2 turn-based strategy tutorial
Thu, 14th April 2022

Water Closet ported to PlayStation Vita
Tue, 4th January 2022

The Legend of Edgar 1.35
Sat, 1st January 2022

Achievements tutorial
Thu, 2nd December 2021

SDL2 Rogue tutorial
Thu, 30th September 2021

All Updates »

Tags

android (3)
battle-for-the-solar-system (9)
blob-wars (9)
brexit (1)
code (6)
edgar (7)
games (39)
lasagne-monsters (1)
making-of (5)
match3 (1)
numberblocksonline (1)
orb (2)
site (1)
tanx (4)
three-guys (3)
three-guys-apocalypse (3)
tutorials (10)
water-closet (4)

Books


The Attribute of the Strong (Battle for the Solar System, #3)

The Pandoran War is nearing its end... and the Senate's Mistake have all but won. Leaving a galaxy in ruin behind them, they set their sights on Sol and prepare to finish their twelve year Mission. All seems lost. But in the final forty-eight hours, while hunting for the elusive Zackaria, the White Knights make a discovery in the former Mitikas Empire that could herald one last chance at victory.

Click here to learn more and read an extract!

« Back to tutorial listing

— A simple turn-based strategy game —
Part 8: Combat #3: Feedback and effects

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

Introduction

Our combat system continues to evolve, but we're missing feedback on our attacks. In this part, we're going to add in some effects, damage indication, and other visual indications as to what's happened.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./sdl2TBS08 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. Play the game as normal. Notice when you attack a ghost, a particle trail now follows the magic attack. The colour will change depending on the wizard using it. When attacks hit, there is a puff of magic (in the same colour as the attack), the ghost will shudder, and a number will appear, showing the damage dealed. If the attack misses, the word "Miss" is shown. When the ghost is destroyed, it vanishes in a puff of white smoke. Once you're finished, close the window to exit.

Inspecting the code

Adding in effects is a simple tasks, although there is a lot to do. The shudder and damage text both add a feeling of impact, and also inform the player of what just happened.

Just a heads-up - since this tutorial involves adding in particle effects in a way that has been covered in several other tutorials (all the way as far back as the original SDL Shooter..!), we're going to run through them all very fast.

We'll start with structs.h:


struct Effect {
	double x;
	double y;
	double dx;
	double dy;
	double life;
	int size;
	SDL_Color color;
	double alpha;
	Effect *next;
};

We've added in a standard Effects structs, to represent a magic trail, strike, or other explosion.

Next, we have DamageText:


typedef struct {
	char text[MAX_NAME_LENGTH];
	double x;
	double y;
	double life;
} DamageText;

DamageText is used for any time we want to display the result of an attack, whether that be a hit or a miss. `text` is the text to display, `x` and `y` are the screen coordinates, and `life` is how long the DamageText will display for.

We've also updated some existing structs:


typedef struct {
	int type;
	double x;
	double y;
	double dx;
	double dy;
	double life;
	double angle;
	int accuracy;
	int damage;
	double trailTimer;
	AtlasImage *texture;
} Bullet;

Bullet now has a `type` field, as well as a trailTimer field. `type` will determine the type of bullet this is (derived from the weapon), while trailTimer will control how often it produces particle effects when the bullet is in-flight.


struct Entity {
	unsigned int id;
	int type;
	char name[MAX_NAME_LENGTH];
	int x;
	int y;
	int side;
	int solid;
	int facing;
	int dead;
	AtlasImage *texture;
	void (*data);
	void (*tick) (Entity *self);
	void (*draw) (Entity *self);
	void (*takeDamage) (Entity *self, int damage);
	void (*die) (Entity *self);
	Entity *next;
};

Entity has gained two new function pointers: `tick` and `die`. `tick` will be called each frame, and will be used to process data specific to the entity. `die` is called when the entity is killed.


typedef struct {
	int hp, maxHP;
	int ap, maxAP;
	int moveRange;
	Weapon weapon;
	double shudder;
	struct {
		int type;
		SDL_Point goal;
	} ai;
} Unit;

The Unit struct has gained a field called `shudder`. This is used to control the shake effect you see whenever the ghost is injured.


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

Finally, we've added in a new linked list to Stage, controlled by effectHead and effectTail, as well as an instance of DamageText (as damageText).

Let's now move over to damageText.c, where we actually handle all of our DamageText functions. Starting with addDamageText:


void addDamageText(int x, int y, char *text, ...)
{
	DamageText *d;
	char buffer[MAX_LINE_LENGTH];
	va_list args;

	va_start(args, text);
	vsprintf(buffer, text, args);
	va_end(args);

	memset(&stage.damageText, 0, sizeof(DamageText));

	d = &stage.damageText;

	STRCPY(d->text, buffer);
	d->x = x;
	d->y = y;
	d->life = FPS / 2;
}

This is a standard function to set our DamageText's data. It takes three parameters: the `x` and `y` screen coordinates, and a variable length char array, identified by `text`. The first thing we're doing in this function is formatting the text, using vsprintf. The formatted text is placed into a char array called `buffer`. Next, we're memsetting Stage's damageText to reset it, before grabbing a pointer to it (`d`). Finally, we're setting all of damageText's fields with that data passed in to the function. For the DamageText's `life`, we're setting a value of FPS / 2, which means it will be visible for half a second.

The next function is doDamageText:


void doDamageText(void)
{
	DamageText *d;

	d = &stage.damageText;

	if (d->life > 0)
	{
		d->life = MAX(d->life - app.deltaTime, 0);

		d->y -= 1.5 * app.deltaTime;
	}
}

A simple function. We're testing if Stage's damageText's `life` is greater than 0, and if so we're reducing its `life` and also decreasing its `y` value. Decreasing its `y` value will make it move up the screen while it's alive.

drawDamageText is equally simple:


void drawDamageText(void)
{
	DamageText *d;

	d = &stage.damageText;

	if (d->life > 0)
	{
		app.fontScale = 0.8;

		drawText(d->text, d->x + 2, d->y + 2, 0, 0, 0, TEXT_ALIGN_CENTER, 0);

		drawText(d->text, d->x, d->y, 255, 255, 255, TEXT_ALIGN_CENTER, 0);

		app.fontScale = 1.0;
	}
}

Once again, we're first testing that Stage's damageText's `life` is greater than 0, then rendering the text of the DamageText itself. Notice how we're drawing the text twice. The first time we're drawing it, we're rendering it completely black, as well as at a 2 pixel offset from its normal `x` and `y`. This is to give the text is a shadow, and help to make it a bit more readable on screen. With the shadow drawn, we're rendering the text in white at the normal x and y position.

That's our DamageText done. We're now going to briefly look at effects.c, where are effects are handled.

Starting with initEffects:


void initEffects(void)
{
	memset(&stage.effectHead, 0, sizeof(Effect));
	stage.effectTail = &stage.effectHead;

	if (particleTexture == NULL)
	{
		particleTexture = getAtlasImage("gfx/effects/particle.png", 1);
	}
}

We're just setting up a linked list to handle our effects and loading the texture for use.

doEffects follows:


void doEffects(void)
{
	Effect *e, *prev;

	prev = &stage.effectHead;

	for (e = stage.effectHead.next ; e != NULL ; e = e->next)
	{
		e->life -= app.deltaTime;

		e->alpha = MAX(e->alpha - app.deltaTime * 8, 0);

		e->x += e->dx;
		e->y += e->dy;

		if (e->life <= 0 || e->alpha <= 0)
		{
			prev->next = e->next;

			if (e == stage.effectTail)
			{
				stage.effectTail = prev;
			}

			free(e);

			e = prev;
		}

		prev = e;
	}
}

We're simply processing our effects here, decreasing each one's `life` and `alpha` values, as well as moving them according to their `dx` and `dy` values. Once their `life` or `alpha` values fall to 0 or less, we're removing the effect.

Onto drawEffects now:


void drawEffects(void)
{
	Effect *e;

	SDL_SetRenderDrawBlendMode(app.renderer, SDL_BLENDMODE_ADD);
	SDL_SetTextureBlendMode(particleTexture->texture, SDL_BLENDMODE_ADD);

	for (e = stage.effectHead.next ; e != NULL ; e = e->next)
	{
		SDL_SetTextureAlphaMod(particleTexture->texture, MIN(e->alpha, 255));
		SDL_SetTextureColorMod(particleTexture->texture, e->color.r, e->color.g, e->color.b);

		blitScaled(particleTexture, e->x, e->y, e->size, e->size, 1);
	}

	SDL_SetTextureColorMod(particleTexture->texture, 255, 255, 255);
	SDL_SetTextureAlphaMod(particleTexture->texture, 255);

	SDL_SetRenderDrawBlendMode(app.renderer, SDL_BLENDMODE_BLEND);
	SDL_SetTextureBlendMode(particleTexture->texture, SDL_BLENDMODE_BLEND);
}

We're looping through all our active effects here, setting the colour and alpha values for each before drawing.

addMagicTrailEffect comes next:


void addMagicTrailEffect(int x, int y, int r, int g, int b)
{
	int c;
	Effect *e;

	e = malloc(sizeof(Effect));
	memset(e, 0, sizeof(Effect));
	stage.effectTail->next = e;
	stage.effectTail = e;

	e->x = x;
	e->y = y;
	e->size = 8;
	e->alpha = 255;
	e->life = FPS + rand() % (int) (FPS / 4);

	c = rand() % 128;

	e->color.r = MIN(r + c, 255);
	e->color.g = MIN(g + c, 255);
	e->color.b = MIN(b + c, 255);
}

This function will create an effect at the `x` and `y` values passed in, as well as apply the RGB values. This function is used by the wizard's magic attacks.

addHitEffect follows:


void addHitEffect(int x, int y, int r, int g, int b)
{
	int i, c;
	Effect *e;

	for (i = 0 ; i < 25 ; i++)
	{
		e = malloc(sizeof(Effect));
		memset(e, 0, sizeof(Effect));
		stage.effectTail->next = e;
		stage.effectTail = e;

		e->x = x;
		e->y = y;
		e->size = 12;
		e->alpha = 255;
		e->life = (FPS / 4) + rand() % (int) (FPS / 2);

		e->dx = rand() % 150 - rand() % 150;
		e->dy = rand() % 150 - rand() % 150;

		e->dx *= 0.005;
		e->dy *= 0.005;

		c = rand() % 128;

		e->color.r = MIN(r + c, 255);
		e->color.g = MIN(g + c, 255);
		e->color.b = MIN(b + c, 255);
	}
}

We're creating a number of effects in a for-loop, at the `x` and `y` location passed into the function, using the RGB values of the colour. The effects will also have random `dx` and `dy` values, to make them expand out from the point of origin.

addDeathEffect now:


void addDeathEffect(int x, int y)
{
	int i;
	Effect *e;

	for (i = 0 ; i < 50 ; i++)
	{
		e = malloc(sizeof(Effect));
		memset(e, 0, sizeof(Effect));
		stage.effectTail->next = e;
		stage.effectTail = e;

		e->x = x;
		e->y = y;
		e->size = 16;
		e->alpha = 255;
		e->life = (FPS / 2) + rand() % (int) (FPS / 2);

		e->dx = rand() % 150 - rand() % 150;
		e->dy = rand() % 150 - rand() % 150;

		e->dx *= 0.01;
		e->dy *= 0.01;

		e->color.r = e->color.g = e->color.b = 255;
	}
}

Much like addHitEffect, except with more effects added and in pure white.

With our effects and damageText functions now defined, we can look into incorporating them into the code. We'll start with bullets.c and the doBullet function:


void doBullet(void)
{
	Bullet *b;

	b = &stage.bullet;

	if (b->life > 0)
	{
		b->life -= app.deltaTime;

		b->x += (b->dx * app.deltaTime);
		b->y += (b->dy * app.deltaTime);

		b->trailTimer = MAX(0, b->trailTimer - app.deltaTime);

		if (b->trailTimer == 0)
		{
			b->trailTimer = 0.75;

			switch (b->type)
			{
				case WT_BLUE_MAGIC:
					addMagicTrailEffect(b->x, b->y, 0, 0, 255);
					break;

				case WT_RED_MAGIC:
					addMagicTrailEffect(b->x, b->y, 255, 0, 0);
					break;

				case WT_PURPLE_MAGIC:
					addMagicTrailEffect(b->x, b->y, 255, 0, 255);
					break;

				default:
					break;
			}
		}

		b->angle += app.deltaTime * 10;

		if (b->angle >= 360)
		{
			b->angle -= 360;
		}

		if (b->life <= 0)
		{
			applyDamage(b);
		}
	}
}

We're now decreasing the value of bullet's trailTimer (and capping it at 0). Once trailTimer hits 0, we're going to reset it to 0.75 so that it doesn't produce another effect too fast, and then test the `type` of bullet this is (derived from the weapon that created it), in a switch statement. Depending on the type, we're going to call addMagicTrailEffect with different RGB parameters. WT_BLUE_MAGIC will use a blue value, WT_RED_MAGIC a red value, and WT_PURPLE_MAGIC a purple value.

The applyDamage function has been updated with similar logic:


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

		switch (b->type)
		{
			case WT_BLUE_MAGIC:
				addHitEffect(b->x, b->y, 0, 0, 255);
				break;

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

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

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

Once we've determined that an attack has landed, we're applying the damage to the target, and also calling addHitEffect with RGB values that match the type of weapon that matches the bullet. If the attack misses, we're calling addDamageText, passing over the screen coordinates of the entity we targetted, and the text "Miss".

That's all we need to do for our bullets. Further effects are added in units.c. If we first turn to initUnits:


void initUnits(void)
{
	moveTimer = 0;

	shudderAmount = 0;
}

We've added in a static variable called shudderAmount, that will control how the units shake when they take damage. We're updating this value in doUnits:


void doUnits(void)
{
	if (stage.routeHead.next != NULL)
	{
		move();
	}

	shudderAmount += app.deltaTime * 5;
}

Each time doUnits is called, we're increasing the value of shudderAmount.

initUnit has seen an update:


Unit *initUnit(Entity *e)
{
	Unit *u;

	u = malloc(sizeof(Unit));
	memset(u, 0, sizeof(Unit));

	e->data = u;
	e->tick = tick;
	e->draw = draw;
	e->takeDamage = takeDamage;

	return u;
}

As we saw earlier, we added in a `tick` function pointer to our Unit struct. We're assigning it to all Units here. `tick` itself is a static function:


static void tick(Entity *self)
{
	Unit *u;

	u = (Unit*) self->data;

	u->shudder = MAX(u->shudder - (0.35 * app.deltaTime), 0);
}

Each time `tick` is called, we're updating the Unit's `shudder` value. We'll decrease upon each call, limiting it to 0. As we'll see in the `draw` function, the higher the value of `shudder`, the more the Unit will shake from side to side. Decreasing it here will reduce the shudder until the unit doesn't move.

We make use of `shudder` and shudderAmount in `draw`:


static void draw(Entity *self)
{
	int x, y;
	Unit *u;

	u = (Unit*) self->data;

	x = MAP_TO_SCREEN(self->x);
	y = MAP_TO_SCREEN(self->y);

	if (u->shudder > 0)
	{
		x += sin(shudderAmount) * u->shudder;
	}

	blitAtlasImage(self->texture, x, y, 1, self->facing == FACING_RIGHT ? SDL_FLIP_NONE : SDL_FLIP_HORIZONTAL);
}

Before drawing the Unit, we're testing if its `shudder` value is greater than 0. If so, we're adjusting the `x` position that we're drawing the entity at, by making use of the sin function. Again, the entity will move rapidly from left to right while `shudder` is greater than 0. As it decreases, the movement will become less pronounced, and the unit will appear to stop shaking.

Finally, we've updated the takeDamage function:


static void takeDamage(Entity *self, int damage)
{
	Unit *u;

	u = (Unit*) self->data;

	u->hp -= damage;

	u->shudder = 10;

	addDamageText(MAP_TO_SCREEN(self->x), MAP_TO_SCREEN(self->y) - (MAP_TILE_SIZE / 2), "%d", damage);

	if (u->hp <= 0)
	{
		self->die(self);
	}
}

We're setting the Unit's `shudder` to 10, to make them start to shake, are making a call to addDamageText, to display the amount of damage done, and finally calling the entity's `die` function if the unit's `hp` falls to 0 or less. The `die` function is handled separately for mages and ghosts, so let's take a look at that now.

Heading to ghosts.c, we've updated initGhost:


Unit *initGhost(Entity *e, char *filename)
{
	Unit *u;

	e->type = ET_GHOST;
	e->solid = 1;
	e->texture = getAtlasImage(filename, 1);
	e->die = die;

	u = initUnit(e);

	return u;
}

We're now assigning the entity's `die` function pointer to the new `die` function in ghosts.c:


static void die(Entity *self)
{
	self->dead = 1;

	addDeathEffect(MAP_TO_SCREEN(self->x), MAP_TO_SCREEN(self->y));
}

The `die` function simply sets the entity's `dead` flag, and also calls addDeathEffect, to generate the white puffs when the ghost is destroyed. Doing things this way means, for example, that we can have ghosts drop items or do something else upon death.

Let's nip over to entities.c, where we can see tick in action, in the doEntites function:


void doEntities(void)
{
	Entity *e, *prev;

	prev = &stage.entityHead;

	for (e = stage.entityHead.next ; e != NULL ; e = e->next)
	{
		e->tick(e);

		if (e->dead)
		{
			prev->next = e->next;

			if (e == stage.entityTail)
			{
				stage.entityTail = prev;
			}

			if (e == stage.targetEntity)
			{
				stage.targetEntity = NULL;
			}

			stage.deadEntityTail->next = e;
			stage.deadEntityTail = e;

			e = prev;
		}

		prev = e;
	}

	selectedUnitPulse += app.deltaTime * 0.1;
}

As you can see, during each iteration of our loop we're calling the entity's `tick` function. That's all we need to do to handle our shuddering!

Phew! Almost done! Let's finally head over to stage.c, and put the final pieces together. Starting with initStage:


void initStage(void)
{
	memset(&stage, 0, sizeof(Stage));

	initEntities();

	initUnits();

	initHud();

	initMap();

	generateMap();

	initPlayer();

	initAI();

	initEffects();

	app.delegate.logic = logic;

	app.delegate.draw = draw;
}

We're calling initEffects, to prepare our effects. `logic` has been changed, too:


static void logic(void)
{
	int wasAnimating;

	wasAnimating = stage.animating;

	if (!stage.animating)
	{
		if (stage.turn == TURN_PLAYER)
		{
			doHud();

			doPlayer();
		}
		else
		{
			doAI();
		}
	}

	doEntities();

	doUnits();

	doBullet();

	doEffects();

	doDamageText();

	stage.animating = stage.routeHead.next != NULL || stage.bullet.life > 0 || stage.effectHead.next != NULL || stage.damageText.life > 0;

	app.mouse.visible = !stage.animating && stage.turn == TURN_PLAYER;

	if (wasAnimating && !stage.animating)
	{
		updateUnitRanges();
	}
}

We're calling doEffects and doDamageText, to process those. We've also updated Stage's animating logic. The flag will be set if we have active effects on screen (Stage's effectHead's `next` is not NULL) or our DamageText is showing (Stage's damageText's `life` is greater than 0).

Finally, we've tweaked `draw`:


static void draw(void)
{
	drawMap();

	drawAStarPath();

	drawEntities();

	drawBullet();

	drawEffects();

	drawDamageText();

	if (stage.turn == TURN_PLAYER)
	{
		drawHud();
	}
}

We've added in calls to drawEffects and drawDamageText, so that they are displayed on screen.

Done! We've now got some great visual feedback on our combat, so we can tell what is happening when we attack enemies.

But ... something's very off. You will noticed that we can attack enemies from anywhere on screen, and that our attacks pass through walls. That's just not cricket. So, in our next part we will add in line of sight checks, as well as distance checks, to ensure that we cannot attack from just anywhere.

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:

Directly

If you do not wish to create an itch.io account, you can also purchase the tutorial bundle using PayPal, and then download the tutorials directly from the main tutorials page.

SDL2_Tutorials.tar.gz 56.76MB 23rd April 2022

Click here to see the list of files in the archive

Mobile site