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 にも同じコンポーネントを再利用できます。

手順①: スクリプトをプロジェクトに追加

  1. res://components/Typewriter.gd のようなパスで保存します。
  2. 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 もどんどんコンポーネント指向にしていきましょう。