Godotで会話シーンを作るとき、ついこういう構成になりがちですよね。

DialogueBox (Control)
 ├── SpeakerLabel (Label)
 ├── BodyLabel    (RichTextLabel)
 ├── FaceTexture  (TextureRect)
 └── DialogueLogic.gd (親ノードにベタ書き)

最初はこれでも動きますが、だんだん次のような「つらみ」が出てきます。

  • 会話ロジックがUIシーンにべったり貼り付いていて、別シーンで使い回しにくい
  • プレイヤーやNPCごとに少し違う挙動を入れたくなると、継承地獄 or コピペ地獄
  • 会話データの管理がバラバラ(スクリプト直書き、配列直書き、etc…)

そこで今回は、「会話表示のロジック」を1つのコンポーネントとして切り出して、どのシーンにもポン付けできるようにしてしまいましょう。
JSONの会話データを読み込み、「話し手・本文・顔アイコン」を順番に表示してくれる DialoguePrinter コンポーネント を用意しておけば、

  • UIの見た目(ラベルやレイアウト)は自由に差し替え
  • 会話データはJSONファイルで一元管理
  • 会話ロジックはコンポーネントに閉じ込めて再利用

という、まさに「継承より合成」な構成にできます。

【Godot 4】JSONで会話をサクッと管理!「DialoguePrinter」コンポーネント

まずは、コピペでそのまま使える GDScript のフルコードから載せておきます。


extends Node
class_name DialoguePrinter
## JSONの会話データを読み込み、
## 「話し手・本文・顔アイコン」を順次表示するコンポーネント。
##
## このノード自体はロジックだけを持ち、
## 実際のUI(Label / RichTextLabel / TextureRectなど)は
## エディタ上で export 変数にアサインして使います。

## --- 設定パラメータ ---------------------------------------------------------

@export_file("*.json")
var dialogue_json_path: String = "" :
	set(value):
		dialogue_json_path = value
		# エディタ上で変更されたときに即時読み込みしたい場合はここで呼んでもOK
		# load_dialogue_from_file()

@export var auto_start: bool = false
## true の場合、_ready() で自動的に最初の会話を表示します。

@export var auto_advance: bool = false
## true の場合、1行表示後に自動で次の行へ進みます(タイマー必須)。
## 手動で next() を呼びたい場合は false にしておきましょう。

@export var auto_advance_time: float = 2.0
## auto_advance が true のとき、次の行へ進むまでの秒数。

@export var typewriter_effect: bool = true
## true の場合、本文を1文字ずつ表示する「タイプライタ風」演出を行います。

@export var typewriter_speed: float = 0.03
## タイプライタ演出の1文字あたりの待ち時間(秒)。

@export var allow_skip_typewriter: bool = true
## true の場合、タイプライタ中に next() を呼ぶと全文を即時表示します。

@export_group("UI Nodes")
@export var speaker_label: Label
## 話し手の名前を表示する Label。

@export var body_label: RichTextLabel
## 本文を表示する RichTextLabel。
## 通常の Label でも動かしたい場合は型を Label に変えてもOKです。

@export var face_texture_rect: TextureRect
## 顔アイコンを表示する TextureRect。不要なら未指定でもOK。

@export_group("Signals")
@export var emit_signals: bool = true
## true のとき、会話の開始・更新・終了時にシグナルを発火します。

## --- シグナル --------------------------------------------------------------

signal dialogue_started()
## 会話再生が開始されたときに発火

signal dialogue_line_changed(index: int, line: Dictionary)
## 表示中の行が変わったときに発火
## index: 現在の行インデックス
## line: 現在の行データ(speaker, text, face など)

signal dialogue_finished()
## すべての行の表示が完了したときに発火

## --- 内部状態 --------------------------------------------------------------

var _lines: Array = []          ## JSONから読み込んだ会話行の配列
var _current_index: int = -1    ## 現在の行インデックス
var _is_showing: bool = false   ## 会話中かどうか
var _is_typing: bool = false    ## タイプライタ演出中かどうか
var _typewriter_task: GDScriptFunctionState

var _auto_timer: Timer          ## 自動送り用のタイマー

## JSONの1行は、例えば以下のような形式を想定しています:
## {
##   "speaker": "Alice",
##   "text": "こんにちは!",
##   "face": "res://assets/faces/alice_happy.png"
## }

## --- ライフサイクル --------------------------------------------------------

func _ready() -> void:
	# 自動送り用タイマーを内部的に生成
	_auto_timer = Timer.new()
	_auto_timer.one_shot = true
	add_child(_auto_timer)
	_auto_timer.timeout.connect(_on_auto_timer_timeout)

	if auto_start and dialogue_json_path != "":
		load_dialogue_from_file(dialogue_json_path)
		start()

## --- 公開API ---------------------------------------------------------------

func load_dialogue_from_file(path: String) -> void:
	## JSONファイルから会話データを読み込む
	## 期待するJSON形式:
	## {
	##   "lines": [
	##     { "speaker": "Alice", "text": "こんにちは!", "face": "res://..." },
	##     { "speaker": "Bob",   "text": "やあ!",     "face": "res://..." }
	##   ]
	## }
	var file := FileAccess.open(path, FileAccess.READ)
	if file == null:
		push_warning("DialoguePrinter: Could not open JSON file: %s" % path)
		return

	var text := file.get_as_text()
	var json := JSON.new()
	var err := json.parse(text)
	if err != OK:
		push_warning("DialoguePrinter: Failed to parse JSON: %s" % path)
		return

	var data = json.data
	if typeof(data) == TYPE_DICTIONARY and data.has("lines") and typeof(data.lines) == TYPE_ARRAY:
		_lines = data.lines
	else:
		push_warning("DialoguePrinter: JSON does not contain 'lines' array: %s" % path)
		_lines = []

	_current_index = -1
	_is_showing = false

func set_lines(lines: Array) -> void:
	## 直接Arrayを渡して会話データをセットする場合に使用。
	## JSONを使わず、コードから組み立てたいときに便利です。
	_lines = lines
	_current_index = -1
	_is_showing = false

func start() -> void:
	## 会話の再生を開始(最初の行を表示)します。
	if _lines.is_empty():
		push_warning("DialoguePrinter: No lines to start.")
		return

	_is_showing = true
	_current_index = -1
	if emit_signals:
		dialogue_started.emit()
	_next_line_internal()

func next() -> void:
	## 次の行へ進めます。
	## - タイプライタ中: 全文を即時表示して終了させる
	## - それ以外: 次の行へ進む
	if not _is_showing:
		return

	if _is_typing and allow_skip_typewriter:
		# タイプライタ演出をスキップして全文表示
		_finish_typewriter_immediately()
	else:
		_next_line_internal()

func is_running() -> bool:
	## 会話が進行中かどうかを返します。
	return _is_showing

func get_current_line() -> Dictionary:
	## 現在表示中の行データを返します(なければ空Dictionary)。
	if _current_index < 0 or _current_index >= _lines.size():
		return {}
	return _lines[_current_index]

## --- 内部処理 --------------------------------------------------------------

func _next_line_internal() -> void:
	_current_index += 1

	if _current_index >= _lines.size():
		# 会話終了
		_is_showing = false
		_current_index = _lines.size() - 1
		if emit_signals:
			dialogue_finished.emit()
		return

	var line: Dictionary = _lines[_current_index]
	_apply_line_to_ui(line)

	if emit_signals:
		dialogue_line_changed.emit(_current_index, line)

	# 自動送りの設定
	if auto_advance:
		_auto_timer.start(auto_advance_time)

func _apply_line_to_ui(line: Dictionary) -> void:
	## 1行分のデータをUIに反映する
	## 想定キー:
	## - "speaker": String   (話し手の名前)
	## - "text":    String   (本文)
	## - "face":    String   (アイコン画像のパス)
	## - "meta":    Dictionary (任意の追加情報: 表情やタグなど)
	##
	## キーがなければ無視されます。

	# 話し手
	if speaker_label and line.has("speaker"):
		speaker_label.text = str(line.speaker)

	# 顔アイコン
	if face_texture_rect and line.has("face"):
		var face_path: String = str(line.face)
		if face_path != "":
			var tex = load(face_path)
			if tex is Texture2D:
				face_texture_rect.texture = tex
			else:
				push_warning("DialoguePrinter: Could not load face texture: %s" % face_path)

	# 本文
	if body_label and line.has("text"):
		var text: String = str(line.text)
		if typewriter_effect:
			_start_typewriter(text)
		else:
			_set_body_text(text)

func _set_body_text(text: String) -> void:
	## 本文ラベルに即時で全文をセット
	if body_label:
		if body_label is RichTextLabel:
			body_label.clear()
			body_label.append_text(text)
		else:
			(body_label as Label).text = text

func _start_typewriter(full_text: String) -> void:
	## タイプライタ演出を開始
	_cancel_typewriter()
	_is_typing = true
	_typewriter_task = _typewriter_coroutine(full_text)
	_typewriter_task.resume()

func _cancel_typewriter() -> void:
	## タイプライタ演出を中断
	_is_typing = false
	if _typewriter_task and not _typewriter_task.is_completed():
		_typewriter_task = null

func _finish_typewriter_immediately() -> void:
	## タイプライタ演出を即座に完了させる
	if not _is_typing:
		return
	_is_typing = false

	var line := get_current_line()
	if line.has("text"):
		_set_body_text(str(line.text))

func _typewriter_coroutine(full_text: String) -> GDScriptFunctionState:
	## 本文を1文字ずつ表示するコルーチン
	if body_label is RichTextLabel:
		body_label.clear()
	else:
		(body_label as Label).text = ""

	var current_text := ""
	for char in full_text:
		if not _is_typing:
			# 中断された場合は即終了
			return GDScriptFunctionState.new()
		current_text += char
		_set_body_text(current_text)
		await get_tree().create_timer(typewriter_speed).timeout

	_is_typing = false
	return GDScriptFunctionState.new()

func _on_auto_timer_timeout() -> void:
	if auto_advance and _is_showing and not _is_typing:
		_next_line_internal()

使い方の手順

ここからは、実際に「プレイヤーとNPCの会話ウィンドウ」を例にして使い方を見ていきましょう。

① JSONで会話データを用意する

まずは会話データを JSON ファイルとして保存します。
例: res://dialogues/sample_conversation.json


{
  "lines": [
    {
      "speaker": "Alice",
      "text": "やあ、ここまで来たんだね!",
      "face": "res://assets/faces/alice_happy.png"
    },
    {
      "speaker": "Player",
      "text": "もちろん。ここで何をしているの?",
      "face": "res://assets/faces/player_normal.png"
    },
    {
      "speaker": "Alice",
      "text": "ちょっとした実験中さ。手伝ってくれる?",
      "face": "res://assets/faces/alice_smile.png"
    }
  ]
}

最低限 speakertext さえあれば動きます。
face は省略してもOKです(その場合は顔アイコンは変わりません)。

② UIシーンを組む(ラベルとテクスチャ)

次に、会話ウィンドウ用の UI シーンを作ります。
例として、こんな構成にしてみましょう。

DialogueBox (Control)
 ├── PanelContainer
 │    └── VBoxContainer
 │         ├── SpeakerLabel (Label)
 │         ├── BodyLabel    (RichTextLabel)
 │         └── HBoxContainer
 │              └── FaceTextureRect (TextureRect)
 └── DialoguePrinter (Node) ← コンポーネント
  • SpeakerLabel: 話し手の名前を表示
  • BodyLabel: 本文を表示(RichTextLabel 推奨)
  • FaceTextureRect: 顔グラフィックを表示
  • DialoguePrinter: 上記コードをアタッチした Node

エディタで DialoguePrinter ノードを選択し、インスペクタから以下を設定します。

  • dialogue_json_path: res://dialogues/sample_conversation.json
  • auto_start: false(プレイヤー入力で開始したい場合)
  • auto_advance: false(1行ずつボタンで送る想定)
  • speaker_label: SpeakerLabel をドラッグして割り当て
  • body_label: BodyLabel を割り当て
  • face_texture_rect: FaceTextureRect を割り当て

③ プレイヤー入力で会話を進める

次に、「スペースキーで会話を進める」ような処理を追加してみます。
これは DialogueBox シーンにスクリプトを貼るのがシンプルです。


extends Control

@onready var dialogue_printer: DialoguePrinter = $DialoguePrinter

func _ready() -> void:
	# 必要に応じてここで明示的にロードしてもOK
	# dialogue_printer.load_dialogue_from_file("res://dialogues/sample_conversation.json")
	pass

func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("ui_accept"):
		# 初回は start()、それ以降は next() という運用でもOK
		if not dialogue_printer.is_running():
			dialogue_printer.start()
		else:
			dialogue_printer.next()

この構成なら、会話ウィンドウを別シーンに差し替えても、
DialoguePrinter さえ同じようにアタッチしておけばロジックはそのまま流用できます。

④ NPCやイベントに「会話コンポーネント」を付けて使い回す

さらに一歩進めて、「NPCが話しかけられたら自分の会話JSONを再生する」ような使い方もできます。

NPC (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── DialoguePrinter (Node)

この NPC の DialoguePrinter にだけ、
dialogue_json_path = res://dialogues/npc_001.json のように別のJSONを割り当てておきます。

あとは、プレイヤーがNPCに接触したときに npc.dialogue_printer.start() を呼ぶだけで、
それぞれのNPCが自分専用の会話を再生してくれるようになります。
ロジックは全部コンポーネント側に閉じ込めてあるので、NPCスクリプトはとてもシンプルに保てますね。

メリットと応用

1. シーン構造がスッキリする
会話ロジックを UI ノードや NPC ノードにベタ書きせず、DialoguePrinter に閉じ込めることで、
各シーンは「見た目」と「入力制御」だけに集中できます。
UIを差し替えたいときも、ラベルとテクスチャだけ入れ替えて export の参照をつなぎ直せばOKです。

2. JSONで会話を一元管理できる
会話テキストがスクリプトから分離されるので、翻訳やライティングの担当者にとっても扱いやすくなります。
Gitでの差分も見やすくなりますし、「この会話どこで使ってるんだっけ?」というときもファイル単位で整理できます。

3. 「継承より合成」で再利用性アップ
プレイヤー用の会話、NPC用の会話、イベント用の会話…と用途が増えても、
それぞれのシーンに DialoguePrinter をアタッチするだけで同じ仕組みを共有できます。
「会話するプレイヤー」「会話する敵」「会話する動く床」など、
継承階層をいじらずに「会話できる」という能力だけ後付けできるのがコンポーネントの強みですね。

4. 応用アイデア

  • JSONに "choice" キーを追加して選択肢付き会話に拡張
  • JSONの "meta" に「表情タグ」や「SE名」を入れて、外部のコンポーネントに通知
  • dialogue_line_changed シグナルを拾ってカメラをズームしたり、キャラのアニメを切り替えたり

最後に、「選択肢付き会話」へ拡張するための簡単な改造案を載せておきます。
JSON側で、選択肢を持つ行に "choices" を追加しておき、
選択肢があるときにポーズして外部UIに通知する、というイメージです。


signal dialogue_choice_requested(line_index: int, choices: Array)
## 例: choices = [
##   { "text": "はい", "next_index": 5 },
##   { "text": "いいえ", "next_index": 7 }
## ]

func _next_line_internal() -> void:
	_current_index += 1

	if _current_index >= _lines.size():
		_is_showing = false
		if emit_signals:
			dialogue_finished.emit()
		return

	var line: Dictionary = _lines[_current_index]

	# 選択肢がある場合は、外部UIに任せる
	if line.has("choices") and typeof(line.choices) == TYPE_ARRAY:
		_is_showing = true
		_cancel_typewriter()
		if emit_signals:
			dialogue_choice_requested.emit(_current_index, line.choices)
		return

	_apply_line_to_ui(line)

	if emit_signals:
		dialogue_line_changed.emit(_current_index, line)

	if auto_advance:
		_auto_timer.start(auto_advance_time)

## 外部UIから選択結果を受け取る想定のAPI
func choose(next_index: int) -> void:
	_current_index = next_index - 1
	_next_line_internal()

このように、会話ロジックをコンポーネントとして切り出しておくと、
「選択肢」「分岐」「クエスト連動」などの機能追加も、
既存のシーン構造を壊さずに後付けしやすくなります。
ぜひ自分のプロジェクト用にカスタマイズしてみてください。