Unityでダイアログを作り始めると、つい「全部ひとつのスクリプトの Update に書いてしまう」ことが多いですよね。
テキストの表示ロジック、スキップ入力、ウィンドウの開閉、そして「ポポポ」みたいな文字送り音まで、全部が1クラスに詰め込まれてしまうと…

  • ちょっと仕様を変えたいだけで巨大なスクリプトを読み解く必要がある
  • 別のダイアログにも使い回したいのに、依存が多すぎてコピペ地獄になる
  • 音だけ変えたいのに、テキスト描画ロジックまで触る羽目になる

こういう「Godクラス」状態を避けるために、テキスト表示文字送り音はきちんと分離しておくのがオススメです。
この記事では、ダイアログ表示中に文字が出るタイミングだけで「ポポポ」音を鳴らす専用コンポーネントとして、TypingAudio を用意してみましょう。

テキストの1文字ごとに「鳴らす・鳴らさない」を判定するロジックをコンポーネント化しておけば、どんなダイアログシステムにも後付けで「ポポポ感」を足せるようになります。

【Unity】ダイアログに「ポポポ感」を足す!「TypingAudio」コンポーネント

ここでは、以下のような前提で設計します。

  • テキストを 1 文字ずつ表示していく側(ダイアログ表示コンポーネント)が、文字が出たタイミングで TypingAudio に通知する
  • TypingAudio は通知を受け取ったときに、文字送り音を鳴らすかどうかを判定して再生するだけ
  • 「記号や空白は音を鳴らさない」「一定間隔以上あけて鳴らす」などの細かいルールは、TypingAudio 側に閉じ込める

つまり、テキスト側は「1文字表示したら OnCharacterTyped(char c) を呼ぶ」だけでOKにしてしまいます。

フルコード:TypingAudio.cs


using UnityEngine;

/// <summary>
/// ダイアログの文字送り時に「ポポポ」音を鳴らす専用コンポーネント。
/// テキスト表示側から 1 文字ごとに OnCharacterTyped を呼び出して使う。
/// </summary>
[RequireComponent(typeof(AudioSource))]
public class TypingAudio : MonoBehaviour
{
    [Header("再生するSEクリップ")]
    [SerializeField]
    private AudioClip typingClip;

    [Header("音量設定")]
    [SerializeField, Range(0f, 1f)]
    private float volume = 0.7f;

    [Header("ピッチのランダム幅(単調さを軽減)")]
    [SerializeField]
    private bool randomizePitch = true;

    [SerializeField, Range(0.5f, 1.5f)]
    private float minPitch = 0.9f;

    [SerializeField, Range(0.5f, 1.5f)]
    private float maxPitch = 1.1f;

    [Header("文字送り音の制御")]
    [Tooltip("句読点や空白など、音を鳴らさない文字のリスト")]
    [SerializeField]
    private string ignoreCharacters = " \n\r\t。、,.!?!?…ー―";

    [Tooltip("この秒数より短い間隔では音を鳴らさない(連打ノイズ対策)")]
    [SerializeField, Min(0f)]
    private float minInterval = 0.03f;

    [Tooltip("この数の文字ごとに1回だけ鳴らす(0か1なら毎文字)")]
    [SerializeField, Min(0)]
    private int playEveryCharacters = 1;

    // 実際に再生を行う AudioSource
    private AudioSource audioSource;

    // 最後に音を鳴らした時間(Time.time)
    private float lastPlayTime = -999f;

    // 直近で「音を鳴らしてもよい文字」が何文字流れたか
    private int typedCountSinceLastPlay = 0;

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

        // SE用途なので spatialBlend や loop は適切に初期化しておく
        audioSource.playOnAwake = false;
        audioSource.loop = false;
        audioSource.spatialBlend = 0f; // 2Dサウンドとして扱う
    }

    /// <summary>
    /// テキスト側から、1文字表示されるたびに呼び出すメソッド。
    /// </summary>
    /// <param name="c">表示された文字</param>
    public void OnCharacterTyped(char c)
    {
        // 1. SEクリップが未設定なら何もしない
        if (typingClip == null)
        {
            return;
        }

        // 2. 無視対象の文字なら何もしない(空白・改行・句読点など)
        if (ShouldIgnoreCharacter(c))
        {
            return;
        }

        // 3. インターバルチェック
        float now = Time.time;
        if (now - lastPlayTime < minInterval)
        {
            // 前回からの経過時間が足りない場合はカウントだけ進めて終了
            typedCountSinceLastPlay++;
            return;
        }

        // 4. 何文字ごとに鳴らすかのチェック
        // playEveryCharacters が 0 or 1 の場合は毎文字鳴らす
        if (playEveryCharacters >= 2)
        {
            typedCountSinceLastPlay++;
            if (typedCountSinceLastPlay < playEveryCharacters)
            {
                return;
            }
        }

        // ここまで来たら実際に再生する
        PlayTypingSound();

        // 状態をリセット
        lastPlayTime = now;
        typedCountSinceLastPlay = 0;
    }

    /// <summary>
    /// 特定の文字を「音を鳴らさない対象」として判定する。
    /// </summary>
    private bool ShouldIgnoreCharacter(char c)
    {
        // ignoreCharacters に含まれているかどうかで判定
        return ignoreCharacters.IndexOf(c) >= 0;
    }

    /// <summary>
    /// 実際に AudioSource を使って文字送り音を再生する。
    /// </summary>
    private void PlayTypingSound()
    {
        if (randomizePitch)
        {
            // 単調にならないように、毎回少しだけピッチを変える
            audioSource.pitch = Random.Range(minPitch, maxPitch);
        }
        else
        {
            audioSource.pitch = 1f;
        }

        // PlayOneShot で重ねがけ再生(素早い入力にも対応)
        audioSource.PlayOneShot(typingClip, volume);
    }

    /// <summary>
    /// ダイアログの開始時などに、内部状態をリセットしたいときに呼ぶ。
    /// (任意・呼ばなくても致命的な問題はない)
    /// </summary>
    public void ResetState()
    {
        lastPlayTime = -999f;
        typedCountSinceLastPlay = 0;
    }
}

ポイント

  • OnCharacterTyped(char c) を公開して、テキスト側から「1文字表示したよ」と通知してもらう形にしている
  • ignoreCharacters で空白や句読点をまとめて無視できるので、日本語ダイアログでも扱いやすい
  • minIntervalplayEveryCharacters で、「高速送り時にうるさすぎる問題」を調整できる
  • AudioSource は同じ GameObject にアタッチしておき、RequireComponent で付け忘れを防止

シンプルな連携例:1文字ずつ表示するダイアログ

使い方をイメージしやすいように、簡易的な「1文字ずつ表示するダイアログ」コンポーネントを用意して、TypingAudio と連携させてみます。


using System.Collections;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// 非常にシンプルな「1文字ずつ表示するだけ」のダイアログ表示例。
/// TypingAudio と組み合わせて使うことを想定。
/// </summary>
[RequireComponent(typeof(Text))]
public class SimpleTypewriterDialog : MonoBehaviour
{
    [Header("表示するフルテキスト")]
    [TextArea(2, 5)]
    [SerializeField]
    private string fullText = "これはサンプルのダイアログです。\n文字送り音が「ポポポ」と鳴ります。";

    [Header("1文字あたりの表示間隔(秒)")]
    [SerializeField, Min(0.001f)]
    private float characterInterval = 0.05f;

    [Header("入力で一気に表示するか")]
    [SerializeField]
    private bool allowSkip = true;

    [Header("文字送り音コンポーネント(任意)")]
    [SerializeField]
    private TypingAudio typingAudio;

    private Text uiText;
    private Coroutine typingCoroutine;
    private bool isSkipping = false;

    private void Awake()
    {
        uiText = GetComponent<Text>();
    }

    private void OnEnable()
    {
        StartTyping();
    }

    /// <summary>
    /// ダイアログの表示を開始する。
    /// </summary>
    public void StartTyping()
    {
        // 状態リセット
        uiText.text = string.Empty;
        isSkipping = false;

        if (typingAudio != null)
        {
            typingAudio.ResetState();
        }

        if (typingCoroutine != null)
        {
            StopCoroutine(typingCoroutine);
        }
        typingCoroutine = StartCoroutine(TypeRoutine());
    }

    private IEnumerator TypeRoutine()
    {
        foreach (char c in fullText)
        {
            // スキップが押されていたら一気に表示
            if (allowSkip && isSkipping)
            {
                uiText.text = fullText;
                yield break;
            }

            uiText.text += c;

            // 1文字表示するたびに TypingAudio に通知
            if (typingAudio != null)
            {
                typingAudio.OnCharacterTyped(c);
            }

            // 次の文字まで待機
            yield return new WaitForSeconds(characterInterval);
        }
    }

    private void Update()
    {
        // 非常にシンプルなスキップ入力(スペースキー)
        if (allowSkip && Input.GetKeyDown(KeyCode.Space))
        {
            isSkipping = true;
        }
    }
}

この SimpleTypewriterDialog 自体はあくまで「例」ですが、テキストを1文字ずつ表示している場所から TypingAudio.OnCharacterTyped を呼ぶという連携の仕方が分かると思います。


使い方の手順

  1. オーディオクリップを用意する
    「ポポポ」系の短いSE(WAV/OGGなど)をプロジェクトにインポートし、
    typingClip として使えるようにしておきます。
    例:Assets/Audio/SE/typing_pop.wav
  2. ダイアログ用の GameObject を作る
    例として、Canvas 内に Text (UI) を置き、以下のような構成にします。
    • GameObject: DialogText
      • Component: Text(または TextMeshProUGUI に置き換えてもOK)
      • Component: AudioSource(自動で付く)
      • Component: TypingAudio
      • Component: SimpleTypewriterDialog(サンプル)

    TypingAudio のインスペクタで、Typing Clip に「ポポポ」SEをセットします。

  3. ダイアログ表示コンポーネントと接続する
    • SimpleTypewriterDialogTyping Audio フィールドに、同じオブジェクト上の TypingAudio をドラッグ&ドロップ
    • 実際のプロジェクトで使っている自作のダイアログコンポーネントがある場合は、
      「1文字ずつ表示している場所」で typingAudio.OnCharacterTyped(c); を呼ぶようにします。
  4. プレイヤー / 敵 / 動く床などへの具体的な利用例
    このコンポーネントはテキスト表示と独立しているので、いろいろなシーンで再利用できます。
    • プレイヤーがNPCと会話するシーン
      会話ウィンドウの Text に TypingAudio を付けるだけで、
      どのNPC会話でも同じ「ポポポ」音を共有できます。
    • 敵ボスのイントロダイアログ
      ボス専用のダイアログUIプレハブに TypingAudio を仕込んでおけば、
      ボスごとにSEやピッチ幅を変えて「機械音声」「低い声」っぽく演出することもできます。
    • 動く床のチュートリアルメッセージ
      ステージギミック(動く床など)のそばにポップアップする説明テキストに TypingAudio を付ければ、
      「チュートリアルのときだけポポポ音を鳴らす」といった演出も簡単です。

メリットと応用

TypingAudio をコンポーネントとして分離しておくと、以下のようなメリットがあります。

  • プレハブ管理が楽になる
    ダイアログUIのプレハブに TypingAudio を一度仕込んでおけば、
    そのプレハブをどのシーンに置いても、同じ文字送り音の挙動を再利用できます。
    「このシーンだけ音量を少し下げたい」といった調整も、TypingAudio のインスペクタだけ触ればOKです。
  • レベルデザイン時に音の演出を差し替えやすい
    ストーリーが進むにつれて「ポポポ」音を変えたい場合も、
    typingClip を差し替えるだけで済むので、スクリプトを書き換える必要がありません。
    シーンごとに別の TypingAudio 設定を持たせることも簡単です。
  • テキスト表示ロジックと完全に分離できる
    テキスト描画側は「1文字出したら OnCharacterTyped を呼ぶだけ」なので、
    将来ダイアログシステムを差し替えても、同じインターフェースを呼ぶだけで音の演出を維持できます。

また、小さな責務に分けておくことで、後からの改造もしやすくなります。
例えば、「文章の途中で一瞬だけ文字送り音を止める」といった演出を追加したい場合、TypingAudio にメソッドを1つ足すだけで対応できます。

改造案:一時的に文字送り音をミュートする

たとえば、「特定のセリフだけ音を鳴らしたくない」「カットシーン中は文字送り音をオフにしたい」とき用に、
TypingAudio に以下のようなメソッドを追加するのもアリですね。


    /// <summary>
    /// 一時的に文字送り音の有効/無効を切り替える。
    /// ダイアログ側からセリフ単位で呼び出してもよい。
    /// </summary>
    public void SetTypingSoundEnabled(bool enabled)
    {
        // 単純にフラグで管理する場合
        _isEnabled = enabled;
    }

この場合はクラス内に


    private bool _isEnabled = true;

を追加し、OnCharacterTyped の先頭で


        if (!_isEnabled)
        {
            return;
        }

とチェックしてあげればOKです。
こうして小さな責務ごとにコンポーネントを分けておくと、仕様変更や演出追加にも柔軟に対応できますね。