【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
– 話し手名を表示する LabeltextLabel: Label | null
– 会話本文を表示する LabelfaceSprite: 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()を呼び出して最初の会話を開始します。
- インスペクタで必須の
updateuseTypewriterが true かつ_isTypingのときだけ動作します。charsPerSecondと経過時間_typingElapsedから表示すべき文字数を計算し、Label.stringを部分文字列に更新します。- 全文表示が完了したら
_isTyping = falseにしてタイプライターを終了します。
_advanceDialogue- まず「タイプライター中にスキップ可能」な状態なら、全文表示に切り替えて早送りし、そこでリターンします。
- そうでなければ
_currentIndexを進めて、会話配列の終端ならendDialogue()を呼び出します。 - 次のエントリを取り出し、話し手名・顔アイコン・本文をそれぞれ更新します。
- タイプライター有効時は
_isTypingを true にし、内部カウンタをリセットして 1 文字ずつ表示を開始します。
_updateFaceSpritefaceSpriteが未設定なら何もしません。faceKeyが空ならspriteFrame = nullにして非表示にします。- 指定されたキーと同じ
SpriteFrame.nameを持つフレームをfaceSpriteFramesの中から検索し、見つかればそれをセットします。 - 見つからない場合は
console.warnで警告を出します。
- 入力ハンドラ
_onTouchEnd/_onMouseUpは単純に_advanceDialogue()を呼びます。_onKeyDownはuseKeyToNextが true で、かつ押されたキーがnextKeyCodeと一致したときだけ_advanceDialogue()を呼びます。
使用手順と動作確認
1. スクリプトファイルの作成
- Assets パネルで右クリック → Create → TypeScript を選択します。
- ファイル名を
DialoguePrinter.tsに変更します。 - 自動生成された中身をすべて削除し、本記事の 「TypeScriptコードの実装」 セクションのコードを貼り付けて保存します。
2. 会話用の UI ノードを作成
簡単なレイアウト例として:
- Hierarchy パネルで右クリック → Create → UI → Canvas(既にある場合は流用)。
- Canvas の子として右クリック → Create → UI → Sprite を作成し、名前を
DialoguePanelに変更します。- 背景用の 9-slice 画像などを設定しておくと会話ウィンドウっぽくなります。
DialoguePanelの子として:- 右クリック → Create → UI → Label を作成し、名前を
SpeakerLabelに変更(話し手名用)。 - 右クリック → Create → UI → Label を作成し、名前を
TextLabelに変更(本文用)。 - 右クリック → Create → UI → Sprite を作成し、名前を
FaceSpriteに変更(顔アイコン用)。
- 右クリック → Create → UI → Label を作成し、名前を
- 各 Label の Font Size / Alignment / Color などを好みに合わせて調整します。
3. 会話 JSON の作成と設定
- Assets パネルで右クリック → Create → Text を選択し、名前を
sample_dialogue.jsonに変更します。 - ダブルクリックしてエディタで開き、次のような 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 の準備
- 顔アイコン用の画像(PNG など)を Assets にインポートします。
- 例:
alice_happy.png,alice_smile.png,bob_normal.png
- 例:
- 各画像を選択し、インスペクタで SpriteFrame が自動生成されていることを確認します。
- 必要に応じて SpriteFrame の Name を、JSON の
faceキーと同じ文字列に変更します。- 例:
alice_happy,alice_smile,bob_normal
- 例:
5. DialoguePrinter コンポーネントのアタッチ
- Hierarchy で
DialoguePanelノードを選択します。 - Inspector の下部で Add Component → Custom → DialoguePrinter を選択してアタッチします。
- Inspector に表示された DialoguePrinter の各プロパティを設定します:
- Dialogue Json:
- 先ほど作成した
sample_dialogue.jsonをドラッグ&ドロップします。
- 先ほど作成した
- Auto Start:
- シーン開始と同時に会話を始めたい場合はチェックを入れます(デフォルト: ON)。
- Speaker Label:
- Hierarchy から
SpeakerLabelノードをドラッグ&ドロップします。
- Hierarchy から
- Text Label:
- Hierarchy から
TextLabelノードをドラッグ&ドロップします。
- Hierarchy から
- Face Sprite:
- Hierarchy から
FaceSpriteノードをドラッグ&ドロップします。
- Hierarchy から
- Face Sprite Frames:
- インスペクタで配列の「+」ボタンを押して要素数を増やし、各スロットに
alice_happy/alice_smile/bob_normalの SpriteFrame をドラッグ&ドロップします。
- インスペクタで配列の「+」ボタンを押して要素数を増やし、各スロットに
- Hide Node On End:
- 会話が終わったらウィンドウを消したい場合はチェックします。
- Use Typewriter:
- 1 文字ずつ表示したい場合はチェック(デフォルト: ON)。
- Chars Per Second:
- 例:
30~40くらいから試すと自然です。
- 例:
- Allow Skip Typing:
- タイプライター中に「次へ」入力が来たら全文表示にスキップしたい場合はチェック(デフォルト: ON)。
- Click To Next:
- 会話ウィンドウ(
DialoguePanel)のクリック / タップで次へ進めたい場合はチェック(デフォルト: ON)。
- 会話ウィンドウ(
- Use Key To Next:
- キーボード操作でも次へ進めたい場合はチェック(デフォルト: ON)。
- Next Key Code:
- Space キー以外にしたい場合はプルダウンから変更(例:
ENTER)。
- Space キー以外にしたい場合はプルダウンから変更(例:
6. プレビューでの動作確認
- メインメニューから Project → Play(または上部の再生ボタン)を押してプレビューを開始します。
- Auto Start が ON の場合:
- シーン開始と同時に、最初の会話が表示されます。
- タイプライター有効時は、文字が 1 文字ずつ表示されていくのを確認できます。
- ウィンドウ(
DialoguePanel)をクリック / タップ、または設定したキー(デフォルト: Space)を押すと:- タイプライター中の場合:
- Allow Skip Typing = true なら全文が一気に表示されます。
- もう一度入力すると次の会話へ進みます。
- タイプライターが終わっている場合:
- そのまま次の会話へ進みます。
- タイプライター中の場合:
- 会話が最後まで進むと:
- Hide Node On End が ON の場合、
DialoguePanelノードが非アクティブになり、ウィンドウが消えます。 - OFF の場合は、最後のメッセージが表示されたままになります。
- Hide Node On End が ON の場合、
もし何も表示されない / 途中で止まる場合は、コンソールに出ている [DialoguePrinter] のログやエラーを確認し、
dialogueJsonが正しくセットされているか- JSON フォーマットが正しいか(
"conversations"配列があるか) speakerLabel,textLabelがそれぞれ正しい Label ノードを指しているか- 顔アイコンの
faceキーと SpriteFrame.name が一致しているか
を確認してください。
まとめ
この DialoguePrinter コンポーネントは、
- 会話データを JSON で管理できる
- 話し手名・本文・顔アイコンをインスペクタから自由に組み合わせられる
- クリック / キー入力のみでメッセージ送りができる
- タイプライター演出とスキップ機能を内蔵している
- 他のスクリプトに一切依存せず、この 1 ファイルだけで完結する
という点で、どのプロジェクトでも再利用しやすい汎用コンポーネントになっています。
応用として、
- JSON に
voiceやbgmなどのフィールドを追加してサウンド再生を連動させる - 会話の途中で選択肢を出す(選択肢用の UI と組み合わせる)
- ゲーム内イベントから
startDialogue()を呼び出して、任意のタイミングで会話を開始する
といった拡張も容易です。
まずは本記事のコードをそのまま導入し、UI デザインや JSON データを差し替えながら、自分のゲームに合った会話システムへ発展させてみてください。




