RPGやアドベンチャーの会話——世界観を伝え、プレイヤーを物語に引き込む要です。でも会話のテキストをスクリプトに直接ベタ書きしてしまうと、セリフを1つ直すたびにコードを開くことになり、選択肢や分岐を足すたびに if が絡まっていきます。
この記事では、テキストと分岐を データ として持つ データ駆動型 のダイアログシステムを作ります。タイプライ ター効果、選択肢による分岐、JSONでの外部管理まで、Godotの標準ノードとGDScriptだけで組み上げます。
この記事でわかること
- 会話を データとして持つ(ロジックとコンテンツを分ける)設計
visible_charactersによる軽い タイプライター効果next_idでつなぐ 選択肢と分岐 のフロー- JSONを読み込み、NPCに話しかけると会話が始まる 実装
データ駆動でロジックとコンテンツを分ける
良いダイアログシステムの鍵は、 「ロジック(処理)」と「コンテンツ(会話の中身)」を分ける ことです。会話文・話者・選択肢といった中身を、GDScriptのコードから切り離して Dictionary や JSON のデータとして持ちます。

こうしておくと、 シナリオはデータを編集するだけで足せて、コードには触れません。表情や効果音を足したくなっても、データにキーを1つ増やすだけです。
まずは会話データを Dictionary で定義します。
# 各会話は一意のIDをキーにする。next_idで次へ繋ぐ
const DIALOGUE_DATA := {
"start": {
"speaker": "老賢者",
"text": "ようこそ、若き旅人よ。わしに何か用かな?",
"choices": [
{"text": "この世界の歴史を教えてください。", "next_id": "history_1"},
{"text": "伝説の剣はどこに?", "next_id": "sword_location"},
{"text": "いえ、別に。", "next_id": "farewell"},
],
},
"history_1": {
"speaker": "老賢者",
"text": "この世界は、古の竜と巨人の戦いで形作られたのじゃ…",
"next_id": "history_2", # 選択肢なし=自動で次へ
},
}
ポイントは、配列ではなく IDをキーにした Dictionary にすること。next_id で好きな順に会話を繋ぎ変えられ、分岐も自然に表現できます。
ダイアログUIを組み立てる
会話を表示するUIシーンを作ります。Control をルートに、次のようなノード構成にします(配置は レイアウトコンテナ、見た目は テーマ に任せると楽です)。

DialogueUI (Control)
├─ PanelContainer … 背景パネル
│ └─ VBoxContainer
│ ├─ SpeakerLabel … 話者名
│ └─ TextLabel … 会話テキスト
└─ ChoicesBox (VBoxContainer) … 選択肢ボタンを入れる
DialogueUI にスクリプトを付けます。まずは変数と「会話を表示する」処理まで。
extends Control
signal dialogue_finished # 会話が終わったら外へ通知する
@onready var speaker_label: Label = $PanelContainer/VBoxContainer/SpeakerLabel
@onready var text_label: Label = $PanelContainer/VBoxContainer/TextLabel
@onready var choices_box: VBoxContainer = $ChoicesBox
var _data: Dictionary = {}
var _current_id: String = ""
var _typing := Timer.new()
func _ready() -> void:
_typing.timeout.connect(_on_typing_tick)
add_child(_typing)
hide()
# 外から呼ぶ入口:データと開始IDを渡す
func start(data: Dictionary, start_id: String) -> void:
_data = data
show()
_show(start_id)
func _show(id: String) -> void:
if not _data.has(id):
_end() # IDが無ければ終了
return
_current_id = id
var entry: Dictionary = _data[id]
speaker_label.text = entry.get("speaker", "")
text_label.text = entry.get("text", "…")
text_label.visible_characters = 0 # まだ1文字も見せない
_typing.start(0.05)
for c in choices_box.get_children():
c.queue_free() # 前の選択肢を消す
タイプライター効果と選択肢
タイプライター効果は、Label の visible_characters を1文字ずつ増やすだけで作れます。text を毎フレーム作り直すより、ずっと軽い方法です。

func _on_typing_tick() -> void:
if text_label.visible_characters < text_label.get_total_character_count():
text_label.visible_characters += 1 # 1文字進める
return
# 打ち終わったら止めて、選択肢か次の会話へ
_typing.stop()
var entry: Dictionary = _data[_current_id]
if entry.has("choices"):
_show_choices(entry["choices"])
elif entry.has("next_id"):
_show(entry["next_id"]) # 選択肢が無ければ自動で次へ
func _show_choices(choices: Array) -> void:
for choice in choices:
var button := Button.new()
button.text = choice["text"]
# 押されたら、その選択肢の next_id へ進む
button.pressed.connect(func() -> void: _show(choice["next_id"]))
choices_box.add_child(button)
クリックや決定キーで、 打っている途中なら全文を即表示(スキップ) し、打ち終わっていれば次へ進む操作も付けておくと快適です。
func _unhandled_input(event: InputEvent) -> void:
if not visible or not event.is_action_pressed("ui_accept"):
return
if _typing.is_stopped():
var entry: Dictionary = _data[_current_id]
if not entry.has("choices") and not entry.has("next_id"):
_end() # 終端なら閉じる
else:
# 打っている途中 → 全文表示にスキップ
text_label.visible_characters = text_label.get_total_character_count()
_typing.stop()
_on_typing_tick()
get_viewport().set_input_as_handled()
func _end() -> void:
hide()
dialogue_finished.emit()
next_idでつなぐ会話の分岐
next_id を使うと、会話は「1本道」にも「枝分かれ」にも自由に組めます。選択肢のそれぞれに next_id を持たせれば、選んだ先へ会話が飛びます。

データがIDでつながっているので、フローはそのまま 会話グラフ になります。分岐を足したいときは、新しいIDのエントリを追加して、どこかの next_id から指すだけ。コードは一切変えません。これがデータ駆動の効きどころです。
実践:NPCに話しかけて会話を始める
最後に、会話データをJSONに移し、 NPCに近づくと会話が始まり、終わると操作が戻る ところまで組みます。RPGの村人でも、ADVのイベントでも同じ形です。
まず、会話を dialogue_data.json に置きます(Dictionary と同じ構造)。
{
"start": {
"speaker": "老賢者",
"text": "ようこそ、若き旅人よ。わしに何か用かな?",
"choices": [
{"text": "この世界の歴史を教えてください。", "next_id": "history_1"},
{"text": "いえ、別に。", "next_id": "farewell"}
]
},
"farewell": { "speaker": "老賢者", "text": "そうか。また来るとよい。" }
}
NPCは Area2D にして、プレイヤーが入ったらJSONを読み込んで会話を開始します。会話が終わったら シグナル で受けて、プレイヤーの操作を戻します。
