« Back to tutorial listing

— Creating a Run and Gun game —
Part 13: A full level!

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

Introduction

It's time to expand our level a bit, and so we've made the map several times larger than before. Previously, we had just one and a half screens to explore. Now, we have several. This large map incorporates all the major aspects of the previous parts, with enemies, doors, barrels, and power-ups to be found. We've also added in a new enemy: a drone.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./gunner13 to run the code. You will see a window open like the one above. The same controls from past tutorials apply. Play the game as normal. You now have 3 lives. Once you've died 3 times, the level will restart. Once you're finished, close the window to exit.

Inspecting the code

Let's jump straight in, start with defs.h:


#define MAP_WIDTH              210
#define MAP_HEIGHT             95

Our map is larger now, so we've updated MAP_WIDTH and MAP_HEIGHT to reflect this. With a larger map, we've also got more entities. We've therefore increased the size of our quadtree candidates:


#define MAX_QT_CANDIDATES      1024

We can now fetch up to 1024 entities when searching our quadtree, which should be more than enough to meet our needs. We've also added in an enum for our drones:


enum {
	DRONE_GREY,
	DRONE_RED,
	DRONE_MAX
};

You will have noticed we have grey drones and red drones. The enum will be used to help with their types, being either DRONE_GREY or DRONE_RED.

Heading over to structs.h now, we've updated the Gunner struct:


typedef struct {
	int rest;
	int frame;
	int ducking;
	int aimingUp;
	int life;
	int weaponType;
	double animTimer;
	double reload;
	double immuneTimer;
	int hasKeycard[KEYCARD_MAX];
	SDL_Point checkpoint;
	double checkpointTimer;
} Gunner;

We've added a field called `rest`, which will represent the number of lives the player has. We've called it "rest" simply because some old 8-bit games used to call lives "rest" (as a bad translation). No other reason.

We've also added in a struct to represent our Drone:


typedef struct {
	int type;
	int life;
	double thinkTime;
	double damageTimer;
	double despawnTimer;
	double engineTimer;
	int shotsToFire;
	double reload;
} Drone;

`type` is the type of Drone this is (red or grey). `life` is the amount of health the Drone has. thinkTime is the amount of time before the Drone takes a new action. damageTimer is used to show if the Drone has taken damage. despawnTimer is a field that will control how long the drone will exist for. Since our drones are spawned in randomly, we don't want 100s of them to clutter up the level. Therefore, they will only live for a certain amount of time before being removed. engineTimer is used to control how often the drone creates its engine effect. shotsToFire is the number of shots a drone is currently firing, while `reload` controls the period between shooting.

While we're on the subject of the Drones, we should now look at how they work. Our drones are defined in a file called drone.c. You will have seen that our drones come in two flavours: a red one and a grey one. Both types are handled here. Starting with addDrone:


void addDrone(int x, int y)
{
	Drone *d;
	Entity *e;

	d = malloc(sizeof(Drone));
	memset(d, 0, sizeof(Drone));

	if (droneTextures[DRONE_GREY] == NULL)
	{
		droneTextures[DRONE_GREY] = getAtlasImage("gfx/sprites/greyDrone.png", 1);
		droneTextures[DRONE_RED] = getAtlasImage("gfx/sprites/redDrone.png", 1);
		bulletTexture = getAtlasImage("gfx/sprites/enemyBullet.png", 1);
	}

	d->life = 4;
	d->despawnTimer = FPS * 15;
	d->type = rand() % DRONE_MAX;

	e = spawnEntity();

	e->x = x;
	e->y = y;
	e->flags = EF_BLOCKS_LOS + EF_WEIGHTLESS;
	e->texture = droneTextures[d->type];
	e->data = d;

	e->hitbox.w = 24;
	e->hitbox.h = 21;

	e->tick = tick;
	e->draw = draw;
	e->takeDamage = takeDamage;
	e->touch = touch;
}

The function takes two parameters: `x` and `y`, rather than an Entity, as it will be spawning the entity to use itself. We're first mallocing and memsetting a Drone, then loading the two drone textures (droneTexures) and bullet texture (bulletTexture) as needed. We're then setting the Drone's `life` to 4 and its despawnTimer to 15 seconds. We're then assign its `type` as a random of DRONE_MAX, meaning it will be of type DRONE_GREY (0) or DRONE_RED (1).

With that done, we're called spawnEntity to create the Entity itself, and assigning its `x` and `y` to the values of the `x` and `y` we passed into the function. We're then setting its `flags`. As well as blocking the line of sight of other enemies, we're setting our Drones to be weightless (EF_WEIGHTLESS). Our Drones fly, so they shouldn't be affected by gravity. The entity's `texture` is set based on the type of drone this is, using the appropriate index in our droneTextures array.

We then set the Drone's `hitbox` width and height (`w` and `h`). You will have noticed that the Drones are a little harder to shooter than one would expect. Their hitbox is smaller than their texture and set around their central body. Bullets won't affect their limbs.

Finally, we set the `tick`, `draw`, takeDamage, and `touch` function pointers.

Let's look at `tick` now. There's quite a lot going on, though some of it will be familiar:


static void tick(Entity *self)
{
	Drone *d;
	int x, y;

	d = (Drone*) self->data;

	d->damageTimer = MAX(d->damageTimer - app.deltaTime, 0);

	d->reload = MAX(d->reload - app.deltaTime, 0);

	d->despawnTimer = MAX(d->despawnTimer - app.deltaTime, 0);

	d->engineTimer = MAX(d->engineTimer - app.deltaTime, 0);

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

		if (d->thinkTime == 0)
		{
			d->thinkTime = FPS;

			if (!stage.player->dead)
			{
				lookForPlayer(self);
			}
		}
	}
	else if (d->reload == 0)
	{
		d->shotsToFire--;

		if (d->type == DRONE_GREY)
		{
			fireBulletX(self);
		}
		else
		{
			fireBulletY(self);
		}
	}

	if (d->engineTimer <= 0)
	{
		addDroneEngineEffect(self->x + (self->texture->rect.w / 2), self->y + self->texture->rect.h, self->dy);

		d->engineTimer = 1;
	}

	if (d->despawnTimer <= 0)
	{
		x = self->x - stage.camera.x;
		y = self->y - stage.camera.y;

		if (!collision(x, y, self->texture->rect.w, self->texture->rect.h, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT))
		{
			self->dead = 1;
		}
	}
	else
	{
		d->despawnTimer = 1;
	}

	self->hitbox.x = self->x + 12;
	self->hitbox.y = self->y + 3;
}

We're extracting the Drone's data and then decreasing its damageTimer, `reload`, despawnTimer, and engineTimer values. We're then checking to see if the drone is attacking, by checking its shotsToFire. The logic for having 0 shotsToFire is just the same as for both the soldier enemies, so we need not say any more about it. If there are shots to fire then we'll be decreasing shotsToFire and then testing the drone's `type`, to see how they should fire. If this is a grey done, we'll call fireBulletX, otherwise we'll call fireBulletY. This means that grey drones will fire horizontally, while red drones will fire vertically.

With that done, we're testing the drone's engineTimer. If it's less than 0, we'll be calling a new function called addDroneEngineEffect. This will generate our purple drone engine effect. We'll see more on this later on. We're then setting engineTimer to 1, to create a brief pause before we create another engine effect.

Next, we're testing to see if despawnTimer is 0 or less. If so, we're going to check if the Drone is on screen, by calling collision and checking if the drone's rectangular area falls within the screen area (taking into account the camera position). If its not, we'll set the drone's dead flag 1 to one, to remove it from the game. If the drone is still on screen, we'll set its despawnTimer to 1, to pause for a moment before we do this test again. In effect, this means that our drones will only live for 15 seconds before becoming eligible for removal by the game. The idea is to stop 100s of drones from existing.

The last thing we do in the function is to update the hitbox's `x` and `y` coordinates, according to the drone's position.

We'll consider the lookForPlayer function now. It's fairly simple:


static void lookForPlayer(Entity *self)
{
	int n, distX, distY;
	Drone *d;

	self->dx = self->dy = 0;

	n = rand() % 5;

	if (n < 2)
	{
		calcSlope(stage.player->x, stage.player->y, self->x, self->y, &self->dx, &self->dy);
	}
	else if (n < 4)
	{
		calcSlope(self->x, self->y, stage.player->x, stage.player->y, &self->dx, &self->dy);
	}

	self->dx *= 1 + (rand() % 3);
	self->dy *= 1 + (rand() % 3);

	distX = abs(self->x - stage.player->x);
	distY = abs(self->y - stage.player->y);

	d = (Drone*) self->data;

	if ((d->type == DRONE_GREY && distX <= SCREEN_WIDTH / 2 && distY <= 256) || (d->type == DRONE_RED && distY <= SCREEN_HEIGHT / 2 && distX <= 256))
	{
		if (canSeePlayer(self))
		{
			d->shotsToFire = 4 + rand() % 5;

			d->thinkTime = FPS * 2;
		}
	}
}

The first thing we're doing is telling the drone to stop moving, by setting its `dx` and `dy` to 0. Next, we're assigning a variable called `n` a random value of 5 (0 - 4). We're then checking this value. If it's less than 2, we're caling calcSlope and feeding in the player's position, plus the drone's position, and its `dx` and `dy` fields. This will cause the drone to move towards the player. If `n` is less than 4, we're calling calcSlope, but with the input values swapped, making the drone move away from the player. We're than multiplying the drone's `dx` and `dy` values by a random of 1 - 3, to randomize its speed.

Next, we want to check to see if the drone is within shooting range of the player. We start by calculating the x and y distances of the drone to the player, assigning these to values named distX and distY. We're then testing the drone's range. This range will depend on the type of drone this is. If it's a grey drone, we want the drone to be within half a screen horizontally of the player, and within 256 pixels vertical of them. If we're using a red drone, we want the drone to be within the full screen height of the player, as well as a horizontal distance of 256 pixels. With these conditions met, we're calling canSeePlayer to perform a LOS check, and then setting the drone's shotsToFire to a random of 4 - 8, before setting its thinkTime back to 2 seconds.

Our draw function is next. It's the same as we've seen many times before:


static void draw(Entity *self)
{
	Drone *d;
	int x, y;

	d = (Drone*) self->data;

	x = self->x - stage.camera.x;
	y = self->y - stage.camera.y;

	if (d->damageTimer == 0)
	{
		blitAtlasImage(self->texture, x, y, 0, SDL_FLIP_NONE);
	}
	else
	{
		SDL_SetTextureColorMod(self->texture->texture, 255, 32, 32);
		blitAtlasImage(self->texture, x, y, 0, SDL_FLIP_NONE);
		SDL_SetTextureColorMod(self->texture->texture, 255, 255, 255);
	}
}

We'll be drawing the drone normally, unless it has taken damage, in which case it will be rendered with a red tint. Our takeDamage function is up next:


static void takeDamage(Entity *self, int amount, Entity *attacker)
{
	Drone *d;
	Entity *e;
	int i, x, y;

	if (attacker == stage.player)
	{
		d = (Drone*) self->data;

		d->life -= amount;

		d->damageTimer = 4;

		d->thinkTime = FPS / 4;

		if (d->life <= 0)
		{
			for (i = 0 ; i < 16 ; i++)
			{
				x = self->x + rand() % self->texture->rect.w;
				y = self->y + rand() % self->texture->rect.h;

				addExplosionEffect(x, y, 64);
			}

			for (i = 0 ; i < 6 ; i++)
			{
				x = self->x + (self->texture->rect.w / 2);
				y = self->y + (self->texture->rect.h / 2);

				addDebris(x, y);
			}

			e = spawnEntity();
			e->x = self->x + (self->texture->rect.w / 2);
			e->y = self->y + (self->texture->rect.h / 2);
			e->dx = (1.0 * (rand() % 400 - rand() % 400)) * 0.01;
			e->dy = (1000 + (rand() % 700)) * -0.01;

			if (d->type == DRONE_GREY)
			{
				initHealth(e);
			}
			else
			{
				initSpreadGun(e);
			}

			e->flags |= EF_BOUNCES;

			self->dead = 1;
		}
	}
}

Again, this is largely the same as we've seen for the doors, oil drums, and soldiers. However, we're doing something extra when the drone is killed. After we've created the explosions and debris, we're then creating a power-up item. We're calling spawnEntity to create a new entity, and setting its `x` and `y` to the middle of the drone. We're then setting its `dx` to a random between -4 and 4, and its `dy` to a random of -17 to -10. This will cause the created entity to be flung. We're then testing the type of drone this was. If it was a grey drone, we'll be calling initHealth and passing in the entity to made. Otherwise, we'll call initSpreadGun and pass in the entity. We're then adding EF_BOUNCES to the entity's `flags`, to make it bounce. In effect, when our drones are killed, they will drop pick-ups for us to collect.

Our `touch` function is next. Again, it's the same as for the soldiers:


static void touch(Entity *self, Entity *other)
{
	if (other == stage.player)
	{
		other->takeDamage(other, 1, self);
	}
}

Should the player touch the drone, they will take a point of damage.

We now come to the two firing functions, fireBulletX and fireBulletY. We'll start with fireBulletX:


static void fireBulletX(Entity *self)
{
	Bullet *b;

	b = spawnBullet(self);
	b->texture = bulletTexture;
	b->damage = 1;
	b->life = FPS * 2;

	b->x = self->x + (self->texture->rect.w / 2) - (b->texture->rect.w / 2);
	b->y = self->y + (self->texture->rect.h / 2) - (b->texture->rect.h / 2);

	b->dx = self->x < stage.player->x ? BULLET_SPEED : -BULLET_SPEED;

	((Drone*) self->data)->reload = RELOAD_SPEED;
}

This function creates a bullet that is fired horizontal. We're calling spawnBullet, setting the `texture`, `damage`, and `life` as usual, and also setting the bullet's `x` and `y` values so that it issues from the center of the drone. For the `dx` value, we're testing where the player is relative to the drone. If the drone's `x` is less than the player's `x`, we'll see `dx` to BULLET_SPEED (12) so that it moves to the right. Otherwise, we'll set it to -BULLET_SPEED, so that it travels to the left. With that done, we're resetting the drone's `reload`.

fireBulletY is quite similar:


static void fireBulletY(Entity *self)
{
	Bullet *b;

	b = spawnBullet(self);
	b->texture = bulletTexture;
	b->damage = 1;
	b->life = FPS * 2;

	b->x = self->x + (self->texture->rect.w / 2) - (b->texture->rect.w / 2);
	b->y = self->y + (self->texture->rect.h / 2) - (b->texture->rect.h / 2);

	b->dy = BULLET_SPEED;

	((Drone*) self->data)->reload = RELOAD_SPEED;
}

The only difference is that the drone will always fire bullets down the screen; we never shoot up.

That's our drones completely covered. Let's briefly turn to effects.c, to see how addDroneEffect is defined:


void addDroneEngineEffect(int x, int y, double dy)
{
	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->dy = 2;
	e->size = 24;
	e->life = FPS;
	e->alpha = 32;

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

The function takes three parameters: `x`, `y`, and `dy`. As per our standard effect creation functions, we're generating the Effect, setting its `x` and `y` to the values passed in, and setting the `dy` to 2, plus the value of the `dy` we passed into the function. This means that the effect will move down when it is created. The `dy` passed into the function is the `dy` of the drone itself. This helps to keep the engine always appearing to blast down, in case the drone is moving down faster than the effect. We then set the effect's `size`, `life` (as 1 second), `alpha`, and `color`. A simple function at the end of the day.

Let's now look at the changes to player.c. We've given the player a number of lives (`rest`), so we've updated initPlayer:


void initPlayer(Entity *e)
{
	// snipped

	g = malloc(sizeof(Gunner));
	memset(g, 0, sizeof(Gunner));

	g->rest = NUM_PLAYER_RESTS;
	g->animTimer = ANIM_TIME;
	g->life = MAX_PLAYER_LIFE;
	g->weaponType = WPN_NORMAL;
	g->checkpoint.x = e->x;
	g->checkpoint.y = e->y;

	// snipped
}

As well as all the other Gunner attributes, we're setting the new `rest` field to NUM_PLAYER_RESTS (defined as 2). Our resetPlayer function has also been changed:


int resetPlayer(void)
{
	Gunner *g;

	g = (Gunner*) stage.player->data;

	if (g->rest > 0)
	{
		stage.player->x = g->checkpoint.x;
		stage.player->y = g->checkpoint.y;
		stage.player->dead = 0;
		stage.player->onGround = 1;
		stage.player->texture = standTexture;

		stage.entityTail->next = stage.player;
		stage.entityTail = stage.player;

		g->life = MAX_PLAYER_LIFE;
		g->weaponType = WPN_NORMAL;
		g->immuneTimer = FPS * 3;

		g->rest--;

		return 1;
	}

	return 0;
}

The function now returns an int. This will tell us whether we were able to reset the player. Before, we were simply putting the player back at the last checkpoint. Now, we're testing the `rest` field, to see if it's greater than 0. If so, we're resetting the player as normal and decrementing `rest`. We're then returning 1, to say that the player was reset. Otherwise, we're returning 0.

If we turn now to stage.c, we can see how this is used in the `logic` function:


static void logic(void)
{
	app.dev.collisionChecks = 0;

	if (stage.player->dead)
	{
		playerRespawnTimer -= app.deltaTime;

		if (playerRespawnTimer <= 0)
		{
			if (resetPlayer())
			{
				playerRespawnTimer = RESPAWN_TIMER_TIME;
			}
			else
			{
				resetStage();

				initStage();
			}
		}
	}

	spawnDrone();

	doEntities();

	doBullets();

	doEffects();

	doHud();

	updateCamera();

	if (app.keyboard[SDL_SCANCODE_F1])
	{
		app.dev.showHitboxes = !app.dev.showHitboxes;

		app.keyboard[SDL_SCANCODE_F1] = 0;
	}
}

When checking if the player is dead, we're testing the result of resetPlayer. If it returns 1, we carry on as before. Otherwise, we're calling a new function named resetStage, followed by initStage. In effect, this means that once a player has run out of rests, the game will restart from the beginning.

We're also calling a new function named spawnDrone. We'll look at this first, before seeing what's involved in resetStage:


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

	droneSpawnTimer -= app.deltaTime;

	if (droneSpawnTimer <= 0)
	{
		droneSpawnTimer = DRONE_SPAWN_TIME;

		x = stage.camera.x + (rand() % SCREEN_WIDTH);
		y = stage.camera.y - 64;

		mx = x / MAP_TILE_SIZE;
		my = y / MAP_TILE_SIZE;

		if (!isInsideMap(mx, my) || stage.map[mx][my] == 0)
		{
			addDrone(x, y);
		}
	}
}

The idea behind this function is to add in a random drone, at a set interval. We start be decreasing a variable named droneSpawnTimer. If this variable is 0 or less, we're resetting droneSpawnTimer to DRONE_SPAWN_TIME (defined as 15). After that, we're assigning a variable named `x` the value of the camera's `x`, plus a random of the screen width, and a variable called `y` the value of the camera's `y`, minus 64. What this means is that we want a drone to spawn in from somewhere at the top of the screen. Before we can add the drone, however, we should check that the location is valid. We're dividing `x` and `y` by MAP_TILE_SIZE and assigning the results to `mx` and `my`, so we can test the desired spawn location against the map. We then test if the drone is outside the map (to allow it to spawn above the player, at the very top of the stage) and also if the tile at the map location is 0 (air). If so, we call addDrone, passing in our `x` and `y` values.

We're very nearly done. The last thing we'll look at is the resetStage function:


static void resetStage(void)
{
	destroyQuadtree(&stage.quadtree);

	clearEntities();

	clearBullets();

	clearEffects();

	free(stage.player->data);

	free(stage.player);
}

The idea behind this function is to fully clear down the stage, removing all data. We're calling a series of functions here: destroyQuadtree, clearEntities, clearBullets, and clearEffects. We're also explicitly freeing the player's data and the player itself. Right now, this function is only called when the player has lost all their lives, so we know that the only reference to them in the stage.player pointer.

If we now look at entities.c, we can see where the clearEntities function is defined:


void clearEntities(void)
{
	Entity *e;

	while (stage.entityHead.next != NULL)
	{
		e = stage.entityHead.next;

		stage.entityHead.next = e->next;

		if (e->data != NULL)
		{
			free(e->data);
		}

		free(e);
	}

	while (deadHead.next != NULL)
	{
		e = deadHead.next;

		deadHead.next = e->next;

		if (e->data != NULL)
		{
			free(e->data);
		}

		free(e);
	}
}

We're setting up while loops for both stage's entity list and the dead list, with the condition to continue while the head's `next` of each is not NULL. We're then grabbing a reference to the next entity, assigning it to `e`, then setting the head's `next` to `e`'s `next`. This cuts it out of the list. We then test whether the entity's `data` field is set and free it if so, before then freeing the entity itself. This loop will continue for all the entities in the list and effectively delete everything in it.

If we look at bullets.s, where clearBullets is defined, we can see we're using the same method to clear the bullet list:


void clearBullets(void)
{
	Bullet *b;

	while (stage.bulletHead.next != NULL)
	{
		b = stage.bulletHead.next;

		stage.bulletHead.next = b->next;

		free(b);
	}
}

We're doing the very same in clearEffects:


void clearEffects(void)
{
	Effect *e;

	while (stage.effectHead.next != NULL)
	{
		e = stage.effectHead.next;

		stage.effectHead.next = e->next;

		free(e);
	}
}

With all our stage data cleared, we're safe to call initStage and set everything back up again, without worrying that we'll combine the new data with the old or create memory leaks. Such code is also handy for changing stages (for a future tutorial).

That's it for this part. We've only got two more things to add in: game controller support, and some finishing touches, such as a title screen, sound, and music.

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