Godot で UI ボタンを量産していると、だいたいこういう悩みが出てきますよね。

  • ボタンごとに AudioStreamPlayer を置いて、pressed / mouse_entered に毎回スクリプトをつなぐのが面倒
  • 共通の SE(決定音・カーソル音)を変えたくなったとき、全部のボタンを修正するハメになる
  • Button を継承した「専用クラス」を増やすと、UI シーンが継承ツリー地獄になる

Godot 標準のやり方だと、

  • MySoundButton.gd みたいなスクリプトを Button に継承して書く
  • もしくは親の「UI 管理ノード」が全部のボタンのシグナルを受け取って SE を鳴らす

…というパターンになりがちですが、どちらも「ボタンの数」が増えるほど管理がつらくなります。

そこで今回は、「継承じゃなくてコンポーネントを足すだけ」で SE を鳴らせるようにする SoundButton コンポーネント を作っていきましょう。
Button の子ノードとしてアタッチするだけで、pressed / mouse_entered に反応して自動で SE 再生してくれる、小さなヘルパーです。

【Godot 4】UI ボタンの SE をコンポーネント化!「SoundButton」コンポーネント

このコンポーネントの思想はシンプルです。

  • Button は「見た目と押されたこと」を担当
  • SoundButton は「押されたとき/ホバーしたときに音を鳴らす」だけを担当

つまり、ボタンの機能を「合成(Composition)」で拡張します。
UI ボタンが 10 個あっても、SoundButton をペタペタ貼っていくだけで SE 対応できますし、ボタンの種類(TextureButton など)を変えてもコンポーネント側のコードはほぼそのまま使い回せます。


フルコード:SoundButton.gd


extends Node
class_name SoundButton
## 親の Button / BaseButton の pressed / mouse_entered に反応して SE を鳴らすコンポーネント
##
## 使い方:
## - Button(または BaseButton 派生ノード)の子としてこのノードを置く
## - inspector から SE を設定する
## - 必要に応じてボリューム・ピッチ・ランダム化などを調整する

@export_category("Target")
## 対象となるボタン。未設定なら「親ノード」が BaseButton なら自動で拾います。
@export var target_button: BaseButton

@export_category("Pressed Sound")
## ボタンが pressed されたときに鳴らす SE。null の場合は鳴らさない。
@export var pressed_stream: AudioStream
## pressed SE の音量(dB)。0 が等倍、-6 で半分くらいの音量。
@export_range(-40.0, 6.0, 0.1)
@export var pressed_volume_db: float = 0.0
## pressed SE のピッチスケール。1.0 が等倍、0.9~1.1 くらいで少し変化をつけると気持ちいいです。
@export_range(0.5, 2.0, 0.01)
@export var pressed_pitch_scale: float = 1.0
## pressed SE のピッチをランダムで揺らす幅。0.0 ならランダムなし。
## 例: 0.05 なら 0.95~1.05 の範囲でランダム再生。
@export_range(0.0, 0.5, 0.01)
@export var pressed_pitch_random: float = 0.0

@export_category("Hover Sound")
## マウスがボタンに乗ったときに鳴らす SE。null の場合は鳴らさない。
@export var hover_stream: AudioStream
## hover SE の音量(dB)。
@export_range(-40.0, 6.0, 0.1)
@export var hover_volume_db: float = -4.0
## hover SE のピッチスケール。
@export_range(0.5, 2.0, 0.01)
@export var hover_pitch_scale: float = 1.0
## hover SE のピッチをランダムで揺らす幅。
@export_range(0.0, 0.5, 0.01)
@export var hover_pitch_random: float = 0.0

@export_category("Playback")
## 同じ SE を連打したときに、前の再生を止めてから再生し直すかどうか。
## UI の「カチカチ」音は上書きしてしまう方が聞きやすいので true 推奨。
@export var stop_previous_on_replay: bool = true
## ゲーム全体が一時停止中でも SE を鳴らすかどうか。
## UI メニューなどはポーズ中でも鳴らしたいので PROCESS_MODE_ALWAYS がデフォルトです。
@export var ignore_pause: bool = true

## 内部で使う AudioStreamPlayer。pressed 用と hover 用で 2 つ持っておきます。
var _pressed_player: AudioStreamPlayer
var _hover_player: AudioStreamPlayer


func _ready() -> void:
	# 対象ボタンが未設定なら、親ノードから自動検出します。
	if target_button == null:
		if get_parent() is BaseButton:
			target_button = get_parent() as BaseButton
		else:
			push_warning("SoundButton: parent is not a BaseButton and target_button is not set. Component will do nothing.")
	
	# ボタンが見つからなかったら、ここで終了。
	if target_button == null:
		return
	
	# 再生用 AudioStreamPlayer を生成して、このノードの子としてぶら下げます。
	_pressed_player = AudioStreamPlayer.new()
	_hover_player = AudioStreamPlayer.new()
	
	# ポーズ中の挙動を設定。
	if ignore_pause:
		_pressed_player.process_mode = Node.PROCESS_MODE_ALWAYS
		_hover_player.process_mode = Node.PROCESS_MODE_ALWAYS
	
	add_child(_pressed_player)
	add_child(_hover_player)
	
	# ボタンのシグナルに接続。
	# pressed: 左クリックや決定キーでボタンが「実行」されたとき
	target_button.pressed.connect(_on_button_pressed)
	
	# mouse_entered: マウスカーソルがボタンの上に乗ったとき
	# キーボードフォーカスでの移動には反応しないことに注意。
	target_button.mouse_entered.connect(_on_button_mouse_entered)


func _on_button_pressed() -> void:
	if pressed_stream == null:
		return
	
	# ストリームと音量・ピッチを設定。
	_pressed_player.stream = pressed_stream
	_pressed_player.volume_db = pressed_volume_db
	_pressed_player.pitch_scale = _get_randomized_pitch(pressed_pitch_scale, pressed_pitch_random)
	
	if stop_previous_on_replay and _pressed_player.playing:
		_pressed_player.stop()
	
	_pressed_player.play()


func _on_button_mouse_entered() -> void:
	if hover_stream == null:
		return
	
	_hover_player.stream = hover_stream
	_hover_player.volume_db = hover_volume_db
	_hover_player.pitch_scale = _get_randomized_pitch(hover_pitch_scale, hover_pitch_random)
	
	if stop_previous_on_replay and _hover_player.playing:
		_hover_player.stop()
	
	_hover_player.play()


## ピッチにランダム幅を加えるヘルパー。
## base: 基本のピッチ
## random_width: 0.05 なら base ± 0.05 の範囲でランダム
func _get_randomized_pitch(base: float, random_width: float) -> float:
	if random_width <= 0.0:
		return base
	var offset := randf_range(-random_width, random_width)
	return base + offset

使い方の手順

ここでは、典型的な「タイトル画面のスタートボタン」に SE をつける例で説明します。

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

  1. res://components/ui/SoundButton.gd など、分かりやすい場所に上記コードを保存します。
  2. Godot エディタを開くと、SoundButton がスクリプトクラスとして認識されます。

手順②:ボタンの子としてコンポーネントを追加

タイトル画面のシーン構成例はこんな感じです:

TitleScreen (Control)
 ├── StartButton (Button)
 │    └── SoundButton (Node)
 ├── OptionsButton (Button)
 │    └── SoundButton (Node)
 └── QuitButton (Button)
      └── SoundButton (Node)
  1. StartButton を選択して、右クリック → 「子ノードを追加」。
  2. Node を追加し、そのノードに SoundButton.gd をアタッチします。
    (または、「ノードを追加」で直接 SoundButton クラスを選んでも OK)
  3. SoundButtonButton の子になっていれば、自動で親をターゲットにしてくれます。

同様に OptionsButton, QuitButton にも SoundButton をペタペタ貼っていくだけで、全ボタン SE 対応になります。

手順③:SE を Inspector から設定

SoundButton ノードを選択すると、Inspector に以下の項目が出てきます。

  • Target
    • target_button: 通常は空のままで OK(親が Button なら自動設定)。
  • Pressed Sound
    • pressed_stream: 決定音の AudioStream を指定(例: res://audio/ui_decide.ogg)。
    • pressed_volume_db: ボリュームを調整。全体がうるさいなら -6dB くらいに。
    • pressed_pitch_scale: 1.0 のままで OK。ちょっと軽くしたいなら 1.05 など。
    • pressed_pitch_random: 0.02~0.05 くらい入れると、連打時の耳障り感が減ります。
  • Hover Sound
    • hover_stream: カーソル移動音の AudioStream を指定(例: res://audio/ui_move.ogg)。
    • hover_volume_db: 決定音より少し小さめ(例: -4dB)にしておくとバランスが良いです。
    • hover_pitch_scale, hover_pitch_random: pressed と同様に調整。
  • Playback
    • stop_previous_on_replay: UI SE は true 推奨。
    • ignore_pause: ポーズメニューのボタンにも使うなら true のままで OK。

手順④:他のシーン・他のボタンにも再利用

このコンポーネントは「ボタンの子ノードとして付けるだけ」なので、プレイヤーのインゲーム UI やポーズメニューでも同じように使えます。

例えば、ゲーム中のポーズメニュー:

PauseMenu (CanvasLayer)
 └── Panel (Control)
      ├── ResumeButton (Button)
      │    └── SoundButton (Node)
      ├── RetryButton (Button)
      │    └── SoundButton (Node)
      └── BackToTitleButton (Button)
           └── SoundButton (Node)

共通の SE を使いたければ、SoundButton をプリセット化(シーン化)しておき、ドラッグ&ドロップで量産していくとさらに楽になります。


メリットと応用

SoundButton コンポーネントを導入すると、UI 設計がかなりスッキリします。

  • 継承クラスが増えない
    「決定音付きボタン」「ホバー音付きボタン」みたいな派生クラスを作らずに済みます。
    どんな種類のボタンでも、「SE が欲しければ SoundButton を付ける」だけです。
  • シーン構造が浅くて済む
    SE 再生のためだけに AudioStreamPlayer をボタンごとに置く必要がなく、SoundButton が内部で管理してくれます。
    UI シーンを開いたときも、「Button + SoundButton」という分かりやすい構造になります。
  • SE の差し替えが局所的
    特定のボタンだけ別の SE にしたいときでも、そのボタンの SoundButton だけ設定を変えれば完了。
    共通 SE を一括管理したければ、「SE 設定用のリソース」を export するように拡張するのもアリです。
  • ゲームロジックと UI SE を分離できる
    「ボタンを押したときに何をするか」と「音を鳴らすかどうか」が完全に別ノードになるので、
    ロジック側のスクリプトはクリーンなまま保てます。

コンポーネント指向で UI を組むと、「ボタンの見た目」「ボタンの効果」「ボタンの SE」「ボタンのアニメーション」などを、それぞれ別ノードとして合成できるようになります。
深い継承ツリーを作るよりも、こうした小さなコンポーネントを組み合わせていく方が、後からの変更に強いですね。

改造案:フォーカス移動(キーボード/ゲームパッド)でも Hover SE を鳴らす

標準の mouse_entered だけだと、キーボードやゲームパッドでフォーカスを移動したときに SE が鳴りません。
そこで、focus_entered シグナルにも対応させる改造例を載せておきます。

以下を _ready() に追記し、対応するコールバックを追加するだけです。


func _ready() -> void:
	# ... 既存の処理 ...

	# キーボード/ゲームパッドでフォーカスが移動したときにも Hover SE を鳴らす
	target_button.focus_entered.connect(_on_button_focus_entered)


func _on_button_focus_entered() -> void:
	# mouse_entered と同じ処理を流用しても良いし、
	# 別の音を鳴らしたければここで分岐しても OK。
	_on_button_mouse_entered()

こうして少しずつ「欲しい振る舞い」をコンポーネント側に足していくと、
UI ボタンはいつでもただの Button のまま、振る舞いだけを合成で積み増していけるようになります。