【ゲーム開発】RPG風のテキストボックスを実装する(タイピング表示、ページ送り対応)

周辺システム

RPGでキャラクターに話しかけた際に表示されるような、テキストの表示(テキストボックス)の実装についての記事です。

↓このような動作を実装します。

どんなジャンルのゲームでも、キャラクターの発言や会話は必要になることが多いですよね。

メインのゲームロジックではないものの、「ゲーム」として完成させる上で重要な要素の一つです。

この記事では、ミニマルなテキストボックスとして、以下のような仕組みについて考えます。

  • 文字を1文字ずつ表示(タイピング表示)
  • キー入力で末尾まで一気に表示/ページ送り

これらを、C++/SDLでどのように実装したかを解説します。

仕様の整理

まずは、今回作るものの仕様を整理します。

  • 日本語に対応する
  • 一連のテキストを、表示可能行数(2行を想定)ずつ表示する
  • 文字数による自動改行はしない
  • テキストボックスを表示している間は、プレイヤーキャラへの入力は無視する
  • テキストは、1文字ずつパラララと表示する(タイピング表示)
  • タイピング表示中にキー入力があったら、表示可能行数まで一気に表示する
  • 表示可能行数まで表示したらキー入力待ちになり、キー入力があったら続きを表示する
  • テキストを最後まで表示して、キー入力があったらテキストボックスを閉じる

本文を順番に表示していくだけの、非常にシンプルなものですね。

文字の装飾や、話者の名前・立ち絵・顔の表示などもありません。

細かいところは異なるかもしれませんが、ドラクエやポケモンのようなあの感じをイメージしていただければと思います。

設計と全体構造

プレイヤーの入力通知先を制御する

UI画面の基底クラス(UIScreenクラス)を定義して、

  • UI画面はUIScreenクラスを継承して実装する
  • ゲームメインはUIScreenをスタックで持つ
  • プレイヤーの入力を最上位のUIScreenに流す(UI画面がなければ、プレイヤーキャラを動かせる)

このようにすることで、プレイヤーが最上位のUI画面としかやりとりできないような仕組みを実現します。

C++
class UIScreen {
 public:
	enum State {
		ACTIVE,
		CLOSING
	};

	UIScreen(Game* game);
	virtual ~UIScreen();

	virtual void ProcessInput(const KeyboardState* key_state);
	virtual void Update();
	virtual void Draw(SDL_Renderer* renderer);

	void Close() { state_ = CLOSING; }

 protected:
  Game* const game_;
	State state_;
};

今回実装するテキストボックスもこの仕組みに乗っかり、テキストボックス表示中にプレイヤーを操作できないようにします。

※「C++ゲームプログラミング」の9章「ユーザインターフェース」を参考にさせていただきました。

Fontクラス

フォントデータのロード/アンロードや、文字列の指定フォントでのTexture化を扱うクラスです。

今回は1種類のフォントを扱うだけなので冗長かもしれませんが、今後複数フォントを扱いたくなることがありそうなので、独立させました。

※こちらも、「C++ゲームプログラミング」の9章「ユーザインターフェース」を参考にさせていただきました。

タイピング表示/ページ送りを処理する

受け取ったテキストをタイピング表示しつつ2行ずつ表示する、TextBoxクラスを作成します。

  • 表示したい一連のテキストは行単位で保持する
  • 表示範囲(テキスト全体のうちどこからどこまでを表示するか)を更新し、描画する
  • 「タイピング表示中」と「入力待ち」の2状態を持つ
C++
class TextBox : public UIScreen {
 public:
	enum State {
		TYPING,
		WAIT_INPUT,
	};

	TextBox(Game* game, const std::string& text);
	~TextBox();

	void ProcessInput(const KeyboardState* key_state) override;
	void Update() override;
	void Draw(SDL_Renderer* renderer) override;

 private:
	static const int MAX_VISIBLE_LINES = 2;  // 表示可能な最大行数

	std::vector<Line> lines_;  // 表示したい一連のテキスト
	int typing_frame_interval_;  // タイピング表示するフレーム間隔
	int interval_count_;  // ↑のカウンタ
	int curr_first_line_;  // 1行目に表示するlines_のインデックス
	int visible_char_counts_[MAX_VISIBLE_LINES];  // 表示行ごとの、表示文字数

	State state_;
};

前述の通り、一番上に表示されているUIが入力を受けるようにしたいので、UIScreenクラスを継承しています。

一連のテキストは、行ごとにLine構造体として保持することにします。Line構造体は、以下のようなパラメータを持っています。

C++
struct Line {
	SDL_Texture* text_texture;  // テキスト(1行単位)のTexture
	std::vector<int> width;  // n文字目まで表示するために必要なTextureの横幅
	int height;  // Textureの高さ
	int num_charas;  // 文字数
};

最初に行全体のTextureを作成して、n文字目まで表示するために必要なTextureの横幅をあらかじめ求めておきます。

こうすることで、表示範囲が更新されるたびにTextureを作成し直すことなく、1文字ずつの表示が可能になります。

等幅フォントの場合はwidthを文字数ごとに計算しておく必要はなく、1文字分の横幅だけ知っておけば済みます。

以下のパラメータで、表示範囲を表現しています。

C++
  static const int MAX_VISIBLE_LINES = 2;  // 表示可能な最大行数
	int curr_first_line_;  // 1行目に表示するlines_のインデックス
	int visible_char_counts_[MAX_VISIBLE_LINES];  // 表示行ごとの、表示文字数

例えば、1行目に表示するテクスチャの横幅は、

lines_[curr_first_line_].width[visible_char_counts_[0]]

のように表現できます。これで「ある行のある文字まで」描画できそうです。

やるべきことを「初期処理」「入力」「更新」「描画」の骨組みに当てはめて、処理の流れをイメージしてみます。

  • 初期処理
    • 受け取ったテキストを行単位で分割する
    • 各行について、描画用のTextureを作り、n文字目までのTexture幅を求めておく
  • 入力
    • タイピング表示中なら、表示範囲の末尾まで一気に表示させる
    • 入力待ち状態なら、次の範囲の表示に移る
    • テキストの最後まで表示が完了していたら、テキストボックスを閉じる
  • 更新
    • タイピング表示中なら、所定フレーム間隔で表示範囲を1文字ずつ広げる
    • 今表示可能な分を表示し終わったら、入力があるまで何もしない
  • 描画
    • テキストボックスの背景(ボックスの枠とか)を表示する
    • 現在の表示範囲に基づいて、テキストを表示する
    • 入力待ち状態なら、プロンプト(▼みたいなやつ)を表示する

全体像がなんとなく見えてきましたね。実装してみましょう!

実装

表示テキスト(スクリプト)の読み込み/整形

日本語(マルチバイト文字)への対応

日本語を扱うために、UTF-8の文字列を扱うことにします。

日本語は1文字≠1byteであるため、文字列(std::string)を1文字ずつ(std::vector<std::string>)に分割するヘルパー関数を作ります。

C++
std::vector<std::string> TextBox::SplitUTF8(const std::string& str) {
	std::vector<std::string> out;

	int len = 1;
	for (int i = 0; i < str.size(); i += len) {
		unsigned char c = str[i];

		if ((c & 0x80) == 0) { len = 1; }
		else if ((c & 0xE0) == 0xC0) { len = 2; }
		else if ((c & 0xF0) == 0xE0) { len = 3; }
		else if ((c & 0xF8) == 0xF0) { len = 4; }

		out.push_back(str.substr(i, len));
	}

	return out;
}

文字コードを確認し、1文字あたりのbyte数で文字列を分割していきます。

↓こんな感じになります。

str = "これはテストです。"
out[0] = "こ"
out[1] = "れ"
out[2] = "は"
out[3] = "テ"
out[4] = "ス"
out[5] = "ト"
out[6] = "で"
out[7] = "す"
out[8] = "。"

行単位で表示データを作成

上記ヘルパー関数で1文字ずつに分解できたので、テキストを表示行単位でLine構造体にしていきます。

C++
void TextBox::ParseScript(const std::string& text) {
	std::stringstream ss(text);
	std::string line_text;

	while (std::getline(ss, line_text)) {
		Line line;
		std::vector<std::string> text_utf8 = SplitUTF8(line_text);
		line.text_texture = font_->RenderText(line_text);
		line.num_charas = text_utf8.size();
		line.width.push_back(0);

		for (int i = 1; i <= line.num_charas; i++) {
			std::string substr;
			int width = 0;
			int height = 0;
			for (int j = 0; j < i; j++) {
				substr += text_utf8[j];
			}
			TTF_SizeUTF8(font_->GetFontData(), substr.c_str(), &width, &height);
			line.width.push_back(width);
			line.height = height;
		}
		lines_.push_back(line);
	}
}

SDLでは、TTF_SizeUTF8を利用することで、指定文字列を表示するために必要な幅と高さを取得することができます。

C++
line.width.push_back(0);

とすることで、0文字目は表示文字列なしということを表せるようにしています。描画のときに少し楽になります。

タイピング表示

タイピング表示中の行を取得

C++
	int typing_line;
	for (typing_line = 0; typing_line < MAX_VISIBLE_LINES; typing_line++) {
		if (visible_char_counts_[typing_line] < lines_[curr_first_line_ + typing_line].num_charas) {
			break;
		}
	}

最初に見つかった「全ての文字を表示していない行」が、現在タイピング表示中の行となります。

表示文字数を更新

C++
	// タイピング表示のカウンタを更新
	interval_count_++;
	if (interval_count_ >= typing_frame_interval_) {
		interval_count_ = 0;
		// 表示文字数を更新
		visible_char_counts_[typing_line]++;
	}

interval_count_で文字送りのスピードを調整できるようにしています。

所定フレーム周期でタイピング表示中の行の表示文字数を更新していきます。

表示範囲を元に描画

C++
	for (int i = 0; i < MAX_VISIBLE_LINES; i++) {
		if (!LineExists(curr_first_line_ + i)) { break; }
		DrawText(renderer, lines_[curr_first_line_ + i], visible_char_counts_[i], first_line_y + (LINE_SPACING * i));
	}

表示可能行数でループを回し、各行を描画していきます。

C++
void TextBox::DrawText(SDL_Renderer* renderer, Line line, int char_count, int y) {
	if (!line.text_texture) { return; }

	const int x = 64;
	SDL_Rect sr = {0, 0, line.width[char_count], line.height};
	SDL_Rect dr = {x, y, line.width[char_count], line.height};

	SDL_RenderCopyEx(
		renderer,
		line.text_texture,
		&sr,
		&dr,
		0,
		nullptr,
		SDL_FLIP_NONE
	);
}

widthをあらかじめ求めておいたため、シンプルに書くことができました。

また、width[0] = 0としておいたため、まだ文字送りされていない行は表示されません。

入力処理

C++
void TextBox::ProcessInput(const KeyboardState* key_state) {
	if (key_state->IsKeyPressed(SDL_SCANCODE_SPACE)) {
		if (state_ == TYPING) {
			for (int i = 0; i < MAX_VISIBLE_LINES; i++) {
				visible_char_counts_[i] = (LineExists(curr_first_line_ + i)) ? lines_[curr_first_line_ + i].num_charas : 0;
			}
			state_ = WAIT_INPUT;
		} else if (state_ == WAIT_INPUT) {
			if (IsLastPage()) {
				Close();
			} else {
				curr_first_line_ += MAX_VISIBLE_LINES;
				for (int i = 0; i < MAX_VISIBLE_LINES; i++) {
					visible_char_counts_[i] = 0;
				}
				state_ = TYPING;
			}
		}
	}
}
C++
		if (state_ == TYPING) {
			for (int i = 0; i < MAX_VISIBLE_LINES; i++) {
				visible_char_counts_[i] = (LineExists(curr_first_line_ + i)) ? lines_[curr_first_line_ + i].num_charas : 0;
			}
			state_ = WAIT_INPUT;
		}

タイピング表示中にキー入力があったら、表示範囲内の行すべての表示文字数を、その行の最大文字数にします。

C++
		} else if (state_ == WAIT_INPUT) {
			if (IsLastPage()) {
				Close();
			} else {
				curr_first_line_ += MAX_VISIBLE_LINES;
				for (int i = 0; i < MAX_VISIBLE_LINES; i++) {
					visible_char_counts_[i] = 0;
				}
				state_ = TYPING;
			}
		}

入力待ち状態だったら、表示範囲を次のページへ切り替え、表示文字数を0に戻します。

また、すべてのテキストが表示し終わっていたら、テキストボックスを閉じます。

ゲームの土台への統合

プレイヤー入力を伝える先を制御するため、ゲームメインの入力処理を、以下のようにします。

C++
void Game::ProcessInput(void) {
	// 中略

	if (ui_stack_.empty()) {
		player_->processInput(input_->keyboard());
	} else {
		ui_stack_.back()->ProcessInput(input_->keyboard());
	}
}

表示しているUI(テキストボックス)があるなら、そちらに入力状態を渡し、UIがないならゲームワールドへ入力状態を渡しています。

よりリッチなテキスト表示のために

  • 文字色やフォントの変更
  • タイピング表示スピードの途中変更
  • 話者の名前や画像の表示

といった拡張について、今後別記事にて対応予定です。

まとめ

この記事では、ミニマルなテキストボックスを、C++/SDLで実装しました。

今回実装したのは

  • 文字を1文字ずつ表示(タイピング表示)
  • ページ単位で表示
  • キー入力で末尾まで一気に表示/ページ送り

というシンプルな仕様でした。

  • 1行ごとにTextureを作成
  • n文字目まで表示するために必要なTextureの横幅を取得

最初にこれらを済ませておくことで、更新するたびにTextureを作成せずに済みます。

皆さまのゲームの完成に少しでも役立てば幸いです。

タイトルとURLをコピーしました