【実践記事・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;
}

次に会話データ全体をまとめるScriptableObjectを作ります。同じフォルダに「DialogueData」という名前でMonoBehaviour Scriptを作成し、クラス宣言のMonoBehaviourをScriptableObjectに書き換えます。

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として作成したあと、クラス定義のMonoBehaviourScriptableObjectに書き換えてください。保存後にUnityがスクリプトを認識し直すと、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)
    {
        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フォルダに「NotoSansJP-Regular.ttf」をコピーしてインポートする

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

  1. メニューの「Window」→「TextMeshPro」→「Font Asset Creator」を開く
  2. Source Font Fileに「NotoSansJP-Regular」を設定する
  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に増やすことで解決しました。

⚠️ ハマったポイント③:文字コードSJIS問題 スクリプトをメモ帳で編集・保存したところ文字コードがSJISになり、CS0101(クラスの二重定義)というコンパイルエラーが出続けました。メモ帳で保存する場合は「名前を付けて保存」→文字コードを「UTF-8」に変更してください。Visual Studioで編集する場合はデフォルトでUTF-8になります。

フォントアセットが生成できたら、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」(テキスト:「閉じる」・下部配置)
  3. 話題カードのプレハブを作る:「Button – TextMeshPro」をHierarchyに作成し名前を「TopicButton」にする。PrefabsフォルダにドラッグしてプレハブにしたあとHierarchy上のものは削除する
  4. TopicButtonプレハブにAdd Component→「Layout Element」を追加し、Preferred Height:80・Min Height:60を設定する(Vertical Layout GroupがHeightを潰してしまうのを防ぐため)
  5. TopicPanelのチェックを外して最初は非表示にする

⚠️ レイアウトのハマりポイント 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)
    {
        CloseTopicPanel();
        if (topic.dialogue != null)
            DialogueManager.Instance.StartDialogue(topic.dialogue, currentCharacter);
    }

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

設計のポイント

foreachの中でTopicData capturedTopic = topic;というローカル変数を挟んでいます。これを省くと、すべてのボタンがループの最後のtopicを参照してしまい「どのボタンを押しても同じ話題が開く」という不具合になります(クロージャの罠)。

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

完成して振り返って

⚠️ ハマったポイント

  • SetActive(false)はStart()ではなくAwake()に書く。複数スクリプトのStart()が競合する問題を防げる
  • foreachのラムダ式にループ変数を渡すときは必ずローカル変数にコピーしてから渡す
  • フォントアセットはAtlas Resolution 8192×8192・Unicode Range (Hex)が日本語ゲームの標準設定
  • メモ帳でC#スクリプトを保存するときは必ずUTF-8を選ぶ。SJISで保存するとコンパイルエラーの原因になる
  • 会話終了後に話題カードパネルを自動で開く設計にしておくとプレイヤーの操作がスムーズになる

次回予告

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


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

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

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


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

コメント

タイトルとURLをコピーしました