実践ガイド

【実践記事・ADV編③】セリフを流す・話題を選ぶ——会話システムの実装

対応バージョン:Unity 6.3 LTS(6000.3系)/WebGL出力 この記事はシリーズ全11回の第3回です。第2回ではプロジェクト作成とシステム設計を行いました。今回からいよいよコードを書いていきます。 【PR】本記事にはアフィリエイトリンクを含みます。

前回の記事でフォルダ構成と設計図が固まりました。今回は実装の核心となる「会話システム」と「話題カードシステム」を作ります。

「▶ボタンを押すとセリフが進む」「話題カードを選ぶと対応する会話が始まる」という、このゲームで一番よく使われる部分です。

目次


STEP1:DialogueSystem(会話テキスト送り)

データ構造を作る

まず1行分のセリフを表すデータ構造を作ります。Scripts/Dialogue/フォルダに「DialogueLine」という名前でMonoBehaviour Scriptを作成し、以下を書いて保存します。

using UnityEngine;

[System.Serializable]
public class DialogueLine
{
    public string speakerName;
    [TextArea(3, 6)]
    public string dialogueText;
    public bool isMonologue;
}

第2回で作成した「Scripts/Dialogue/DialogueData.cs」を開き、以下の内容に置き換えます。まだ作成していない場合は同名のスクリプトを新規作成してください。同じ名前空間にDialogueDataクラスを二重に定義しないよう確認します。

using UnityEngine;

[CreateAssetMenu(fileName = "DialogueData", menuName = "MysteryADV/DialogueData")]
public class DialogueData : ScriptableObject
{
    public string sceneTitle;
    public DialogueLine[] lines;

    [Header("フラグ設定")]
    public string setFlagOnComplete;

    [Header("証拠設定")]
    public EvidenceData grantEvidenceOnComplete;
}

⚠️ 注意点 MonoBehaviour Scriptとして作成したあと、クラス定義のMonoBehaviourをScriptableObjectに書き換えてください。このコードは第4回で作成するEvidenceDataを参照します。この回のDialogueManagerも第4回のFlagManagerとEvidenceManagerを参照するため、第4回まで必要なスクリプトを作成し、コンパイルエラーを解消してから動作確認してください。正常にコンパイルされると、ProjectウィンドウでCreate→「MysteryADV」→「DialogueData」を選べます。

会話UIを作る

🔧 詳しい手順

  1. Hierarchyで右クリック→「UI (Canvas)」→「Panel」を選択し、名前を「DialoguePanel」にする
  2. Canvas ScalerのUI Scale Modeを「Scale With Screen Size」・Reference Resolutionを1920×1080に設定する
  3. Rect TransformをShift+Altを押しながら下に横いっぱい(Bottom Stretch)に設定し、Heightを250にする
  4. DialoguePanelの中に「UI (Canvas)」→「Text – TextMeshPro」を2つ作成する
    • 1つ目:名前「SpeakerNameText」(話者名・左上・Font Size:24)
    • 2つ目:名前「DialogueText」(本文・中央・Font Size:22)
  5. 「UI (Canvas)」→「Button – TextMeshPro」を作成し名前を「NextButton」にする。ボタン内のText (TMP)を「次→」に変更し、右下に配置する

DialogueManagerを作る

Scripts/Dialogue/フォルダに「DialogueManager」という名前でMonoBehaviour Scriptを作成します。

using UnityEngine;
using TMPro;

public class DialogueManager : MonoBehaviour
{
    public static DialogueManager Instance;

    [Header("UI参照")]
    public GameObject dialoguePanel;
    public TMP_Text speakerNameText;
    public TMP_Text dialogueText;

    private DialogueLine[] lines;
    private int currentIndex = 0;
    private bool isPlaying = false;
    private DialogueData currentData;
    private CharacterData currentCharacter;

    void Awake()
    {
        Instance = this;
        dialoguePanel.SetActive(false);
    }

    void Start() { }

    public void StartDialogue(DialogueData data, CharacterData character = null)
    {
        if (data == null || data.lines == null || data.lines.Length == 0)
        {
            Debug.LogWarning("会話データにセリフがありません");
            return;
        }
        currentData = data;
        currentCharacter = character;
        lines = data.lines;
        currentIndex = 0;
        isPlaying = true;
        dialoguePanel.SetActive(true);
        ShowLine();
    }

    public void ShowNextLine()
    {
        if (!isPlaying) return;
        currentIndex++;
        if (currentIndex >= lines.Length)
        {
            EndDialogue();
            return;
        }
        ShowLine();
    }

    void ShowLine()
    {
        DialogueLine line = lines[currentIndex];
        speakerNameText.text = line.isMonologue ? "" : line.speakerName;
        dialogueText.text = line.isMonologue
            ? "(" + line.dialogueText + ")"
            : line.dialogueText;
    }

    void EndDialogue()
    {
        isPlaying = false;
        dialoguePanel.SetActive(false);

        if (currentData != null && currentData.setFlagOnComplete != "")
            FlagManager.Instance.SetFlag(currentData.setFlagOnComplete);

        if (currentData != null && currentData.grantEvidenceOnComplete != null)
            EvidenceManager.Instance.CollectEvidence(currentData.grantEvidenceOnComplete);

        if (currentCharacter != null)
            TopicCardManager.Instance.OpenTopicPanel(currentCharacter);
    }
}

設計のポイント

dialoguePanel.SetActive(false)をAwake()に書いているのは、Start()で呼ぶと別スクリプトのStart()と実行順序が競合するためです。シーン開始時に有効なオブジェクトでは、各Awake()がStart()より前に呼ばれます。無効なオブジェクトを後で有効化する場合などは実行時期が異なります。会話が終わったあとにTopicCardManager.Instance.OpenTopicPanel()を呼ぶことで、話題カードパネルが自動で再表示されます。

🔧 詳しい手順(GameManagerへの設置)

  1. Hierarchyで右クリック→「Create Empty」→名前を「GameManager」にする
  2. GameManagerにAdd ComponentでDialogueManagerを追加する
  3. Dialogue Panel・Speaker Name Text・Dialogue Textの各欄に対応するオブジェクトをドラッグ&ドロップして接続する
  4. NextButtonのOn Click ()にGameManagerをドラッグ&ドロップ→「DialogueManager」→「ShowNextLine」を設定する

日本語フォントの設定

ここが今回の実装で一番時間がかかった部分です。デフォルトの状態だと日本語が「□□□□」という豆腐になってしまいます。

Noto Sans JPをインポートする

  1. https://fonts.google.com/noto/specimen/Noto+Sans+JP から「Download family」でZIPをダウンロードして解凍する
  2. Assets/Sprites/Fontsフォルダに入手した日本語対応のTTFまたはOTFをコピーする。ファイル名は配布版によって異なるため、Unityで認識できるものを確認する

フォントアセットを生成する

  1. メニューの「Window」→「TextMeshPro」→「Font Asset Creator」を開く
  2. Source Font Fileにインポートした日本語対応フォントを設定する
  3. Character Setを「Unicode Range (Hex)」に変更する
  4. Character Sequence欄に以下を入力する
0020-007E,2000-206F,3000-9FFF,F900-FAFF,FF00-FFEF
  1. Atlas Resolutionを8192×8192に設定する
  2. 「Generate Font Atlas」をクリックして完了を待つ(数分かかります)
  3. 完了したら「Save」でAssets/Sprites/Fontsフォルダに保存する

⚠️ ハマったポイント①:Character Setの形式エラー 最初「Custom Range」を選択して同じ文字列を入力したところFormatExceptionが発生しました。「Unicode Range (Hex)」を選択することで解決しました。

⚠️ ハマったポイント②:Atlasのサイズ不足 4096×4096では「Characters included: 5592/29631」となり日本語の文字が半分以上収まりませんでした。この環境では8192×8192に増やすことで解決しました。必要な文字を絞り、生成結果の欠字と対象端末のテクスチャ上限も確認してください。

⚠️ ハマったポイント③:文字コードとクラスの重複 スクリプトはUTF-8で保存し、文字化けを避けます。CS0101は同じ名前空間に同名の定義があることを示すエラーです。文字コードだけを原因と決めつけず、同名のクラスが重複していないか確認してください。

フォントアセットが生成できたら、SpeakerNameText・DialogueText・NextButton内のText (TMP)それぞれのFont Asset欄に作成したフォントアセットをドラッグ&ドロップして設定します。

日本語が正しく表示されたDialoguePanel

STEP2:TopicCardSystem(話題カード選択UI)

データ構造を作る

TopicData.cs(Scripts/Dialogue/フォルダに作成)

using UnityEngine;

[CreateAssetMenu(fileName = "TopicData", menuName = "MysteryADV/TopicData")]
public class TopicData : ScriptableObject
{
    public string topicID;
    public string topicTitle;
    public DialogueData dialogue;
    public bool isUnlocked = true;

    [Header("フラグ設定")]
    public string unlockFlagID;
    public string setFlagOnComplete;
}

CharacterData.cs(同フォルダに作成)

using UnityEngine;

[CreateAssetMenu(fileName = "CharacterData", menuName = "MysteryADV/CharacterData")]
public class CharacterData : ScriptableObject
{
    public string characterID;
    public string characterName;
    public TopicData[] topics;
}

話題カードのUIを作る

🔧 詳しい手順

  1. Hierarchyで右クリック→「UI (Canvas)」→「Panel」を選択し、名前を「TopicPanel」にする(Width:600・Height:500・画面中央)
  2. TopicPanel内に以下を作成する
    • 「Text – TextMeshPro」→名前「TopicPanelTitle」(テキスト:「話題を選んでください」・上部配置)
    • 「Panel」→名前「TopicButtonContainer」(Add Component→Vertical Layout Group・Spacing:10・Control Child Size Width:オン)
    • 「Button – TextMeshPro」→名前「CloseButton」(テキスト:「閉じる」・下部配置)。動的ボタン専用のTopicButtonContainerの外、TopicPanelの直下に置く
  3. 話題カードのプレハブを作る:「Button – TextMeshPro」をHierarchyに作成し名前を「TopicButton」にする。PrefabsフォルダにドラッグしてプレハブにしたあとHierarchy上のものは削除する
  4. TopicButtonプレハブにAdd Component→「Layout Element」を追加し、Preferred Height:80・Min Height:60を設定する(Vertical Layout GroupがHeightを潰してしまうのを防ぐため)
  5. 常時有効なGameObjectにTopicCardManagerを追加して参照を設定し、CloseButtonのButtonのOn ClickにそのGameObjectのTopicCardManager.CloseTopicPanelを登録する。TopicPanelのチェックを外して最初は非表示にする。この段階で試すには、DialogueDataを1件作ってセリフを入力し、TopicDataにその会話を設定して有効化し、CharacterDataのTopicsに登録する。初回表示を呼ぶ処理はこの回には含まれず、別途OpenTopicPanelにCharacterDataを渡す必要がある。後続回のCharacterClickHandlerでも呼び出せる

⚠️ レイアウトのハマりポイント Vertical Layout GroupのControl Child Size(Height)をオンにすると、TopicButtonのHeightが10px程度に潰されてボタンがほぼ見えなくなりました。Layout ElementでPreferred Heightを指定することで解決しました。

TopicCardManagerを作る

Scripts/UI/フォルダに「TopicCardManager」という名前でMonoBehaviour Scriptを作成します。

using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class TopicCardManager : MonoBehaviour
{
    public static TopicCardManager Instance;

    [Header("UI参照")]
    public GameObject topicPanel;
    public Transform topicButtonContainer;
    public GameObject topicButtonPrefab;
    public GameObject closeButton;

    private CharacterData currentCharacter;

    void Awake()
    {
        Instance = this;
        topicPanel.SetActive(false);
    }

    public void OpenTopicPanel(CharacterData characterData)
    {
        currentCharacter = characterData;
        topicPanel.SetActive(true);
        RefreshTopicButtons();
    }

    public void CloseTopicPanel()
    {
        topicPanel.SetActive(false);
        ClearButtons();
    }

    void RefreshTopicButtons()
    {
        ClearButtons();

        foreach (TopicData topic in currentCharacter.topics)
        {
            if (!topic.isUnlocked) continue;

            GameObject btn = Instantiate(topicButtonPrefab, topicButtonContainer);
            TMP_Text label = btn.GetComponentInChildren<TMP_Text>();
            label.text = topic.topicTitle;

            TopicData capturedTopic = topic;
            btn.GetComponent<Button>().onClick.AddListener(() =>
            {
                OnTopicSelected(capturedTopic);
            });
        }
    }

    void OnTopicSelected(TopicData topic)
    {
        if (topic == null || topic.dialogue == null || topic.dialogue.lines == null || topic.dialogue.lines.Length == 0)
        {
            Debug.LogWarning("話題の会話データにセリフがありません");
            return;
        }
        CloseTopicPanel();
        DialogueManager.Instance.StartDialogue(topic.dialogue, currentCharacter);
    }

    void ClearButtons()
    {
        foreach (Transform child in topicButtonContainer)
            Destroy(child.gameObject);
    }
}

設計のポイント

foreachの中でTopicData capturedTopic = topic;というローカル変数を挟んでいます。現在のC#のforeachでは反復ごとに変数が用意されるため、このコピーは必須ではありません。コードでは各ボタンに渡す値を明示するために残しています。forのループ変数などをラムダ式で捕捉する場合は、共有される変数に注意が必要です。

話題カードが表示されクリックすると会話が始まる

完成して振り返って

⚠️ ハマったポイント

  • 初期状態の設定にAwake()を使う場合は、対象オブジェクトの有効・無効による呼び出し時期にも注意する
  • ラムダ式がどの変数を捕捉するか確認する。現在のC#のforeachでは反復ごとに変数が用意される
  • フォントアセットは必要な文字、端末の上限や容量を確認して設定する。8192×8192はこの例の設定であり、すべての環境の標準ではない
  • メモ帳でC#スクリプトを保存するときは必ずUTF-8を選ぶ。SJISで保存するとコンパイルエラーの原因になる
  • 会話終了後に話題カードパネルを自動で開く設計にしておくとプレイヤーの操作がスムーズになる

次回予告

第4回では、FlagManager(フラグ管理・話題解放)とEvidenceSystem(証拠入手・通知)を実装します。「会話を聞いたら新しい話題が増える」「証拠を入手すると画面に通知が出る」という、ゲームの進行を管理する核心部分です。


あわせて読みたいおすすめ書籍【PR】

今回実装した会話システム・UIレイアウト・TextMeshProの設定についてさらに学びたい方には、以下の書籍もおすすめです。本セクションにはアフィリエイトリンクを含みます。

※このセクションのリンクはアフィリエイトリンクであり、商品が購入された場合、サイト運営者に紹介料が支払われることがあります【PR】。


この記事を作成するにあたって参考にしたサイト

Microsoft:C# foreachの仕様 / Microsoft:CS0101 / Unity:Awake

← 記事一覧・検索