SDL Text Engine to render interaction text
created: 2025-03-17 18:02:57 UTClast updated: 2025-03-18 04:57:15 UTC
One feature that I need to get compatible with what I had in C is the ability to render text to the screen. However, in my C version, it just render static text to the screen and kept it up during the lifetime of the program. A simple 'Hello World.' Now, I need to do something more complicated than that. An example is, I need a way for when a player gets to an intractable object, a way to signal to them an action they can take. For instance, there is an item hidden in a barrel and the player can search this barrel.
To get started with some text rendering, SDL has SDL_ttf for rendering text. There are several ways to do this, but I went with utilizing the SDL_TextEngine object. There are severals of creating this text rendering engine depending on if a surface is being used or rendering is needing to be done on the GPU or with a 2D renderer. Since this is just using the 2D renderer, I will go with that route.
In my Graphics initialization logic, the following code initializes and creates the text engine.
To get started with some text rendering, SDL has SDL_ttf for rendering text. There are several ways to do this, but I went with utilizing the SDL_TextEngine object. There are severals of creating this text rendering engine depending on if a surface is being used or rendering is needing to be done on the GPU or with a 2D renderer. Since this is just using the 2D renderer, I will go with that route.
In my Graphics initialization logic, the following code initializes and creates the text engine.
if (TTF_Init() == false) { SDL_Log("SDL Error: %s\n", SDL_GetError()); return false; } textEngine = TTF_CreateRendererTextEngine(renderer); if (!textEngine) { SDL_Log("SDL Error: %s\n", SDL_GetError()); return false; }
Now, the text rendering engine requires fonts. These fonts are stored in-memory in an unordered map. The unordered map is keyed on an Id with the font as its value.
std::unordered_map<int, TTF_Font*> fonts;
Below is the code to load, unload and clear.
void Graphics::LoadFont(int id, float size, std::string path) { TTF_Font *font = nullptr; auto iter = fonts.find(id); if (iter != fonts.end()) { std::cerr << "Font is already present, no need to load!" << std::endl; return; } font = TTF_OpenFont(path.c_str(), size); if (font == nullptr) { std::cerr << "Font could not be opened." << std::endl; SDL_Log("Reason for font fail load: %s\n", SDL_GetError()); return; } fonts.emplace(id, font); } TTF_Font *Graphics::GetFont(int id) { auto iter = fonts.find(id); if (iter == fonts.end()) { std::cerr << "Could not find font: " << id << std::endl; return nullptr; } return iter->second; } void Graphics::ClearFonts() { for (auto font : fonts) { TTF_CloseFont(font.second); } fonts.clear(); }
Loading fonts is straight forward. See is a font exists in the map with that id, if now, we will load the font given a path.
Getting a font is simple searching the unordered map based on the key, or Id field.
Clearing the fonts is a function to clean up all the fonts, right now this is called at game shutdown time, but may be getting called between level loads in the future.
Next question is, where do we get the information for loading the fonts?
Right now, that information is stored with the level configuration. However, that may change in the future to where fonts are in an over-arching game configuration and only loaded and unloaded at initialization. Below is an example of the configuration in the level's yaml definition.
fonts: - id: 1 path: "./assets/fonts/antiquity-print.ttf" size: 12.0
Now, to make it useful. The initial feature is for when a player approaches an intractable object in the world. As given in the example above, the player must find a key. The key is hidden in some object like a barrel. Or it could be that there is a table with a paper on it that the player must get that will give a clue to solve some sort of puzzle in the level. Before looking at the code, here is what the actors look like in my development level.
actors: - x: 250 y: 250 sprite: "light" - x: 300 y: 250 sprite: "armor" - x: 400 y: 400 sprite: "barrel" components: - type: "interactable_radius" radius: 64 action: "pickup" display_text: "Pick me up" font: 1
The actor of interest is the last one. The first few fields are unchanged. It has an x and y position along with a sprite. However, now it has components. Right now, the only attachable component from the level definition is the interactable_radius component. Technically, that isn't the only one, the sprite field creates a SpriteComponent, but that is assumed by the sprite field in an actor. The intractable_radius tells the loader to create and attach an InteractableRadiusComponent. This component is defined by a radius, action, display_text, and a font_id.
The radius is essentially a bound we test against the player's coordinates. If the player has a distance of less than 64, this component will render the display text. Right now the action is not used, but is a placeholder for future use. The font_id maps to the font in the font's array at the bottom of the level definition. So the program knows to render "Pick me up" with the antiquity-pretty font that has been loaded into the game.
Here is the class definition for the InteractableRadiusComponent.
#pragma once #include "../Component.h" #include <string> class InteractableRadiusComponent : public Component { public: InteractableRadiusComponent(class Actor *owner, int fontId, std::string displayText, float radius); void Update(float deltaTime); private: bool active; int fontId; std::string displayText; float radius; };
Now, the implementation.
#include "InteractableRadiusComponent.h" #include "../Actor.h" #include "../Game.h" #include "../Graphics.h" #include "../Player.h" #include "../MathUtils.h" #include "../DrawableText.h" #include <cmath> #include <iostream> InteractableRadiusComponent::InteractableRadiusComponent( Actor *owner, int fontId, std::string displayText, float radius) : Component(owner), fontId(fontId), displayText(displayText), radius(radius) {} void InteractableRadiusComponent::Update(float deltaTime) { Player* player = owner->GetGame()->GetPlayer(); float x = owner->GetX(); float y = owner->GetY(); float playerRotation = player->GetRotation(); float atan2Cal = std::atan2(y - player->GetY(), x - player->GetX()); float angleActorPlayer = playerRotation - atan2Cal; if (angleActorPlayer > PI) { angleActorPlayer -= TWO_PI; } if (angleActorPlayer < -PI) { angleActorPlayer += TWO_PI; } angleActorPlayer = fabs(angleActorPlayer); const float epsilon = 0.2; if (angleActorPlayer >= (FOV_ANGLE / 2) + epsilon) { if (active) { active = false; owner->GetGame()->ResetPopupText(); } return; } float distance = DistanceBetweenPoints(player->GetX(), player->GetY(), x, y); if (distance < radius) { if (!active) { owner->GetGame()->SetPopupText(true, fontId, displayText); active = true; return; } } else { active = false; owner->GetGame()->ResetPopupText(); } }
The main function of cern is the Update function. It performs a few test, depending on the results, it sets a field in the game class. The first test will check to see that the sprite is within the player's field of view. If now, the function returns. However, if the text is active to the screen, remove it. The next test is to see if it is within the radius of the object. So the distance is checked from the player to the object.
Perhaps a future optimization here. The checking within the field of view is tested regardless if the player is within the radius. Could potentially change the order of those tests. Since the FOV test does call atan2 which is an expensive instruction in general. No reason to run it unless the player is within the bounds. One other future optimization is changing how the DistanceBetweenPoints is done. Right now is does the distance formula, which has a square root operation. Not horrible, but not optimal. Could change to checking against a squared distance to optimize out the use of the square root.
Lastly, the pop up text needs to be drawn.
void Graphics::PutText(int fontId, std::string displayText, int fpsize, int x, int y) { auto iter = fonts.find(fontId); if (iter == fonts.end()) { std::cerr << "Error getting font to render text" << std::endl; return; } TTF_Font *font = iter->second; if (TTF_SetFontSize(font, fpsize) == false) { SDL_Log("Error setting font size: %s\n", SDL_GetError()); return; } TTF_Text *text = TTF_CreateText(textEngine, font, displayText.c_str(), 0); if (text == nullptr) { SDL_Log("Error in creating text: %s\n", SDL_GetError()); TTF_DestroyText(text); return; } if (TTF_SetTextColor(text, 0xFF, 0xFF, 0x00, 0xFF) == false) { SDL_Log("Error setting text color: %s\n", SDL_GetError()); TTF_DestroyText(text); return; } if (TTF_DrawRendererText(text, x, y) == false) { SDL_Log("Error drawing text: %s\n", SDL_GetError()); } TTF_DestroyText(text); }