ダイアログシステムを作るとき、つい「専用の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 を作成してシーンにアタッチ
- プロジェクト内に
addons/components/TypingAudio.gdなどのパスで保存します。 - Godot エディタで DialogBox シーンを開き、+ ノード追加 から
Nodeを追加。 - 追加した 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()
と挿入すると、毎回微妙に違う高さの「ポポポ」が鳴るようになります。
こういう「ちょっとした遊び」も、コンポーネントとして独立しているからこそ、安心して追加できますね。
継承より合成で、ダイアログ周りのコードをどんどんスリムにしていきましょう。
