RPGの村人との会話、ADVのキャラクターの語り、チュートリアルの説明——ゲームで「文章を見せる」場面は驚くほど多いものです。ところがUnityで会話ウィンドウを作ろうとすると、テキスト表示・送り・演出・分岐がバラバラに絡まって、意外と手が止まります。実は、 データとUIを分離 して考えると、会話システムはとてもすっきり作れます。
この記事では、最小構成の会話エンジンを1本組み立てます。セリフを ScriptableObject で定義し、TextMeshPro で 1文字ずつ表示するタイプライター演出、クリックで送る 仕組み、そして 選択肢による分岐 まで。ADVの会話パート、RPGのイベントシーン、ノベルゲームの本文——どれもこの土台から作れます。
この記事でわかること
- セリフを ScriptableObject で定義する(データとUIの分離)
- TextMeshPro で1文字ずつ出す タイプライター演出
- クリックで送る(演出中は全文表示 → 次のセリフ)
- 選択肢による分岐(ボタン生成 → 次の会話へ)
- 実践:村人との会話+選択肢で反応が変わる
動作確認環境: Unity 6 (6000.x)
データと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;
bodyText.ForceMeshUpdate(); // 見える文字数を数える(<color>などのタグは数えない)
int total = bodyText.textInfo.characterCount;
for (int i = 1; i <= total; i++)
{
yield return new WaitForSeconds(1f / charsPerSecond);
bodyText.maxVisibleCharacters = i; // 表示文字数を1ずつ増やす
}
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側でコルーチンを開始すると(TypeLineを public にしてStartCoroutine(typewriter.TypeLine(...))と書くなど)、コルーチンはDialogueManagerの上で動くため、Typewriter側からは止められず、 スキップしたのに文字送りが続く バグになります。上のコードのように「開始(Play)も停止も同じコンポーネントの中で完結させる」のがコツです。
ポイントは クリックの2段階 です。 演出の途中でクリックされたら、まず全文を即座に表示 する(せっかちなプレイヤーへの配慮)。 表示が終わった後にクリックされたら、次のセリフへ進む。この2段階にすると、「待たされる」ストレスがなくなり、会話のテンポが一気に良くなります。
選択肢による分岐
会話の醍醐味は 選択肢 です。「はい/いいえ」でストーリーが変わる、あの仕組みを作ります。

考え方はシンプルです。 セリフデータに「選択肢」と「各選択肢が進む先の会話」を持たせる。選択肢が来たらボタンを生成し、押されたボタンに対応する次の会話へ切り替えます。
[System.Serializable]
public class DialogueChoice
{
public string choiceText; // 「クエストを受ける」など
public DialogueData nextDialogue; // 選んだら進む先の会話
}
DialogueLine に DialogueChoice[] choices を持たせておき、選択肢のあるセリフに来たら、uGUIボタン を選択肢の数だけ生成します。ボタンを生成して分岐させる部分は、これだけのコードで動きます。
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class ChoicePanel : MonoBehaviour
{
[SerializeField] private DialogueManager manager;
[SerializeField] private Transform buttonParent; // Vertical Layout Group付きの親
[SerializeField] private Button buttonPrefab; // TMPテキスト付きのボタンPrefab
public void Show(DialogueChoice[] choices)
{
// 前回の選択肢ボタンを消す
foreach (Transform child in buttonParent) Destroy(child.gameObject);
foreach (var choice in choices)
{
Button button = Instantiate(buttonPrefab, buttonParent);
button.GetComponentInChildren<TMP_Text>().text = choice.choiceText;
button.onClick.AddListener(() =>
{
gameObject.SetActive(false);
manager.StartDialogue(choice.nextDialogue); // 選んだ先の会話へ
});
}
gameObject.SetActive(true);
}
}
押されたボタンが「対応する nextDialogue の会話を開始する」——会話データ同士を 参照でつないでいく イメージです。複雑な分岐も、この「会話→選択肢→次の会話」の連鎖で表現できます。
分岐が複雑になったら: 会話が数十本に増え、分岐が入り組んできたら、専用のダイアログ管理アセット(Yarn Spinner、Ink、Dialogue Systemなど)の導入を検討しましょう。まずは自作で仕組みを理解し、規模が大きくなったらツールに移るのが王道です。
実践:反応が変わる村人の会話
ここまでを組み合わせて、 話しかけると喋り、選択肢で反応が変わる村人 を作りましょう。RPGのクエスト受注NPC、ADVの攻略キャラ、ノベルゲームの分岐——どれも同じ仕組みです。

進行役の DialogueManager が全体を制御します。流れはこうです。
- プレイヤーが村人に近づいて決定キー →
DialogueManager.StartDialogue(villagerDialogue)を呼ぶ - 会話ウィンドウを開き、
linesを先頭から1行ずつ、話者名をセットしてタイプライターで表示 - クリックで送り、選択肢のあるセリフに来たらボタンを生成
- 選ばれたボタンの
nextDialogueに切り替えて続行 - 最後のセリフまで来たらウィンドウを閉じる
using TMPro;
using UnityEngine;
public class DialogueManager : MonoBehaviour
{
[SerializeField] private GameObject window;
[SerializeField] private TMP_Text speakerText;
[SerializeField] private TypewriterText typewriter;
[SerializeField] private ChoicePanel choicePanel;
private DialogueData current;
private int index;
public void StartDialogue(DialogueData data)
{
current = data;
index = 0;
window.SetActive(true);
ShowLine();
}
void ShowLine()
{
var line = current.lines[index];
speakerText.text = line.speaker; // 話者名
typewriter.Play(line.text); // タイプ表示はTypewriter側で開始・管理する
}
// クリックで呼ぶ
public void OnClick()
{
if (!typewriter.SkipOrAdvance()) return; // 演出中なら全文表示だけ
// 表示し終えたセリフに選択肢があれば、送らずに分岐へ
var line = current.lines[index];
if (line.choices != null && line.choices.Length > 0)
{
choicePanel.Show(line.choices);
return;
}
index++;
if (index < current.lines.Length) ShowLine();
else window.SetActive(false); // 会話終了
}
}
ポイントは2つです。 「進行はインデックスで管理する」(今何行目かを index で持ち、クリックで進める。この単純さが安定の秘訣)と、 「演出とデータを切り離す」(タイプライターは TypewriterText、データは DialogueData、進行は DialogueManager と役割を分ける。1つのクラスに詰め込まない)。会話中はBGMを少し下げると聞き取りやすくなります——AudioMixerで動的にBGMを制御 のダッキングと組み合わせると、プロっぽい演出になります。
おまけ:先に知っておくと良いこと
- テキストは必ずTextMeshProで: 通常のTextコンポーネントは日本語や装飾に弱いです。会話は文章量が多いので、TextMeshPro 一択。日本語フォントのアセット作成を忘れずに。
- リッチテキストで表現力アップ: TextMeshProは
<color><b><size>などのタグが使えます。「重要な単語だけ色を変える」といった演出が、セリフ本文にタグを埋め込むだけでできます。 - ボイスやSEと同期: セリフの1文字ごとに小さな「ピッ」というSEを鳴らすと、往年のADV風になります。タイプライターのループ内でSEを鳴らすだけです。
- オートセーブとの相性: どの会話まで進んだかを セーブ&ロード で記録すれば、「一度聞いた会話はスキップ」なども実装できます。
まとめ
- 会話システムは データ(ScriptableObject)・UI(Canvas)・進行役(DialogueManager) の3層に分ける
- セリフは ScriptableObject で定義。シナリオはInspectorで編集でき、コード不要
- タイプライター演出 は
maxVisibleCharactersをコルーチンで増やす。クリックは「全文表示 → 次へ」の2段階 - 選択肢分岐 は、セリフに「選択肢→次の会話」を持たせ、ボタンでつなぐ
- 進行は インデックスで管理。演出・データ・進行を切り離すのが安定のコツ
まずは3〜4行の短い会話を1本、ScriptableObjectで作って表示するところから始めてください。タイプライターで文字が流れ、クリックで送れた瞬間、もう立派な会話エンジンです。あなたのゲームのキャラクターは、プレイヤーに何を語りかけますか?
さらに学ぶために
- ScriptableObjectでデータ駆動設計 ——会話データの土台
- TextMeshProで高品質なテキスト表示 ——セリフ表示とリッチテキスト
- uGUIボタンのイベント処理 ——選択肢ボタンの実装
- AudioMixerで動的にBGMを制御 ——会話中のダ ッキング演出
- クエスト・目標の管理 ——選んだ答えや進み具合を、会話の外に覚えておく
- Unity公式ドキュメント:ScriptableObject ——データアセットの一次情報