Unityを触り始めた頃は、つい何でもかんでも Update() に書いてしまいがちですよね。
「ボイスが再生されたら字幕を出す」「表示時間が来たら消す」「次のセリフに進む」…といった処理を全部1つの巨大スクリプトに押し込むと、すぐにカオスになります。

  • どのオブジェクトが字幕を出しているのか分からない
  • UIの見た目を変えたいだけなのにゲームロジックまで巻き込んでしまう
  • 別シーンや別プロジェクトに流用しづらい

こういうときこそ「責務ごとに小さなコンポーネントを切り出す」コンポーネント指向の出番です。
この記事では、ボイス再生に合わせて画面下部に字幕テキストを表示する専用コンポーネントとして、SubtitleSystem を実装していきます。

【Unity】ボイス連動で字幕をサクッと管理!「SubtitleSystem」コンポーネント

今回の SubtitleSystem は以下のような役割だけに絞った、シンプルなコンポーネントです。

  • 字幕用の UI(Text または TextMeshProUGUI)を管理する
  • 「この音声クリップにはこの字幕」というデータをキューとして再生する
  • 音声の長さ or 手動で指定した時間だけ字幕を表示し、自動で消す

ボイスの再生自体は AudioSource に任せて、SubtitleSystem は「字幕の表示・非表示」と「どのテキストを出すか」だけに集中させます。
これで、プレイヤーや敵AIなどのスクリプトからは、SubtitleSystem に対して「このセリフをしゃべって」と命令するだけで済みます。

フルコード:SubtitleSystem.cs


using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;          // Text 用
using TMPro;                  // TextMeshProUGUI 用

/// <summary>
/// 1つの字幕エントリ(音声クリップ + テキスト + 表示時間)
/// </summary>
[Serializable]
public class SubtitleEntry
{
    [Tooltip("再生するボイス音声クリップ")]
    public AudioClip voiceClip;

    [Tooltip("画面下部に表示する字幕テキスト")]
    [TextArea(2, 4)]
    public string subtitleText;

    [Tooltip("字幕の表示時間(秒)。0 以下の場合は voiceClip.length を使用")]
    public float displayDuration = 0f;
}

/// <summary>
/// ボイス再生に合わせて字幕を画面下部に表示するコンポーネント。
/// シーン内に1つ置いて、他のスクリプトから呼び出して使う想定。
/// </summary>
[RequireComponent(typeof(AudioSource))]
public class SubtitleSystem : MonoBehaviour
{
    [Header("字幕表示先 UI")]

    [SerializeField]
    [Tooltip("字幕テキストを表示する Text (旧UGUI)。どちらか片方を設定すればOK")]
    private Text legacyText;

    [SerializeField]
    [Tooltip("字幕テキストを表示する TextMeshProUGUI。どちらか片方を設定すればOK")]
    private TextMeshProUGUI tmpText;

    [SerializeField]
    [Tooltip("字幕をまとめてON/OFFするためのルート GameObject(Canvasの子など)。任意")]
    private GameObject subtitleRoot;

    [Header("デフォルト設定")]

    [SerializeField]
    [Tooltip("字幕表示中に自動でフェードアウトさせるか")]
    private bool useFadeOut = true;

    [SerializeField]
    [Tooltip("フェードアウトにかける時間(秒)")]
    private float fadeOutDuration = 0.5f;

    [SerializeField]
    [Tooltip("新しい字幕を再生する前に、現在の字幕を強制的に止めるか")]
    private bool interruptCurrent = true;

    // 内部状態
    private AudioSource audioSource;
    private Coroutine playRoutine;
    private Queue<SubtitleEntry> queue = new Queue<SubtitleEntry>();

    private void Awake()
    {
        audioSource = GetComponent<AudioSource>();

        // どちらのUIも設定されていない場合、エラーログを出しておく
        if (legacyText == null && tmpText == null)
        {
            Debug.LogError(
                "[SubtitleSystem] 字幕を表示する Text または TextMeshProUGUI が設定されていません。",
                this
            );
        }

        // 初期状態では字幕を非表示にしておく
        SetSubtitleVisible(false);
        SetTextAlpha(1f);
        SetText(string.Empty);
    }

    /// <summary>
    /// 外部から呼び出して、1つの字幕を即座に再生する。
    /// キューはクリアされ、現在の字幕も中断される。
    /// </summary>
    public void PlaySubtitle(SubtitleEntry entry)
    {
        if (entry == null)
        {
            Debug.LogWarning("[SubtitleSystem] null の SubtitleEntry が渡されました。");
            return;
        }

        // 今の再生を中断するかどうか
        if (interruptCurrent && playRoutine != null)
        {
            StopCoroutine(playRoutine);
            playRoutine = null;
        }

        // キューをクリアして、このエントリだけにする
        queue.Clear();
        queue.Enqueue(entry);

        // 再生開始
        if (playRoutine == null)
        {
            playRoutine = StartCoroutine(PlayQueueRoutine());
        }
    }

    /// <summary>
    /// 外部から呼び出して、字幕をキューに追加する。
    /// すでに再生中なら、続きとして順番に流れる。
    /// </summary>
    public void EnqueueSubtitle(SubtitleEntry entry)
    {
        if (entry == null)
        {
            Debug.LogWarning("[SubtitleSystem] null の SubtitleEntry が渡されました。");
            return;
        }

        queue.Enqueue(entry);

        // 何も再生していないときだけコルーチンを起動
        if (playRoutine == null)
        {
            playRoutine = StartCoroutine(PlayQueueRoutine());
        }
    }

    /// <summary>
    /// 現在の字幕とキューをすべて停止・クリアする。
    /// </summary>
    public void StopAllSubtitles()
    {
        if (playRoutine != null)
        {
            StopCoroutine(playRoutine);
            playRoutine = null;
        }

        queue.Clear();
        audioSource.Stop();
        SetSubtitleVisible(false);
        SetText(string.Empty);
    }

    /// <summary>
    /// キューに溜まった字幕を順番に再生するコルーチン。
    /// </summary>
    private IEnumerator PlayQueueRoutine()
    {
        while (queue.Count > 0)
        {
            SubtitleEntry current = queue.Dequeue();
            yield return StartCoroutine(PlayOneSubtitleRoutine(current));
        }

        // 全部再生し終わったらクリーンアップ
        playRoutine = null;
        SetSubtitleVisible(false);
        SetText(string.Empty);
    }

    /// <summary>
    /// 1つの字幕(音声 + テキスト)を再生するコルーチン。
    /// </summary>
    private IEnumerator PlayOneSubtitleRoutine(SubtitleEntry entry)
    {
        if (entry.voiceClip == null && string.IsNullOrEmpty(entry.subtitleText))
        {
            yield break;
        }

        // 表示テキストをセット
        SetText(entry.subtitleText);
        SetTextAlpha(1f);
        SetSubtitleVisible(true);

        // 音声を再生
        if (entry.voiceClip != null)
        {
            audioSource.clip = entry.voiceClip;
            audioSource.Play();
        }

        // 表示時間を決定
        float duration = entry.displayDuration;
        if (duration <= 0f)
        {
            // 指定されていなければ、音声クリップの長さを使う
            if (entry.voiceClip != null)
            {
                duration = entry.voiceClip.length;
            }
            else
            {
                // 音声がない場合のデフォルト時間
                duration = 2.0f;
            }
        }

        // フェードアウト時間を含めた待機
        float visibleTime = duration;
        if (useFadeOut && fadeOutDuration > 0f)
        {
            visibleTime = Mathf.Max(0f, duration - fadeOutDuration);
        }

        // フェードアウト前の表示時間
        float elapsed = 0f;
        while (elapsed < visibleTime)
        {
            elapsed += Time.deltaTime;
            yield return null;
        }

        // フェードアウト処理
        if (useFadeOut && fadeOutDuration > 0f)
        {
            float fadeElapsed = 0f;
            while (fadeElapsed < fadeOutDuration)
            {
                fadeElapsed += Time.deltaTime;
                float t = Mathf.Clamp01(fadeElapsed / fadeOutDuration);
                float alpha = Mathf.Lerp(1f, 0f, t);
                SetTextAlpha(alpha);
                yield return null;
            }
        }

        // 表示を消す
        SetSubtitleVisible(false);
        SetText(string.Empty);
    }

    /// <summary>
    /// 実際にテキストをUIに流し込むヘルパー。
    /// Text / TextMeshProUGUI のどちらにも対応。
    /// </summary>
    private void SetText(string text)
    {
        if (legacyText != null)
        {
            legacyText.text = text;
        }

        if (tmpText != null)
        {
            tmpText.text = text;
        }
    }

    /// <summary>
    /// テキストのアルファ値だけを変更する。
    /// </summary>
    private void SetTextAlpha(float alpha)
    {
        if (legacyText != null)
        {
            Color c = legacyText.color;
            c.a = alpha;
            legacyText.color = c;
        }

        if (tmpText != null)
        {
            Color c = tmpText.color;
            c.a = alpha;
            tmpText.color = c;
        }
    }

    /// <summary>
    /// 字幕ルートの表示・非表示を切り替える。
    /// subtitleRoot が未設定の場合は何もしない。
    /// </summary>
    private void SetSubtitleVisible(bool visible)
    {
        if (subtitleRoot != null)
        {
            subtitleRoot.SetActive(visible);
        }
    }
}

使い方の手順

  1. UI を用意する
    • Hierarchy で UI > Canvas を作成(既にあるならそれを使用)。
    • Canvas の子に UI > Text - TextMeshProUI > Text を作成。
    • アンカーを画面下部中央に設定し、幅を横いっぱい・高さをお好みで調整。
    • フォントサイズ、色(白文字+黒アウトラインなど)、中央揃えなどを設定。
    • 必要であれば、その Text を空の GameObject(例: SubtitleRoot)でラップしておくと ON/OFF しやすいです。
  2. SubtitleSystem コンポーネントを配置する
    • 空の GameObject を作成し、名前を SubtitleSystem にする。
    • この GameObject に SubtitleSystem スクリプトをアタッチ。
    • 自動で AudioSource も付くので、そのまま BGM 用とは別に「ボイス用」として使います。
    • インスペクターで以下を設定:
      • Legacy Text または Tmp Text に、先ほど作った字幕用 Text をドラッグ&ドロップ。
      • Subtitle Root に、字幕全体をまとめた GameObject(Text 自身でもOK)を設定。
      • フェードアウトを使いたければ Use Fade Out にチェックを入れ、Fade Out Duration を 0.5〜1.0 秒程度に。
  3. 字幕データを用意して再生する(例:プレイヤーのセリフ)
    プレイヤーがスイッチを押したときにしゃべる、という簡単な例です。
    
    using UnityEngine;
    
    /// <summary>
    /// プレイヤーがスイッチを押したときにセリフを再生する例。
    /// </summary>
    public class PlayerVoiceExample : MonoBehaviour
    {
        [SerializeField]
        private SubtitleSystem subtitleSystem;
    
        [SerializeField]
        private AudioClip switchOnVoice;
    
        private void Reset()
        {
            // 同じシーン内から SubtitleSystem を自動検索(任意)
            subtitleSystem = FindObjectOfType<SubtitleSystem>();
        }
    
        public void OnPressedSwitch()
        {
            if (subtitleSystem == null)
            {
                Debug.LogWarning("[PlayerVoiceExample] SubtitleSystem が設定されていません。");
                return;
            }
    
            // 再生したい字幕データを作成
            SubtitleEntry entry = new SubtitleEntry
            {
                voiceClip = switchOnVoice,
                subtitleText = "よし、スイッチを入れたぞ。",
                displayDuration = 0f // 0 の場合、voiceClip.length が使われる
            };
    
            // 即座にこの字幕を再生
            subtitleSystem.PlaySubtitle(entry);
        }
    }
    
    • switchOnVoice にセリフの AudioClip を設定。
    • スイッチ側のスクリプトから OnPressedSwitch() を呼んであげればOKです(イベントでも、トリガーでも)。
  4. キュー再生の例(敵が連続でしゃべる)
    敵が登場時に 2〜3 行しゃべる、といったシーンではキュー再生が便利です。
    
    using UnityEngine;
    
    /// <summary>
    /// 敵の登場時に、複数行のセリフを順番に再生する例。
    /// </summary>
    public class EnemyIntroVoice : MonoBehaviour
    {
        [SerializeField]
        private SubtitleSystem subtitleSystem;
    
        [SerializeField]
        private AudioClip line1;
    
        [SerializeField]
        private AudioClip line2;
    
        private void Start()
        {
            if (subtitleSystem == null)
            {
                subtitleSystem = FindObjectOfType<SubtitleSystem>();
            }
    
            if (subtitleSystem == null)
            {
                Debug.LogWarning("[EnemyIntroVoice] SubtitleSystem が見つかりません。");
                return;
            }
    
            // 1行目
            SubtitleEntry entry1 = new SubtitleEntry
            {
                voiceClip = line1,
                subtitleText = "フッ…ここまで来るとはな、勇者よ。",
                displayDuration = 0f
            };
    
            // 2行目
            SubtitleEntry entry2 = new SubtitleEntry
            {
                voiceClip = line2,
                subtitleText = "だが、ここがお前の墓場だ!",
                displayDuration = 0f
            };
    
            // 順番にキューへ追加
            subtitleSystem.EnqueueSubtitle(entry1);
            subtitleSystem.EnqueueSubtitle(entry2);
        }
    }
    

    こうしておくと、line1line2 の順に、音声と字幕が自動で流れてくれます。

メリットと応用

SubtitleSystem を使うことで、こんなメリットがあります。

  • プレハブ化しやすい
    字幕用 Canvas と Text、SubtitleSystem をまとめて 1 プレハブにしておけば、どのシーンにもドラッグ&ドロップで持ち込めます。
    シーンごとに UI を作り直す必要がなくなり、統一感のある字幕表示が簡単に実現できます。
  • レベルデザインが楽になる
    「このトリガーに入ったらこのセリフをしゃべる」「このギミックを動かしたら警告を出す」といったロジック側は、
    ただ SubtitleSystemSubtitleEntry を投げるだけで済みます。
    レベルデザイナーは、AudioClip とテキストをインスペクターで差し替えるだけでセリフを変更できます。
  • 責務が分離される
    プレイヤーや敵のスクリプトは「いつしゃべるか」だけを知っていればOKで、「どう表示するか(フェード、フォント、レイアウト)」は SubtitleSystem 側に閉じ込められます。
    これにより、巨大な God クラスを避け、コンポーネントごとに役割がはっきりした設計になります。

さらに、ちょっとした改造で色々遊べます。
例として、キャラクターごとに字幕の色を変える簡単な拡張案を載せておきます。


/// <summary>
/// 特定キャラ用の色で字幕を再生するヘルパー。
/// 例えば、プレイヤーは青、敵は赤など。
/// </summary>
public void PlayColoredSubtitle(SubtitleEntry entry, Color color)
{
    // まず現在のテキストカラーを保存(必要ならフィールドに退避してもOK)
    Color? originalLegacy = null;
    Color? originalTmp = null;

    if (legacyText != null)
    {
        originalLegacy = legacyText.color;
        legacyText.color = color;
    }

    if (tmpText != null)
    {
        originalTmp = tmpText.color;
        tmpText.color = color;
    }

    // 通常の再生を行う
    PlaySubtitle(entry);

    // ここでは簡略化のため、色の復元は行っていません。
    // 実運用ではコルーチン終了後に元の色へ戻す処理を追加するとよいでしょう。
}

このように、SubtitleSystem をベースに「キャラ別カラー」「BGMの音量をボイス中だけ下げる」「自動改行や文字送り演出」など、責務を追加したくなったら別コンポーネントとして切り出して連携させていくと、きれいな設計を保ちやすくなります。
まずはこの記事のコードをコピペして、シンプルな字幕システムをゲームに組み込んでみてください。