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

作成: 2025-12-08最終更新: 2025-12-16

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

導入:なぜダイアログシステムは重要なのか

ゲームにおいて、キャラクターとの会話は世界観を伝え、プレイヤーを物語に引き込むための 最も重要な要素の一つ です。特にRPGやアドベンチャーゲームでは、ダイアログシステムがゲーム体験の質を大きく左右します。

多くの初心者が陥りがちなのは、ロジックとコンテンツをスクリプト内にハードコーディングしてしまい、拡張性やメンテナンス性を失ってしまうことです。

本記事では、単なるテキスト表示に留まらない、以下の機能を備えた データ駆動型 のダイアログシステムを、Godot Engineで構築する方法をステップバイステップで解説します。

  • 読みやすいタイプライター効果
  • プレイヤーの選択による会話分岐
  • メンテナンス性に優れたデータ駆動設計(JSON対応)
  • 拡張性を見据えたイベントベースの連携

1. 設計思想:なぜ「データ駆動」が重要なのか

優れたダイアログシステムの鍵は、 「ロジック」と「コンテンツ」の分離 にあります。これを実現するのが データ駆動設計 です。会話の内容、話者、選択肢といった「コンテンツ」を、GDScriptのコードという「ロジック」から切り離し、DictionaryJSONのようなデータ構造として管理します。

このアプローチにより、以下のような絶大なメリットが生まれます。

  • メンテナンス性の向上: シナリオライターはコードに触れることなく、JSONファイルを編集するだけで会話を追加・修正できます。
  • 拡張性の確保: 新しい機能(例:キャラクターの表情変化、音声再生)を追加する際も、データ構造に新しいキーを追加し、ロジック側で対応する処理を記述するだけで済みます。
  • 再利用性の向上: 同じダイアログシステムを、異なるNPCやイベントで簡単に再利用できます。

まずは、GDScriptのDictionaryArrayを使って、このデータ構造を定義してみましょう。

# dialogue_data.gd
# 初期段階ではGDScriptファイルとしてデータを定義するが、後にJSONへ移行する。
const DIALOGUE_DATA = {
    # 各会話エントリーは、一意のIDをキーとするDictionaryで管理
    "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で自由自在に会話フローを繋ぎ変えることができます。


2. UIの構築と基本スクリプト

まずは、会話を表示するためのUIシーンを作成します。Controlノードをルートとし、以下のようにノードを配置してください。

  • DialogueUI (Control)
    • PanelContainer (背景パネル)
      • VBoxContainer (垂直レイアウト)
        • SpeakerLabel (Label - 話者名)
        • TextLabel (Label - 会話テキスト)
    • ChoicesBox (VBoxContainer - 選択肢ボタンのコンテナ)

次に、DialogueUIにアタッチする基本スクリプトを作成します。このスクリプトが、ダイアログシステムの心臓部となります。

# DialogueUI.gd
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

# タイプライター効果用のTimer
var typing_timer: Timer = Timer.new()
var current_text: String = ""
var typing_speed: float = 0.05

var dialogue_data: Dictionary = {}
var current_dialogue_id: String

func _ready():
    typing_timer.timeout.connect(_on_typing_timer_timeout)
    add_child(typing_timer)
    # 最初は非表示にしておく
    hide()

# --- Public API ---
func start(data: Dictionary, start_id: String):
    """ダイアログを開始する"""
    self.dialogue_data = data
    show()
    _show_dialogue(start_id)

# --- Private Methods ---
func _show_dialogue(id: String):
    if not dialogue_data.has(id):
        push_error("Dialogue ID not found: " + id)
        end_dialogue()
        return

    current_dialogue_id = id
    var entry = dialogue_data[id]

    speaker_label.text = entry.get("speaker", "")
    current_text = entry.get("text", "...")

    # タイプライター効果を開始
    text_label.text = current_text
    text_label.visible_characters = 0
    typing_timer.start(typing_speed)

    # 選択肢をクリア
    for child in choices_box.get_children():
        child.queue_free()

func _on_typing_timer_timeout():
    if text_label.visible_characters < current_text.length():
        text_label.visible_characters += 1
    else:
        typing_timer.stop()
        # タイプ完了後、選択肢があれば表示
        var entry = dialogue_data[current_dialogue_id]
        if entry.has("choices"):
            _display_choices(entry["choices"])
        # next_idがあり、選択肢がない場合は自動で次に進む
        # 注意: 長文が連続すると一気に進んでしまうため、
        # 必要に応じて「クリック待ち」オプション(例: "wait_for_input": true)を
        # データに追加し、ここでチェックする実装も検討してください
        elif entry.has("next_id"):
            _show_dialogue(entry["next_id"])
        else:
            # 終端ノード。クリックで閉じるのを待つ
            pass

func _display_choices(choices: Array):
    for choice_data in choices:
        var button = Button.new()
        button.text = choice_data["text"]
        # ラムダ式で引数を渡す
        button.pressed.connect(func(): _on_choice_selected(choice_data["next_id"]))
        choices_box.add_child(button)

func _on_choice_selected(next_id: String):
    _show_dialogue(next_id)

func end_dialogue():
    """ダイアログを終了し、シグナルを発行する"""
    hide()
    dialogue_finished.emit()

# 入力処理でスキップや進行を制御
func _unhandled_input(event: InputEvent):
    if not is_visible():
        return

    # マウスクリックか決定キーで処理
    if event.is_action_pressed("ui_accept"):
        if typing_timer.is_stopped():
            # タイプが完了していて、選択肢がない場合
            var entry = dialogue_data[current_dialogue_id]
            if not entry.has("choices") and not entry.has("next_id"):
                end_dialogue()
        else:
            # タイプ中なら全文表示(スキップ)
            typing_timer.stop()
            text_label.visible_characters = current_text.length()
            _on_typing_timer_timeout() # 選択肢表示処理を即時実行
        get_viewport().set_input_as_handled()

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

ダイアログシステムの実装時には、いくつかの落とし穴が存在します。ここでは、初心者が陥りがちな間違いと、それを避けるためのベストプラクティスを比較してみましょう。

よくある間違いベストプラクティス
会話データをスクリプトにハードコーディングする会話データをJSONやResourceファイルとして外部化する。 これにより、コードを変更せずにシナリオを編集できる。
get_node() を多用して密結合な作りにするsignal を活用して疎結合な設計にする。 例えば、ダイアログ終了時にdialogue_finishedシグナルを発行し、プレイヤーの操作を再開させる。
入力処理を実装しない_unhandled_input でテキストスキップや会話進行の機能を提供する。 プレイヤーの快適な体験に不可欠。
状態管理が複雑化するシンプルな状態(例:is_typing)とデータ(current_dialogue_id)で管理する。 複雑な状態機械は避け、データ駆動でフローを制御する。
拡張性を考慮しない最初からDictionaryベースのデータ構造を採用する。 将来的に「話者の表情」「効果音」などのキーを追加するだけで、簡単に機能を拡張できる。

4. パフォーマンスと代替パターン

パフォーマンスに関する考察

本記事で採用している visible_charactersTimer で更新する方法は、Godot Engineに最適化された非常に効率的な手法です。Stringをフレームごとに操作してLabeltextプロパティを更新するよりも、はるかにパフォーマンスが高くなります。

代替パターン:自作 vs Dialogicアドオン

ダイアログシステムを自作する以外に、Godot Asset Libraryで入手できる高機能なアドオン Dialogic を利用する選択肢もあります。

項目自作システム(本記事)Dialogicアドオン
カスタマイズ性非常に高い。 独自のロジックや特殊な機能を自由に追加できる。限定的。 アドオンの提供する枠組みの中で実装する必要がある。
学習コスト中程度。 Godotの基本とGDScriptの理解が必要だが、システム構造を深く学べる。低い。 ビジュアルエディタで直感的に操作でき、プログラミング知識が少なくても始められる。
開発速度遅い。 基本的な機能をゼロから構築する必要がある。速い。 豊富な機能がプリセットされており、すぐに会話を作成し始められる。
最適なケース学習目的、小規模プロジェクト、または完全に独自のUI/UXが求められる場合。大規模プロジェクト、非プログラマーがシナリオを編集する場合、迅速なプロトタイピングが必要な場合。

5. 最終ステップ:データ管理の外部化と実践

最後に、このシステムをより実践的にするために、会話データをGDScriptファイルから JSONファイル に移しましょう。

  1. プロジェクトフォルダに dialogue_data.json というファイルを作成します。
{
    "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"
    },
    "farewell": {
        "speaker": "老賢者",
        "text": "そうか。また何か用ができたら、いつでも来るとよい。"
    }
}
  1. NPCやイベントトリガーのスクリプトから、このJSONを読み込んでダイアログを開始します。
# NPCScript.gd
extends Area2D

@onready var dialogue_ui = $DialogueUI # シーンにインスタンス化しておく

var dialogue_data: Dictionary

func _ready():
    # JSONファイルを読み込んでパースする
    var file = FileAccess.open("res://dialogue_data.json", FileAccess.READ)
    if file == null:
        push_error("Failed to open dialogue file")
        return
    var content = file.get_as_text()
    dialogue_data = JSON.parse_string(content)

    # ダイアログ終了シグナルを接続
    dialogue_ui.dialogue_finished.connect(_on_dialogue_finished)

func _on_body_entered(body):
    # プレイヤーが範囲内に入ったらダイアログを開始
    if body.is_in_group("player"):
        # プレイヤーの入力を一時的に無効化
        body.set_process_unhandled_input(false)
        dialogue_ui.start(dialogue_data, "start")

func _on_dialogue_finished():
    # プレイヤーの入力を再度有効化
    var player = get_tree().get_first_node_in_group("player")
    if player:
        player.set_process_unhandled_input(true)

まとめ

本記事で紹介したダイアログシステムは、Godot Engineの基本的なUIノードとGDScriptの機能だけで、 テキスト表示、タイプライター効果、選択肢による分岐 という核となる機能をすべて実現しています。

このシステムを基盤として、以下のような拡張を行うことで、よりリッチな会話体験を提供できます。

  • データ管理の外部化: DIALOGUE_DATA をJSONやCSVファイルとして外部化
  • 演出の追加: 話者ごとのポートレート表示、会話中のSE/BGM変更
  • 条件分岐: プレイヤーのステータスやゲーム内のフラグに応じた動的な変更