GodotでBGMを切り替えるとき、素直に AudioStreamPlayer をシーンごとに置いて play() / stop() したり、BGM用の親シーンを継承して共通処理を書く…という実装をしがちですよね。
でもこのやり方だと、
- シーンごとにBGMロジックがバラバラに散らばる
- フェード処理を毎回コピペ or 継承で頑張る必要がある
- 「エリアに入ったらこの曲へ」「出たら元の曲へ」といった制御が煩雑
しかも、Godot標準の AudioStreamPlayer は「クロスフェード」自体の仕組みは持っていないので、自前でボリュームを操作するコードを書かないといけません。
そこで今回は、「BGMの切り替えロジック」を 1 つのコンポーネントに閉じ込めてしまう方式を紹介します。
シーン階層をムダに深くせず、好きなノードにポン付けするだけで、エリア移動時にBGMを滑らかにクロスフェードしてくれる BGMTransition コンポーネント を作っていきましょう。
【Godot 4】エリア移動もヌルッと繋ぐ!「BGMTransition」コンポーネント
このコンポーネントの役割はシンプルです。
- 親ノードの中にある
AudioStreamPlayer/AudioStreamPlayer2D/AudioStreamPlayer3Dを対象にする - 指定した曲へ、指定時間でクロスフェードしながら切り替える
- 「元の曲へ戻す」こともできる
つまり、「BGMの状態管理」と「フェード処理」を 1 つのコンポーネントにまとめて、どのシーンでも再利用できるようにしよう、という発想ですね。
フルコード:BGMTransition.gd
extends Node
class_name BGMTransition
## 親にぶら下がっている AudioStreamPlayer 系ノードの音量を操作して、
## エリア移動などのタイミングで BGM をクロスフェードさせるコンポーネント。
##
## 想定構成:
## BGMController (Node or any)
## └── AudioStreamPlayer
## └── BGMTransition (このスクリプトをアタッチ)
@export_group("ターゲット設定")
## 対象となる AudioStreamPlayer を明示的に指定したい場合に使います。
## 未指定 (null) の場合は、親ノード内から最初に見つかった AudioStreamPlayer を自動検出します。
@export var target_player: AudioStreamPlayer
## フェード時間(秒)
## 0.0 にすると即時切り替えになります。
@export_range(0.0, 10.0, 0.1)
var default_fade_time: float = 1.5
## フェード時に使う補間カーブ。
## null の場合は線形補間。カーブを設定すると、フェードの「滑らかさ」を調整できます。
@export var fade_curve: Curve
@export_group("音量設定")
## BGM の通常再生時のターゲット音量 (dB)。
## -6dB ~ -12dB あたりを基準にすると扱いやすいです。
@export_range(-40.0, 0.0, 0.5)
var base_volume_db: float = -6.0
## フェードアウト時に下げきる音量 (dB)。
## 実質ミュートにしたいなら -80dB くらいにしておきます。
@export_range(-80.0, 0.0, 1.0)
var min_volume_db: float = -60.0
@export_group("自動動作")
## true の場合、シーンが ready になった時点で現在の BGM を「元の曲」として記録します。
## エリア遷移前の BGM を覚えておき、戻るときに復元したいときに便利です。
@export var remember_initial_stream: bool = true
## デバッグログを出すかどうか
@export var debug_log: bool = false
# 内部状態
var _current_player: AudioStreamPlayer
var _original_stream: AudioStream
var _original_volume_db: float
var _tween: Tween
func _ready() -> void:
# 対象プレイヤーの自動検出
if target_player:
_current_player = target_player
else:
_current_player = _find_audio_player_in_parent()
if not _current_player:
push_warning("[BGMTransition] AudioStreamPlayer が見つかりませんでした。親ノードに AudioStreamPlayer を追加してください。")
return
# 元の状態を保存
_original_stream = _current_player.stream
_original_volume_db = _current_player.volume_db
if remember_initial_stream:
if debug_log:
print("[BGMTransition] 初期ストリームを記憶しました: ", _original_stream)
# 通常時のボリュームを base_volume_db に合わせておく
if _current_player.stream:
_current_player.volume_db = base_volume_db
func _find_audio_player_in_parent() -> AudioStreamPlayer:
## 親ノードから最初に見つかった AudioStreamPlayer 系を返します。
## 見つからなければ null。
var parent := get_parent()
if not parent:
return null
# AudioStreamPlayer, AudioStreamPlayer2D, AudioStreamPlayer3D を順に探す
var player := parent.get_node_or_null("AudioStreamPlayer")
if player and player is AudioStreamPlayer:
return player
# 子孫ノードも含めて探索したい場合は以下のようにしてもOK
# for child in parent.get_children():
# if child is AudioStreamPlayer:
# return child
# 名前で見つからなかったら型で総当たり検索
for child in parent.get_children():
if child is AudioStreamPlayer:
return child
return null
## 指定した AudioStream にクロスフェードしながら切り替える。
## fade_time を省略すると default_fade_time が使われます。
func crossfade_to(stream: AudioStream, fade_time: float = -1.0) -> void:
if not _current_player:
push_warning("[BGMTransition] AudioStreamPlayer が設定されていないため、crossfade_to は無視されました。")
return
if not stream:
push_warning("[BGMTransition] 渡された AudioStream が null です。crossfade_to は無視されました。")
return
if fade_time < 0.0:
fade_time = default_fade_time
if debug_log:
print("[BGMTransition] crossfade_to: ", stream, " (", fade_time, "秒)")
# 既存のTweenが生きていたら止める
_kill_tween()
# すでに同じストリームを再生中なら、単に音量だけ戻す
if _current_player.stream == stream:
_tween = _create_volume_tween(_current_player, _current_player.volume_db, base_volume_db, fade_time)
return
# フェードアウト → ストリーム切り替え → フェードイン の2段階構成
_tween = create_tween()
_tween.set_parallel(false) # 直列実行
# 1. フェードアウト
_append_volume_tween(_tween, _current_player, _current_player.volume_db, min_volume_db, fade_time * 0.5)
# 2. ストリーム切り替え(コールバック)
_tween.tween_callback(Callable(self, "_switch_stream").bind(stream))
# 3. フェードイン
_append_volume_tween(_tween, _current_player, min_volume_db, base_volume_db, fade_time * 0.5)
## 元のBGM(シーン開始時に記録したストリーム)へ戻す。
## 初期ストリームが存在しない場合は何もしません。
func crossfade_back(fade_time: float = -1.0) -> void:
if not _original_stream:
push_warning("[BGMTransition] 元のストリームが記録されていないため、crossfade_back は無視されました。")
return
crossfade_to(_original_stream, fade_time)
## 即時に曲を切り替える(フェードなし)。
func switch_immediately(stream: AudioStream) -> void:
if not _current_player:
return
_kill_tween()
_switch_stream(stream)
_current_player.volume_db = base_volume_db
## 現在の AudioStreamPlayer を取得(外部からも参照できるように)
func get_player() -> AudioStreamPlayer:
return _current_player
## 内部用: 実際に stream を差し替えて再生開始する
func _switch_stream(stream: AudioStream) -> void:
if debug_log:
print("[BGMTransition] ストリーム切り替え: ", stream)
_current_player.stream = stream
if stream:
_current_player.play()
else:
_current_player.stop()
## 内部用: 単発の音量Tweenを作る(既存Tweenは殺さない)
func _create_volume_tween(player: AudioStreamPlayer, from_db: float, to_db: float, duration: float) -> Tween:
var tween := create_tween()
_append_volume_tween(tween, player, from_db, to_db, duration)
return tween
## 内部用: 渡されたTweenに音量Tweenを追加する
func _append_volume_tween(tween: Tween, player: AudioStreamPlayer, from_db: float, to_db: float, duration: float) -> void:
tween.tween_property(player, "volume_db", from_db, to_db, duration).set_trans(Tween.TRANS_LINEAR).set_ease(Tween.EASE_IN_OUT)
# カーブが設定されていれば、Tween完了時にカーブに沿った補正をかけるなどの拡張も可能。
# シンプルに行きたいのでここでは線形のみとしています。
## 内部用: 既存Tweenを止める
func _kill_tween() -> void:
if _tween and _tween.is_valid():
_tween.kill()
_tween = null
使い方の手順
ここでは「ステージごとにBGMを変えたい2Dアクションゲーム」を例にします。
プレイヤーが特定エリアに入ったらボス戦BGMへクロスフェードし、エリアから出たら元のフィールドBGMへ戻す、という流れを想定しましょう。
① BGMコントローラシーンを作る
まずは、BGMをまとめて管理する専用シーンを作成します。
BGMController (Node) ├── AudioStreamPlayer └── BGMTransition (Node) ← このノードに BGMTransition.gd をアタッチ
AudioStreamPlayerに「フィールドBGM」を設定しておきます。BGMTransitionノードに、上記のスクリプトBGMTransition.gdをアタッチします。target_playerは未設定でもOK(親のAudioStreamPlayerを自動検出します)。
これで、ゲーム全体のBGMは BGMController シーンが担当する形になり、各ステージシーンからは「コンポーネントに命令を投げるだけ」で済むようになります。
② ステージシーンからBGMを切り替える
次に、プレイヤーがエリアに入ったらBGMを切り替える仕組みを作ります。
例として、ボス部屋の入口に Area2D を置き、そこにスクリプトを書きます。
BossEntrance (Area2D) ├── CollisionShape2D └── (任意の可視ノード)
BossEntrance.gd:
extends Area2D
@export var boss_bgm: AudioStream ## ボス戦用BGM
@export var bgm_controller_path: NodePath ## BGMController へのパス
var _bgm_transition: BGMTransition
func _ready() -> void:
var controller = get_node_or_null(bgm_controller_path)
if controller:
_bgm_transition = controller.get_node_or_null("BGMTransition")
if not _bgm_transition:
push_warning("[BossEntrance] BGMTransition が見つかりません。パス設定を確認してください。")
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
func _on_body_entered(body: Node) -> void:
if not _bgm_transition:
return
if not (body is CharacterBody2D):
return # プレイヤーだけを想定
# ボス戦BGMへクロスフェード
_bgm_transition.crossfade_to(boss_bgm, 2.0)
func _on_body_exited(body: Node) -> void:
if not _bgm_transition:
return
if not (body is CharacterBody2D):
return
# 元のフィールドBGMへクロスフェードで戻す
_bgm_transition.crossfade_back(2.0)
このように、ボス入口は「どのBGMへ切り替えるか」だけ知っていればOKで、
実際のフェード処理やプレイヤーのボリューム管理はすべて BGMTransition コンポーネント側に任せられます。
③ プレイヤーシーン側の構成例
プレイヤーのシーン構成は特に変える必要はありません。一般的な構成例はこんな感じです。
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── Camera2D
BossEntrance の _on_body_entered などで CharacterBody2D をチェックしているので、プレイヤーが CharacterBody2D であればそのまま動作します。
④ エリアごとに違うBGMを設定する
同じ BGMController シーンをゲーム全体で使いまわしつつ、エリアごとに別の Area2D を置いて、
それぞれに違う AudioStream をエクスポートで割り当てれば、簡単に「エリアごとのBGM」を実現できます。
例えば:
Stage1 (Node2D) ├── BGMController (インスタンス) ├── Player (インスタンス) ├── BossEntrance (Area2D) ← boss_bgm = boss_theme_1.ogg └── SecretRoomEntrance (Area2D) ← boss_bgm = secret_theme.ogg
どのエリアも BGMTransition コンポーネントに対して同じAPI (crossfade_to / crossfade_back) で命令するだけなので、実装がかなりスッキリします。
メリットと応用
この BGMTransition コンポーネントを使うことで、次のようなメリットがあります。
- 継承いらずでどこからでも使える
BGM専用のベースシーンを継承して…という手間がなくなり、
ただのNodeとして好きなシーンに置くだけでクロスフェード機能を共有できます。 - シーン構造がフラットで見通しが良い
「BGM管理のためだけの深いノード階層」を作る必要がなく、BGMController+BGMTransitionというシンプルな構成で済みます。 - フェードの挙動を一括で調整できる
フェード時間や音量レンジ、補間カーブなどはコンポーネントのエクスポート変数で一元管理できるので、
「ゲーム全体のBGMのノリ」を後からまとめて調整しやすくなります。 - テストもしやすい
別シーンでBGMControllerだけを読み込んで、エディタ上からcrossfade_to()を叩くテストシーンを作ることも容易です。
コンポーネント指向の良さは、「1つの責務に特化したノードを作り、必要な場所にだけアタッチする」ことにあります。
今回のように BGM の切り替えロジックをコンポーネント化しておくと、ゲームが大きくなっても管理コストがほとんど増えません。
改造案:シーン開始時に自動で特定BGMへフェードインする
例えば、「このステージは開始時から専用BGMを流したい」という場合、BGMTransition に次のような簡単な改造を加えることができます。
@export_group("自動スタート")
@export var auto_start_stream: AudioStream ## シーン開始時に流したいBGM
@export var auto_start_fade_in: bool = true ## フェードインするかどうか
func _ready() -> void:
# 既存の _ready ロジック
# ...
if auto_start_stream:
if auto_start_fade_in:
# 無音からフェードイン
if _current_player:
_current_player.volume_db = min_volume_db
crossfade_to(auto_start_stream, default_fade_time)
else:
switch_immediately(auto_start_stream)
こうすると、BGM専用のシーンを読み込んだだけで自動的に特定の曲へ切り替わるようになり、
「タイトル画面用BGM」「リザルト画面用BGM」なども同じコンポーネントで一括管理できるようになります。
継承に頼らず、「BGMを切り替えたい場所」にこのコンポーネントをぽんっと置いていくスタイル、ぜひ試してみてください。
