Unityの会話シーンを作り始めると、つい「とりあえず Update に全部書くか…」となりがちですよね。テキストの表示、スキップ入力、ウィンドウの開閉、SEの再生などをひとつのスクリプトに押し込んでしまうと、だんだんカオスになっていきます。
とくに「文字送り音(タイプ音)」は、会話システムの中でも独立した振る舞いなのに、巨大な会話スクリプトの一部として書かれがちです。その結果、
- 会話ロジックとサウンドロジックが絡み合って修正しづらい
- 別のUIで同じタイプ音を使いたくなったときに再利用できない
- サウンドだけ差し替えたいのに、会話コードを触る必要が出てくる
といった問題が起きます。
そこでこの記事では、「文字が出るたびにビープ音を鳴らす」 という責務だけを持った、小さなコンポーネント TypingSound を用意して、会話ロジックからサウンド処理をきれいに切り離してみましょう。
【Unity】会話テキストにタイプ音を添える!「TypingSound」コンポーネント
以下は、「1文字表示されるたびに短いビープ音を鳴らす」 ためのコンポーネントです。
会話テキスト側は「今1文字表示したよ」という合図だけを送ればOKで、音の再生間隔やボリューム、ランダムピッチなどはこのコンポーネント側で完結します。
ソースコード(フルコード)
using UnityEngine;
/// <summary>
/// 会話ウィンドウなどで、文字が1つ表示されるたびにタイプ音(ビープ音)を鳴らすコンポーネント。
/// ・「文字送り」のロジックから完全に分離して、サウンドだけを担当します。
/// ・TextMeshPro などのテキスト表示スクリプトから、OnCharacterPrinted を呼び出して使います。
/// </summary>
[RequireComponent(typeof(AudioSource))]
public class TypingSound : MonoBehaviour
{
[Header("基本設定")]
[SerializeField]
private AudioClip typingClip; // 1文字ごとに鳴らす短いビープ音
[SerializeField, Tooltip("文字送り音の音量")]
[Range(0f, 1f)]
private float volume = 0.5f;
[SerializeField, Tooltip("何文字ごとに音を鳴らすか(1なら毎文字)")]
private int playInterval = 1;
[SerializeField, Tooltip("文字送り音を鳴らす最小間隔(秒)。0にすると毎フレームでも鳴る可能性があります")]
private float minTimeInterval = 0.02f;
[Header("ピッチ設定")]
[SerializeField, Tooltip("ピッチをランダム化するか")]
private bool useRandomPitch = true;
[SerializeField, Tooltip("ランダムピッチの最小値")]
[Range(0.5f, 2.0f)]
private float minPitch = 0.95f;
[SerializeField, Tooltip("ランダムピッチの最大値")]
[Range(0.5f, 2.0f)]
private float maxPitch = 1.05f;
[Header("フィルタリング")]
[SerializeField, Tooltip("この文字は音を鳴らさない(句読点など)。空の場合はすべて鳴らします")]
private char[] ignoreCharacters = new char[] { ' ', ' ', '、', '。', ',', '.' };
[SerializeField, Tooltip("同じ文字が連続したときは音を鳴らさない(例: 伸ばし記号など)")]
private bool ignoreSameCharSequence = false;
// 内部状態
private AudioSource audioSource;
private float lastPlayTime = -999f;
private int printedCharCount = 0;
private char? lastPlayedChar = null;
private void Awake()
{
// 必須コンポーネントの AudioSource をキャッシュ
audioSource = GetComponent<AudioSource>();
// 再生は PlayOneShot を使うので、AudioSource.clip は空でもOK
audioSource.playOnAwake = false;
audioSource.loop = false;
// ランダムピッチの範囲を安全側に補正
if (maxPitch < minPitch)
{
float tmp = minPitch;
minPitch = maxPitch;
maxPitch = tmp;
}
}
/// <summary>
/// 会話システム側から呼び出す「1文字表示されたよ」の通知。
/// 表示された文字(char)を渡すことで、句読点などをフィルタリングできます。
/// </summary>
/// <param name="printedChar">新しく表示された文字</param>
public void OnCharacterPrinted(char printedChar)
{
printedCharCount++;
// 句読点や空白など、音を鳴らしたくない文字の場合はスキップ
if (ShouldIgnoreCharacter(printedChar))
{
return;
}
// 同じ文字が連続している場合はスキップ(オプション)
if (ignoreSameCharSequence && lastPlayedChar.HasValue && lastPlayedChar.Value == printedChar)
{
return;
}
// 何文字ごとに鳴らすか(1なら毎文字)
if (playInterval > 1 && (printedCharCount % playInterval != 0))
{
return;
}
// 最小再生間隔を満たしているかチェック
if (Time.unscaledTime - lastPlayTime < minTimeInterval)
{
return;
}
PlayTypingSoundInternal(printedChar);
}
/// <summary>
/// 「文字は渡さないけど、とにかく1文字進んだよ」という通知用。
/// 文字種によるフィルタリングが不要な場合はこちらでもOKです。
/// </summary>
public void OnCharacterPrinted()
{
// ダミー文字を渡す(フィルタリングが不要なら特に意味はない)
OnCharacterPrinted('\0');
}
/// <summary>
/// 内部用:実際にタイプ音を再生する処理。
/// </summary>
/// <param name="printedChar">再生トリガーとなった文字</param>
private void PlayTypingSoundInternal(char printedChar)
{
if (typingClip == null)
{
// クリップが設定されていない場合は何もしない
return;
}
// ランダムピッチ設定
if (useRandomPitch)
{
audioSource.pitch = Random.Range(minPitch, maxPitch);
}
else
{
audioSource.pitch = 1f;
}
// 実際の再生
audioSource.PlayOneShot(typingClip, volume);
lastPlayTime = Time.unscaledTime;
lastPlayedChar = printedChar;
}
/// <summary>
/// 音を鳴らさない文字かどうか判定する。
/// </summary>
private bool ShouldIgnoreCharacter(char c)
{
if (ignoreCharacters == null || ignoreCharacters.Length == 0)
{
return false;
}
foreach (char ignore in ignoreCharacters)
{
if (c == ignore)
{
return true;
}
}
return false;
}
/// <summary>
/// 会話が始まるたびにカウンタや状態をリセットしたい場合に呼び出します。
/// (任意。呼ばなくても動作はしますが、演出を安定させたいときに便利です)
/// </summary>
public void ResetState()
{
printedCharCount = 0;
lastPlayedChar = null;
lastPlayTime = -999f;
}
}
使い方の手順
ここでは例として、TextMeshPro の会話ウィンドウ にタイプ音を付けるケースを想定しますが、敵のセリフUIやチュートリアルポップアップなど、どんなテキストUIにも同じように使えます。
-
コンポーネントをアタッチする
- 会話ウィンドウ用の GameObject(例:
DialogueWindow)を選択します。 TypingSoundスクリプトを追加します。
自動的にAudioSourceも付与されます([RequireComponent]のおかげですね)。
- 会話ウィンドウ用の GameObject(例:
-
AudioClip を設定する
- 短くカットしたビープ音(WAV/OGG など)を用意し、Project にインポートします。
TypingSoundのTyping Clipにそのクリップをドラッグ&ドロップします。- 必要に応じて
Volume、Play Interval(何文字ごとに鳴らすか)、Min Time Intervalなどを調整します。
-
文字送りスクリプトから通知を送る
既に「1文字ずつテキストを表示する」スクリプトを持っている前提で、その中に
TypingSoundへの通知を1行だけ追加します。たとえば、TextMeshProUGUI を使った非常にシンプルな文字送りスクリプトはこんな感じです。
using System.Collections; using TMPro; using UnityEngine; /// <summary> /// とてもシンプルな文字送りコンポーネントの例。 /// TypingSound と組み合わせることで、文字ごとにタイプ音を鳴らせます。 /// </summary> [RequireComponent(typeof(TextMeshProUGUI))] public class SimpleTypewriter : MonoBehaviour { [SerializeField] private float charsPerSecond = 30f; // 1秒あたりに表示する文字数 [SerializeField] private TypingSound typingSound; // 同じウィンドウに付けた TypingSound を参照 private TextMeshProUGUI tmp; private Coroutine typingRoutine; private void Awake() { tmp = GetComponent<TextMeshProUGUI>(); } /// <summary> /// 指定した文章を1文字ずつ表示します。 /// </summary> public void StartTyping(string text) { // すでに文字送り中なら止める if (typingRoutine != null) { StopCoroutine(typingRoutine); } typingRoutine = StartCoroutine(TypeRoutine(text)); } private IEnumerator TypeRoutine(string text) { tmp.text = string.Empty; // TypingSound の状態をリセット(任意) if (typingSound != null) { typingSound.ResetState(); } float interval = 1f / Mathf.Max(charsPerSecond, 0.01f); for (int i = 0; i < text.Length; i++) { char c = text[i]; // 文字を1つ追加 tmp.text += c; // ここで TypingSound に「1文字表示されたよ」と通知 if (typingSound != null) { typingSound.OnCharacterPrinted(c); } yield return new WaitForSeconds(interval); } typingRoutine = null; } }会話ウィンドウの GameObject に
SimpleTypewriterをアタッチし、
インスペクターでTyping Soundフィールドに先ほどのTypingSoundをドラッグして関連付ければOKです。 -
実際に呼び出してみる
たとえば、シーンのどこかにこんなテスト用スクリプトを置いておくと、ゲーム開始時に自動でタイプ音付きのテキストが流れます。
using UnityEngine; public class DialogueTestStarter : MonoBehaviour { [SerializeField] private SimpleTypewriter typewriter; [TextArea] [SerializeField] private string testText = "こんにちは!\nこれはタイプ音付きの会話テストです。"; private void Start() { if (typewriter != null) { typewriter.StartTyping(testText); } } }これで、プレイヤーキャラクターの会話、敵のセリフウィンドウ、チュートリアルのポップアップなど、テキストを1文字ずつ表示するあらゆるUIに簡単にタイプ音を付けられます。
メリットと応用
TypingSound を分離したことで、次のようなメリットがあります。
- 会話ロジックとサウンドロジックが完全に分離
「いつ文字を出すか」は会話コンポーネント、「どう音を鳴らすか」はTypingSoundが担当するので、それぞれを独立して調整・テストできます。 - プレハブ単位で簡単に演出を変えられる
主人公の会話ウィンドウには柔らかいピコピコ音、ロボットの会話ウィンドウにはメカっぽいビープ音、ボスのセリフには低めのタイプ音…といった差別化も、TypingSoundの設定を変えたプレハブを複製するだけで実現できます。 - レベルデザインや演出調整がしやすい
テキストの内容を一切触らずに、Play IntervalやMin Time Intervalを調整するだけで、
「うるさすぎない程度に間引く」「重要なセリフだけ毎文字鳴らす」といった微調整ができます。 - 他のUIでも再利用しやすい
会話以外にも、チュートリアルの説明文、ログウィンドウ、NPCの頭上吹き出しなど、
「1文字ずつ出るテキスト」ならどこでも同じコンポーネントを使い回せます。
さらに、TypingSound 自体も小さな責務に絞ってあるので、改造しても他の部分に影響が波及しにくいのがポイントです。
改造案:行の先頭だけ少しピッチを変える
例えば、「行の先頭の文字だけ少し高いピッチにする」といった演出を入れたい場合、TypingSound に次のようなメソッドを追加して、文字送り側から行番号を教えてあげる、というアプローチもできます。
/// <summary>
/// 行番号付きで文字送りを通知する応用版。
/// 行の先頭だけピッチを少し高くする例。
/// </summary>
public void OnCharacterPrintedWithLine(char printedChar, int lineIndex, int charIndexInLine)
{
// 行頭の1文字目だけ、少し高めのピッチにしたい
bool isLineHead = (charIndexInLine == 0);
if (isLineHead)
{
// 一時的にピッチ設定を変えてから再生
float originalMin = minPitch;
float originalMax = maxPitch;
minPitch += 0.05f;
maxPitch += 0.1f;
OnCharacterPrinted(printedChar);
// 元の設定に戻す
minPitch = originalMin;
maxPitch = originalMax;
}
else
{
OnCharacterPrinted(printedChar);
}
}
このように、「どのタイミングでどんな音を鳴らすか」というロジックを少しずつ追加していくことで、会話システム全体を肥大化させることなく、演出だけを強化していけるのがコンポーネント指向の良さですね。
