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

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


The Third Side (Battle for the Solar System, #2)

The White Knights have had their wings clipped. Shot down and stranded on a planet in independent space, the five pilots find themselves sitting directly in the path of the Pandoran war machine as it prepares to advance The Mission. But if they can somehow survive and find a way home, they might just discover something far more worrisome than that which destroyed an empire.

Click here to learn more and read an extract!

« Back to tutorial listing

— Making a 2D split screen game —
Part 8: Live. Die. Repeat. And Repeat.

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

Introduction

It's time to fully implement our combat system. We have a lot of other things in place, but one of the tenants of our gameplay - the ability for the players to battle one another - has been quite absent. In this part, we'll finally fix all that.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./versus08 to run the code. You will see a window open like the one above, with each player on either side of our zone. Use the default controls (or, at your option, copy the config.json file from a previous tutorial, to use that - remember to exit the game before copying the replacement file). Play the game as normal. Players have unlimited lives, and will respawn whenever they are defeated. Notice how your guns can overheat and stop firing if you try to use them for too long. Also note that only three rockets are available, per life. Once you're finished, close the window to exit.

Inspecting the code

Adding in our fully realised combat is simple enough, and sticking with our plan to keep these updates short and concise, we'll discover there isn't anything difficult to deal with. Once again, if you've been following these tutorials through from the beginning, you won't find anything out of the ordinary. Let's dive in!

Starting first with defs.h:


#define MAX_GUN_HEAT      500.0
#define MAX_ROCKETS       3

We've added in two defines here. MAX_GUN_HEAT is the maximum heat that the players' guns can produce before they stop firing. MAX_ROCKETS is the maximum number of rockets the player can have at any time.

We've also added a new enum:


enum
{
	BT_BULLET,
	BT_ROCKET
};

This enum defines our bullet types (BT). BT_BULLET is our standard guns. BT_ROCKET is the type given to rockets.

Now over to structs.h, where we've made some updates. First, we've updated Player:


typedef struct
{
	uint8_t num;
	int     health;
	double  shield;
	double  reload;
	double  gunHeat;
	int     rockets;
	double  spinTimer;
	Entity *spinInflictor;
} Player;

We've added in a few new fields here. gunHeat is the current value of the player's gun's heat. `rockets` is the number of rockets they currently have. spinTimer is a variable that will control for how long the player's ship is spinning uncontrollably after being hit by a rocket's shockwave. spinInflictor is a pointer to the entity that inflicted the spin (which could be either the opposing player or the owner of the rocket themselves!).

Next, we've updated Bullet:


struct Bullet
{
	uint8_t    type;
	SDL_FPoint position;
	SDL_FPoint dir;
	int        damage;
	int        radius;
	double     health;
	double     angle;
	Model     *model;
	Entity    *owner;
	void (*die)(Bullet *self);
	Bullet *next;
};

Two new variables here: `damage`, which will be the amount of damage this bullet inflicts; and `die`, a function pointer that will be invokes when the bullets is destroyed.

With that done, it's over once more to player.c, where we've made a good number of changes and updates. Starting first with `tick`:


static void tick(Entity *self)
{
	Player     *p;
	SDL_FPoint *c;

	p = (Player *)self->data;

	p->gunHeat = MAX(p->gunHeat - app.deltaTime, 0);

	p->shield = MAX(p->shield - app.deltaTime, 0);

	p->spinTimer = MAX(p->spinTimer - app.deltaTime, 0);

	if (p->spinTimer == 0)
	{
		steer(self, p);

		fire(self, p);

		p->spinInflictor = NULL;
	}
	else
	{
		self->angle += self->dir.x > 0 ? -8 * app.deltaTime : 8 * app.deltaTime;
	}

	// snipped
}

We're reducing the player's gunHeat, `shield`, and spinTimers now, limiting all of them to 0. We're then testing if the spinTimer is 0, before calling `steer` and `fire` (and setting the spinInflictor to NULL). Otherwise, we'll update the player's `angle`, turning them clockwise or anti-clockwise, depending on their current horizontal velocity. What this basically means is that if the player's spinTimer is 0, they have full control over their ship. Otherwise, we'll make them spin helplessly.

Next, we've updated `fire`:


static void fire(Entity *self, Player *p)
{
	p->reload = MAX(p->reload - app.deltaTime, 0);

	if (p->reload == 0 && isControl(CONTROL_FIRE, p->num))
	{
		if (p->gunHeat < MAX_GUN_HEAT)
		{
			firePlayerBullet(self, p->num);
		}

		p->reload = FPS / 12;

		p->gunHeat = MIN(p->gunHeat + 10, MAX_GUN_HEAT + 50);
	}

	if (p->rockets > 0 && isControl(CONTROL_ALT_FIRE, p->num))
	{
		clearControl(CONTROL_ALT_FIRE, p->num);

		firePlayerRocket(self);

		p->rockets--;
	}
}

There's a bit more to this function now, but nothing difficult to work with. First, we've updated our regular gun firing logic. Now, when we fire, we're checking if the player's gunHeat is less than MAX_GUN_HEAT. If it is, we'll call firePlayerBullet as normal. In all cases, we'll be updating the player's `reload`, and also increasing their gunHeat. Note that we're allowing the player's gunHeat to actually exceed the maximum here. We're doing this to create a small delay where they are unable to start firing again, due to their weapon overheating. They will need to pause, to let it cool down.

Next up, we've added in a test for firing rockets (known here as ALT_FIRE - alternative fire). Unlike our guns, we're not restricted by a reload counter; we can fire all our rockets one after another if we want, simply by pressing the control quickly. With the control pressed and the player having some rockets available, we clear the control, and then call a new function named firePlayerRocket (we'll come to this in a bit). After that, we'll decrement the player's `rockets`.

Next, we've made a minor change to `touch`:


static void touch(Entity *self, Entity *other)
{
	Player *p;
	double  damage;

	p = (Player *)self->data;

	switch (other->type)
	{
		case ET_NONE:
			damage = sqrt((self->dir.x * self->dir.x) + (self->dir.y * self->dir.y)) * 5;

			if (p->spinTimer > 0)
			{
				damage *= 2.5;
			}

			takeDamage(self, damage, p->spinInflictor != NULL ? p->spinInflictor : other);
			bounce(self);
			break;

		// snipped
	}
}

Now, when the player makes contact with the world, we're testing if they're in a helpless spin (spinTimer > 0). If so, we'll increase `damage` by 2.5 times..! So, this means that if the player is spinning after being hit by a rocket shockwave and make contact with the environment, they'll take more damage than they normally would! We've also updated takeDamage to take this into account, passing over the spinInflictor as the attacker, if required. As we'll see in a later part, this will have implications for scoring points.

Finally, we've updated `respawn`:


static void respawn(Entity *self)
{
	Player *p;

	// snipped

	p->shield = FPS * 5;
	p->health = MAX_PLAYER_HEALTH;
	p->rockets = MAX_ROCKETS;
	p->spinTimer = 0;
	p->gunHeat = 0;
}

Now, when a player respawns, we're setting their `rockets` back to the maximum (MAX_ROCKETS), and resetting their spinTimer and gunHeat levels.

That's it for player.c. We can now move on to see what other changes we've made.

Moving first to bullets.c, we've likewise made a number of tweaks and additions. Starting first with initBullets:


void initBullets(void)
{
	memset(&head, 0, sizeof(Bullet));
	tail = &head;

	if (playerBulletModels[0] == NULL)
	{
		playerBulletModels[0] = loadModel("data/models/player1Bullet");
		playerBulletModels[1] = loadModel("data/models/player2Bullet");
		rocketModel = loadModel("data/models/rocket");
	}
}

As we've already seen, the player is now able to fire a rocket, as well as their main guns. Like the bullets themselves, the rockets have their own model. We're now loading that, as rocketModel. Note that we use the same rocket model for both players.

Next up is the update to touchWorld:


static void touchWorld(Bullet *b)
{
	Triangle *t;

	for (t = getWorldTriangles(); t != NULL; t = t->next)
	{
		if (pointInTriangle(b->position.x, b->position.y, t->points[0].x, t->points[0].y, t->points[1].x, t->points[1].y, t->points[2].x, t->points[2].y))
		{
			b->die(b);
			return;
		}
	}

	// snipped
}

This just a small update. Before, when a bullet hit the environment we'd simply set its `health` to 0, to remove it. Now, we're calling the bullet's `die` function. This is because we want things to happen, such as the rocket exploding, or (later on) have the bullets generate impact effects.

We've next updated touchEntities:


static void touchEntities(Bullet *b)
{
	Entity *e;

	for (e = zone.entityHead.next; e != NULL; e = e->next)
	{
		if (b->owner && e->takeDamage != NULL && circleCircleCollision(b->position.x, b->position.y, b->radius, e->position.x, e->position.y, e->radius))
		{
			e->takeDamage(e, b->damage, b->owner);
			b->die(b);
			return;
		}
	}
}

A few changes here. First, we're testing if the entity that the bullet has come into contact with can be hurt (its takeDamage is set). If so, and a collision has occurred, we'll call the takeDamage function, passing over the bullet's `damage`, as well as its `owner` (as the attacker). We'll then call the bullet's `die` function, before returning. While they don't exist right now, we don't want our bullets to otherwise be blocked by or affect entities that cannot be harmed, hence the test against takeDamage before invoking.

Next, firePlayerBullet has also seen a couple of minor tweaks:


void firePlayerBullet(Entity *e, int num)
{
	Bullet *b;

	b = spawnBullet();
	b->type = BT_BULLET;
	b->position = e->position;
	b->dir.x = e->dir.x + (12 * sin(TO_RAIDANS(e->angle)));
	b->dir.y = e->dir.y + (12 * -cos(TO_RAIDANS(e->angle)));
	b->damage = 2;
	b->health = FPS * 3;
	b->radius = 3;
	b->angle = e->angle;
	b->owner = e;
	b->model = playerBulletModels[num];
	b->die = bulletDie;
}

We're now setting the bullet's `damage` to 2, as well as setting the `die` function pointer, to bulletDie (we'll come to this in a bit).

The firePlayerRocket function follows:


void firePlayerRocket(Entity *e)
{
	Bullet *b;

	b = spawnBullet();
	b->type = BT_ROCKET;
	b->position = e->position;
	b->dir.x = e->dir.x + (16 * sin(TO_RAIDANS(e->angle)));
	b->dir.y = e->dir.y + (16 * -cos(TO_RAIDANS(e->angle)));
	b->damage = 0;
	b->health = FPS * 60;
	b->radius = 5;
	b->angle = e->angle;
	b->owner = e;
	b->model = rocketModel;
	b->die = rocketDie;
}

This is quite a lot like the firePlayerBullet function, except that we're not passing over the Player's `num`, since we're using the same model for both players. Aside from that, the rockets will have a different `type`, live longer (60 seconds), use a different `model`, and call a different `die` function. Of note is that rockets don't cause any damage! This is because, as we'll see shortly, when a rocket dies it explodes, damaging everything nearby.

First up, we'll look at bulletDie:


static void bulletDie(Bullet *self)
{
	self->health = 0;
}

Right now, there's nothing special going on here, other than that we're setting the bullet's `health` to 0. We'll be expanding on this function in future. Let's move on to the rocketDie function:


static void rocketDie(Bullet *self)
{
	Entity *e;
	Player *p;
	double  distance, force, dx, dy;
	int     damage;

	for (e = zone.entityHead.next; e != NULL; e = e->next)
	{
		if (e->takeDamage != NULL)
		{
			distance = hypot(self->position.x - e->position.x, self->position.y - e->position.y);

			if (distance <= ROCKET_SPLASH_RANGE)
			{
				damage = (int)(ROCKET_SPLASH_RANGE - distance) * 0.75;

				e->takeDamage(e, damage, self->owner);

				if (e->type == ET_PLAYER)
				{
					p = (Player *)e->data;

					force = pow(ROCKET_SPLASH_RANGE - distance, 0.8) * 0.125;

					normalizeVector(e->position.x, e->position.y, self->position.x, self->position.y, &dx, &dy);

					e->dir.x = dx * force;
					e->dir.y = dy * force;

					p->spinTimer = FPS * 1.5;
					p->spinInflictor = self->owner;
				}
			}
		}
	}

	self->health = 0;
}

Okay, so there's quite a lot more going on here, compared to when a normal bullet dies. The summary of this function is that we'll loop through all the entities in our Zone, searching for all those within range of the explosion, and cause damage to them.

Let's now go through that in greater detail. For each entity (`e`) that can be damaged, we're using the hypot function (from math.h) to determine the distance from the rocket's impact point. We test if the `distance` is less than or equal to the rocket's splash damage range (ROCKET_SPLASH_RANGE), and then calculate the `damage` we want to apply. The closer the entity is, the greater the damage they will take. We call the entity's takeDamage function, passing over the `damage`, and also the `owner` of the bullet as the attacker.

Next, we test if the affected entity is a player (ET_PLAYER). If so, we'll push the player away from the area of impact. Again, we use the `distance` the entity is from the point of impact when deciding how forcefully to push them (as `force`). We'll calculate the direction to push the player (by calling normalizeVector in util.c), multiply the resulting vector (as `dx` and `dy`) by `force`, and apply that to the player's `dir`. Finally, we set the Player's spinTimer and spinInflictor values, to cause them to spin helplessly for a time.

The last thing we do is set the bullet's `health` to 0.

That's it for bullets.c. With that done, we have a working combat system, for the two players to make use of. What we're missing is some visual indications of what is available to the players. For that, we need to update the HUD.

Turning to hud.c, we've updated drawBottomBars:


static void drawBottomBars(void)
{
	int     i;
	Player *p;

	drawRect(0, SCREEN_HEIGHT - 25, SCREEN_WIDTH, 30, 0, 0, 0, 200);

	for (i = 0; i < NUM_PLAYERS; i++)
	{
		p = (Player *)zone.players[i]->data;

		drawHealth(p);

		drawGunHeat(p);

		drawRocketCount(p);
	}
}

We're calling drawGunHeat and drawRocketCount. We'll start first with drawGunHeat:


static void drawGunHeat(Player *p)
{
	double    percent;
	SDL_Rect  r;
	SDL_Color c;

	r.x = (SCREEN_WIDTH / 2) * p->num;
	r.x += 325;
	r.x += PADDING;
	r.y = SCREEN_HEIGHT - 20;
	r.w = 100;
	r.h = 15;

	percent = MIN(p->gunHeat, MAX_GUN_HEAT) / MAX_GUN_HEAT;

	if (percent < 0.8)
	{
		c.r = 64;
		c.g = 225;
		c.b = 64;
	}
	else
	{
		c.r = 255;
		c.g = 64;
		c.b = 64;
	}

	drawRect(r.x, r.y, r.w, r.h, c.r * 0.5, c.g * 0.5, c.b * 0.5, 255);

	drawRect(r.x, r.y, r.w * percent, r.h, c.r, c.g, c.b, 255);
}

This function is pretty easy to understand. As the name suggests, it exists to draw the level of gunHeat for the Player. We do this by setting up an SDL_Rect (`r`) to define our heat bar. Next, we determine the level of gun heat, as a percentage (`percent`). If this is less than 80%, we'll render the bar in green. Otherwise, we'll draw it in red (via an SDL_Color variable called `c`). We then draw two rectangles. The first will be half the brightness of `c`, and the using the full width of the SDL_Rect. The second is the calculated percentage width of the bar, with the normal RGB values of `c`. This means that we'll see our gun heat bar expanding as the value increases, starting green and turning red as it approached the maximum.

Lastly, we have drawRocketCount:


static void drawRocketCount(Player *p)
{
	SDL_Rect  r;
	int       i;
	SDL_Color c;

	r.x = 0;
	r.y = SCREEN_HEIGHT - 20;
	r.w = 18;
	r.h = 15;

	for (i = 0; i < MAX_ROCKETS; i++)
	{
		c.r = 255;
		c.g = 128;
		c.b = 0;

		r.x = (SCREEN_WIDTH / 2) * p->num;
		r.x += 460;
		r.x += i * (r.w + 3);

		if (i >= p->rockets)
		{
			c.r *= 0.5;
			c.g *= 0.5;
		}

		drawRect(r.x, r.y, r.w, r.h, c.r, c.g, c.b, 255);
	}
}

Another simple to understand function. We're drawing three orange rectangles on screen (via an SDL_Color, `c`), for the player, using a for-loop. If the value of `i` equals or exceeds the Player's rockets, we'll halve the brightness of our SDL_Color. This means that as we fire our rockets, one of our rectangles will go dark, indicating that it has been used.

Hurrah! Another part done and finished. Things are progressing really well, and we've now put most of the core gameplay in place.

It's sort of boring that the players ships simply vanish when they're destroy, though. The rockets are even more boring (and sort of hard to appreciate). What we need is some sort of particle system, so that we can see explosions and other little effects.

Therefore, in our next part we'll go about implementing a particle system, made up purely of triangles, to make things a little nicer to look at.

Purchase

The source code for all parts of this tutorial (including assets) is available for purchase, as part of the SDL2 tutorials bundle:

From itch.io

Mobile site