キャラクターがしゃべるゲームを作り始めると、「ボイスを鳴らすだけ」では物足りなくなってきますよね。
字幕も出したい、ログも取りたい、スキップもしたい……と欲張り始めると、Label を直接いじるスクリプトがどんどん肥大化していきます。
さらにありがちなパターンとして、
Playerシーンの中にLabelを直置きして、そこで字幕制御もやってしまう- 敵キャラやNPCごとに「字幕用の処理」をコピペしてしまう
- UIシーン側にロジックを書いてしまい、ゲームロジックとUIが密結合になる
こうなると、あとから「字幕のスタイルだけ変えたい」「ボイス無しでも字幕だけ出したい」といった要求が出たときに、あちこちのシーンを修正するハメになります。
Godot標準の「深いノード階層+継承」で頑張ろうとすると、UIとロジックがどんどん絡み合ってメンテがつらくなります。
そこで今回は、「ボイス再生に合わせて、画面下部に字幕テキストを表示する」機能を、完全に独立したコンポーネントとして切り出してみましょう。
どのシーンにもポン付けできる SubtitleSystem コンポーネントを用意しておけば、プレイヤーでも敵でもイベントシーンでも、同じAPIで字幕を出せるようになります。
【Godot 4】ボイスと字幕をコンポーネント化!「SubtitleSystem」コンポーネント
この SubtitleSystem は、ざっくり言うと:
- 指定した
AudioStreamPlayerでボイスを再生 - 同時に、指定した
Labelに字幕テキストを表示 - ボイス終了(または指定秒数経過)で自動的に字幕を消す
- シーンのどこにぶら下げても動く「コンポーネント」として設計
というシンプルな構成です。
「字幕用のUIレイアウト」と「字幕の制御ロジック」をきれいに分離できるので、UIデザイナーとロジック担当が気持ちよく分業できます。
フルコード: SubtitleSystem.gd
extends Node
class_name SubtitleSystem
## ボイス再生に合わせて字幕を表示するコンポーネント。
##
## - 任意の AudioStreamPlayer と Label を参照して動作
## - ボイスの長さ or 固定時間で自動的に字幕を消す
## - ボイス無しで字幕だけ表示することも可能
##
## 使い方の例:
## var subtitle: SubtitleSystem = $SubtitleSystem
## subtitle.show_subtitle("こんにちは!", preload("res://voice/hello.ogg"))
@export_category("References")
## 字幕を表示する Label。
## 画面下部の UI シーンの Label をアサインしておくと便利です。
@export var subtitle_label: Label
## ボイスを再生する AudioStreamPlayer。
## プレイヤーやイベント用の AudioStreamPlayer をアサインします。
@export var voice_player: AudioStreamPlayer
@export_category("Display Settings")
## 字幕を表示する最大幅(文字数ベースの目安)。
## 0 の場合は改行処理を行わず、そのまま表示します。
@export_range(0, 200, 1)
var wrap_char_limit: int = 40
## ボイス無しの場合に、字幕を表示し続ける秒数。
## 0 以下の場合は、手動で clear_subtitle() を呼ぶまで残ります。
@export_range(0.0, 60.0, 0.1)
var default_duration_sec: float = 3.0
## ボイス終了後も字幕を少し残しておきたいときの余韻時間(秒)。
@export_range(0.0, 5.0, 0.1)
var tail_hold_sec: float = 0.3
@export_category("Behavior")
## 新しい字幕が来たときに、前の字幕を強制的に消して上書きするか。
## false の場合は、前の字幕再生が終わるまで新しい字幕を無視します。
@export var override_current: bool = true
## デバッグ用: コンソールにログを出すかどうか。
@export var print_debug_log: bool = false
## 内部状態
var _is_showing: bool = false
var _current_text: String = ""
var _current_timer: SceneTreeTimer
func _ready() -> void:
# ラベルが設定されていれば初期状態では非表示にしておきます。
if subtitle_label:
subtitle_label.visible = false
# AudioStreamPlayer が設定されていれば、再生終了シグナルにフックします。
if voice_player:
voice_player.finished.connect(_on_voice_finished)
if print_debug_log:
print("[SubtitleSystem] Ready")
## メインAPI:
## 字幕テキストと任意のボイスを指定して、字幕の表示+ボイス再生を行う。
##
## text: 表示する字幕テキスト
## voice: 再生する AudioStream。null の場合はボイス無しで字幕だけ表示
## duration_sec: ボイス無し時の表示時間。0以下なら default_duration_sec を使用。
func show_subtitle(
text: String,
voice: AudioStream = null,
duration_sec: float = 0.0
) -> void:
if not subtitle_label:
push_warning("[SubtitleSystem] subtitle_label is not assigned.")
return
if _is_showing and not override_current:
if print_debug_log:
print("[SubtitleSystem] Subtitle already showing. Ignored new request.")
return
# 既存のタイマーがあればキャンセル
if _current_timer:
_current_timer.timeout.disconnect(_on_subtitle_timeout)
_current_timer = null
_is_showing = true
_current_text = text
# テキストの整形(必要なら簡易的に改行を挿入)
var display_text := _apply_wrap(text)
subtitle_label.text = display_text
subtitle_label.visible = true
if print_debug_log:
print("[SubtitleSystem] Show: ", display_text)
# ボイスが指定されていれば再生
if voice and voice_player:
voice_player.stream = voice
voice_player.play()
# 再生時間が取得できる場合は、その長さ+余韻時間で字幕を消す
var length_sec := _get_stream_length(voice)
if length_sec > 0.0:
_start_timer(length_sec + tail_hold_sec)
else:
# 長さが取れない場合は default_duration_sec を使用
var dur := (duration_sec > 0.0) ? duration_sec : default_duration_sec
if dur > 0.0:
_start_timer(dur)
else:
# ボイス無し。指定時間 or デフォルト時間で字幕を消す。
var dur := (duration_sec > 0.0) ? duration_sec : default_duration_sec
if dur > 0.0:
_start_timer(dur)
## 字幕を即座に消す(ボイスの再生は止めない)。
func clear_subtitle() -> void:
if not subtitle_label:
return
if _current_timer:
_current_timer.timeout.disconnect(_on_subtitle_timeout)
_current_timer = null
subtitle_label.visible = false
subtitle_label.text = ""
_current_text = ""
_is_showing = false
if print_debug_log:
print("[SubtitleSystem] Cleared subtitle")
## ボイスの再生を止め、字幕も消す。
func stop_all() -> void:
if voice_player and voice_player.playing:
voice_player.stop()
clear_subtitle()
if print_debug_log:
print("[SubtitleSystem] Stop all")
## 現在表示中の字幕テキストを取得する。
func get_current_text() -> String:
return _current_text
## (オプション)ボイス終了時に自動で呼ばれるハンドラ。
func _on_voice_finished() -> void:
# すでにタイマーで管理している場合は、ここでは何もしない。
# 「ボイス終了と同時に即座に消したい」場合は、ここで clear_subtitle() を呼んでもよい。
if not _current_timer:
# タイマーが無い=明示的な時間指定をしていない場合、
# 余韻時間だけ字幕を残してから消す。
if tail_hold_sec > 0.0:
_start_timer(tail_hold_sec)
else:
clear_subtitle()
## 内部: 指定秒数後に字幕を消すためのタイマーを開始する。
func _start_timer(sec: float) -> void:
if sec <= 0.0:
return
if _current_timer:
_current_timer.timeout.disconnect(_on_subtitle_timeout)
_current_timer = get_tree().create_timer(sec)
_current_timer.timeout.connect(_on_subtitle_timeout)
if print_debug_log:
print("[SubtitleSystem] Start timer: ", sec, " sec")
func _on_subtitle_timeout() -> void:
_current_timer = null
clear_subtitle()
## 内部: 簡易的な文字数ベースの改行処理。
## wrap_char_limit が 0 の場合は何もしない。
func _apply_wrap(text: String) -> String:
if wrap_char_limit <= 0:
return text
var result := ""
var count := 0
for ch in text:
result += ch
count += 1
if count >= wrap_char_limit and ch == " ":
result += "\n"
count = 0
return result
## 内部: AudioStream から再生時間(秒)を推定する。
## 長さが取得できないタイプのストリームの場合は 0 を返す。
func _get_stream_length(stream: AudioStream) -> float:
if stream is AudioStreamOggVorbis:
return (stream as AudioStreamOggVorbis).get_length()
if stream is AudioStreamMP3:
return (stream as AudioStreamMP3).get_length()
if stream is AudioStreamWAV:
return (stream as AudioStreamWAV).get_length()
# その他のタイプは未対応
return 0.0
使い方の手順
① UIシーンに字幕用の Label を用意する
まずは画面下部に字幕を出すための UI シーンを作りましょう。
例として、ゲーム全体の UI をまとめる HUD シーンを作成します。
HUD (CanvasLayer) └── SubtitleLabel (Label)
HUDはCanvasLayerで作るとカメラ移動に影響されず便利です。SubtitleLabelには、中央寄せ・アウトライン・背景などお好みのスタイルを設定しておきます。
② SubtitleSystem コンポーネントを追加する
次に、同じ HUD シーンに SubtitleSystem を追加して、先ほどの SubtitleLabel とボイス用の AudioStreamPlayer を紐付けます。
HUD (CanvasLayer) ├── SubtitleLabel (Label) ├── VoicePlayer (AudioStreamPlayer) └── SubtitleSystem (Node) <-- このノードに SubtitleSystem.gd をアタッチ
Inspector での設定:
subtitle_labelにSubtitleLabelをドラッグ&ドロップvoice_playerにVoicePlayerをドラッグ&ドロップ- 必要に応じて
wrap_char_limitやdefault_duration_secを調整
これで UI 側の準備は完了です。
③ プレイヤーやイベントから呼び出してみる
例として、プレイヤーがしゃべるシーン構成はこんな感じになります:
MainScene (Node2D)
├── Player (CharacterBody2D)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ └── PlayerController (Script)
└── HUD (CanvasLayer)
├── SubtitleLabel (Label)
├── VoicePlayer (AudioStreamPlayer)
└── SubtitleSystem (Node)
PlayerController.gd から字幕を出すコード例:
extends CharacterBody2D
@onready var subtitle_system: SubtitleSystem = $"../HUD/SubtitleSystem"
func _unhandled_input(event: InputEvent) -> void:
# 例: スペースキーでしゃべる
if event.is_action_pressed("ui_accept"):
_say_hello()
func _say_hello() -> void:
# ボイスファイルをプリロード
var voice: AudioStream = preload("res://voice/hello.ogg")
# 字幕テキストと一緒に再生
subtitle_system.show_subtitle("やあ、ここまで来たんだね!", voice)
このように、プレイヤー側は 「しゃべりたいときに show_subtitle を呼ぶだけ」で済みます。
字幕の表示位置やフォント、ボイスの長さに応じた表示時間などは、すべて SubtitleSystem 側で面倒を見てくれます。
④ イベントシーンや敵キャラでも再利用する
同じ SubtitleSystem コンポーネントを、イベントシーンや敵キャラでも使い回しましょう。
例えばイベントシーン:
Cutscene01 (Node2D)
├── Camera2D
├── NPC_A (Node2D)
├── NPC_B (Node2D)
└── HUD (CanvasLayer)
├── SubtitleLabel (Label)
├── VoicePlayer (AudioStreamPlayer)
└── SubtitleSystem (Node)
カットシーン制御スクリプトから:
extends Node2D
@onready var subtitle_system: SubtitleSystem = $HUD/SubtitleSystem
func _ready() -> void:
_play_scene()
func _play_scene() -> void:
# A がしゃべる
subtitle_system.show_subtitle(
"ここから先は危険だ。覚悟はいいか?",
preload("res://voice/cutscene01_a.ogg")
)
await get_tree().create_timer(3.5).timeout
# B が答える(ボイス無しで字幕だけ)
subtitle_system.show_subtitle(
"……もちろん。行こう。",
null,
2.5
)
どのシーンでも同じ API で呼べるので、「字幕の出し方」を覚えるコストが 1 回で済むのがポイントですね。
メリットと応用
SubtitleSystem をコンポーネントとして切り出すことで、いろいろと嬉しい効果があります。
- シーン構造がスッキリする
字幕のロジックをプレイヤーや敵キャラのスクリプトから追い出せるので、各スクリプトが「自分の責務」に集中できます。 - UI とロジックの分離
字幕の見た目(フォント、位置、背景)はHUDシーン側で完結。
ゲームロジック側はテキストとボイスだけ渡すので、UIの変更に巻き込まれません。 - 再利用性の高さ
プロジェクトをまたいでも、SubtitleSystem.gdと UI シーンをコピペすればそのまま使えます。
「このゲーム、前作と同じ字幕システムでいいよね?」というときに超便利です。 - テストがしやすい
SubtitleSystem単体でテストシーンを作れば、ボイス無し・ボイスあり・長文などを簡単に検証できます。
コンポーネント化しておくと、あとから「やっぱりログも取りたい」「自動スキップを付けたい」といった要望にも、この 1 ファイルを拡張するだけで対応できます。
改造案: シンプルな「スキップ」機能を追加する
例えば、プレイヤーがボタンを押したら現在の字幕をスキップして次に進めたい、というときは、こんなメソッドを追加すると便利です。
## プレイヤー操作などから呼び出して、現在の字幕をスキップする。
## - ボイスが再生中なら止める
## - 字幕も即座に消す
func skip_current() -> void:
if voice_player and voice_player.playing:
voice_player.stop()
clear_subtitle()
if print_debug_log:
print("[SubtitleSystem] Skipped current subtitle")
これを追加しておけば、プレイヤー側からは:
if Input.is_action_just_pressed("ui_cancel"):
subtitle_system.skip_current()
と呼ぶだけで、どんなシーンでも統一されたスキップ挙動を実現できます。
このように、字幕周りの仕様変更をすべて SubtitleSystem に閉じ込めておくと、プロジェクト全体の保守がかなり楽になりますね。
継承で巨大な UI クラスを作るのではなく、こうした小さなコンポーネントを積み上げていくスタイルに慣れておくと、Godot 4 の開発がずっと気持ちよくなります。ぜひ自分のプロジェクト用にカスタマイズしてみてください。
