Godot 4 で会話シーンを作るとき、Label に長い文章を一気に表示してしまうと、どうしても「味気ない」感じになりますよね。
そこで多くの人は、_process() の中で visible_characters を手動で増やして「文字送り」を作ろうとします。
ですが、毎回こんなコードを書いていませんか?
# PlayerDialogue.gd とか…
extends Label
var speed := 30.0
var _t := 0.0
func _process(delta: float) -> void:
_t += delta * speed
visible_characters = int(_t)
・毎回 Label を継承したスクリプトを書く
・シーンごとに微妙に処理が違ってきてコピペ地獄
・UI の構造が「Label を継承した謎クラス」で埋まる
…と、継承前提で組んでいくと、会話 UI が増えるほど管理がつらくなっていきます。
そこで今回は、「Label を継承しない」、親の Label に文字送りを付与するコンポーネントとして、Typewriter コンポーネントを用意しました。
どんな Label にもポン付けできて、継承ツリーを汚さない、合成(Composition)スタイルの実装です。
【Godot 4】会話UIをコンポーネント化!「Typewriter」コンポーネント
フルコード: Typewriter.gd
extends Node
class_name Typewriter
## 親の Label の visible_characters を増やして
## 会話風の「文字送り」を表現するコンポーネント。
##
## 想定利用:
## - 親ノードに Label / RichTextLabel を置き、このノードを子として追加
## - テキストをセットして `start()` を呼ぶだけで文字送り開始
## - シグナルで完了通知も受け取れる
@export_range(1.0, 120.0, 1.0)
var chars_per_second: float = 30.0:
set(value):
chars_per_second = max(value, 0.0)
## 文字送り開始時に自動で先頭から表示し直すかどうか
@export var reset_on_start: bool = true
## 自動で再生を開始するか(_ready で start())
@export var auto_start: bool = false
## 自動開始時に、親 Label の text をそのまま使うか
## false の場合、自分で set_text() で渡す前提
@export var use_parent_text_on_auto_start: bool = true
## 文字送り完了後に自動で全表示にするか
## false にすると、最後の visible_characters のまま止まる
@export var force_show_all_on_finish: bool = true
## 文字送り中に Enter / Space などで「一気に表示」するための入力アクション名
## 空文字にしておくと、入力によるスキップは無効
@export var skip_input_action: StringName = "ui_accept"
## 文字送りを適用する対象。デフォルトでは親ノードを自動検出する。
@export var target_label_path: NodePath
## 文字送り完了時に発火するシグナル
signal finished
## 内部状態
var _label: Label = null
var _rich_label: RichTextLabel = null
var _text: String = ""
var _elapsed: float = 0.0
var _is_playing: bool = false
var _total_chars: int = 0
func _ready() -> void:
_resolve_target_label()
if auto_start:
# 自動開始モード:親 Label の text をそのまま使うかどうか
if use_parent_text_on_auto_start and _label:
set_text(_label.text)
elif use_parent_text_on_auto_start and _rich_label:
set_text(_rich_label.text)
# すでに set_text 済みなら、そのまま start()
start()
func _process(delta: float) -> void:
if not _is_playing:
return
# スキップ入力チェック(任意)
if skip_input_action != StringName("") and Input.is_action_just_pressed(skip_input_action):
_skip_to_end()
return
if chars_per_second <= 0.0:
# 0 以下なら「一瞬で終わる」モード扱い
_skip_to_end()
return
_elapsed += delta
var visible := int(_elapsed * chars_per_second)
# visible_characters は -1 で「全表示」
if visible >= _total_chars:
_finish_typewriting()
else:
_apply_visible_characters(visible)
## -- 公開API -------------------------------------------------------------
## 文字送りの対象テキストをセットする。
## その場では再生開始しないので、別途 start() を呼ぶ。
func set_text(text: String) -> void:
_text = text
_total_chars = text.length()
if _label:
_label.text = text
elif _rich_label:
_rich_label.text = text
else:
push_warning("Typewriter: No target Label / RichTextLabel found.")
# 文字送り前は非表示にしておく
_apply_visible_characters(0)
## 文字送りを開始する。
## reset_on_start が true の場合、先頭からやり直す。
func start() -> void:
if not _label and not _rich_label:
_resolve_target_label()
if not _label and not _rich_label:
push_warning("Typewriter.start(): No target Label / RichTextLabel to typewrite.")
return
if _text.is_empty():
# 親 Label の text をそのまま使うフォールバック
if _label:
_text = _label.text
elif _rich_label:
_text = _rich_label.text
_total_chars = _text.length()
if reset_on_start:
_elapsed = 0.0
_apply_visible_characters(0)
_is_playing = true
## 文字送りを停止(一時停止)する。
func stop() -> void:
_is_playing = false
## 現在のテキストを一気に最後まで表示する。
func skip() -> void:
_skip_to_end()
## 文字送り中かどうかを返す。
func is_playing() -> bool:
return _is_playing
## -- 内部処理 ------------------------------------------------------------
func _resolve_target_label() -> void:
# すでに取得済みならスキップ
if _label or _rich_label:
return
var target: Node = null
if target_label_path != NodePath(""):
target = get_node_or_null(target_label_path)
else:
# デフォルトでは親ノードを対象にする
target = get_parent()
if target is Label:
_label = target
# Godot 4 の Label は visible_characters プロパティを持つ
return
elif target is RichTextLabel:
_rich_label = target
# RichTextLabel も visible_characters を持っている
return
else:
push_warning("Typewriter: Target node is not Label nor RichTextLabel: %s" % [str(target)])
func _apply_visible_characters(count: int) -> void:
# Label / RichTextLabel いずれかに反映
if _label:
_label.visible_characters = count
elif _rich_label:
_rich_label.visible_characters = count
func _skip_to_end() -> void:
if not _label and not _rich_label:
return
_elapsed = float(_total_chars) / max(chars_per_second, 1.0)
_finish_typewriting()
func _finish_typewriting() -> void:
_is_playing = false
if force_show_all_on_finish:
_apply_visible_characters(-1) # -1 = 全表示
else:
_apply_visible_characters(_total_chars)
finished.emit()
使い方の手順
基本は「文字を表示したい Label の子に Typewriter を置く」だけです。
シーン構造をスッキリさせつつ、どの Label にも同じコンポーネントを再利用できます。
手順①: スクリプトをプロジェクトに追加
res://components/Typewriter.gdのようなパスで保存します。- Godot エディタで再読み込みすると、ノード追加ダイアログのスクリプト一覧に
Typewriterが出てくるようになります(class_nameのおかげですね)。
手順②: Label の子に Typewriter を追加
例えば「会話ウィンドウ」のシーンをこんな感じにします。
DialogueBox (Control)
├── Panel
└── TextLabel (Label)
└── Typewriter (Node)
TextLabelに普通にテキストを設定しておきます。Typewriterノードを追加し、インスペクターからパラメータを調整します。chars_per_second: 1 秒あたり何文字表示するか(例: 30)auto_start: シーンが表示されたら自動で文字送りを始めるかuse_parent_text_on_auto_start: 親 Label のtextをそのまま使うかskip_input_action:ui_acceptなどのアクション名(空ならスキップ機能なし)
手順③: スクリプトから制御する(プレイヤー会話の例)
プレイヤーが NPC に話しかけたときだけ文字送りを開始したい場合は、DialogueBox 側のスクリプトから set_text() と start() を呼び出します。
# DialogueBox.gd
extends Control
@onready var label: Label = $TextLabel
@onready var typewriter: Typewriter = $TextLabel/Typewriter
func show_message(text: String) -> void:
visible = true
typewriter.set_text(text)
typewriter.start()
func _ready() -> void:
# 文字送り完了時に自動で閉じる例
typewriter.finished.connect(_on_typewriter_finished)
func _on_typewriter_finished() -> void:
# ここで「次のメッセージに進む」「ボタンを有効にする」などもOK
print("Typewriter finished!")
この DialogueBox を、プレイヤーや NPC から呼び出すイメージです。
Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
└── DialogueBox (Control)
├── Panel
└── TextLabel (Label)
└── Typewriter (Node)
プレイヤーのスクリプト側から:
# Player.gd の一部
@onready var dialogue_box: Control = $DialogueBox
func talk_to_npc() -> void:
dialogue_box.show_message("こんにちは!今日は Godot でコンポーネント指向開発をしてみましょう。")
手順④: 別の用途に再利用する(チュートリアルメッセージ、動く床の説明など)
同じコンポーネントを、別の UI にもそのまま付けられます。
例えば、動く床に乗ったときにチュートリアルメッセージを出したい場合:
MovingPlatform (Node2D)
├── Sprite2D
├── CollisionShape2D
└── HintLabel (Label)
└── Typewriter (Node)
# MovingPlatform.gd
extends Node2D
@onready var hint_label: Label = $HintLabel
@onready var typewriter: Typewriter = $HintLabel/Typewriter
func show_hint() -> void:
hint_label.visible = true
typewriter.set_text("この床は自動で動きます。落ちないように注意しましょう!")
typewriter.start()
Label 自体はどこでも同じ。文字送りのロジックは Typewriter だけに閉じ込めているので、どのシーンでも同じコンポーネントをペタペタ貼っていくだけで統一感のある UI にできます。
メリットと応用
- 継承ツリーを汚さない
「会話用 Label」「チュートリアル用 Label」「システムメッセージ用 Label」…と、用途ごとにクラスを増やさなくて済みます。
どれもただのLabelで、文字送りはTypewriterコンポーネントが担当。 - シーン構造がフラットで読みやすい
「Label を継承した独自クラス」が増えると、エディタ上でパッと見たときに何が何だかわからなくなりがちです。
今回の構成なら、「Label + Typewriter」という組み合わせが目で見てすぐ分かります。 - 再利用性が高い
プレイヤー会話、NPC の吹き出し、チュートリアルウィンドウ、ステージ開始演出など、全部同じコンポーネントで OK。
速度やスキップ操作だけパラメータで変えれば、挙動は統一しつつ個性も出せます。 - 差し替えや改造がしやすい
文字送りの仕様を変えたくなったらTypewriter.gdだけ直せば全 UI に反映されます。
「1 文字ずつではなく、単語単位で送りたい」「句読点で少し間を空けたい」などの改造もコンポーネント 1 箇所で済みます。
つまり、「継承で UI クラスを増やす」のではなく、コンポーネントを貼るだけで機能を足す形にすることで、プロジェクト全体の見通しがかなり良くなります。
改造案: 句読点で一瞬だけウェイトを入れる
例えば、日本語の会話だと「。」「、」「!」「?」の直後に少しだけ間を空けると、より「しゃべっている感」が出ます。
簡単な改造として、_process() 内で句読点を検出して一瞬だけ速度を落とす例を示します。
# Typewriter.gd の _process を少し改造する例
var _pause_timer: float = 0.0
@export var punctuation_pause: float = 0.12 # 句読点で止める時間(秒)
func _process(delta: float) -> void:
if not _is_playing:
return
if _pause_timer > 0.0:
_pause_timer -= delta
return
if skip_input_action != StringName("") and Input.is_action_just_pressed(skip_input_action):
_skip_to_end()
return
if chars_per_second <= 0.0:
_skip_to_end()
return
_elapsed += delta
var visible := int(_elapsed * chars_per_second)
if visible >= _total_chars:
_finish_typewriting()
return
# 直前の文字が句読点なら一瞬だけポーズ
if visible > 0 and visible <= _total_chars:
var ch := _text[visible - 1]
if ch == "。" or ch == "、" or ch == "!" or ch == "?":
_pause_timer = punctuation_pause
_apply_visible_characters(visible)
このように、コンポーネント 1 つを改造するだけで、ゲーム全体の会話演出を一気にアップグレードできます。
「継承より合成」で、Godot の UI もどんどんコンポーネント指向にしていきましょう。
