Your guide to learning game development
The animations we added last chapter really helped to liven up the presentation of the game. Looking past the slightly improved aesthetics we also made it much easier for the player to see important information about how the game is progressing. Displaying the player character's health on the screen rather than logging the value to the console is much easier to keep an eye on, to say the least. Wouldn't it be nice if we did the same thing for the player's score? Right now we're logging the score to the console as it's updated, but it would be much more convenient if the player could instead see this in the game window. In this chapter we'll finally address this by rendering text to the screen.
The process of drawing text to the computer screen can actually be a bit involved. This is because modern computers allow text to be rendered in a wide variety of styles (called "fonts") and at arbitrary sizes. To support this flexibility, each character (called a "glyph") of a given font is typically stored in a file as a series of mathematical equations that describe its shape. But how do we go about rendering something so abstract? We have to transform these shapes into pixels that can be displayed on the screen. This conversion is called "rasterization" and is quite complex. Due to this complexity, developers will typically rely on third-party libraries such as FreeType2 to handle font rasterization. Games and applications usually rasterize all the character needed up-front into a single texture called a "bitmap font." Rendering text then is not too unlike rendering sprites; once a given characters needs to be drawn to the screen, the appropriate portion of the texture is sampled from and then rendered. Metadata is additionally stored with the bitmap font that explains where these characters are located in the texture and how they should be spaced in relation to one another.
Fortunately for us, SDL2 provides a simple satellite library called SDL2_ttf that can handle rendering text for us. Rather than rasterize characters into a bitmap font, SDL2_ttf generates a new texture for each piece of text that needs to be drawn. This is a pretty inefficient way of rendering text and wouldn't scale very well for a professional quality title. Our project is particularly simple, however, so the library is perfectly suitable for our needs. Anyway, to begin using the library it must be initialized much like the rest of SDL2. Add the following below our call to IMG_Init to initialize SDL2_ttf:
if (TTF_Init() != 0)
{
fprintf(stderr, "TTF_Init Error: ", TTF_GetError());
return 1;
}
scope(exit) TTF_Quit();
Before we can draw any text SDL2_ttf first needs to load a font file and prepare it for rendering at a given size. Once this is done, the result is stored in a TTF_Font pointer. In this game we'll need two TTF_Font pointers, one for large text and another for medium. We'll also need to store texture pointers for each piece of text we want to render. Add the following to the GameState:
TTF_Font* fontLarge;
TTF_Font* fontMedium;
SDL_Texture* text_score;
SDL_Texture* text_gameOver;
SDL_Texture* text_finalScore;
Remember how we used metaprogramming to automatically generate code that loads an image file into each SDL_Texture pointer in the GameState? If we left the code as it is now, it would try to load an image file into these new SDL_Texture pointers, fail to do so, and abort the game. This is certainly not what we want. We're going to be generating these textures at runtime, so we need to tell the metaprogramming code to ignore them. Fortunately this is quite easy due to how we've named these members. The members we want to load images into have names that begin with "texture_" so we only need to process members that fit that bill:
static if(is(typeof(member) == SDL_Texture*) && memberName.length >= 8 && memberName[0 .. 8] == "texture_")
{
// ... Texture loading code remains unchanged
}
Now we're ready to load in some fonts. We could make a new function specifically for this task, but I think it makes more sense to re-purpose the loadTextures function to load all the assets used by the game. To make this intent clear, let's rename loadTextures to loadAssets and unloadTextures to unloadAssets. Naturally, the old calls to these functions will need to be changed to match their new names. Additionally, these these functions must now be called before initGame, as we'll soon be making changes to that function that depend on the fonts already having been loaded properly. Now append the following to the newly christened loadAssets function:
s.fontMedium = TTF_OpenFont("assets" ~ dirSep ~ "LiberationSans-Bold.ttf", 24);
if(!s.fontMedium)
{
fprintf(stderr, "TTF_Open Error: %s\n", TTF_GetError());
success = false;
}
s.fontLarge = TTF_OpenFont("assets" ~ dirSep ~ "LiberationSans-Bold.ttf", 48);
if(!s.fontLarge)
{
fprintf(stderr, "TTF_Open Error: %s\n", TTF_GetError());
success = false;
}
This isn't much different than the texture loading code. TTF_OpenFont takes a path to the font file followed by the desired point size. Point size effectively means the pixel height of the font. If the file failed to load, it returns null. We need those fonts to display important information to the player so we can't run the game without them. Therefore, if a font fails to load we log a description of the error and tell the calling function it failed, which will then abort the game.
After these fonts have successfully loaded we would like to unload them as well. Append the following to the unloadAssets function:
if(s.fontLarge) TTF_CloseFont(s.fontLarge);
if(s.fontMedium) TTF_CloseFont(s.fontMedium);
Now let's add a function that will take in some text and then generate a new texture from it using a given font:
SDL_Texture* newTextureFromText(SDL_Renderer* renderer, TTF_Font* font, const(char)* text, ubyte r, ubyte g, ubyte b)
{
SDL_Color color;
color.r = r;
color.g = g;
color.b = b;
color.a = 255;
SDL_Surface* textSurface = TTF_RenderText_Blended(font, text, color);
if (!textSurface)
{
fprintf(stderr, "TTF_ERROR: ", TTF_GetError());
return null;
}
SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, textSurface);
if (!texture)
{
fprintf(stderr, "SDL_ERROR: ", SDL_GetError());
}
SDL_FreeSurface(textSurface);
return texture;
}
This function shouldn't seem that unusual as it's conceptually rather similar to the newTextureFromFile function we added in an earlier chapter. In this function, we take a pointer to a font loaded by SDL2_ttf and "bake" the text into a new texture. The last three parameters determine the color of the resulting text, which we store in an SDL_Color variable. We can bake the text by calling TTF_RenderText_Blended and passing it the font, the text we wish to bake, and the text color. However, much like IMG_Load, TTF_RenderText_Blended returns an SDL_Ruface pointer that we must convert into an SDL_Texture before it can be rendered. Like before, we do this by passing the SDL_Surface to SDL_CreateTextureFromSurface. We then return the result of calling this function, freeing the SDL_Surface before the function exits. Should any of these steps fail, the function logs an error message and returns null.
What we need now is a function to render a texture centered at a given point:
void renderTextureCentered(SDL_Renderer* renderer, SDL_Texture* texture, Vect2 pos)
{
SDL_Rect bgDest = void;
SDL_QueryTexture(texture, null, null, &bgDest.w, &bgDest.h);
bgDest.x = cast(int)pos.x - (bgDest.w / 2);
bgDest.y = cast(int)pos.y - (bgDest.h / 2);
SDL_RenderCopy(renderer, texture, null, &bgDest);
}
This code should look quite familiar to you as this is how we're rendering the background texture. In fact, why don't we use this new function to render the background instead? Replace the old background rendering code with the following:
renderTextureCentered(renderer, s.texture_bg, Vect2(windowWidth / 2, windowHeight / 2));
Now that we have all that out of the way, let's move on to finally generating text for the player to see! We'll start by baking some text inside the initGame function. As the newTextureFromText function requires the rendering context, we'll need to change the initGame function to take this as a parameter and update our call to the function accordingly. Make the following changes to the initGame function:
void initGame(GameState* s, SDL_Renderer* renderer)
{
// ... Old initialization code goes here
s.text_gameOver = newTextureFromText(renderer, s.fontLarge, "Game Over", 255, 255, 255);
s.text_score = newTextureFromText(renderer, s.fontMedium, "Score: 0", 0, 0, 0);
}
The player's score can change during the game, so we'll need to regenerate the score texture each time the player collects a treasure. But to do that we need a way to generate a string with a textual representation of the player's current score. In C this can be done using the snprintf function, but here we'll be using the format function from the D standard library. If you prefer to use snprintf, feel free to use it instead. By default, the format function generates new strings using memory from the garbage collected heap, though you can pass it a fixed sized buffer if desired. Since we'll be passing the result to a function that expects a c-string, we need to make sure the string is terminated by the null character (\0). Before we can use this function, however, we need to import the appropriate module:
import std.format;
Regenerating the score texture needs to be done inside the updateGame function. Just like earlier, this means we need need to modify the function to take the rendering context as an argument and modify where we're calling the function as needed. Now replace the code where we logged the player's score to the console with the following:
SDL_DestroyTexture(s.text_score);
s.text_score = newTextureFromText(renderer, s.fontMedium, format("Score: %s\0", s.score).ptr, 0, 0, 0);
The last texture we want to generate will show the player's final score on the Game Over screen:
if(s.health <= 0)
{
// ...
s.text_finalScore = newTextureFromText(renderer, s.fontMedium, format("Final Score: %s", s.score).ptr, 255, 255, 255);
}
Let's put this all together and actually render the textures we generated so far. Modify the renderGame function to mirror the following:
if(!s.gameOver)
{
// ...
renderTextureCentered(renderer, s.text_score, Vect2(windowWidth / 2, 16));
}
else
{
// ...
renderTextureCentered(renderer, s.text_gameOver, Vect2(windowWidth / 2, windowHeight / 2));
renderTextureCentered(renderer, s.text_finalScore, Vect2(windowWidth / 2, windowHeight / 2 + 58));
}
Compile and run the game. You should see the player's score at the top of the screen. Collect treasure and see how the text updates to reflect the change in score. Try losing all the player character's health and notice the big "Game Over" text with the player's final score underneath. With just a little amount of text, the status of the game is much easier for the player to understand. We still have a little more ground to cover before we call this project complete, but the game is much better now than when we started.