RPGやアドベンチャーの会話——世界観を伝え、プレイヤーを物語に引き込む要です。でも会話のテキストをスクリプトに直接ベタ書きしてしまうと、セリフを1つ直すたびにコードを開くことになり、選択肢や分岐を足すたびに if が絡まっていきます。
この記事では、テキストと分岐を データ として持つ データ駆動型 のダイアログシステムを作ります。タイプライター効果、選択肢による分岐、JSONでの外部管理まで、Godotの標準ノードとGDScriptだけで組み上げます。
この記事でわかること
- 会話を データとして持つ(ロジックとコンテンツを分ける)設計
visible_charactersによる軽い タイプライター効果next_idでつなぐ 選択肢と分岐 のフロー- JSONを読み込み、NPCに話しかけると会話が始まる 実装
動作確認環境: Godot 4.7
データ駆動でロジックとコンテンツを分ける
良いダイアログシステムの鍵は、 「ロジック(処理)」と「コンテンツ(会話の中身)」を分ける ことです。会話文・話者・選択肢といった中身を、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
_finish_typing()
# 打ち終わった(またはスキップした)ら、全文を見せて、選択肢があれば出す。次の文へは決定キーで進む
func _finish_typing() -> void:
_typing.stop()
text_label.visible_characters = text_label.get_total_character_count()
var entry: Dictionary = _data[_current_id]
if entry.has("choices"):
_show_choices(entry["choices"])
func _show_choices(choices: Array) -> void:
var first: Button = null
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)
if first == null:
first = button
if first:
first.grab_focus() # キーボードでも選べるように、最初の選択肢にフォーカス
クリックや決定キーで、 打っている途中なら全文を即表示(スキップ) し、打ち終わっていれば次へ進む操作も付けておくと快適です。
func _unhandled_input(event: InputEvent) -> void:
if not visible or not event.is_action_pressed("ui_accept"):
return
if not _typing.is_stopped():
_finish_typing() # 打っている途中 → 全文表示にスキップ
else:
var entry: Dictionary = _data[_current_id]
if entry.has("choices"):
return # 選択肢はボタンで選ぶ
elif entry.has("next_id"):
_show(entry["next_id"]) # 打ち終わっていれば、次の文へ
else:
_end() # 終端なら閉じる
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"}
]
},
"history_1": { "speaker": "老賢者", "text": "この世界は、古の竜と巨人の戦いで形作られたのじゃ…" },
"farewell": { "speaker": "老賢者", "text": "そうか。また来るとよい。" }
}
NPCは Area2D にして、プレイヤーが入ったらJSONを読み込んで会話を開始します。会話が終わったら シグナル で受けて、プレイヤーの操作を戻します。

# npc.gd
extends Area2D
@onready var dialogue_ui: Control = $DialogueUI
var _data: Dictionary = {}
func _ready() -> void:
var file := FileAccess.open("res://dialogue_data.json", FileAccess.READ)
if file:
_data = JSON.parse_string(file.get_as_text()) # JSON → Dictionary
body_entered.connect(_on_body_entered)
dialogue_ui.dialogue_finished.connect(_on_finished)
func _on_body_entered(body: Node2D) -> void:
if body.is_in_group("player"):
body.set_physics_process(false) # 会話中はプレイヤーを止める(移動は _physics_process で読んでいるため)
dialogue_ui.start(_data, "start")
func _on_finished() -> void:
var player := get_tree().get_first_node_in_group("player")
if player:
player.set_physics_process(true) # 操作を戻す
ポイントは2つです。
- 会話はデータ、進行はシグナル :
DialogueUIは「渡されたデータを表示して、終わったらdialogue_finishedを出す」だけ。NPC側は開始と後片付けだけ。役割がきれいに分かれます。 - 会話中はプレイヤーを止める :
set_physics_process(false)でプレイヤーの処理を止め、終了シグナルで戻すと、会話中に動いてしまう事故を防げます(移動を_physics_processで読んでいるプレイヤーは、set_process_unhandled_input(false)では止まりません)。
条件分岐(フラグで会話を変える)や、表情・SEの追加も、データにキーを足して _show() で拾うだけで広げられます。
よくある間違いとベストプラクティス
| よくある間違い | ベストプラクティス |
|---|---|
| 会話データをスクリプトにハードコーディングする | JSONや Resource に 外部化 し、コードを変えずにシナリオを編集する |
get_node() で密結合に作る | signal(dialogue_finished など)で 疎結合 にし、終了後の処理をつなぐ |
| 入力処理を作らない | _unhandled_input で スキップと会話進行 を提供する(快適さに直結) |
| 複雑な状態機械で管理する | _current_id などの シンプルなデータ でフローを制御する |
| 拡張性を 考えない | 最初から Dictionary ベース にし、表情・SEはキー追加で拡張する |
おまけ:自作とDialogicアドオン
- 大規模なら Dialogic も選択肢 :Asset Libraryの
Dialogicアドオンは、ビジュアルエディタで会話を組めます。非プログラマーがシナリオを書く・素早く量産する用途では自作より速いことも。完全に独自のUI/挙動が欲しい、仕組みを学びたいなら自作が向きます。 - データは Resource でも持てる :JSONの代わりに カスタムリソース で会話を持つと、型安全でエディタから編集できます。
- 多言語対応 :テキストをキー化し、
tr()と翻訳CSVで差し替えると言語切り替えに対応できます。仕組みは TranslationServerで多言語対応 にあります。 - 見た目はテーマで :ダイアログの枠やボタンは テーマ で一括デザインすると統一感が出ます。
- セリフを依頼の進み具合で変える :依頼を受ける前・集めている途中・報告のあとでセリフを切り替える例は クエスト入門 にあります。
まとめ
- データ駆動 :会話をコードから切り離し、IDをキーにした
Dictionary/ JSON で持つ - タイプライター :
visible_charactersを1文字ずつ増やすだけ。軽くてスキップも簡単 - 分岐 :選択肢の
next_idで会話グラフを自由に組む。追加はデータだけ - 連携 :
DialogueUIは表示に徹し、開始と終了はdialogue_finishedシグナルでNPC側とつなぐ
まずは Dictionary の会話を1つ表示するところから始めて、選択肢 → JSON外部化 → NPCトリガー、と段階的に育てていきましょう。会話枠の見た目は テーマ、配置は レイアウトコンテナ と組み合わせると、ぐっと本格的になります。