概要
フォントと表示する文字列の情報(TTF_Text)を適切に管理する.その利用例として,画面にメッセージボックス的な表示を行う.
はじめに
前回
前回は入力を管理するクラスを作成しました.それを利用して8方向の移動を実装してみました.
今回
ここまでナイーブに利用していたTTF_FontやTTF_Text(SDL_ttfによって提供されるFont/文字列表示情報の構造体)を管理するクラスを作成し,分かりやすくかつ安全に利用できるようにすることを目指します.
特に今回のプロジェクトにおいてはすべての出力を(画像は使わずに)文字列で賄う予定ですので,TTF_Textの管理には少し気を遣ってあげたいところです.
開発環境
Windows11 Home 25H2
Visual Studio Community 2026
使用している外部ライブラリ:
SDL3 (version: 3.2.26)
SDL3_ttf (version: 3.2.2)
Fontクラス
構想
TTF_Fontは,フォントサイズを含めてただ一つのフォントを表すことができます.しかし,同一アプリ内で別のサイズのフォントやそもそも別のフォント(ウェイトが違うなど)を利用したい瞬間は当然存在するはず.これらをまとめて管理するクラスを考えましょう.ただしここでは簡単のためサイズ違いのみを考慮してコーディングします.
実は,参考にしている「ゲームプログラミング C++」(参考資料1)ではこのクラスでTTF_Text(に相当するもの)の作成も行っていました.しかしSDL3_ttfから提供されているTTF_Text構造体は,作成した後からフォントも含めて中身を変更することができるようになったので,フォントとの結びつきは弱くなっています.そのためこれに関しては,後に説明するTextFactoryクラスに譲ることとしましょう.
実装
こうなると実は大したものではなく,フォントの読み込みと破棄を代行するのみとなります.
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <string>
#include <map>
#include <vector>
#include <cmath>
#include <stdexcept>
class Font
{
public:
Font();
~Font();
bool Load(const std::string& fileName);
void Unload();
TTF_Font* GetFont(float fontSize);
private:
std::map<float, TTF_Font*> mFontMap;
};
Font::Font()
:mFontMap()
{
}
Font::~Font()
{
Unload();
}
bool Font::Load(const std::string& fileName)
{
std::vector<float> fontSizes = {
8, 9, 10, 11, 12, 14, 16, 18,
20, 22, 24, 26, 28
};
Unload();
for ( auto& size : fontSizes )
{
TTF_Font* font = TTF_OpenFont ( fileName.c_str ( ), size );
if ( font == nullptr )
{
SDL_Log ( "Failed to load font %s in size %f", fileName.c_str ( ), size );
return false;
}
mFontMap[size] = font;
}
return true;
}
void Font::Unload()
{
for (auto& pair : mFontMap)
{
TTF_CloseFont(pair.second);
}
mFontMap.clear();
}
TTF_Font* Font::GetFont(float fontSize) {
if (mFontMap.empty()) {
throw std::runtime_error("error: font not exist");
}
auto iter = mFontMap.lower_bound(fontSize);
if ( iter == mFontMap.end ( ) ) {
--iter;
} else if (iter != mFontMap.begin()) {
auto prev = iter;
--prev;
if ((fontSize - prev->first) < (iter->first - fontSize)) {
iter = prev;
}
}
if (std::abs(iter->first - fontSize) > 0.01f) {
SDL_Log("Point size %f is unsupported, using %f instead", fontSize, iter->first);
}
return iter->second;
}
メンバ関数LoadでTTF_OpenFontを用いてフォントを複数のサイズで読み込んでおき,連想配列で管理します.GetFont関数では,float型でフォントのサイズを引数にとって,最も近いサイズのフォントへのポインタを返します.最後にUnload関数でTTF_CloseFontを呼び出します.
TextSystemクラス(など)
構想
TTF_TextはTTF_DrawRendererText関数などによって文字列を画面に表示するための構造体です.これについて以下の点を考慮するべきでしょう:
- TTF_CreateTextで作成した後,使用しなくなったらTTF_DestroyTextを実行する必要がある.一元的に管理することでTTF_DestroyTextが必ず実行される状況を作りたい.
- 「床のタイル」や「特定の敵」など全く同じ文字をいくつも並べる状況が想定されるが,それぞれに対してTTF_Textを作成するのは余分なコストとなる.自動的に判定して一つのTTF_Textを共有したい(つまりキャッシュを作る).
- しかしながら,TTF_Textは動的に変化させることも想定された構造体.あとから文字列やフォントを変更して必要な部分だけ計算するというようなことも可能で,その設計には沿うようにしたい.
これらの要件より,TTF_Textをラップするクラスとして作成したら変更しないStaticTextHandle,後から動的に変化させても良いDynamicTextHandleの二つを用意することにします.これらをまとめてTextSystemクラスで管理しましょう.他にもいくつかクラスを用意することにして,その大まかな関係性を下に示します.
TextSystem
├ TTF_TextEngine* (テキスト生成時,出力するRendererを指定するために必要な構造体)
├ DynamicTextRegistry
└ TextCache
└StaticTextHandle
└TTF_Text*
DynamicTextHandle (TextSystemはDynamicTextHandleを生成するが所有はしない)
├ TextModel
└ TTF_Text*
ここで,TextCacheは生成されたStaticTextHandleのweek_ptrを連想配列で,中身がどこかで所有され続けている限りTTF_Textを再生成せず参照できるようにします.また,DynamicTextRegistryはDynamicTextHandleを監視し(管理はしない),全テキストに対する操作が必要になったときにそれぞれの関数を呼び出すためのクラスです.
実装
末端側から順番に見てゆきましょう.
class TextHandle {
public:
virtual ~TextHandle ( ) = default;
virtual void draw (float x, float y, Color::Name color = Color::Name::White ) = 0;
};
まずDynamic/StaticTextHandleの共通して利用したい描画関数をスーパークラスとして定義しておきます.
class StaticTextHandle :
public TextHandle
{
public:
StaticTextHandle ( TTF_TextEngine* engine, const std::string& text, TTF_Font* font); // 略
~StaticTextHandle ( ); //略
void draw ( float x, float y, Color::Name color = Color::Name::White ) override;
private:
TTF_Text* text_;
Color::Name lastColor_ = Color::Name::White;
};
void StaticTextHandle::draw ( float x, float y, Color::Name color ) {
if ( text_ ) {
if ( color != lastColor_ ) {
Color::RGBA c( color ); // これはRGBとα値を持つクラス
TTF_SetTextColor ( text_, c.r, c.g, c.b, c.a );
lastColor_ = color;
}
TTF_DrawRendererText( text_, x, y );
}
}
StaticTextHandleクラスは,コンストラクタ・デストラクタでTTF_Textの生成/削除を行います.また,draw関数では色のみ変更して描画することができます.色情報もTTF_Textが持つので,こうするとTTF_Textが完全にimmutableにはならないのですが,色の変更に関しては毎フレーム行っても問題ない程度の計算時間(のはず,たぶん)なのでOKとします.
class DynamicTextHandle :
public TextHandle
{
public:
DynamicTextHandle (DynamicTextRegistry& registry, TTF_TextEngine* engine, const std::string& text, TTF_Font* font);
~DynamicTextHandle ( );
TextModel& model ( ) { return model_; }
void draw ( float x, float y, Color::Name color = Color::Name::None ) override;
// engine変更時に呼ばれる
void rebuild ( TTF_TextEngine* newEngine ); //定義は略
private:
void sync ( );
private:
DynamicTextRegistry& registry_;
TTF_TextEngine* engine_;
TextModel model_;
std::string lastText_;
Color::Name lastColor_ = Color::Name::White;
TTF_Text* text_ = nullptr;
long long syncedVersion_ = -1;
};
DynamicTextHandle::DynamicTextHandle (DynamicTextRegistry& registry, TTF_TextEngine* engine, const std::string& text, TTF_Font* font)
: registry_ ( registry ), engine_ ( engine ), model_ ( text, font ), lastText_ ( text )
{
registry_.registerHandle ( this );
}
DynamicTextHandle::~DynamicTextHandle ( ) {
registry_.unregisterHandle ( this );
if ( text_ ) TTF_DestroyText ( text_ );
}
void DynamicTextHandle::draw ( float x, float y, Color::Name color ) {
sync ( );
// あとはStaticと同じ
}
void DynamicTextHandle::sync ( ) {
if ( model_.version ( ) == syncedVersion_ ) return;
if ( !text_ ) {
text_ = TTF_CreateText ( engine_, model_.font ( ), model_.text ( ).c_str ( ),model_.text ( ).size( ));
Color::RGBA c ( model_.color ( ) );
TTF_SetTextColor ( text_, c.r, c.g, c.b, c.a );
lastColor_ = model_.color ( );
}
else {
if ( TTF_GetTextFont ( text_ ) != model_.font ( ) ) {
TTF_SetTextFont ( text_, model_.font ( ) );
}
// 一部略
}
syncedVersion_ = model_.version ( );
}
DynamicTextHandleクラスは,生成/破棄時にDynamicTextRegistryクラスに登録/削除を行います.DynamicTextRegistry(実装は省略します)ではTextSystemクラスからTTF_TextEngineを変更する指示が出たときに全DynamicTextHandleインスタンスに対してrebuild関数を呼ばせます.TTF_Textの扱いに関しては,コンストラクタ呼び出し時にはまだ作成せず,初回の描画時に作成します.削除するのはデストラクタ呼び出し時で,これはStaticの方と変わりません.
また,DynamicTextHandleにおけるTTF_textの状態はメンバであるTextModelクラスで管理しています.
class TextModel {
public:
TextModel ( const std::string& text, TTF_Font* font, Color::Name color = Color::Name::White )
: text_ ( text ), font_ ( font ), color_(color) {
}
const std::string& text ( ) const { return text_; }
TTF_Font* font ( ) const { return font_; }
Color::Name color() const { return color_; }
void setText ( const std::string& s ) {
if ( text_ != s ) {
text_ = s;
++version_;
}
}
void setFont ( TTF_Font* f ) {
if ( font_ != f ) {
font_ = f;
++version_;
}
}
void setColor ( Color::Name c ) {
if ( color_ != c ) {
color_ = c;
++version_;
}
}
long long version ( ) const { return version_; }
private:
std::string text_;
TTF_Font* font_;
Color::Name color_;
long long version_ = 0;
};
TextModelクラスの中身を更新すると,メンバversion_の値が変わり,それによってDynamicTextHandleに更新したことが伝わります.
struct TextKey {
TTF_Font* font;
std::string text;
bool operator==( const TextKey& ) const = default;
};
struct TextKeyHash {
size_t operator()( const TextKey& key ) const {
return std::hash<std::string>()(key.text) ^ (std::hash<TTF_Font*>()(key.font) << 1);
}
};
class TextCache {
public:
TextCache ( TTF_TextEngine* engine ) : engine_ ( engine ) {}
~TextCache ( ) {
clear ( );
}
std::shared_ptr<StaticTextHandle> acquire ( const TextKey& key );
void clear ( ); // 定義略
private:
TTF_TextEngine* engine_;
std::unordered_map<TextKey, std::weak_ptr<StaticTextHandle>, TextKeyHash > cache_;
};
std::shared_ptr<StaticTextHandle> TextCache::acquire ( const TextKey& key )
{
auto it = cache_.find ( key );
if ( it != cache_.end ( ) ) { // キャッシュヒット
if (auto sp = it->second.lock()) {
return sp;
}
}
auto handle = std::make_shared<StaticTextHandle> ( engine_, key.text, key.font );
cache_[key] = handle;
return handle;
}
TextCacheクラスはよくある連想配列によるデータ管理のクラスですね.文字列とポインタの組をキーにしたいので,std::unordered_mapで使うためのハッシュを自力で用意する必要があります(TextKeyHashクラス).
class TextSystem
{
public:
explicit TextSystem ( TTF_TextEngine* engine )
: engine_ ( engine ), registry_ ( ), cache_ ( engine ) {
}
TTF_TextEngine* engine ( ) const { return engine_; }
std::shared_ptr<DynamicTextHandle> createDynamic (const std::string& text, TTF_Font* font);
std::shared_ptr<StaticTextHandle> getStatic ( const std::string& text, TTF_Font* font ) {
return cache_.acquire ( TextKey{ font, text } );
}
void setEngine ( TTF_TextEngine* newEngine );
private:
TTF_TextEngine* engine_;
DynamicTextRegistry registry_;
TextCache cache_;
};
std::shared_ptr<DynamicTextHandle> TextSystem::createDynamic (const std::string& text, TTF_Font* font) {
return std::make_shared<DynamicTextHandle> ( registry_, engine_, text, font );
}
void TextSystem::setEngine ( TTF_TextEngine* newEngine ) {
engine_ = newEngine;
// Dynamic再構築
registry_.rebuildAll ( newEngine );
// Static破棄
cache_.clear ( );
}
最後にこれらを統合するTextSystemクラスです.特にいうことはない…気がします.
以上全体の利用方法としては,
- (SDLの関数でTTF_TextEngineを作成したら)TextSystemクラスを作成
- createDynamic/getStatic関数でDynamic/StaticTextHandleのshared_ptrを得る
- 適宜利用する.DynamicTextHandleは与えられたshared_ptrの生存期間が過ぎると自動で破棄される.StaticTextHandleは複数の利用者が同時に持っている可能性があり,全員のshared_ptrの生存期間が過ぎるとTextCacheから消える.どちらにせよ削除のことを考える必要は無い
となります.つまり,利用者側から見るとDynamic/StaticTextHandleの使い方さえ知っていればよく,ほぼSDLの関数群を隠蔽することができました.達成感.
ただ,問題があるとすれば,おそらくローグライクを作る分にはStaticTextHandleさえあれば十分だということ…必要な情報の更新頻度的には最悪毎回TTF_Textを作り直しても問題ない気がします.慣れないなりに結構時間をかけて頑張ったので役に立つときがくれば良いですが…
Messageクラス
以上のクラス群の利用例として,画面にメッセージを表示するクラスを考えます.随時利用者側からは表示してほしい文字列の情報が送られてくるとして,過去の履歴4件分を常に表示するようにしましょう.ついでに,(せっかく色々と使えるようにしたので)最新のメッセージ以外はフォントを変えるように実装してみましょう.
class Message
{
public:
Message(TextSystem* tSystem, Font* font);
~Message();
void AddMessage(std::string message);
void DisplayMessages();
void Update();
void goNextTurn();
private:
TextSystem* tSystem_;
std::deque<std::shared_ptr<DynamicTextHandle> > mTexts;
Font* mFont;
const int max_messages = 4; // 表示する最大メッセージ数
int new_mes_num; // 新しいメッセージの数
const Color::Name oldMesColor = Color::Name::Gray;
};
Message::Message(TextSystem* tSystem, Font* font)
:tSystem_(tSystem)
, mFont(font)
, new_mes_num( 0 )
{
}
Message::~Message()
{
mTexts.clear ( );
}
void Message::AddMessage(std::string message)
{
mTexts.push_back( tSystem_->createDynamic(message, mFont->GetFont(20.0f)) );
++new_mes_num;
}
void Message::DisplayMessages()
{
int size = (mTexts.size() > max_messages ? max_messages : mTexts.size());
for (int i = 0; i < size; ++i) {
mTexts[mTexts.size() - 1 - i]->draw(0, i * 30.0f + 460.0f);
}
}
void Message::Update()
{
while(mTexts.size() > max_messages) {
mTexts.pop_front();
}
}
void Message::goNextTurn()
{
int idx = ( mTexts.size ( ) - new_mes_num );
for ( int i = 0; i < idx; ++i ) {
mTexts[i]->model ( ).setColor ( oldMesColor );
mTexts[i]->model ( ).setFont ( mFont->GetFont(16.0f) );
}
new_mes_num = 0;
}
こんな感じになりました.管理を考える必要が無いのは大変楽ですね!

画像にはここまでで記事にしていない要素もありますが,今回の成果物としては下のメッセージ部分(が簡単に表示できるようになったこと).
おわりに
この手の全体の基盤になる構造は丁寧に作らないと後で地獄を見るのだろうな,と思いつつ,プロジェクトの序盤だとあまり成果が目に見えないので飽きてきますね…頑張ります.まだ細かい部分が詰められていないのも気になるところ.
テキスト表示が問題なくできるようになったということは,あとはゲームの中身を作るだけのはず.これからペースを上げて実装して行きたいところです.
ここまで読んでいただきありがとうございます.
次回はキャラクタークラスの作成を考える予定です.
参考文献
- Sanjay Madhav. 吉川邦夫訳. ゲームプログラミング C++. 翔泳社, 2018.
- SDL Wiki https://wiki.libsdl.org/SDL3/FrontPage