« Back to tutorial listing

— 2D Shoot 'Em Up Tutorial —
Part 14: Highscore table (Part 2)

Introduction

Note: this tutorial builds upon the ones that came before it. If you aren't familiar with the previous tutorials in this series you should read those first.

We must now collect score pods to earn points, making the game a little more tricky. Wouldn't it be fun if we had a highscore table? This part of the tutorial will do just that. Extract the archive, run cmake CMakeLists.txt, followed by make to build. Once compiling is finished type ./shooter14 to run the code.

A 1280 x 720 window will open, with a colorful background. A highscore table is displayed, as in the screenshot above. Press the left control key to start. A spaceship sprite will also be shown. The ship can now be moved using the arrow keys. Up, down, left, and right will move the ship in the respective directions. You can also fire by holding down the left control key. Enemies (basically red versions of the player's ship) will spawn from the right and move to the left. Shoot enemies to destroy them. Enemies can fire back, so you should avoid their shots. Score points by collect points pods released from destroyed enemy ships. The highscore table is shown upon the player's death. If the player has earned a highscore, they will be prompted to enter their name. The highscore table is then shown and the game can be played again. Close the window by clicking on the window's close button.

Inspecting the code

In our last tutorial, we added a highscore table. Now we've added the ability for the player to type in their name when they gain enough points to earn a place. Before we get to that, let's take a look at the updates we've made to the text drawing function, to mitigate any confusion later. First, we've updated defs.h:


enum
{
	TEXT_LEFT,
	TEXT_CENTER,
	TEXT_RIGHT
};

We've added an enum to specify the alignment of the text, either left, center, or right. Next, we've updated the drawText function, adding a new parameter called align, to allow us to control the text's placement:


void drawText(int x, int y, int r, int g, int b, int align, char *format, ...)
{
	...
	len = strlen(drawTextBuffer);

	switch (align)
	{
		case TEXT_RIGHT:
			x -= (len * GLYPH_WIDTH);
			break;

		case TEXT_CENTER:
			x -= (len * GLYPH_WIDTH) / 2;
			break;
	}

We first get the length of the text, then test the alignment. Note that if the alignment is TEXT_LEFT we don't do anything. If the text is aligned to the right, we calculate its width and then subtract that from x. Things are made easier here since we're using a fixed width font. Had the width of the glyphs been variable, we'd need to get the width of each individual glyph. We do a similar thing when the text is centered, except that we divide the width of the text by 2 before subtracting. This means that the text is horizontally aligned. Easy.

Now onto the text input handling. Updating structs.h:


typedef struct {
	...
	char inputText[MAX_LINE_LENGTH];
} App;

typedef struct {
	char name[MAX_SCORE_NAME_LENGTH];
	...
} Highscore;

We've added a large char array to App, to use as a buffer for the captured player input. We've also added a char array to Highscore, to hold the player's name. MAX_SCORE_NAME_LENGTH is 16 character long (note, that due to the null terminator, the player will only be able to enter 15 characters..!). We next update input.c, making an addition to doInput:


void doInput(void)
{
	...
	memset(app.inputText, '\0', MAX_LINE_LENGTH);

	...
	case SDL_TEXTINPUT:
		STRNCPY(app.inputText, event.text.text, MAX_LINE_LENGTH);
		break;

We clear app.inputText and then test for a new SDL event: SDL_TEXTINPUT. When this event is received, we'll have access to a text buffer called event.text.text, containing a string of characters that were typed since the last call. We'll copy these into app.inputText. Note that we're not interested in concatenating this string; we're only going to use the most recent input, saving us some bother of testing the string length to prevent buffer overflows, etc. As you can see, grabbing text input in SDL is very simple: we just check for SDL_TEXTINPUT in the event queue. Input can actually be controlled by calling SDL_StartTextInput and SDL_StopTextInput, to turn it off and on respectively. By default, it's on. We're going to leave it this way.

The bulk of these updates happens, as expected, in highscores.c. Starting with initHighscoreTable:


void initHighscoreTable(void)
{
	...
	for (i = 0 ; i < NUM_HIGHSCORES ; i++)
	{
		highscores.highscore[i].score = NUM_HIGHSCORES - i;
		STRNCPY(highscores.highscore[i].name, "ANONYMOUS", MAX_SCORE_NAME_LENGTH);
	}

	newHighscore = NULL;

	cursorBlink = 0;
}

We're going to set the names of the initial highscore earners to ANONYMOUS. Note that we're using capital letters here. This is because our text drawing routine doesn't support lowercase letters. Next, we're initializing two variables: newHighscore and cursorBlink. We'll come to these in a bit. Moving onto logic, we've made some tweaks:


static void logic(void)
{
	...
	if (newHighscore != NULL)
	{
		doNameInput();
	}
	else
	{
		if (app.keyboard[SDL_SCANCODE_LCTRL])
		{
			initStage();
		}
	}

	if (++cursorBlink >= FPS)
	{
		cursorBlink = 0;
	}
}

We're testing to see if newHighscore is not NULL. If it's not, we're calling a new function called doInput. newHighscore is a pointer to a Highscore object, which will be assigned if the player gains a highscore. If newHighscore is NULL, we're handling the highscore table as we normally do. We're also incrementing the cursorBlink variable, resetting it to 0 if it equals or exceeds FPS. This means the counter will reset once per second. This will be used to control the blinking of a cursor when we're handling text input, to prompt the player to type something and not show an empty space.

We'll look at the doNameInput function now. This is where we'll handle a player typing their name when they get a highscore:


static void doNameInput(void)
{
	int i, n;
	char c;

	n = strlen(newHighscore->name);

	for (i = 0 ; i < strlen(app.inputText) ; i++)
	{
		c = toupper(app.inputText[i]);

		if (n < MAX_SCORE_NAME_LENGTH - 1 && c >= ' ' && c <= 'Z')
		{
			newHighscore->name[n++] = c;
		}
	}

	if (n > 0 && app.keyboard[SDL_SCANCODE_BACKSPACE])
	{
		newHighscore->name[--n] = '\0';

		app.keyboard[SDL_SCANCODE_BACKSPACE] = 0;
	}

	if (app.keyboard[SDL_SCANCODE_RETURN])
	{
		if (strlen(newHighscore->name) == 0)
		{
			STRNCPY(newHighscore->name, "ANONYMOUS", MAX_SCORE_NAME_LENGTH);
		}

		newHighscore = NULL;
	}
}

We're first grabbing the current length of the name in newHighscore (into a variable called n), then stepping through all the characters in app.inputText. We're uppercasing the letter (again, because we don't support lowercase characters) and then performing several tests. First, we're ensuring that n is still less than MAX_SCORE_NAME_LENGTH and also that the current character is within the range of those we support by drawText. With those tests passed, we're appending the character to newHighscore's name. We'll also check to see if the player has pressed backspace. If so, we'll delete the most recent character in the name input (by simply replacing it with a null terminator). We clear the backspace key afterwards, to prevent it from erasing the entire name input all at once. Finally, if the player presses return we're setting newHighscore to NULL so that the regular logic and drawing functions to handle and display the highscore table take over. One other thing we're doing is checking if the player has entered anything when they press return. If not, we're naming them ANONYMOUS (note that this won't work if the player just enters all spaces - fixing this can be an exercise for the reader).

That's all our logic routines handled, so let's look at rendering. Starting with draw:


static void draw(void)
{
	...
	if (newHighscore != NULL)
	{
		drawNameInput();
	}
	else
	{
		drawHighscores();
	}
}

Again, we're checking to see if newHighscore is assigned and not NULL, and if so we're calling a new function called drawNameInput. Otherwise, the highscore table is rendered as usual. drawNameInput is quite simple:


static void drawNameInput(void)
{
	SDL_Rect r;

	drawText(SCREEN_WIDTH / 2, 70, 255, 255, 255, TEXT_CENTER, "CONGRATULATIONS, YOU'VE GAINED A HIGHSCORE!");

	drawText(SCREEN_WIDTH / 2, 120, 255, 255, 255, TEXT_CENTER, "ENTER YOUR NAME BELOW:");

	drawText(SCREEN_WIDTH / 2, 250, 128, 255, 128, TEXT_CENTER, newHighscore->name);

	if (cursorBlink < FPS / 2)
	{
		r.x = ((SCREEN_WIDTH / 2) + (strlen(newHighscore->name) * GLYPH_WIDTH) / 2) + 5;
		r.y = 250;
		r.w = GLYPH_WIDTH;
		r.h = GLYPH_HEIGHT;

		SDL_SetRenderDrawColor(app.renderer, 0, 255, 0, 255);
		SDL_RenderFillRect(app.renderer, &r);
	}

	drawText(SCREEN_WIDTH / 2, 625, 255, 255, 255, TEXT_CENTER, "PRESS RETURN WHEN FINISHED");
}

As you can you see, we're mainly just drawing text, including the name that has been typed in so far (newHighscore->name). However, we're also drawing the cursor as a green rectangular block. We're doing this by calculating the width of the input name (as we do in drawText), and then placing the block slightly to the right of that. We're also taking into consideration the centering. The rectangle is then drawn by calling SDL_SetRenderDrawColor to set the color and SDL_RenderFillRect to draw the actual rectangle.

Looking at drawHighscores, we can see that this too has seen some tweaks:


static void drawHighscores(void)
{
	int i, y, r, g, b;

	y = 150;

	drawText(SCREEN_WIDTH / 2, 70, 255, 255, 255, TEXT_CENTER, "HIGHSCORES");

	for (i = 0 ; i < NUM_HIGHSCORES ; i++)
	{
		r = 255;
		g = 255;
		b = 255;

		if (highscores.highscore[i].recent)
		{
			b = 0;
		}

		drawText(SCREEN_WIDTH / 2, y, r, g, b, TEXT_CENTER, "#%d. %-15s ...... %03d", (i + 1), highscores.highscore[i].name, highscores.highscore[i].score);

		y += 50;
	}

	drawText(SCREEN_WIDTH / 2, 600, 255, 255, 255, TEXT_CENTER, "PRESS FIRE TO PLAY!");
}

We're looping through all the highscores and drawing the position, name, and score. We're padding the name with up to 15 spaces so that our list doesn't look misaligned and drawing the recent score in yellow. Not a huge number of tweaks. Finally, let's look at addHighscore:


void addHighscore(int score)
{
	...
	newHighscore = NULL;

	for (i = 0 ; i < NUM_HIGHSCORES ; i++)
	{
		highscores.highscore[i] = newHighscores[i];

		if (highscores.highscore[i].recent)
		{
			newHighscore = &highscores.highscore[i];
		}
	}
}

This is where we're assigning the newHighscore variable. We're NULLing it to start with and then looping through the all our qualifying highscores to see if any are recent. If so, we're assigned them to newHighscore.

Our little shooter is almost done. All that's left to do is add a title screen and put in some finishing touches. We'll do all this in the next tutorial and then move onto an exciting new project.

Exercises

  • Load and save the highscore table.

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