キャラクターがしゃべるゲームを作り始めると、「ボイスを鳴らすだけ」では物足りなくなってきますよね。
字幕も出したい、ログも取りたい、スキップもしたい……と欲張り始めると、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)
  • HUDCanvasLayer で作るとカメラ移動に影響されず便利です。
  • SubtitleLabel には、中央寄せ・アウトライン・背景などお好みのスタイルを設定しておきます。

② SubtitleSystem コンポーネントを追加する

次に、同じ HUD シーンに SubtitleSystem を追加して、先ほどの SubtitleLabel とボイス用の AudioStreamPlayer を紐付けます。

HUD (CanvasLayer)
 ├── SubtitleLabel (Label)
 ├── VoicePlayer (AudioStreamPlayer)
 └── SubtitleSystem (Node)  <-- このノードに SubtitleSystem.gd をアタッチ

Inspector での設定:

  • subtitle_labelSubtitleLabel をドラッグ&ドロップ
  • voice_playerVoicePlayer をドラッグ&ドロップ
  • 必要に応じて wrap_char_limitdefault_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 の開発がずっと気持ちよくなります。ぜひ自分のプロジェクト用にカスタマイズしてみてください。