ダイアログシステムを作るとき、つい「専用のDialogBoxシーン」を継承でゴリゴリ作りがちですよね。
ラベルの中で文字送りを制御して、その中でサウンドも鳴らして…とやっていると、だんだんクラスが太ってきて「この機能だけ別のUIでも使いたいのに!」と困る瞬間が出てきます。

さらに、

  • 別シーンのラベルでも同じ「ポポポ」音を使いたい
  • UIデザイナーから「このラベルだけ音を変えたい」と言われた
  • 敵のセリフ、チュートリアルメッセージ、NPC会話…全部で同じ仕組みを使いたい

こういうときに、継承ベースの巨大なDialogクラスだと、修正が広範囲に波及してしまいます。
そこで「文字送りのタイミングで音を鳴らす」という機能だけを、コンポーネントとして切り出してしまいましょう。

今回紹介する TypingAudio コンポーネントは、「文字が1文字表示されるたびにSEを鳴らす」 という一点に特化したシンプルなノードです。
ダイアログ本体の実装にほとんど手を入れず、「音を鳴らす責務」だけを別ノードに分離できます。

【Godot 4】ポポポで気持ちいい文字送り!「TypingAudio」コンポーネント

コンセプト

  • ラベル側(ダイアログシステム)は「文字を出すこと」だけを担当
  • TypingAudio は「文字が出たタイミングで音を鳴らす」だけを担当
  • シグナル or 明示的なメソッド呼び出しのどちらでも使える
  • 複数のラベル・複数のダイアログシーンで簡単に再利用できる

Godot 4 らしく、AudioStreamPlayer を内部に持ったコンポーネントノードとして実装していきます。


フルコード: TypingAudio.gd


extends Node
class_name TypingAudio
"""
TypingAudio コンポーネント

「文字送り」系の処理から呼び出して使うコンポーネント。
- 1文字ごと、または数文字ごとに効果音を鳴らす
- 同じ文字が連続するときのミュート
- 特定の文字(句読点など)では音を変える/鳴らさない

などのカスタマイズを、ダイアログ本体から切り離して管理できます。
"""

@export_group("基本設定")
@export var enabled: bool = true:
	set(value):
		enabled = value
		# 無効化されたら音を止める
		if not enabled and is_instance_valid(_player):
			_player.stop()

@export var stream: AudioStream: # 通常の文字送り音(ポポポなど)
	get:
		return _stream
	set(value):
		_stream = value
		if is_instance_valid(_player):
			_player.stream = _stream

@export var volume_db: float = -6.0:
	set(value):
		volume_db = value
		if is_instance_valid(_player):
			_player.volume_db = volume_db

@export var pitch_scale: float = 1.0:
	set(value):
		pitch_scale = value
		if is_instance_valid(_player):
			_player.pitch_scale = pitch_scale

@export_group("鳴らす頻度")
# 何文字ごとに音を鳴らすか(例: 1 = 毎文字, 2 = 2文字に1回)
@export_range(1, 10, 1) var play_every_n_chars: int = 1

# 同じ文字が連続したときに音を鳴らさないか
@export var skip_same_char_as_previous: bool = false

@export_group("句読点・例外文字")
# これらの文字のときは別のSEを鳴らす
@export var punctuation_chars: String = "、。,.!?!?"
@export var punctuation_stream: AudioStream

# これらの文字はそもそも音を鳴らさない
@export var mute_chars: String = " \t\n"

@export_group("内部状態(基本は触らない)")
@export var reset_on_dialog_start: bool = true

var _player: AudioStreamPlayer
var _stream: AudioStream
var _char_count: int = 0
var _last_char: String = ""

/**
 * ダイアログ開始時に呼び出すと、カウンタなどをリセットします。
 * DialogManager などから
 *   typing_audio.on_dialog_started()
 * のように呼んであげると安全です。
 */
func on_dialog_started() -> void:
	if reset_on_dialog_start:
		_char_count = 0
		_last_char = ""
		# 再生中の音があれば止める
		if is_instance_valid(_player):
			_player.stop()

/**
 * 「1文字が表示された」ときに呼び出すメソッド。
 * 
 * 例: ダイアログ側のコードで
 *   typing_audio.on_character_typed(char)
 * のように使います。
 */
func on_character_typed(char: String) -> void:
	if not enabled:
		return

	if char.is_empty():
		return

	_char_count += 1

	# ミュート対象の文字なら何もしない
	if char in mute_chars:
		_last_char = char
		return

	# 同じ文字が連続している場合にスキップするオプション
	if skip_same_char_as_previous and char == _last_char:
		_last_char = char
		return

	# N文字ごとに鳴らす
	if _char_count % play_every_n_chars != 0:
		_last_char = char
		return

	# 句読点かどうか判定
	var use_punctuation_stream := false
	if punctuation_stream and char in punctuation_chars:
		use_punctuation_stream = true

	_play_sound(use_punctuation_stream)
	_last_char = char

/**
 * ダイアログが完全に終了したときに呼びたい場合のフック。
 * 特に何もしませんが、将来の拡張用に用意してあります。
 */
func on_dialog_finished() -> void:
	# 必要ならここで後処理を追加
	pass

func _ready() -> void:
	# 内部用の AudioStreamPlayer を自動生成
	_player = AudioStreamPlayer.new()
	add_child(_player)
	_player.name = "TypingAudioPlayer"

	# 初期設定を反映
	_player.stream = _stream
	_player.volume_db = volume_db
	_player.pitch_scale = pitch_scale

func _play_sound(use_punctuation_stream: bool) -> void:
	if not is_instance_valid(_player):
		return

	# 再生中でも一旦止めてから鳴らす(ポポポ感を出すため)
	if _player.playing:
		_player.stop()

	if use_punctuation_stream and punctuation_stream:
		_player.stream = punctuation_stream
	else:
		_player.stream = _stream

	if _player.stream:
		_player.play()

使い方の手順

ここでは、プレイヤーのセリフ用ダイアログボックスを例に、TypingAudio を組み込んでみます。

シーン構成例

DialogBox (Control)
 ├── Panel
 ├── Label                      # 文字送り対象
 ├── TypingAudio (Node)         # ★今回作ったコンポーネント
 └── DialogController (Node)    # 文字送りロジック本体

手順①: TypingAudio.gd を作成してシーンにアタッチ

  1. プロジェクト内に addons/components/TypingAudio.gd などのパスで保存します。
  2. Godot エディタで DialogBox シーンを開き、+ ノード追加 から Node を追加。
  3. 追加した Node にスクリプトとして TypingAudio.gd をアタッチし、ノード名を TypingAudio にしておきます。

手順②: インスペクタで音源を設定

  • stream に「ポポポ」系のSE(AudioStream)を設定
  • 句読点だけ別の音にしたい場合は punctuation_stream に別SEを設定
  • play_every_n_chars を 1(毎文字)〜3(3文字に1回)など好みで調整
  • スペースや改行で鳴らしたくない場合は mute_chars をそのまま利用

手順③: DialogController から呼び出す

ダイアログ本体の「文字送りロジック」側で、文字を1つ表示するたびに
typing_audio.on_character_typed(char) を呼びます。

例として、かなりシンプルなダイアログコントローラを示します。


# DialogController.gd
extends Node

@export var label: Label
@export var typing_audio: TypingAudio
@export var chars_per_second: float = 30.0

var _text: String = ""
var _visible_count: int = 0
var _time_accum: float = 0.0
var _is_typing: bool = false

func start_dialog(text: String) -> void:
	_text = text
	_visible_count = 0
	_time_accum = 0.0
	_is_typing = true
	label.text = ""
	if typing_audio:
		typing_audio.on_dialog_started()

func _process(delta: float) -> void:
	if not _is_typing:
		return

	if chars_per_second <= 0.0:
		return

	_time_accum += delta
	var chars_per_frame := int(chars_per_second * _time_accum)

	while chars_per_frame > 0 and _visible_count < _text.length():
		var char := _text[_visible_count]
		_visible_count += 1
		label.text = _text.substr(0, _visible_count)

		# ★ ここで TypingAudio に通知
		if typing_audio:
			typing_audio.on_character_typed(char)

		chars_per_frame -= 1
		_time_accum = 0.0

	if _visible_count >= _text.length():
		_is_typing = false
		if typing_audio:
			typing_audio.on_dialog_finished()

手順④: 実際に使ってみる

たとえば、ゲーム開始時にテスト表示してみましょう。


func _ready() -> void:
	# テスト用のセリフ
	var text := "こんにちは、プレイヤーさん!\nこのダイアログはポポポ音付きです。"
	start_dialog(text)

これだけで、文字が1文字出るたびに「ポポポ」音が鳴るようになります。
ダイアログのデザインを変えたいときは DialogBox シーン側をいじるだけで、TypingAudio はそのまま再利用できます。


メリットと応用

TypingAudio をコンポーネント化することで、次のようなメリットがあります。

  • ダイアログロジックがスリムになる
    ダイアログ側は「文字をいつ出すか」だけに集中でき、「音をどう鳴らすか」は TypingAudio に丸投げできます。
  • 他のシーンでも使い回せる
    チュートリアルウィンドウ、NPCの吹き出し、敵の挑発メッセージなど、
    文字送りさえしていればどこにでも同じコンポーネントをアタッチできます。
  • ノード階層が浅く保てる
    「DialogBoxBase → PlayerDialogBox → NPCDialogBox → BossDialogBox…」みたいな継承ツリーを増やさずに、
    必要なシーンに TypingAudio をポンと足すだけで済みます。
  • サウンド調整をUIデザイナーに丸投げしやすい
    エンジニアは「いつ鳴るか」だけを決めて、音源や音量・ピッチなどはインスペクタでいじってもらえます。

さらに応用として、キャラクターごとに TypingAudio を差し替えると、
「主人公は軽いポポポ音」「老人は低くてゆっくり」「ロボットはビープ音」など、
キャラの個性をサウンドで出すことも簡単になります。

改造案: 文字数に応じてピッチをランダム変化させる

TypingAudio にちょっとした「揺らぎ」を入れて、機械的な印象を減らしてみましょう。
以下の関数を TypingAudio.gd に追加し、_play_sound() の中から呼び出すと、毎回少しだけピッチが変わります。


@export_group("ランダムピッチ")
@export var enable_random_pitch: bool = false
@export_range(0.0, 1.0, 0.01) var random_pitch_range: float = 0.1

func _apply_random_pitch() -> void:
	if not enable_random_pitch:
		return
	if not is_instance_valid(_player):
		return
	# 例: pitch_scale 1.0 ± random_pitch_range の範囲でランダム
	var base := pitch_scale
	var offset := randf_range(-random_pitch_range, random_pitch_range)
	_player.pitch_scale = base + offset

そして _play_sound() 内の _player.play() の前に、


	_apply_random_pitch()
	_player.play()

と挿入すると、毎回微妙に違う高さの「ポポポ」が鳴るようになります。
こういう「ちょっとした遊び」も、コンポーネントとして独立しているからこそ、安心して追加できますね。

継承より合成で、ダイアログ周りのコードをどんどんスリムにしていきましょう。