【Godot】シンプルなダイアログシステムの作り方(テキスト、選択肢、分岐)

作成: 2025-12-08最終更新: 2026-07-08

Godotで、タイプライター効果・選択肢・分岐・JSONによるデータ駆動設計を取り入れた、拡張性の高いダイアログシステムを作る方法を解説します。

RPGやアドベンチャーの会話——世界観を伝え、プレイヤーを物語に引き込む要です。でも会話のテキストをスクリプトに直接ベタ書きしてしまうと、セリフを1つ直すたびにコードを開くことになり、選択肢や分岐を足すたびに if が絡まっていきます。

この記事では、テキストと分岐を データ として持つ データ駆動型 のダイアログシステムを作ります。タイプライター効果、選択肢による分岐、JSONでの外部管理まで、Godotの標準ノードとGDScriptだけで組み上げます。

ゲームの会話シーンのイメージ。NPCの下に、話者名・会話テキスト・選択肢ボタンを持つダイアログパネルが表示されている

この記事でわかること

  • 会話を データとして持つ(ロジックとコンテンツを分ける)設計
  • visible_characters による軽い タイプライター効果
  • next_id でつなぐ 選択肢と分岐 のフロー
  • JSONを読み込み、NPCに話しかけると会話が始まる 実装

Sponsored

データ駆動でロジックとコンテンツを分ける

良いダイアログシステムの鍵は、 「ロジック(処理)」と「コンテンツ(会話の中身)」を分ける ことです。会話文・話者・選択肢といった中身を、GDScriptのコードから切り離して Dictionary や JSON のデータとして持ちます。

データ駆動の図。ロジック(GDScript)とコンテンツ(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 をルートに、次のようなノード構成にします(配置は レイアウトコンテナ、見た目は テーマ に任せると楽です)。

ダイアログUIのノード構造と表示の対応図。DialogueUI>PanelContainer>VBoxContainer>SpeakerLabel/TextLabelとChoicesBoxが、実際の話者名・テキスト・選択肢ボタンに対応する
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()   # 前の選択肢を消す
Sponsored

タイプライター効果と選択肢

タイプライター効果は、Labelvisible_characters を1文字ずつ増やすだけで作れます。text を毎フレーム作り直すより、ずっと軽い方法です。

タイプライター効果の図。visible_charactersを増やすと、テキストが1文字目から全部表示まで少しずつ現れる
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 を持たせれば、選んだ先へ会話が飛びます。

next_idでつながる会話の分岐グラフ。startから歴史・剣・いいえの選択肢でhistory・sword・farewellへ分岐し、historyはnext_idでhistory_2へ続く

データがIDでつながっているので、フローはそのまま 会話グラフ になります。分岐を足したいときは、新しいIDのエントリを追加して、どこかの next_id から指すだけ。コードは一切変えません。これがデータ駆動の効きどころです。

Sponsored

実践: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を読み込んで会話を開始します。会話が終わったら シグナル で受けて、プレイヤーの操作を戻します。

NPCトリガーの流れ図。プレイヤーがArea2Dに入るとJSON読込→会話開始、dialogue_finishedで操作が戻る。会話中�はプレイヤーを止める
# 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_process_unhandled_input(false)   # 会話中はプレイヤー操作を止める
        dialogue_ui.start(_data, "start")

func _on_finished() -> void:
    var player := get_tree().get_first_node_in_group("player")
    if player:
        player.set_process_unhandled_input(true)   # 操作を戻す

ポイントは2つです。

  • 会話はデータ、進行はシグナルDialogueUI は「渡されたデータを表示して、終わったら dialogue_finished を出す」だけ。NPC側は開始と後片付けだけ。役割がきれいに分かれます。
  • 会話中はプレイヤーを止めるset_process_unhandled_input(false) で入力を止め、終了シグナルで戻すと、会話中に動いてしまう事故を防げます。

条件分岐(フラグで会話を変える)や、表情・SEの追加も、データにキーを足して _show() で拾うだけで広げられます。

よくある間違いとベストプラクティス

よくある間違いベストプラクティス
会話データをスクリプトにハードコーディングするJSONや Resource外部化 し、コードを変えずにシナリオを編集する
get_node() で密結合に作るsignaldialogue_finished など)で 疎結合 にし、終了後の処理をつなぐ
入力処理を作らない_unhandled_inputスキップと会話進行 を提供する(快適さに直結)
複雑な状態機械で管理する_current_id などの シンプルなデータ でフローを制御する
拡張性を考えない最初から Dictionary ベース にし、表情・SEはキー追加で拡張する

おまけ:自作とDialogicアドオン

  • 大規模なら Dialogic も選択肢 :Asset Libraryの Dialogic アドオンは、ビジュアルエディタで会話を組めます。非プログラマーがシナリオを書く・素早く量産する用途では自作より速いことも。完全に独自のUI/挙動が欲しい、仕組みを学びたいなら自作が向きます。
  • データは Resource でも持てる :JSONの代わりに カスタムリソース で会話を持つと、型安全でエディタから編集できます。
  • 多言語対応 :テキストをキー化し、tr() と翻訳CSVで差し替えると言語切り替えに対応できます。
  • 見た目はテーマで :ダイアログの枠やボタンは テーマ で一括デザインすると統一感が出ます。

まとめ

  • データ駆動 :会話をコードから切り離し、IDをキーにした Dictionary / JSON で持つ
  • タイプライターvisible_characters を1文字ずつ増やすだけ。軽くてスキップも簡単
  • 分岐 :選択肢の next_id で会話グラフを自由に組む。追加はデータだけ
  • 連携DialogueUI は表示に徹し、開始と終了は dialogue_finished シグナルでNPC側とつなぐ

まずは Dictionary の会話を1つ表示するところから始めて、選択肢 → JSON外部化 → NPCトリガー、と段階的に育てていきましょう。会話枠の見た目は テーマ、配置は レイアウトコンテナ と組み合わせると、ぐっと本格的になります。