Unityを触り始めた頃、「会話シーンぐらいなら Update に全部書いても動くしOKでしょ?」と、1つのスクリプトに入力処理・UI更新・データ管理を全部突っ込んでしまいがちですよね。
ところがこのやり方だと、

  • 会話のデータ構造が複雑になると、ifswitch だらけで読めない
  • UIのレイアウトを変えたいだけなのに、ロジック側のコードまで触る羽目になる
  • プレイヤー、NPC、イベントシーンなどで会話仕様が微妙に違うと、巨大な God クラスが爆誕する

といった問題が一気に噴き出します。

そこでこの記事では、

  • 「会話データの読み込み」と
  • 「UIへの表示」

に責務を絞ったコンポーネント 「DialoguePrinter」 を用意して、
JSONの会話データを順番に表示する処理を、きれいに分離して実装していきます。

【Unity】JSONからサクッと会話再生!「DialoguePrinter」コンポーネント

以下のコンポーネントは、

  • JSONファイルから会話データを読み込み
  • 話し手名・本文テキスト・顔アイコン(Sprite)を
  • ボタン入力(またはキー入力)に合わせて順次表示

するところまでを一つにまとめたものです。
入力の検知は Unity の新Input System / 旧Input どちらでも差し替えやすいよう、Update 内の簡単なチェックだけにとどめています。

前提となるJSONフォーマット

まず、読み込むJSONの形式を決めておきます。
以下のような JSON を Resources/Dialogue/sample_dialogue.json に置く想定です。


{
  "entries": [
    {
      "speaker": "Alice",
      "text": "やあ!ここまで来たんだね。",
      "icon": "alice_face"
    },
    {
      "speaker": "Bob",
      "text": "もちろんさ。準備はできてる?",
      "icon": "bob_face"
    },
    {
      "speaker": "Alice",
      "text": "行こう!冒険の始まりだよ!",
      "icon": "alice_face_happy"
    }
  ]
}
  • speaker … 話し手の名前
  • text … 会話本文
  • icon … Resources から読み込む顔アイコンSpriteの名前

アイコン画像は Resources/Icons/ フォルダに alice_face.png のような名前で置いておきます。


DialoguePrinter フルコード


using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro; // TextMeshPro を使う場合

/// <summary>
/// JSONから会話データを読み込み、
/// 話し手・本文・顔アイコンを順次表示するコンポーネント。
/// 
/// - JSONは Resources フォルダから読み込む
/// - 顔アイコンは Resources/Icons から Sprite を読み込む
/// - UI は TextMeshProUGUI / Image を想定
/// </summary>
public class DialoguePrinter : MonoBehaviour
{
    [Header("JSON設定")]
    [SerializeField]
    private string dialogueJsonPath = "Dialogue/sample_dialogue";
    // Resources/Dialogue/sample_dialogue.json を読み込む想定

    [Header("UI参照")]
    [SerializeField]
    private TextMeshProUGUI speakerText;
    
    [SerializeField]
    private TextMeshProUGUI bodyText;
    
    [SerializeField]
    private Image faceImage;

    [Header("入力設定")]
    [SerializeField]
    private KeyCode nextKey = KeyCode.Space; // 次の会話へ進むキー

    [Header("タイプライター演出")]
    [SerializeField]
    private bool useTypewriterEffect = true;
    
    [SerializeField, Min(0.001f)]
    private float charInterval = 0.03f; // 1文字あたりの表示間隔(秒)

    [Header("自動再生")]
    [SerializeField]
    private bool playOnStart = true;

    /// <summary>
    /// 会話データ全体
    /// </summary>
    [Serializable]
    private class DialogueData
    {
        public List<DialogueEntry> entries;
    }

    /// <summary>
    /// 会話1件分
    /// </summary>
    [Serializable]
    private class DialogueEntry
    {
        public string speaker;
        public string text;
        public string icon; // Resources/Icons から読み込むSprite名
    }

    private DialogueData currentDialogue;
    private int currentIndex = -1;

    // タイプライター演出用
    private Coroutine typewriterCoroutine;
    private bool isPrinting = false;
    private string currentFullText = string.Empty;

    // 会話終了イベント(任意で他のコンポーネントから購読できるように)
    public event Action OnDialogueFinished;

    private void Start()
    {
        // UI参照が設定されていない場合は警告を出しておく
        if (speakerText == null || bodyText == null || faceImage == null)
        {
            Debug.LogWarning("[DialoguePrinter] UI参照が設定されていません。インスペクターで設定してください。");
        }

        // JSONをロード
        LoadDialogueJson(dialogueJsonPath);

        if (playOnStart)
        {
            StartDialogue();
        }
    }

    private void Update()
    {
        // 非アクティブ時やデータ無しのときは何もしない
        if (!isActiveAndEnabled || currentDialogue == null || currentDialogue.entries == null)
        {
            return;
        }

        // キー入力で次の会話へ
        if (Input.GetKeyDown(nextKey))
        {
            HandleNextInput();
        }
    }

    /// <summary>
    /// JSONファイルを読み込んでパースする
    /// </summary>
    /// <param name="resourcePath">Resourcesからのパス(拡張子なし)</param>
    private void LoadDialogueJson(string resourcePath)
    {
        // Resources.Load で TextAsset として取得
        TextAsset jsonAsset = Resources.Load<TextAsset>(resourcePath);
        if (jsonAsset == null)
        {
            Debug.LogError($"[DialoguePrinter] JSONが見つかりません: Resources/{resourcePath}.json");
            return;
        }

        try
        {
            // JsonUtility でパース
            currentDialogue = JsonUtility.FromJson<DialogueData>(jsonAsset.text);

            if (currentDialogue == null || currentDialogue.entries == null || currentDialogue.entries.Count == 0)
            {
                Debug.LogError("[DialoguePrinter] JSONの形式が不正、または entries が空です。");
                currentDialogue = null;
            }
        }
        catch (Exception e)
        {
            Debug.LogError($"[DialoguePrinter] JSONパースに失敗しました: {e.Message}");
            currentDialogue = null;
        }
    }

    /// <summary>
    /// 会話再生を最初から開始する
    /// </summary>
    public void StartDialogue()
    {
        if (currentDialogue == null || currentDialogue.entries == null || currentDialogue.entries.Count == 0)
        {
            Debug.LogWarning("[DialoguePrinter] 会話データがありません。");
            return;
        }

        currentIndex = -1;
        ShowNextEntry();
    }

    /// <summary>
    /// 外部から「次へ」を呼び出したい場合用
    /// </summary>
    public void Next()
    {
        HandleNextInput();
    }

    /// <summary>
    /// 入力時の挙動をまとめた関数
    /// </summary>
    private void HandleNextInput()
    {
        // タイプライター中ならスキップして全文表示
        if (useTypewriterEffect && isPrinting)
        {
            SkipTypewriter();
            return;
        }

        // 次の会話へ
        ShowNextEntry();
    }

    /// <summary>
    /// 次の会話エントリを表示する
    /// </summary>
    private void ShowNextEntry()
    {
        if (currentDialogue == null || currentDialogue.entries == null)
        {
            return;
        }

        currentIndex++;

        // 最後まで到達したら終了
        if (currentIndex >= currentDialogue.entries.Count)
        {
            EndDialogue();
            return;
        }

        DialogueEntry entry = currentDialogue.entries[currentIndex];

        // 話し手名の更新
        if (speakerText != null)
        {
            speakerText.text = entry.speaker;
        }

        // 顔アイコンの更新
        UpdateFaceIcon(entry.icon);

        // 本文の更新(タイプライター演出あり/なし)
        if (bodyText != null)
        {
            if (useTypewriterEffect)
            {
                StartTypewriter(entry.text);
            }
            else
            {
                bodyText.text = entry.text;
            }
        }
    }

    /// <summary>
    /// 顔アイコンSpriteを Resources/Icons から読み込む
    /// </summary>
    /// <param name="iconName">Sprite名</param>
    private void UpdateFaceIcon(string iconName)
    {
        if (faceImage == null)
        {
            return;
        }

        if (string.IsNullOrEmpty(iconName))
        {
            // null や空文字が来たらアイコン非表示にするなど
            faceImage.sprite = null;
            faceImage.enabled = false;
            return;
        }

        // 既に同じアイコンなら何もしない
        if (faceImage.sprite != null && faceImage.sprite.name == iconName)
        {
            faceImage.enabled = true;
            return;
        }

        // Resources/Icons/iconName からSpriteをロード
        Sprite iconSprite = Resources.Load<Sprite>($"Icons/{iconName}");
        if (iconSprite == null)
        {
            Debug.LogWarning($"[DialoguePrinter] 顔アイコンが見つかりません: Resources/Icons/{iconName}");
            faceImage.sprite = null;
            faceImage.enabled = false;
            return;
        }

        faceImage.sprite = iconSprite;
        faceImage.enabled = true;
    }

    /// <summary>
    /// タイプライター演出を開始する
    /// </summary>
    /// <param name="fullText">全文テキスト</param>
    private void StartTypewriter(string fullText)
    {
        if (bodyText == null)
        {
            return;
        }

        // 既に実行中なら止める
        if (typewriterCoroutine != null)
        {
            StopCoroutine(typewriterCoroutine);
        }

        currentFullText = fullText;
        typewriterCoroutine = StartCoroutine(TypewriterCoroutine(fullText));
    }

    /// <summary>
    /// タイプライター演出を即座にスキップして全文表示する
    /// </summary>
    private void SkipTypewriter()
    {
        isPrinting = false;

        if (typewriterCoroutine != null)
        {
            StopCoroutine(typewriterCoroutine);
            typewriterCoroutine = null;
        }

        if (bodyText != null)
        {
            bodyText.text = currentFullText;
        }
    }

    /// <summary>
    /// 1文字ずつテキストを表示するコルーチン
    /// </summary>
    private IEnumerator TypewriterCoroutine(string fullText)
    {
        isPrinting = true;

        if (bodyText == null)
        {
            yield break;
        }

        bodyText.text = string.Empty;

        // 1文字ずつ追加していく
        for (int i = 0; i < fullText.Length; i++)
        {
            bodyText.text += fullText[i];
            yield return new WaitForSeconds(charInterval);

            // 途中でスキップされた場合はループを抜ける
            if (!isPrinting)
            {
                yield break;
            }
        }

        isPrinting = false;
        typewriterCoroutine = null;
    }

    /// <summary>
    /// 会話が最後まで再生し終わったときに呼ばれる
    /// </summary>
    private void EndDialogue()
    {
        Debug.Log("[DialoguePrinter] 会話が終了しました。");

        // 必要に応じてUIを隠す
        // ここではとりあえず本文だけ空にしておく
        if (bodyText != null)
        {
            bodyText.text = string.Empty;
        }

        // イベント通知
        OnDialogueFinished?.Invoke();
    }
}

使い方の手順

ここからは、実際に Unity 上で「プレイヤーが NPC に話しかけたら会話ウィンドウが出る」ケースを例に、使い方を手順で見ていきましょう。

手順①:フォルダ構成とJSON・アイコンの準備

  1. Assets/Resources/Dialogue/ フォルダを作成し、
    中に sample_dialogue.json を配置します。
    中身は冒頭で示した JSON そのままでOKです。
  2. Assets/Resources/Icons/ フォルダを作成し、
    alice_face.png, bob_face.png, alice_face_happy.png などの顔画像を入れます。
    ファイル名(拡張子を除く)が JSON の icon と一致するようにします。

手順②:UIキャンバスの作成

  1. GameObject > UI > Canvas でキャンバスを作成します。
  2. キャンバスの子に以下のUIを配置します(例):
    • Image(顔アイコン用)
    • TextMeshPro – Text(話し手名用)
    • TextMeshPro – Text(本文用)
  3. それぞれのRectTransformを、好みのレイアウト(左に顔、右上に名前、右下に本文など)に調整します。

手順③:DialoguePrinter コンポーネントをアタッチ

  1. キャンバスの下に空の GameObject を作り、名前を DialoguePanel などにします。
  2. DialoguePanelDialoguePrinter スクリプトをアタッチします。
  3. インスペクターで以下のフィールドを設定します。
    • Dialogue Json Path: Dialogue/sample_dialogue(拡張子なし)
    • Speaker Text: 話し手名用 TextMeshProUGUI
    • Body Text: 本文用 TextMeshProUGUI
    • Face Image: 顔アイコン用 Image
    • Next Key: Space(任意のキー)
    • Use Typewriter Effect: 好みで ON/OFF
    • Play On Start: シーン開始と同時に会話を始めたい場合は ON

手順④:具体的な使用例

例1:プレイヤーがNPCに近づいて会話開始

プレイヤーが NPC の近くで「Eキー」を押すと会話を開始し、
会話は DialoguePrinter 側で Space キーを押すたびに進む、という構成にしてみましょう。

  1. NPC の GameObject に SphereCollider(IsTrigger ON)を付けて「会話トリガー」にします。
  2. NPC に以下のような簡単なスクリプトを追加します。

using UnityEngine;

/// <summary>
/// プレイヤーが近づいたら E キーで会話を開始する簡単な例。
/// DialoguePrinter はシーン上のどこかに存在している前提。
/// </summary>
public class NpcDialogueTrigger : MonoBehaviour
{
    [SerializeField]
    private DialoguePrinter dialoguePrinter;

    [SerializeField]
    private string dialogueJsonPath = "Dialogue/sample_dialogue";

    private bool isPlayerInRange = false;

    private void Start()
    {
        if (dialoguePrinter != null)
        {
            // NPCごとに別のJSONを使いたい場合は、ここで差し替えてもよい
            // 例: dialoguePrinter.SetJsonPath(dialogueJsonPath); など
            // (SetJsonPathを実装するのは応用編)
        }
    }

    private void Update()
    {
        if (!isPlayerInRange)
        {
            return;
        }

        // プレイヤーが範囲内で E キーを押したら会話開始
        if (Input.GetKeyDown(KeyCode.E))
        {
            if (dialoguePrinter != null)
            {
                dialoguePrinter.StartDialogue();
            }
        }
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Player"))
        {
            isPlayerInRange = true;
            Debug.Log("プレイヤーが会話範囲に入りました。Eキーで会話開始。");
        }
    }

    private void OnTriggerExit(Collider other)
    {
        if (other.CompareTag("Player"))
        {
            isPlayerInRange = false;
            Debug.Log("プレイヤーが会話範囲から離れました。");
        }
    }
}

このように、会話の「開始条件」(プレイヤーが近づく、ボタンを押すなど)は別コンポーネントに任せ、
会話の「再生・表示」だけを DialoguePrinter に持たせることで、責務がきれいに分離できます。

例2:動く床のチュートリアルメッセージとして使う

「動く床に乗ったときだけ、足元にチュートリアルメッセージを出したい」ような場合も、
動く床のオブジェクトに DialoguePrinter を持たせておけば、

  • 動く床用の JSON(「この床は一定時間ごとに動きます」など)を読み込む
  • プレイヤーが床に乗ったときに StartDialogue() を呼ぶ

という形で、会話ロジックを再利用できます。


メリットと応用

DialoguePrinter をコンポーネントとして切り出すことで、

  • プレハブ化がしやすい
    会話ウィンドウ(Canvas + Text + Image + DialoguePrinter)を1つのプレハブにしておけば、
    どのシーンでも同じ見た目・同じ仕様で会話を再生できます。
  • レベルデザインが楽になる
    JSON とアイコン画像を差し替えるだけで、別の NPC や別のイベント会話を簡単に追加できます。
    会話の順番や文言は JSON 側で完結するので、レベルデザイナーがスクリプトを触らずに調整できます。
  • 責務が明確で保守しやすい
    「会話開始条件」「入力処理」「会話ウィンドウの見た目」をそれぞれ別コンポーネントに分けられるため、
    巨大な God クラスを避けつつ、仕様変更にも対応しやすくなります。

たとえば、将来的に

  • 選択肢付きの会話
  • ボイス再生との同期
  • ローカライズ(多言語対応)

などを追加したくなったときも、DialoguePrinter の内部を少しずつ拡張していけば対応できます。

改造案:会話終了時に自動でパネルを非表示にする

最後に、簡単な改造案を1つだけ紹介しておきます。
会話が終わったら自動で UI パネルを非表示にしたい場合、以下のようなメソッドを DialoguePrinter に追加できます。


    /// <summary>
    /// 会話パネル全体を ON/OFF するヘルパー。
    /// DialoguePrinter をアタッチしている GameObject の
    /// SetActive をラップしているだけだが、外部から呼びやすくする。
    /// </summary>
    public void SetPanelVisible(bool visible)
    {
        // 自身のGameObjectごと表示/非表示を切り替える
        gameObject.SetActive(visible);
    }

そして EndDialogue() の最後で SetPanelVisible(false); を呼べば、
会話が終わったタイミングでウィンドウが自動的に閉じるようになります。
逆に、会話開始前に SetPanelVisible(true); を呼ぶようにしておけば、
通常は非表示のままにしておく、といった運用も簡単ですね。

このように、小さなコンポーネントを積み重ねていくと、
プロジェクト全体の見通しがどんどん良くなっていきます。
ぜひ、自分のプロジェクト向けに DialoguePrinter をカスタマイズしてみてください。