RPGの村人との会話、ADVのキャラクターの語り、チュートリアルの説明——ゲームで「文章を見せる」場面は驚くほど多いものです。ところがUnityで会話ウィンドウを作 ろうとすると、テキスト表示・送り・演出・分岐がバラバラに絡まって、意外と手が止まります。実は、 データとUIを分離 して考えると、会話システムはとてもすっきり作れます。
この記事では、最小構成の会話エンジンを1本組み立てます。セリフを ScriptableObject で定義し、TextMeshPro で 1文字ずつ表示するタイプライター演出、クリックで送る 仕組み、そして 選択肢による分岐 まで。ADVの会話パート、RPGのイベントシーン、ノベルゲームの本文——どれもこの土台から作れます。
この記事でわかること
- セリフを ScriptableObject で定義する(データとUIの分離)
- TextMeshPro で1文字ずつ出す タイプライター演出
- クリックで送る(演出中は全文表示 → 次のセリフ)
- 選択肢による分岐(ボタン生成 → 次の会話へ)
- 実践:村人との会話+選択肢で反応が変わる
動作確認環境: Unity 2022.3 LTS / Unity 6
データとUIを分離する
会話システムでいちばん大事な考え方は、 「何を喋るか(データ)」と「どう見せるか(UI)」を分ける ことです。ここがごちゃ混ぜになると、セリフを1つ直すたびにコードをいじる羽目になります。

構成は3つの部品に分かれます。
| 部品 | 役割 |
|---|---|
| 会話データ(ScriptableObject) | 話者名とセリフのリスト。「何を喋るか」だけを持つ |
| 会話UI(Canvas) | ウィンドウ・話者名・本文・選択肢ボタン。「どう見せるか」だけを持つ |
| DialogueManager(スクリプト) | データを読み、UIに1行ずつ流し込む「進行役」 |
こう分けておくと、シナリオを書く人は ScriptableObjectのInspectorでセリフを編集するだけ で済み、コードを触る必要がありません。UIの見た目を変えたいときも、データはそのまま。この分離が、あとで会話を増やすときに効いてきます。
セリフをScriptableObjectで定義する
まず「何を喋るか」を ScriptableObject で定義します。1つの会話(一連のやり取り)を1つのアセットにするイメージです。

using UnityEngine;
// 1行のセリフ
[System.Serializable]
public class DialogueLine
{
public string speaker; // 話者名
[TextArea] public string text; // セリフ本文
public DialogueChoice[] choices; // 選択肢(無ければ空のまま。後述の分岐で使う)
}
// 会話1本ぶんのデータ(Create > Dialogue で作れる)
[CreateAssetMenu(fileName = "NewDialogue", menuName = "Dialogue/Dialogue Data")]
public class DialogueData : ScriptableObject
{
public DialogueLine[] lines; // セリフを順に並べる
}
[CreateAssetMenu] を付けると、Project ビューの Create > Dialogue > Dialogue Data から会話アセットを作れます。あとはInspectorで lines にセリフを追加していくだけ。「村人A」「こんにちは、旅人さん。」といった内容を、コードを触らずに並べられます。会話を増やしたいときは、このアセットを複製するだけで済みます。
タイプライター演出とクリック送り
セリフを一気にドンと出すより、 1文字ずつ「カタカタ」と表示 されると、ぐっとゲームらしくなります。これはTextMeshProの maxVisibleCharacters で実現します。

仕組みはこうです。 本文を全部 text に入れておき、maxVisibleCharacters を0から少しずつ増やす と、表示される文字数だけが増えていき、タイプライター風になります。
using System.Collections;
using TMPro;
using UnityEngine;
public class TypewriterText : MonoBehaviour
{
[SerializeField] private TMP_Text bodyText;
[SerializeField] private float charsPerSecond = 30f;
private Coroutine typeRoutine; // 自分が開始したコルーチンを覚えておく
private bool isTyping;
// 外から呼ぶのはこれ。開始も停止も、このコンポーネントの中で完結させる
public void Play(string line)
{
if (typeRoutine != null) StopCoroutine(typeRoutine); // 前の演出が残っていたら止める
typeRoutine = StartCoroutine(TypeLine(line));
}
private IEnumerator TypeLine(string line)
{
bodyText.text = line; // 全文をセット(まだ見えない)
bodyText.maxVisibleCharacters = 0;
isTyping = true;
int total = line.Length;
for (int i = 0; i <= total; i++)
{
bodyText.maxVisibleCharacters = i; // 表示文字数を1ずつ増やす
yield return new WaitForSeconds(1f / charsPerSecond);
}
isTyping = false;
}
// クリック時:演出中なら全文表示、完了後なら次へ進めたい合図を返す
public bool SkipOrAdvance()
{
if (isTyping)
{
StopCoroutine(typeRoutine); // 自分で開始したコルーチンだから確実に止められる
bodyText.maxVisibleCharacters = bodyText.text.Length; // 全文即表示
isTyping = false;
return false; // まだ次へは行かない
}
return true; // 次のセリフへ進んでOK
}
}
注意(定番の落とし穴):
StopCoroutine/StopAllCoroutinesは、 そのコンポーネント自身がStartCoroutineしたコルーチンにしか効きません 。もし進行役のDialogueManager側でStartCoroutine(typewriter.TypeLine(...))と書いてしまうと、コルーチンはDialogueManagerの上で動くため、Typewriter側からは止められず、 スキップしたのに文字送りが続く バグになります。上のコードのように「開始(Play)も停止も同じコンポーネントの中で完結させる」のがコツです。
ポイントは クリックの2段階 です。 演出の途中でクリックされたら、まず全文を即座に表示 する(せっかちなプレイヤーへの配慮)。 表示が終わった後にクリックされたら、次のセリフへ進む。この2段階にすると、「待たされる」ストレスがなくなり、会話のテンポが一気に良くなります。
選択肢による分岐
会話の醍醐味は 選択肢 です。「はい/いいえ」でストーリーが変わる、あの仕組みを作ります。
