Godot 4でUIを作っていると、ボタンやラベルのテキストを「読み上げ」したくなる場面ってありますよね。
でも標準だと、LabelButton に「読み上げ」機能は組み込まれていませんし、Control を継承して毎回「TTS付きボタン」「TTS付きラベル」を作るのも、だんだんツラくなってきます。

  • ボタンごとに _pressed()OS のTTS APIを呼ぶ
  • ラベルごとに「フォーカス時に読み上げる」処理をコピペ
  • UIを増やすたびにイベント接続&コールバック地獄

…といった「継承+イベント配線」スタイルだと、アクセシビリティ対応を後から足すのがとても面倒です。

そこで、UIノードにポン付けできる「読み上げコンポーネント」を用意して、継承ではなくコンポーネント合成でTTS対応してしまいましょう。
今回紹介する 「TextToSpeech」コンポーネント をアタッチするだけで、Label / Button / CheckBox などに簡単に読み上げ機能を追加できます。

【Godot 4】UIをサクッと音声対応!「TextToSpeech」コンポーネント

以下は、Godot 4向けの TextToSpeech コンポーネントのフルコードです。
OS側にTTS APIがある前提で、OS.tts_speak() のような関数を呼び出す形にしてあります(実際のTTS API名はターゲットプラットフォームやプラグインに合わせて書き換えてください)。

フルコード


extends Node
class_name TextToSpeech
## UI のテキストを OS の TTS 機能に送るアクセシビリティ用コンポーネント。
## 親の Control からテキストを取得し、フォーカス・ホバー・クリック時などに読み上げます。
##
## 想定している親ノード:
## - Label, RichTextLabel
## - Button, CheckBox, OptionButton
## - LineEdit, TextEdit
## などの Control 派生ノード

@export_group("基本設定")
@export var auto_on_focus: bool = true:
	set(value):
		auto_on_focus = value
		_update_focus_connections()

@export var auto_on_hover: bool = false:
	set(value):
		auto_on_hover = value
		_update_hover_connections()

@export var auto_on_pressed: bool = true:
	set(value):
		auto_on_pressed = value
		_update_pressed_connections()

## 読み上げるテキストを明示的に指定したい場合に使う。
## 空文字の場合は「親 Control から自動取得」を試みる。
@export_multiline var override_text: String = ""

@export_group("読み上げオプション")
## 読み上げの前に既存の読み上げを止めるかどうか
@export var stop_previous_before_speak: bool = true

## 連続で読み上げるときの最小インターバル(秒)
## スパム防止用。0 なら制限なし。
@export_range(0.0, 5.0, 0.1) var min_interval_sec: float = 0.2

## 読み上げを有効/無効にするフラグ(UIから一括で切り替えたいときなど)
@export var enabled: bool = true

## OS 側の TTS API に渡すオプション用(プラットフォームやプラグインに合わせて使う)
@export var tts_voice_id: String = ""
@export_range(0.1, 3.0, 0.1) var tts_rate: float = 1.0
@export_range(0.1, 3.0, 0.1) var tts_pitch: float = 1.0
@export var tts_volume_db: float = 0.0

var _last_speak_time: float = -100.0
var _control: Control

func _ready() -> void:
	# 親が Control であることを前提にする(UIコンポーネント用)
	_control = get_parent() as Control
	if _control == null:
		push_warning("TextToSpeech: 親ノードが Control ではありません。このコンポーネントは UI ノード向けです。")
		return

	_update_focus_connections()
	_update_hover_connections()
	_update_pressed_connections()

	# フォーカス可能にしたい場合はここで true にする案もある
	# ただし既存設定を壊したくないのでデフォルトでは触らない
	# if auto_on_focus and not _control.focus_mode:
	#     _control.focus_mode = Control.FOCUS_ALL

func _update_focus_connections() -> void:
	if not is_inside_tree():
		return
	if _control == null:
		return

	# 既存接続をクリア
	if _control.is_connected("focus_entered", Callable(self, "_on_control_focus_entered")):
		_control.disconnect("focus_entered", Callable(self, "_on_control_focus_entered"))

	if auto_on_focus:
		# フォーカス時に読み上げ
		_control.focus_mode = max(_control.focus_mode, Control.FOCUS_ALL)
		_control.connect("focus_entered", Callable(self, "_on_control_focus_entered"))

func _update_hover_connections() -> void:
	if not is_inside_tree():
		return
	if _control == null:
		return

	if _control.is_connected("mouse_entered", Callable(self, "_on_control_mouse_entered")):
		_control.disconnect("mouse_entered", Callable(self, "_on_control_mouse_entered"))

	if auto_on_hover:
		_control.connect("mouse_entered", Callable(self, "_on_control_mouse_entered"))

func _update_pressed_connections() -> void:
	if not is_inside_tree():
		return
	if _control == null:
		return

	# Button 系だけ pressed を見る。それ以外は無視。
	if _control is BaseButton:
		var btn := _control as BaseButton
		if btn.is_connected("pressed", Callable(self, "_on_button_pressed")):
			btn.disconnect("pressed", Callable(self, "_on_button_pressed"))

		if auto_on_pressed:
			btn.connect("pressed", Callable(self, "_on_button_pressed"))

# --- シグナルハンドラ ---

func _on_control_focus_entered() -> void:
	_speak_from_control("focus")

func _on_control_mouse_entered() -> void:
	_speak_from_control("hover")

func _on_button_pressed() -> void:
	_speak_from_control("pressed")

# --- メイン処理 ---

## 外部から任意のテキストを読み上げたい場合に使うユーティリティ
func speak_text(text: String) -> void:
	if not enabled:
		return

	if text.strip_edges() == "":
		return

	var now := Time.get_ticks_msec() / 1000.0
	if min_interval_sec > 0.0 and now - _last_speak_time < min_interval_sec:
		# 連打などでのスパムを防止
		return

	_last_speak_time = now

	# ここで実際の OS / プラグインの TTS API を呼び出す。
	# プロジェクトで使っている TTS プラグインに合わせて書き換えてください。
	#
	# 例: 仮想的な OS.tts_speak(text, voice_id, rate, pitch, volume_db) を想定
	#
	if stop_previous_before_speak:
		_stop_tts()

	_call_tts_api(text)

## 親 Control からテキストを取得して speak_text() を呼ぶ
func _speak_from_control(trigger: String) -> void:
	var text := _get_text_from_control()
	if text == "":
		return

	# デバッグ用ログ(必要なければコメントアウト)
	#print("TextToSpeech: trigger=%s text=%s" % [trigger, text])

	speak_text(text)

## 親 Control から「読み上げ対象の文字列」を抽出する
func _get_text_from_control() -> String:
	# 明示的な override があればそちらを優先
	if override_text.strip_edges() != "":
		return override_text

	if _control == null:
		return ""

	# ラベル系
	if _control is Label:
		return (_control as Label).text

	if _control is RichTextLabel:
		# 実運用ではタグを剥がしたりする処理を入れてもよい
		return (_control as RichTextLabel).text

	# ボタン系
	if _control is BaseButton:
		return (_control as BaseButton).text

	# テキスト入力系
	if _control is LineEdit:
		return (_control as LineEdit).text

	if _control is TextEdit:
		return (_control as TextEdit).text

	# その他: "text" プロパティを持っている場合はそれを読む
	if _control.has_method("get_text"):
		return str(_control.call("get_text"))

	if _control.has_variable("text"):
		return str(_control.get("text"))

	return ""

# --- 実際の TTS 呼び出し部分(プロジェクトに合わせてカスタマイズ) ---

func _call_tts_api(text: String) -> void:
	# ここはダミー実装です。
	# 実際には OS やプラグインの TTS 関数を呼び出してください。
	#
	# 例1: 独自の TTSManager シングルトンを呼ぶ場合
	# if Engine.has_singleton("TTSManager"):
	#     var tts = Engine.get_singleton("TTSManager")
	#     tts.speak(text, tts_voice_id, tts_rate, tts_pitch, tts_volume_db)
	# else:
	#     push_warning("TTSManager が見つかりません。TTS は実行されません。 text=%s" % text)
	#
	# 例2: OS.tts_speak(text) のような API がある場合
	# OS.tts_speak(text)
	#
	# 今回はサンプルとしてログに出すだけにします。
	print("[TTS] speak: ", text)

func _stop_tts() -> void:
	# ここもプロジェクトの TTS 実装に合わせて書き換えてください。
	#
	# 例: TTSManager.stop_all()
	# if Engine.has_singleton("TTSManager"):
	#     Engine.get_singleton("TTSManager").stop_all()
	pass

使い方の手順

ここからは、具体的なシーン構成とともに使い方を見ていきましょう。

手順①: スクリプトを用意する

  1. 上記の TextToSpeech.gd をプロジェクト内(例: res://components/accessibility/TextToSpeech.gd)に保存します。
  2. Godot エディタを開き、スクリプトが正しくインポートされていることを確認します。
    class_name TextToSpeech を指定しているので、ノード追加ダイアログの「スクリプト」タブやインスペクタから直接アタッチできます。

手順②: UI ノードにコンポーネントをアタッチする

例として、メインメニューの「スタート」ボタンに読み上げ機能を付けてみます。

MainMenu (Control)
 ├── MarginContainer
 │    └── VBoxContainer
 │         ├── TitleLabel (Label)
 │         └── StartButton (Button)
 │              └── TextToSpeech (Node)
 └── ...
  1. StartButtonButton)を選択します。
  2. 右クリック → 「子ノードを追加」 → Node を追加します。
  3. 追加した NodeTextToSpeech.gd をアタッチします。
    あるいはノード追加ダイアログから直接 TextToSpeech を検索して追加してもOKです。

この状態で、auto_on_pressed = true のままなら、ボタンを押したときにボタンのテキスト(Start など)が読み上げ対象になります。

手順③: フォーカス時・ホバー時の読み上げを有効化する

キーボード操作やゲームパッド操作に対応したい場合は、フォーカス時の読み上げがあるとかなり使いやすくなります。

  1. TextToSpeech ノードを選択します。
  2. インスペクタで以下のように設定します:
    • auto_on_focus = true
    • auto_on_hover = true(マウスホバーで読み上げたい場合)
    • auto_on_pressed = true(クリック時も読み上げたい場合)

これで、以下のような挙動になります。

  • キーボードでボタンにフォーカスが移ったとき → テキストを読み上げ
  • マウスがボタンに乗ったとき → テキストを読み上げ
  • ボタンを押したとき → テキストを読み上げ

同じ要領で、ラベルにも付けてみましょう。

TitleLabel (Label)
 └── TextToSpeech (Node)

タイトルラベルは押せないので、auto_on_pressed = false にして、auto_on_focus だけ有効にする、などの調整ができます。

手順④: プレイヤー名入力欄など、テキスト入力にも適用する

プレイヤー名入力画面の例です。

NameInputScene (Control)
 ├── VBoxContainer
 │    ├── NameLabel (Label)
 │    │    └── TextToSpeech (Node)
 │    ├── NameLineEdit (LineEdit)
 │    │    └── TextToSpeech (Node)
 │    └── OkButton (Button)
 │         └── TextToSpeech (Node)
 └── ...
  • NameLabelTextToSpeech: auto_on_focus = false, auto_on_hover = true などにして、マウスを乗せたときに「名前を入力してください」と読み上げる。
  • NameLineEditTextToSpeech: auto_on_focus = true にして、フォーカスされたときに「名前入力欄」と読み上げる。
  • OkButtonTextToSpeech: auto_on_focus = true, auto_on_pressed = true にして、「決定」ボタンであることを知らせる。

もし LineEdit に表示されているテキストではなく、別の説明文を読み上げたい場合は、override_text に「プレイヤー名を入力してください」などを設定しておくと、そちらが優先されます。

メリットと応用

この TextToSpeech コンポーネントを使うと、UI のアクセシビリティ対応がかなり楽になります。

  • 継承しないので、既存の ButtonLabel をそのまま使える
  • 「TTS付きボタン」「TTS付きラベル」などの派生クラスを乱立させずに済む
  • UI のどこにでもポン付けできるので、「このボタンだけ読み上げたい」といったピンポイント対応がしやすい
  • シーン構造がシンプル:
        StartButton (Button)
    └── TextToSpeech (Node)

    で完結し、スクリプトもコンポーネント側にまとまる


  • プロジェクト全体で TTS の仕様が変わっても、TextToSpeech の中身を差し替えるだけで一括更新できる

レベルデザイン的にも、「この画面は視覚障害のあるプレイヤーに優しくしたい」と思ったときに、該当UIに TextToSpeech を追加していくだけで対応できるのはかなり大きなメリットです。
深いノード階層にロジックを書き散らすのではなく、「読み上げ」という責務を1つのコンポーネントに閉じ込めておけるのが、まさに継承より合成の良さですね。

改造案: 「説明テキスト」を別ラベルから拾う

UIによっては、ボタンのテキストは短く、横に長い説明ラベルを置いているパターンも多いと思います。
その場合、「読み上げは説明ラベルの方を読んでほしい」ということがあります。

そんなときのために、説明ラベルを参照して読み上げる機能を追加してみましょう。


@export_group("説明テキスト参照")
@export var description_label_path: NodePath

func _get_text_from_control() -> String:
	# まず override_text
	if override_text.strip_edges() != "":
		return override_text

	# 説明ラベルが指定されていれば、そちらを優先
	if description_label_path != NodePath(""):
		var node := get_node_or_null(description_label_path)
		if node and node is Label:
			return (node as Label).text

	# それ以外は従来どおり親 Control から取得
	return _get_text_from_control_default()

func _get_text_from_control_default() -> String:
	if _control == null:
		return ""

	if _control is Label:
		return (_control as Label).text
	# ... 既存の判定をここに移動 ...
	return ""

こうしておくと、ボタンの子に説明ラベルを置いて、description_label_path にそのラベルを指定するだけで、「見た目は短いボタンテキスト、読み上げは長い説明文」という構成が簡単に実現できます。

このように、TextToSpeech コンポーネントをベースに、プロジェクトのアクセシビリティ要件に合わせて少しずつ拡張していくと、UIの音声対応がどんどん気持ちよくなっていきますね。継承ツリーではなく、コンポーネントの組み合わせで勝負していきましょう。