Jsonで複数キャラの表情制御が可能なダイアログを作る【Unityでゲーム制作①】

*本記事はChatGPTを使って生成しています(表現が冗長・おかしいかもしれません)

やあ、こんにちわ。
ikigami.devチームです。
チームの期待の新星 **22世紀最大の VibeCoder**の私、**山田** でよ。

今日からなんと **毎週ゲーム開発記事を出す企画** が始まりました 🥶  
(この記事ちゃんと完走できるのか…？)

本稿では **「JSONで表情つきダイアログを回す仕組み」** を  
**最小3スクリプト + 1 JSON** で実装していきます。まずは動作イメージをどうぞ👇

![Screen Recording 2025-04-28 at 13.28.55.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3722357/83854233-0d6e-40ba-9162-444a90103e3d.gif)





## 🛠️ 環境 & 事前準備

| 項目           | バージョン例 |
| -------------- | ------------ |
| Unity          | 2022.3 LTS   |
| TextMeshPro    | デフォルト   |


1. **新規プロジェクト** (URP/Built-in どちらでも可)  
2. フォルダを 2 つ作成  
   - `Assets/StreamingAssets/Dialogue/` … ここに JSON  
   - `Assets/Resources/Characters/` …… ここに表情スプライト  
3. `Input System` で `Interact` アクション (例: `E` キー) を用意  



## 📂 フォルダ構成

~~~~text
Assets/
 ├- Resources/
 │   └- Characters/
 │       └- bio/
 │           ├- default.png
 │           └- angry.png
 ├- StreamingAssets/
 │   └- Dialogue/
 │       └- bio.json
 └- Scripts/
     ├- Dialogue/
     │   ├- DialogueManager.cs
     │   └- TalkScript.cs
     ├- GameData.cs
     └- NPC/
         └- SampleNPC.cs
~~~~

> **命名ルール**  
> `Resources/Characters/{speakerid}/{expression}.png`  
> … この形でキャラのidと表情を管理してます。

## System Overview
```mermaid
%%{init: {'theme': 'default'}}%%
graph TB
    subgraph "ユーザー操作"
        Player["プレイヤーが'E'キーを押す"]
    end
    Player -->|開始| NPC
    subgraph "NPC処理"
        NPC["NPCのInteractメソッド"]
    end
    NPC -->|呼び出し| GameData
    subgraph "データ読み込み"
        GameData["GameDataが対話データをロード"]
        GameData -->|取得| DialogueData["対話データ"]
    end
    GameData -->|処理| TalkScript
    subgraph "対話処理"
        TalkScript["TalkScriptが対話を解釈"]
        TalkScript -->|ロード| Sprites["キャラクター画像"]
    end
    TalkScript -->|表示| UI
    subgraph "ユーザーインターフェース"
        UI["対話UI"]
        UI -->|フィードバック| Player
    end
```

## 🔑 JSON で会話を記述する

~~~~json:bio.json
{
  "npc": "BIO",
  "situations": [
    {
      "key": "Sample",
      "lines": [
        {
          "speakerid":   "bio",
          "displayname": "BIO",
          "expression":  "default",
          "text": [
            "なんだね君は...?",
            "さっさとどこかに消えろ。",
            "まったくやかましいな。",
            "私はこのゲームの中ボスだ！",
            "そうそう、またあとで出会うだろう"
          ]
        }
      ]
    }
  ]
}
~~~~

| フィールド      | 意味                                            |
| --------------- | ----------------------------------------------- |
| `npc`           | 登場キャラ (= JSON 1 枚につき 1 キャラ想定)     |
| `situations[]`  | シーンやクエストごとのまとまり                 |
| `lines[]`       | 1 発話単位で「表情 + 文章配列」を管理           |
| `speakerid`     | どのキャラの立ち絵を使うかキメるよう     |
| `expression`     | speakeridのキャラの表情を指定するid     |




## 🎤 TalkScript ― 文字を 1 字ずつ表示 + スキップ

**フルコード**

```csharp:TalkScript.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;
using UnityEngine.UI;

public class TalkScript : MonoBehaviour
{
    TMP_Text textComp;
    Image image;
    public AudioClip sfx;
    public float printRate = 7.5f;
    [SerializeField] bool skipPrint = false;
    public bool isActive;
    public Coroutine currentTextSequence = null;

    void Start()
    {
        textComp = transform.Find("TextBox").Find("Text").GetComponent<TMP_Text>();
        image = transform.Find("IconBox").Find("Icon").GetComponent<Image>();
        Hide();
    }

    void Update()
    {
        if (Input.GetButtonDown("Interact"))
        {
            skipPrint = true;
        }
    }

    public void Show()
    {
        foreach (Transform t in transform)
            t.gameObject.SetActive(true);
    }

    public void Hide()
    {
        foreach (Transform t in transform)
            t.gameObject.SetActive(false);
        textComp.text = null;
        isActive = false;
    }

    // JSON 行ごとの処理
    public IEnumerator ShowLine(LineData line)
    {
        if (image != null && !string.IsNullOrEmpty(line.speakerid) && !string.IsNullOrEmpty(line.expression))
        {
            var sprite = Resources.Load<Sprite>($"Characters/{line.speakerid}/{line.expression}");
            if (sprite != null)
                image.sprite = sprite;
            else
                Debug.LogError($"Sprite not found: Characters/{line.speakerid}/{line.expression}");
        }
        Show();
        isActive = true;

        foreach (string t in line.text)
        {
            skipPrint = false;
            Coroutine c = StartCoroutine(PrintChar(t));
            yield return c;
            skipPrint = false;
            yield return new WaitUntil(() => skipPrint); // Wait until Interacted.
            
        }
        Hide();
    }

    IEnumerator PrintChar(string text)
    {
        textComp.text = "";
        foreach (char c in text)
        {
            if (skipPrint) { textComp.text = text; break; }

            if (sfx != null)
                GetComponent<AudioSource>().PlayOneShot(sfx);

            textComp.text += c;
            yield return new WaitForSeconds(1 / printRate);
        }
    }
}
```   

### 解説

| 目的                         | 仕組み |
| ---------------------------- | ------ |
| **スキップ判定**             | `skipPrint` を `Update()` で E キー検知して立てる |
| **文字送り**                 | `PrintChar()` で `WaitForSeconds(1/printRate)` |
| **立ち絵差し替え**           | `Resources.Load<Sprite>()` で都度ロード（表情ごとフォルダ分け） |
| **UI の一括表示/非表示**    | `Show()/Hide()` で子オブジェクトをループ操作（レイアウトの自由度◎） |
| **外部判定フラグ**           | `isActive` を GameData が監視し、会話中の入力ロックに利用 |


## 🧬 GameData ― シングルトンで全システムを仲介

**ダイアログ関連部のみ抜粋 & 解説**

```csharp:GameData.cs (talk 部分)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Linq;
using System.IO;

[System.Serializable] // Required for JsonUtility
public class DialogueData
{
    public string npc;
    public SituationData[] situations;
}

[System.Serializable] // Required for JsonUtility
public class SituationData
{
    public string key;
    public LineData[] lines;
}

[System.Serializable] // Required for JsonUtility
public class LineData
{
    public string speakerid;
    public string displayname;
    public string expression;
    public string[] text;
}

public class GameData : MonoBehaviour
{
    public static GameData Instance { get; private set; }
    public GameObject canvasPrefab;
    TalkScript _talkScript;
    GameObject _canvas;

    void Awake()
    {
        // If there is an instance, and it's not me, delete myself.
        if (Instance != null && Instance != this) 
        { 
            Destroy(this.gameObject); 
        } 
        else 
        { 
            Instance = this; 
        } 
        
        DontDestroyOnLoad(gameObject);

        // assignment
        _canvas = Instantiate(canvasPrefab, transform);
        _talkScript = _canvas.GetComponentInChildren<TalkScript>();
    }

    // From JSON
    public void StartDialogue(string npcKey, string situationKey)
    {
        if (!IsTalking())
        {
            StartCoroutine(StartDialogueCoroutine(npcKey, situationKey));
        }
    }

    public DialogueData LoadDialogue(string npcKey)
    {
        if (dialogueCache.ContainsKey(npcKey))
            return dialogueCache[npcKey];

        string path = Path.Combine(Application.streamingAssetsPath, "Dialogue", npcKey.ToLower() + ".json");
        if (!File.Exists(path))
        {
            Debug.LogError($"Dialogue JSON not found: {path}");
            return null;
        }
        string json = File.ReadAllText(path);
        DialogueData data = JsonUtility.FromJson<DialogueData>(json);
        dialogueCache[npcKey] = data;
        return data;
    }

    public IEnumerator StartDialogueCoroutine(string npcKey, string situationKey)
    {
        DialogueData data = LoadDialogue(npcKey);
        if (data == null)
            yield break;

        SituationData situation = null;
        foreach (var s in data.situations)
        {
            if (s.key == situationKey)
            {
                situation = s;
                break;
            }
        }
        if (situation == null)
        {
            Debug.LogError($"Situation '{situationKey}' not found for NPC '{npcKey}'");
            yield break;
        }

        foreach (var line in situation.lines)
        {
            yield return _talkScript.ShowLine(line);
        }
    }
```   


**ポイント**

| 役割 | 説明 |
| ---- | ---- |
| **中央ハブ** | HP・ミッション等と同じ Singleton にまとめることで *どこからでも* 会話開始できる |
| **重複防止** | `IsTalking()` で多重起動を防ぎ、入力ロック処理と連携しやすくする |
| **Canvas プレハブ** | UI をシーンに置かず動的生成 → シーン遷移で TalkScript が消えない |
| **キャッシュ** |1 度読み込んだ JSON は `Dictionary` に保持して GC & IO 削減。 | 
|**StreamingAssets** |ビルド後も外部 JSON の差し替えが可能。  |
|**コルーチン** | `TalkScript.ShowLine()` を逐次実行し “セリフが終わるまで次へ進まない” 挙動を保証。  |



## 🤖 SampleNPC ― 会話のトリガーは一行で充分

```csharp:SampleNPC.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SampleNPC : NPC
{
    public override void Interact()
    {
        GameData.Instance.StartDialogue("bio", "Sample");
    }
}
```   

### 解説

- **目的別 JSON 呼び出し** … `"bio"` は NPC、`"Sample"` はシチュエーション。  
- **汎用化** … テキストを喋らせたい場合は `dialogue` 変数を直接 `Talk(text)` するだけで再利用可。  



## 🎨 表情スプライトを増やすときは…

| ファイルパス                                   | 用途   |
| ---------------------------------------------- | ------ |
| `Resources/Characters/bio/default.png`         | 通常   |
| `Resources/Characters/bio/angry.png`           | 怒り   |
| `Resources/Characters/bio/surprised.png` (例)  | 驚き   |

1. 画像を `png` で追加  
2. JSON の `expression` を新しい名前に変更  
3. **コード変更なしで完了！**



## ✨ 今後の拡張アイデア

| 機能               | 実装ヒント                             |
| ------------------ | -------------------------------------- |
| 選択肢付き会話     | `LineData` に `choices[]` を追加        |
| 立ち絵左右表示     | `speakerPosition` を JSON に追加        |
| セリフごとボイス   | `voiceClipPath` を追加 → `AudioSource`  |



## 📝 まとめ

- **JSON × Resources** でシナリオ班だけでも会話をゴリゴリ更新可能  
- 必要ファイルは **３ スクリプト & 1 JSON** のみ  
- **表情追加・セリフ変更** も *ノーコード* でサクサク



## 📡 次回予告

> **「超簡単なUnityロードスクリーンを解説してみた！」**  
> 毎週更新予定なので “LGTM 👍” と 🦆 フォローで応援おねがいします！

もし少しでも役にたったと感じたら、僕たちが開発しているゲームを見てみてください！
↓↓↓開発中のゲームの最新情報は以下から↓↓↓

https://x.com/ikigami_game

---