Unityを触り始めた頃は、ついなんでもかんでも Update() に書いてしまいがちですよね。会話シーンの「文字送り」を作るときも、
- 1文字ずつ増やす処理
- スキップ入力の判定
- 次の行への切り替え
…みたいなものを全部ひとつの巨大スクリプトに詰め込んでしまうと、すぐにカオスになります。
そこでこの記事では、「文字送り」だけに責務を絞ったコンポーネント Typewriter を作って、
ラベル(Label)の visibleCharacters を増やしていくだけのシンプルで再利用しやすい仕組みにしてみましょう。
【Unity】UIの文字送りをコンポーネント化!「Typewriter」コンポーネント
今回作る Typewriter コンポーネントは、
- 親(または指定した)ラベルのテキストを自動で1文字ずつ表示
- 秒間何文字か(speed)をインスペクターから調整
- ボタン(キー)入力で「一気に全文表示」や「次の文へ進む」などに連携しやすい API を用意
といった、「文字送りだけに集中した小さな部品」です。
会話システムの本体は別スクリプトに任せて、このコンポーネントは「テキストをちょっとずつ見せる」ことだけをやらせましょう。
前提:どのラベルを想定しているか
Unity6 では、UI Toolkit の Label などを使ってテキストを表示できますが、
ここでは「親(または指定)Label に visibleCharacters プロパティがある」前提で話を進めます。
UI Toolkit の Label を使う場合は、
- UXML 上の Label を取得してラップしたクラス
- あるいは独自の Label コンポーネント(
visibleCharactersを持つ)
などを用意してもらえれば、この Typewriter と組み合わせて使えます。
この記事では「IVisibleText インターフェース」を挟むことで、TextMeshPro など他のテキストコンポーネントにも差し替えやすい形にしておきます。
ソースコード(フルコード)
using System;
using UnityEngine;
#region インターフェース定義
/// <summary>「見える文字数」を制御できるテキストコンポーネント用インターフェース</summary>
public interface IVisibleText
{
/// <summary>テキスト全文</summary>
string FullText { get; set; }
/// <summary>現在表示している文字数</summary>
int VisibleCharacters { get; set; }
/// <summary>テキストの総文字数(キャッシュでも毎回計算でもOK)</summary>
int TotalCharacters { get; }
}
#endregion
#region サンプル実装:単純な VisibleTextLabel
/// <summary>
/// Unity の任意のテキスト表示をラップして、
/// IVisibleText として扱えるようにするためのサンプルコンポーネント。
///
/// 実際には UI Toolkit の Label や TextMeshProUGUI などに合わせて
/// 中身を書き換えて使ってください。
/// </summary>
public class VisibleTextLabel : MonoBehaviour, IVisibleText
{
// 実際の描画には TextMeshProUGUI や UI Toolkit の Label を使うことを想定。
// ここではサンプルとして、インスペクター上で確認できる string を使用します。
[SerializeField] private string fullText = string.Empty;
// 現在表示している文字数
[SerializeField] private int visibleCharacters = 0;
public string FullText
{
get => fullText;
set
{
fullText = value;
// テキストが変わったら表示文字数をリセットするなどは
// 必要に応じて実装してください。
}
}
public int VisibleCharacters
{
get => visibleCharacters;
set
{
visibleCharacters = Mathf.Clamp(value, 0, TotalCharacters);
// 実際にはここで UI コンポーネントに反映させます。
// 例:TextMeshProUGUI.text = fullText.Substring(0, visibleCharacters);
// 例:label.visibleCharacters = visibleCharacters;
}
}
public int TotalCharacters => string.IsNullOrEmpty(fullText) ? 0 : fullText.Length;
}
#endregion
/// <summary>
/// 親(または指定した)IVisibleText の visibleCharacters を増やして、
/// 会話のような文字送りを表現する Typewriter コンポーネント。
/// </summary>
[DisallowMultipleComponent]
public class Typewriter : MonoBehaviour
{
[Header("参照設定")]
[Tooltip("文字送り対象のテキスト。未指定の場合は親から IVisibleText を探します。")]
[SerializeField] private MonoBehaviour visibleTextSource; // IVisibleText を実装したコンポーネント
[Header("文字送り設定")]
[Tooltip("1秒あたりに表示する文字数")]
[SerializeField] private float charactersPerSecond = 30f;
[Tooltip("有効にすると、Awake 時に自動で対象テキストを初期化します")]
[SerializeField] private bool autoInitializeOnAwake = true;
[Tooltip("Awake 時にすでにセットされているテキストを自動で再生開始します")]
[SerializeField] private bool playOnAwake = true;
// 実際に利用する IVisibleText への参照(キャスト済み)
private IVisibleText targetText;
// 再生中かどうか
private bool isPlaying = false;
// 1文字進めるまでの時間(charactersPerSecond から算出)
private float secondsPerCharacter => charactersPerSecond > 0f
? 1f / charactersPerSecond
: 0.0001f;
// 経過時間の蓄積
private float elapsed = 0f;
// 現在の表示文字数(int だと細かい時間積算がしづらいので float で持っておく)
private float currentVisibleCount = 0f;
private void Awake()
{
// visibleTextSource が未設定なら、親方向に IVisibleText を探索
if (visibleTextSource == null)
{
visibleTextSource = GetComponentInParent<MonoBehaviour>();
}
if (visibleTextSource is IVisibleText vt)
{
targetText = vt;
}
else
{
Debug.LogError(
$"[Typewriter] IVisibleText を実装したコンポーネントが見つかりません。" +
$" ゲームオブジェクト: {gameObject.name}",
this
);
enabled = false;
return;
}
if (autoInitializeOnAwake)
{
InitializeCurrentText();
}
if (playOnAwake)
{
Play();
}
}
private void Update()
{
if (!isPlaying || targetText == null) return;
// 経過時間を加算
elapsed += Time.deltaTime;
// 経過時間に応じて何文字進めるかを計算
float targetVisible = elapsed / secondsPerCharacter;
// すでに進めた分に追加
if (targetVisible > currentVisibleCount)
{
currentVisibleCount = targetVisible;
int newVisibleInt = Mathf.Clamp(
Mathf.FloorToInt(currentVisibleCount),
0,
targetText.TotalCharacters
);
targetText.VisibleCharacters = newVisibleInt;
// 全文表示し終えたら自動停止
if (newVisibleInt >= targetText.TotalCharacters)
{
isPlaying = false;
}
}
}
/// <summary>
/// 現在の FullText を元に、visibleCharacters を 0 にリセットして準備します。
/// テキストを差し替えた後に呼ぶ想定です。
/// </summary>
public void InitializeCurrentText()
{
if (targetText == null) return;
elapsed = 0f;
currentVisibleCount = 0f;
targetText.VisibleCharacters = 0;
}
/// <summary>
/// 新しいテキストをセットして、文字送りを最初から再生します。
/// </summary>
public void SetTextAndPlay(string text)
{
if (targetText == null) return;
targetText.FullText = text;
InitializeCurrentText();
Play();
}
/// <summary>
/// 文字送りを開始(または再開)します。
/// </summary>
public void Play()
{
if (targetText == null) return;
isPlaying = true;
}
/// <summary>
/// 文字送りを一時停止します。
/// </summary>
public void Pause()
{
isPlaying = false;
}
/// <summary>
/// 文字送りを停止し、最初に戻します。
/// </summary>
public void StopAndReset()
{
isPlaying = false;
InitializeCurrentText();
}
/// <summary>
/// 即座に全文を表示します(スキップ用)。
/// </summary>
public void ShowAllImmediately()
{
if (targetText == null) return;
currentVisibleCount = targetText.TotalCharacters;
targetText.VisibleCharacters = targetText.TotalCharacters;
isPlaying = false;
}
/// <summary>
/// 現在のテキストが全文表示されているかどうか。
/// 会話システム側で「次の行に進んでよいか?」の判定に使えます。
/// </summary>
public bool IsFinished()
{
if (targetText == null) return false;
return targetText.VisibleCharacters >= targetText.TotalCharacters;
}
/// <summary>
/// 現在の FullText を取得します。
/// </summary>
public string GetCurrentText()
{
return targetText?.FullText ?? string.Empty;
}
}
使い方の手順
ここでは、シンプルな会話ウィンドウを例に、プレイヤーの入力で文字送りを制御するケースを想定してみます。
-
① テキスト用オブジェクトを用意する
- Canvas(または UI Document)配下に「DialogueText」オブジェクトを作成
- そこに
VisibleTextLabelコンポーネントをアタッチ FullTextに適当なテキストを入れておく(例:「こんにちは、勇者さま。」)
-
② Typewriter コンポーネントをアタッチ
- 同じ「DialogueText」オブジェクトに
Typewriterコンポーネントをアタッチ Visible Text SourceにVisibleTextLabelをドラッグ&ドロップCharacters Per Secondを 20~40 くらいに設定(好みで調整)Auto Initialize On AwakeとPlay On Awakeを ON にすると、ゲーム開始と同時に文字送りが始まります
- 同じ「DialogueText」オブジェクトに
-
③ プレイヤー入力でスキップ・次の行へ
例えば、スペースキーで「スキップ or 次の行へ」を実装したい場合は、別スクリプトで Typewriter を参照して制御します。using UnityEngine; /// <summary>スペースキーで文字送りを制御する簡単な例</summary> public class DialogueController : MonoBehaviour { [SerializeField] private Typewriter typewriter; // 実際には複数行の会話データを持っておいて、 // currentIndex を進めながら SetTextAndPlay する想定です。 [TextArea] [SerializeField] private string[] dialogueLines; private int currentIndex = 0; private void Start() { if (typewriter == null) { typewriter = GetComponentInChildren<Typewriter>(); } if (dialogueLines.Length > 0 && typewriter != null) { typewriter.SetTextAndPlay(dialogueLines[currentIndex]); } } private void Update() { if (typewriter == null) return; // スペースキーが押されたら if (Input.GetKeyDown(KeyCode.Space)) { // まだ全文表示されていないなら、まずはスキップ(全文表示) if (!typewriter.IsFinished()) { typewriter.ShowAllImmediately(); } else { // 全文表示済みなら、次の行へ currentIndex++; if (currentIndex < dialogueLines.Length) { typewriter.SetTextAndPlay(dialogueLines[currentIndex]); } else { Debug.Log("会話が最後まで終わりました。"); } } } } } -
④ 敵のセリフや動く看板にも再利用
- 敵キャラの頭上に小さなキャンバスを置き、そこに
VisibleTextLabel+Typewriterを載せておけば、出現時に「グハハ!」と文字送りで喋らせることができます。 - 「動く床」や「ギミック」の説明文を、プレイヤーが近づいたときだけ Typewriter で表示させる、なんて使い方もできます。
- どのオブジェクトでも「テキストをちょっとずつ見せたい」と思ったら、このコンポーネントをポン付けするだけで済むようになります。
- 敵キャラの頭上に小さなキャンバスを置き、そこに
メリットと応用
Typewriter コンポーネントのメリット
- 責務が明確:テキストの「文字送り」だけを担当し、会話の進行ロジックとは分離できます。
- プレハブ化が簡単:
会話ウィンドウのプレハブに Typewriter を仕込んでおけば、どのシーンでも同じ挙動で文字送りが使えます。 - レベルデザインが楽:
レベルデザイナーは「この敵が出たらこのセリフを流す」「この仕掛けを起動したらこの説明文を流す」といったデータだけを用意し、Typewriter はただ呼び出すだけでOKになります。 - UI 実装の差し替えに強い:
IVisibleTextインターフェースを挟んでいるので、TextMeshPro・UI Toolkit・独自ラベルなど、下層の実装を差し替えても Typewriter のコードはそのまま再利用できます。
応用・改造案
例えば、「句読点のところだけ、少し長めに間を取る」改造をしてみると、会話のテンポがかなり良くなります。
以下は、Typewriter 内に追加できる簡単なアイデア関数です(実際に組み込む場合は Update の経過時間計算と組み合わせてください)。
/// <summary>
/// 次に表示する文字が句読点なら、追加のウェイト時間を返す例。
/// (「、」「。」のときだけ 0.15 秒待つ)
/// </summary>
private float GetExtraWaitForNextChar()
{
if (targetText == null) return 0f;
int nextIndex = targetText.VisibleCharacters;
if (nextIndex < 0 || nextIndex >= targetText.TotalCharacters) return 0f;
char c = targetText.FullText[nextIndex];
if (c == '、' || c == '。')
{
return 0.15f;
}
return 0f;
}
このように、小さいコンポーネントとして Typewriter を育てていけば、
巨大な God クラスにしなくても、表現力のある会話システムを組み立てられるようになります。
「文字送り」は Typewriter、「会話の進行」は DialogueController、といったふうに、責務を分けていく設計を意識してみましょう。




