【Cocos Creator】アタッチするだけ!DialoguePrinter (会話表示)の実装方法【TypeScript】

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

【Cocos Creator 3.8】DialoguePrinter の実装:アタッチするだけで JSON 会話データを順次表示する汎用スクリプト

このガイドでは、Cocos Creator 3.8.7 + TypeScript で、JSON の会話データを読み込み、話し手名・本文テキスト・顔アイコンを順番に表示する「DialoguePrinter」コンポーネントを実装します。

このコンポーネントは、

  • 会話シーンのメッセージ送り
  • キャラクターとの会話ウィンドウ
  • 簡易ノベルパート

などでそのまま使えるように設計します。他のカスタムスクリプトに一切依存せず、必要なノード参照や設定はすべてインスペクタから指定できるようにします。


コンポーネントの設計方針

1. 機能要件の整理

DialoguePrinter コンポーネントは、以下のような JSON 形式の会話データを扱うことを想定します:

{
  "conversations": [
    {
      "speaker": "Alice",
      "text": "こんにちは!今日はいい天気ですね。",
      "face": "alice_happy"
    },
    {
      "speaker": "Bob",
      "text": "本当だね。散歩に行こうか?",
      "face": "bob_normal"
    }
  ]
}
  • speaker: 話し手の名前(文字列)
  • text: 会話本文(文字列)
  • face: 顔アイコンを特定するキー(スプライトフレーム名など)

コンポーネントの主な機能は次の通りです。

  • インスペクタで指定した TextAsset(JSON) を読み込む
  • 指定した Label に話し手名、別の Label に本文を表示する
  • 指定した Sprite に顔アイコン(SpriteFrame)を表示する
  • 「次へ進む」入力(クリック/タップ/キー)で会話を順次進める
  • 1 文字ずつのタイプライター風表示(スキップも可能)
  • 会話終了時に自動で非アクティブ化する(オプション)

2. 外部依存をなくすための設計

このコンポーネントは、以下の方針で完全に単体で完結させます。

  • GameManager などのシングルトンは一切使用しない
  • 会話ウィンドウ内で必要なノード(名前ラベル、本文ラベル、顔スプライト)は インスペクタから Node / Label / Sprite を直接指定する
  • 会話 JSON は TextAsset としてアサインし、JSON.parse で読み込む
  • 顔アイコンは SpriteFrame の配列をインスペクタに並べ、face キーと SpriteFrame.name を対応させて切り替える
  • 入力(次へ進む)は、以下の 2 種類をサポート
    • このコンポーネントが付いているノードのクリック/タップ
    • 任意のキーコード(例: Space, Enter)

また、防御的な実装として:

  • 必要な Label / Sprite / TextAsset が未設定の場合は コンソールにエラーログを出して処理を止める
  • JSON パースに失敗した場合もエラーログを出し、ゲームが落ちないようにする

3. インスペクタで設定可能なプロパティ設計

以下のようなプロパティを用意します。

  • 会話データ関連
    • dialogueJson: TextAsset | null
      – 会話データ JSON を格納した TextAsset
      – フォーマット: 上記の { "conversations": [ ... ] } 形式
    • autoStart: boolean
      true: onLoad で自動的に会話を開始
      false: 公開メソッド startDialogue() を呼ぶまで待機
  • 表示ノード関連
    • speakerLabel: Label | null
      – 話し手名を表示する Label
    • textLabel: Label | null
      – 会話本文を表示する Label
    • faceSprite: Sprite | null
      – 顔アイコンを表示する Sprite(任意。未指定なら顔表示なし)
    • faceSpriteFrames: SpriteFrame[]
      – 顔アイコン候補の SpriteFrame の配列
      – JSON の face キーと SpriteFrame.name をマッチさせて切り替える
    • hideNodeOnEnd: boolean
      – 会話終了時に、このコンポーネントが付いている Node を active = false にするかどうか
  • タイプライター表示関連
    • useTypewriter: boolean
      true: 1 文字ずつ表示
      false: 一瞬で全文表示
    • charsPerSecond: number
      – 1 秒あたりに表示する文字数(タイプライター時のみ有効)
      – 例: 30 なら 1 秒で 30 文字
    • allowSkipTyping: boolean
      – タイプライター中に「次へ入力」が来たら全文表示にスキップするかどうか
  • 入力関連
    • clickToNext: boolean
      – このコンポーネントが付いた Node のクリック / タップで次のメッセージへ進む
    • useKeyToNext: boolean
      – キーボード入力で次のメッセージへ進む
    • nextKeyCode: KeyCode
      – 次へ進むキー(デフォルト: KeyCode.SPACE

TypeScriptコードの実装

以下が完成した DialoguePrinter.ts の全コードです。

import { _decorator, Component, Node, Label, Sprite, SpriteFrame, TextAsset, input, Input, EventTouch, EventKeyboard, KeyCode, UITransform, EventMouse } from 'cc';
const { ccclass, property } = _decorator;

interface DialogueEntry {
    speaker: string;
    text: string;
    face?: string;
}

interface DialogueJson {
    conversations: DialogueEntry[];
}

@ccclass('DialoguePrinter')
export class DialoguePrinter extends Component {

    // ==== 会話データ関連 ====

    @property({
        type: TextAsset,
        tooltip: '会話データを含む JSON の TextAsset。\n形式: { "conversations": [ { "speaker": "...", "text": "...", "face": "..." }, ... ] }',
    })
    public dialogueJson: TextAsset | null = null;

    @property({
        tooltip: 'true の場合、onLoad 時に自動で会話を開始します。\nfalse の場合は startDialogue() を明示的に呼び出してください。',
    })
    public autoStart: boolean = true;

    // ==== 表示ノード関連 ====

    @property({
        type: Label,
        tooltip: '話し手の名前を表示する Label。',
    })
    public speakerLabel: Label | null = null;

    @property({
        type: Label,
        tooltip: '会話本文を表示する Label。',
    })
    public textLabel: Label | null = null;

    @property({
        type: Sprite,
        tooltip: '顔アイコンを表示する Sprite。未指定の場合は顔アイコンを表示しません。',
    })
    public faceSprite: Sprite | null = null;

    @property({
        type: [SpriteFrame],
        tooltip: '使用する顔アイコンの SpriteFrame 一覧。\nJSON の "face" キーと SpriteFrame.name を一致させて利用します。',
    })
    public faceSpriteFrames: SpriteFrame[] = [];

    @property({
        tooltip: '会話終了時に、このコンポーネントが付いている Node を非アクティブにします。',
    })
    public hideNodeOnEnd: boolean = false;

    // ==== タイプライター表示関連 ====

    @property({
        tooltip: 'true の場合、本文を 1 文字ずつ表示するタイプライター演出を行います。',
    })
    public useTypewriter: boolean = true;

    @property({
        tooltip: '1 秒あたりに表示する文字数(タイプライター有効時のみ)。\n例: 30 なら 1 秒で 30 文字表示します。',
        min: 1,
    })
    public charsPerSecond: number = 30;

    @property({
        tooltip: 'タイプライター中に次へ入力があった場合、全文表示にスキップできるようにします。',
    })
    public allowSkipTyping: boolean = true;

    // ==== 入力関連 ====

    @property({
        tooltip: 'このコンポーネントが付いている Node のクリック / タップで次のメッセージへ進めます。\nUI ボタンではなく、ノード全体をタップ領域にしたい場合に有効です。',
    })
    public clickToNext: boolean = true;

    @property({
        tooltip: 'キーボード入力で次のメッセージへ進めます。',
    })
    public useKeyToNext: boolean = true;

    @property({
        type: KeyCode,
        tooltip: '次のメッセージへ進むためのキーコード。\nデフォルトは Space キーです。',
    })
    public nextKeyCode: KeyCode = KeyCode.SPACE;

    // ==== 内部状態 ====

    private _dialogues: DialogueEntry[] = [];
    private _currentIndex: number = -1;
    private _isTyping: boolean = false;
    private _typingElapsed: number = 0;
    private _currentFullText: string = '';
    private _currentVisibleChars: number = 0;

    onLoad() {
        // 必要コンポーネントの存在チェック
        if (!this.speakerLabel) {
            console.error('[DialoguePrinter] speakerLabel が設定されていません。Inspector で Label を指定してください。');
        }
        if (!this.textLabel) {
            console.error('[DialoguePrinter] textLabel が設定されていません。Inspector で Label を指定してください。');
        }
        if (!this.dialogueJson) {
            console.error('[DialoguePrinter] dialogueJson が設定されていません。Inspector で JSON の TextAsset を指定してください。');
        }

        // JSON 読み込み
        if (this.dialogueJson) {
            this._loadDialogueFromJson(this.dialogueJson);
        }

        // 初期表示をクリア
        if (this.speakerLabel) {
            this.speakerLabel.string = '';
        }
        if (this.textLabel) {
            this.textLabel.string = '';
        }
        if (this.faceSprite) {
            this.faceSprite.spriteFrame = null;
        }

        // 入力イベント登録
        if (this.clickToNext) {
            // Node へのタップ/クリックイベント
            this.node.on(Input.EventType.TOUCH_END, this._onTouchEnd, this);
            this.node.on(Input.EventType.MOUSE_UP, this._onMouseUp, this);
        }
        if (this.useKeyToNext) {
            input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this);
        }

        if (this.autoStart) {
            this.startDialogue();
        }
    }

    onDestroy() {
        // イベント解除(念のため)
        this.node.off(Input.EventType.TOUCH_END, this._onTouchEnd, this);
        this.node.off(Input.EventType.MOUSE_UP, this._onMouseUp, this);
        input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this);
    }

    update(deltaTime: number) {
        if (!this.useTypewriter) {
            return;
        }

        if (!this._isTyping) {
            return;
        }

        if (!this.textLabel) {
            return;
        }

        // 経過時間に応じて表示文字数を増やす
        this._typingElapsed += deltaTime;
        const charsToShow = Math.floor(this._typingElapsed * this.charsPerSecond);

        if (charsToShow !== this._currentVisibleChars) {
            this._currentVisibleChars = Math.min(charsToShow, this._currentFullText.length);
            this.textLabel.string = this._currentFullText.substring(0, this._currentVisibleChars);
        }

        // 全文表示が完了したらタイピング終了
        if (this._currentVisibleChars >= this._currentFullText.length) {
            this._isTyping = false;
        }
    }

    // ==== 公開メソッド ====

    /**
     * 会話を最初から開始します。
     * JSON が読み込まれていない場合は何も行いません。
     */
    public startDialogue(): void {
        if (this._dialogues.length === 0) {
            console.warn('[DialoguePrinter] 会話データが空です。dialogueJson の内容を確認してください。');
            return;
        }
        this._currentIndex = -1;
        this._advanceDialogue();
    }

    /**
     * 現在の会話を強制的に終了します。
     * hideNodeOnEnd が true の場合はノードを非アクティブにします。
     */
    public endDialogue(): void {
        this._isTyping = false;
        if (this.hideNodeOnEnd) {
            this.node.active = false;
        }
    }

    // ==== 内部ロジック ====

    private _loadDialogueFromJson(asset: TextAsset): void {
        try {
            const obj = JSON.parse(asset.text) as DialogueJson;
            if (!obj.conversations || !Array.isArray(obj.conversations)) {
                console.error('[DialoguePrinter] JSON の形式が不正です。 "conversations" 配列が見つかりません。');
                return;
            }
            this._dialogues = obj.conversations;
        } catch (e) {
            console.error('[DialoguePrinter] JSON のパースに失敗しました。', e);
        }
    }

    private _advanceDialogue(): void {
        // まだタイプライター中で、スキップを許可している場合は全文表示に切り替える
        if (this._isTyping && this.allowSkipTyping && this.textLabel) {
            this._isTyping = false;
            this._currentVisibleChars = this._currentFullText.length;
            this.textLabel.string = this._currentFullText;
            return;
        }

        // 次のインデックスへ
        this._currentIndex++;

        if (this._currentIndex >= this._dialogues.length) {
            // 会話終了
            this.endDialogue();
            return;
        }

        const entry = this._dialogues[this._currentIndex];

        // 話し手名の表示
        if (this.speakerLabel) {
            this.speakerLabel.string = entry.speaker ?? '';
        }

        // 顔アイコンの切り替え
        this._updateFaceSprite(entry.face);

        // 本文の表示
        if (this.textLabel) {
            this._currentFullText = entry.text ?? '';
            if (this.useTypewriter) {
                this._isTyping = true;
                this._typingElapsed = 0;
                this._currentVisibleChars = 0;
                this.textLabel.string = '';
            } else {
                this._isTyping = false;
                this.textLabel.string = this._currentFullText;
            }
        }
    }

    private _updateFaceSprite(faceKey?: string): void {
        if (!this.faceSprite) {
            return;
        }

        if (!faceKey) {
            this.faceSprite.spriteFrame = null;
            return;
        }

        const frame = this.faceSpriteFrames.find(sf => sf && sf.name === faceKey) || null;
        if (!frame) {
            console.warn(`[DialoguePrinter] face "${faceKey}" に対応する SpriteFrame が見つかりません。SpriteFrame.name を確認してください。`);
        }
        this.faceSprite.spriteFrame = frame;
    }

    // ==== 入力イベントハンドラ ====

    private _onTouchEnd(event: EventTouch): void {
        // タップ領域を UITransform の矩形に限定したい場合はここで判定してもよい
        this._advanceDialogue();
    }

    private _onMouseUp(event: EventMouse): void {
        this._advanceDialogue();
    }

    private _onKeyDown(event: EventKeyboard): void {
        if (!this.useKeyToNext) {
            return;
        }
        if (event.keyCode === this.nextKeyCode) {
            this._advanceDialogue();
        }
    }
}

コードのポイント解説

  • onLoad
    • インスペクタで必須の speakerLabel, textLabel, dialogueJson がセットされているかチェックし、未設定なら console.error を出しておきます。
    • dialogueJson があれば _loadDialogueFromJson() でパースして内部配列 _dialogues に格納します。
    • Label / Sprite の初期表示をクリアします。
    • clickToNext が true の場合、Node に対して TOUCH_END / MOUSE_UP を登録します。
    • useKeyToNext が true の場合、グローバル input に対して KEY_DOWN を登録します。
    • autoStart が true なら startDialogue() を呼び出して最初の会話を開始します。
  • update
    • useTypewriter が true かつ _isTyping のときだけ動作します。
    • charsPerSecond と経過時間 _typingElapsed から表示すべき文字数を計算し、Label.string を部分文字列に更新します。
    • 全文表示が完了したら _isTyping = false にしてタイプライターを終了します。
  • _advanceDialogue
    • まず「タイプライター中にスキップ可能」な状態なら、全文表示に切り替えて早送りし、そこでリターンします。
    • そうでなければ _currentIndex を進めて、会話配列の終端なら endDialogue() を呼び出します。
    • 次のエントリを取り出し、話し手名・顔アイコン・本文をそれぞれ更新します。
    • タイプライター有効時は _isTyping を true にし、内部カウンタをリセットして 1 文字ずつ表示を開始します。
  • _updateFaceSprite
    • faceSprite が未設定なら何もしません。
    • faceKey が空なら spriteFrame = null にして非表示にします。
    • 指定されたキーと同じ SpriteFrame.name を持つフレームを faceSpriteFrames の中から検索し、見つかればそれをセットします。
    • 見つからない場合は console.warn で警告を出します。
  • 入力ハンドラ
    • _onTouchEnd / _onMouseUp は単純に _advanceDialogue() を呼びます。
    • _onKeyDownuseKeyToNext が true で、かつ押されたキーが nextKeyCode と一致したときだけ _advanceDialogue() を呼びます。

使用手順と動作確認

1. スクリプトファイルの作成

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を DialoguePrinter.ts に変更します。
  3. 自動生成された中身をすべて削除し、本記事の 「TypeScriptコードの実装」 セクションのコードを貼り付けて保存します。

2. 会話用の UI ノードを作成

簡単なレイアウト例として:

  1. Hierarchy パネルで右クリック → Create → UI → Canvas(既にある場合は流用)。
  2. Canvas の子として右クリック → Create → UI → Sprite を作成し、名前を DialoguePanel に変更します。
    • 背景用の 9-slice 画像などを設定しておくと会話ウィンドウっぽくなります。
  3. DialoguePanel の子として:
    • 右クリック → Create → UI → Label を作成し、名前を SpeakerLabel に変更(話し手名用)。
    • 右クリック → Create → UI → Label を作成し、名前を TextLabel に変更(本文用)。
    • 右クリック → Create → UI → Sprite を作成し、名前を FaceSprite に変更(顔アイコン用)。
  4. 各 Label の Font Size / Alignment / Color などを好みに合わせて調整します。

3. 会話 JSON の作成と設定

  1. Assets パネルで右クリック → Create → Text を選択し、名前を sample_dialogue.json に変更します。
  2. ダブルクリックしてエディタで開き、次のような JSON を記述します:
{
  "conversations": [
    {
      "speaker": "Alice",
      "text": "こんにちは!今日はいい天気ですね。",
      "face": "alice_happy"
    },
    {
      "speaker": "Bob",
      "text": "本当だね。散歩に行こうか?",
      "face": "bob_normal"
    },
    {
      "speaker": "Alice",
      "text": "うん、行こう!",
      "face": "alice_smile"
    }
  ]
}

face の値(alice_happy など)は、後で設定する SpriteFrame.name と一致させます。

4. 顔アイコン SpriteFrame の準備

  1. 顔アイコン用の画像(PNG など)を Assets にインポートします。
    • 例: alice_happy.png, alice_smile.png, bob_normal.png
  2. 各画像を選択し、インスペクタで SpriteFrame が自動生成されていることを確認します。
  3. 必要に応じて SpriteFrame の Name を、JSON の face キーと同じ文字列に変更します。
    • 例: alice_happy, alice_smile, bob_normal

5. DialoguePrinter コンポーネントのアタッチ

  1. Hierarchy で DialoguePanel ノードを選択します。
  2. Inspector の下部で Add Component → Custom → DialoguePrinter を選択してアタッチします。
  3. Inspector に表示された DialoguePrinter の各プロパティを設定します:
  • Dialogue Json:
    • 先ほど作成した sample_dialogue.json をドラッグ&ドロップします。
  • Auto Start:
    • シーン開始と同時に会話を始めたい場合はチェックを入れます(デフォルト: ON)。
  • Speaker Label:
    • Hierarchy から SpeakerLabel ノードをドラッグ&ドロップします。
  • Text Label:
    • Hierarchy から TextLabel ノードをドラッグ&ドロップします。
  • Face Sprite:
    • Hierarchy から FaceSprite ノードをドラッグ&ドロップします。
  • Face Sprite Frames:
    • インスペクタで配列の「+」ボタンを押して要素数を増やし、各スロットに alice_happy / alice_smile / bob_normal の SpriteFrame をドラッグ&ドロップします。
  • Hide Node On End:
    • 会話が終わったらウィンドウを消したい場合はチェックします。
  • Use Typewriter:
    • 1 文字ずつ表示したい場合はチェック(デフォルト: ON)。
  • Chars Per Second:
    • 例: 3040 くらいから試すと自然です。
  • Allow Skip Typing:
    • タイプライター中に「次へ」入力が来たら全文表示にスキップしたい場合はチェック(デフォルト: ON)。
  • Click To Next:
    • 会話ウィンドウ(DialoguePanel)のクリック / タップで次へ進めたい場合はチェック(デフォルト: ON)。
  • Use Key To Next:
    • キーボード操作でも次へ進めたい場合はチェック(デフォルト: ON)。
  • Next Key Code:
    • Space キー以外にしたい場合はプルダウンから変更(例: ENTER)。

6. プレビューでの動作確認

  1. メインメニューから Project → Play(または上部の再生ボタン)を押してプレビューを開始します。
  2. Auto Start が ON の場合:
    • シーン開始と同時に、最初の会話が表示されます。
    • タイプライター有効時は、文字が 1 文字ずつ表示されていくのを確認できます。
  3. ウィンドウ(DialoguePanel)をクリック / タップ、または設定したキー(デフォルト: Space)を押すと:
    • タイプライター中の場合:
      • Allow Skip Typing = true なら全文が一気に表示されます。
      • もう一度入力すると次の会話へ進みます。
    • タイプライターが終わっている場合:
      • そのまま次の会話へ進みます。
  4. 会話が最後まで進むと:
    • Hide Node On End が ON の場合、DialoguePanel ノードが非アクティブになり、ウィンドウが消えます。
    • OFF の場合は、最後のメッセージが表示されたままになります。

もし何も表示されない / 途中で止まる場合は、コンソールに出ている [DialoguePrinter] のログやエラーを確認し、

  • dialogueJson が正しくセットされているか
  • JSON フォーマットが正しいか("conversations" 配列があるか)
  • speakerLabel, textLabel がそれぞれ正しい Label ノードを指しているか
  • 顔アイコンの face キーと SpriteFrame.name が一致しているか

を確認してください。


まとめ

この DialoguePrinter コンポーネントは、

  • 会話データを JSON で管理できる
  • 話し手名・本文・顔アイコンをインスペクタから自由に組み合わせられる
  • クリック / キー入力のみでメッセージ送りができる
  • タイプライター演出とスキップ機能を内蔵している
  • 他のスクリプトに一切依存せず、この 1 ファイルだけで完結する

という点で、どのプロジェクトでも再利用しやすい汎用コンポーネントになっています。

応用として、

  • JSON に voicebgm などのフィールドを追加してサウンド再生を連動させる
  • 会話の途中で選択肢を出す(選択肢用の UI と組み合わせる)
  • ゲーム内イベントから startDialogue() を呼び出して、任意のタイミングで会話を開始する

といった拡張も容易です。

まずは本記事のコードをそのまま導入し、UI デザインや JSON データを差し替えながら、自分のゲームに合った会話システムへ発展させてみてください。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!