Godotで「シーンごとにBGMが変わる」まではよくやるんですが、戦闘に入ったらドラムが乗る / ボス戦でギターが追加されるみたいな「レイヤー型BGM」をやろうとすると、案外めんどうなんですよね。
- 各シーンごとに AudioStreamPlayer を何個も置いて、スクリプトで個別制御
- プレイヤー / 敵 / ステージ側それぞれが BGM の状態をいじり始めて、責務がぐちゃぐちゃ
- 「通常BGM → 戦闘BGM → ボスBGM」のクロスフェードを継承ベースで書くと、シーン増加で破綻しがち
Godot標準のやり方(各シーンに AudioStreamPlayer を直接仕込む + 継承で BGM ロジックを共有)は、規模が大きくなるほど破綻しやすい構造になりがちです。
そこで今回は、「BGMは1つのコンポーネントに閉じ込めて、シーンにはただアタッチするだけ」というコンポーネント指向のアプローチで解決してみましょう。
紹介するのは、戦闘状態に応じてドラムやギターのトラックをフェードイン / フェードアウトで重ねてくれる「DynamicMusic」コンポーネントです。
【Godot 4】戦闘で自動ミックスアップ!「DynamicMusic」コンポーネント
このコンポーネントは、ざっくりいうと:
- ベースBGM(ループ)
- 戦闘用レイヤー(ドラム)
- さらに激しいレイヤー(ギター)
のような複数の AudioStream を、同じタイミングでループ再生しながら、音量だけをフェードで切り替える仕組みです。
「戦闘が始まったら enter_combat() を呼ぶ」「戦闘が終わったら exit_combat()」みたいなシンプルな API で扱えるようにしておきます。
フルコード: DynamicMusic.gd
extends Node
class_name DynamicMusic
## DynamicMusic
## ベースBGMに複数のレイヤー(ドラム、ギター等)を重ねて
## 戦闘状態に合わせて音量をフェードイン / フェードアウトするコンポーネント。
##
## 想定ノード:
## - シーンのルート(例: LevelRoot)や専用の Music ノードにアタッチして使う。
##
## 使い方の例:
## - 通常時: base_stream だけが鳴っている
## - 戦闘時: combat_layers の音量をフェードインして盛り上げる
## - ボス時: boss_layers の音量もさらに足して激しくする
@export_group("Base BGM")
## ベースとなるBGM。常に再生される軸のトラック。
@export var base_stream: AudioStream
## ベースBGMの初期音量(dB)。通常は0.0でOK。
@export_range(-40.0, 6.0, 0.1, "or_greater") var base_volume_db: float = 0.0
@export_group("Combat Layers")
## 戦闘時に追加されるレイヤー音源(ドラム等)。
## 配列の index に特別な意味はなく、「戦闘レイヤー群」として扱う。
@export var combat_layers: Array[AudioStream] = []
## 戦闘レイヤーのターゲット音量(dB)
@export_range(-40.0, 6.0, 0.1, "or_greater") var combat_volume_db: float = -3.0
@export_group("Boss Layers")
## ボス戦など、さらに激しい状態で追加するレイヤー音源(ギター等)。
@export var boss_layers: Array[AudioStream] = []
## ボスレイヤーのターゲット音量(dB)
@export_range(-40.0, 6.0, 0.1, "or_greater") var boss_volume_db: float = -3.0
@export_group("Fade Settings")
## フェード時間(秒)。0.0 にすると即時切り替え。
@export_range(0.0, 10.0, 0.05, "or_greater") var fade_time: float = 1.0
## _process でフェードを更新するかどうか。
## 1フレームごとの更新が不要なゲームでは OFF にして、自前の tick で update_fade() を呼んでもOK。
@export var use_process_update: bool = true
@export_group("Bus / Routing")
## 再生に使うオーディオバス名。
## BGM 専用のバスを作っている場合はここで指定。
@export var bus_name: StringName = &"Music"
## ループ再生するかどうか。全トラック共通。
@export var loop: bool = true
# 内部状態管理
enum MusicState {
IDLE, # 通常状態(ベースのみ)
COMBAT, # 戦闘状態(戦闘レイヤーON)
BOSS # ボス状態(戦闘 + ボスレイヤーON)
}
var _state: MusicState = MusicState.IDLE
# 実際の AudioStreamPlayer ノードたち
var _base_player: AudioStreamPlayer
var _combat_players: Array[AudioStreamPlayer] = []
var _boss_players: Array[AudioStreamPlayer] = []
# フェード用の現在音量 / 目標音量(dB)
var _current_combat_db: float = -80.0
var _target_combat_db: float = -80.0
var _current_boss_db: float = -80.0
var _target_boss_db: float = -80.0
func _ready() -> void:
# ベースBGMプレイヤーを生成
_base_player = _create_player("BasePlayer", base_stream, base_volume_db)
add_child(_base_player)
# 戦闘レイヤーのプレイヤー群を生成
for i in combat_layers.size():
var player := _create_player("CombatLayer_%d" % i, combat_layers[i], -80.0)
_combat_players.append(player)
add_child(player)
# ボスレイヤーのプレイヤー群を生成
for i in boss_layers.size():
var player := _create_player("BossLayer_%d" % i, boss_layers[i], -80.0)
_boss_players.append(player)
add_child(player)
# 全トラックを同時スタートすることで、タイミングを合わせる
_start_all_streams()
if use_process_update:
set_process(true)
else:
set_process(false)
func _process(delta: float) -> void:
update_fade(delta)
## 外からも呼べるフェード更新関数。
## use_process_update = false のときは、ゲーム側のタイマー等から呼び出してください。
func update_fade(delta: float) -> void:
if fade_time <= 0.0:
# 即時反映モード
_current_combat_db = _target_combat_db
_current_boss_db = _target_boss_db
else:
var t := delta / fade_time
_current_combat_db = lerp(_current_combat_db, _target_combat_db, clamp(t, 0.0, 1.0))
_current_boss_db = lerp(_current_boss_db, _target_boss_db, clamp(t, 0.0, 1.0))
_apply_layer_volumes()
## ---- 状態遷移API ---------------------------------------------------------
## 通常状態(非戦闘)に戻す。
func enter_idle() -> void:
_state = MusicState.IDLE
_target_combat_db = -80.0
_target_boss_db = -80.0
## 戦闘状態に入る。ドラム等の戦闘レイヤーをONにする。
func enter_combat() -> void:
_state = MusicState.COMBAT
_target_combat_db = combat_volume_db
_target_boss_db = -80.0
## ボス戦状態に入る。戦闘レイヤー + ボスレイヤーをONにする。
func enter_boss() -> void:
_state = MusicState.BOSS
_target_combat_db = combat_volume_db
_target_boss_db = boss_volume_db
## 今の状態を取得(デバッグ用など)
func get_state() -> MusicState:
return _state
## ---- 内部ユーティリティ -------------------------------------------------
func _create_player(name: String, stream: AudioStream, volume_db: float) -> AudioStreamPlayer:
var p := AudioStreamPlayer.new()
p.name = name
p.stream = stream
p.volume_db = volume_db
p.bus = bus_name
p.autoplay = false
p.pitch_scale = 1.0
p.stream_paused = false
p.finished.connect(_on_any_player_finished)
return p
func _start_all_streams() -> void:
# すべてのプレイヤーを同時に再生開始。
# ループ設定は AudioStream 側に依存するが、loop が false の場合は手動で再スタートする。
if _base_player.stream:
_base_player.play()
for p in _combat_players:
if p.stream:
p.play()
for p in _boss_players:
if p.stream:
p.play()
if loop:
# ループを疑似的に実現するため、finished シグナルで再生し直す。
# (AudioStream 側でループ設定している場合は、ここはあまり呼ばれない想定)
pass
func _apply_layer_volumes() -> void:
# ベースは常に指定音量で固定
if _base_player:
_base_player.volume_db = base_volume_db
# 戦闘レイヤーの音量を反映
for p in _combat_players:
p.volume_db = _current_combat_db
# ボスレイヤーの音量を反映
for p in _boss_players:
p.volume_db = _current_boss_db
func _on_any_player_finished() -> void:
# ループフラグが立っている場合、すべてのプレイヤーを再度同時に再生。
if not loop:
return
# 全部止めてから同時スタートし直すことで、同期ずれを防ぐ。
if _base_player.playing:
_base_player.stop()
for p in _combat_players:
if p.playing:
p.stop()
for p in _boss_players:
if p.playing:
p.stop()
_start_all_streams()
使い方の手順
ここからは、実際に「通常BGM + 戦闘でドラム + ボスでギター」という構成で使う例を見ていきましょう。
① シーンに DynamicMusic コンポーネントを追加する
まずは、ステージのルートシーン(例: Level01.tscn)にコンポーネントとしてアタッチします。
LevelRoot (Node2D) ├── TileMap ├── Player (CharacterBody2D) ├── EnemySpawner (Node) └── DynamicMusic (Node) ← このノードに DynamicMusic.gd をアタッチ
- DynamicMusic ノードを追加し、スクリプトに上記の
DynamicMusic.gdを割り当てます。 - インスペクタで以下を設定します:
- Base BGM / base_stream: 通常時のループBGM
- Combat Layers / combat_layers: 戦闘時に足したいドラムやパーカッションのトラック
- Boss Layers / boss_layers: ボス戦でさらに足すギターやシンセのトラック
- Fade Settings / fade_time: 1.0〜2.0秒くらいにしておくと自然なフェードになります
- Bus / Routing / bus_name: BGM用のバスを
Musicなどで用意しているなら、その名前を指定
② プレイヤーや敵から状態遷移を呼び出す
戦闘の開始・終了を知っているのは、多くの場合 Player や EnemyManager です。
でも、BGMのロジック自体は DynamicMusic に閉じ込めておきたいので、プレイヤー側は「状態を通知するだけ」にしましょう。
例: プレイヤーが敵に見つかったら戦闘開始、敵が全滅したら戦闘終了とする場合:
# Player.gd (一例)
extends CharacterBody2D
@onready var dynamic_music: DynamicMusic = get_tree().get_first_node_in_group("dynamic_music")
var in_combat: bool = false
func _ready() -> void:
# DynamicMusic ノードに "dynamic_music" グループを付けておくと便利
# (シーン内のどこにあっても取得できる)
pass
func on_spotted_enemy() -> void:
if in_combat:
return
in_combat = true
if dynamic_music:
dynamic_music.enter_combat()
func on_all_enemies_defeated() -> void:
in_combat = false
if dynamic_music:
dynamic_music.enter_idle()
func on_boss_appeared() -> void:
if dynamic_music:
dynamic_music.enter_boss()
DynamicMusic ノードには、インスペクタから「ノード > グループ」で dynamic_music グループを付けておくと、どこからでも簡単にアクセスできます。
③ シーン構成図の具体例
プレイヤーと敵、DynamicMusic をまとめたシーン構成の一例です:
LevelRoot (Node2D) ├── TileMap ├── Player (CharacterBody2D) │ ├── Sprite2D │ └── CollisionShape2D ├── EnemySpawner (Node) │ └── Enemy (CharacterBody2D) └── DynamicMusic (Node) ← コンポーネント
ポイントは、DynamicMusic はどのノードも継承していない「ただのコンポーネント」であることです。
Player や Enemy から見れば、「戦闘開始したよ」「ボス出たよ」と通知するだけで、BGMのミックスはすべて DynamicMusic 側が面倒を見てくれます。
④ 動く床や別シーンでもそのまま再利用
例えば、別のステージシーン Level02.tscn でも同じ DynamicMusic を使いたい場合:
- DynamicMusic ノードをシーンにコピペ(もしくはインスタンス)
- インスペクタで別の BGM / レイヤーを設定
- Player や EnemySpawner からは同じように
enter_combat()/enter_boss()を呼ぶだけ
この「APIは共通、データ(AudioStream)はシーンごとに差し替え」という構造が、コンポーネント指向の強みですね。
メリットと応用
DynamicMusic コンポーネントを使うことで、次のようなメリットがあります。
- シーン構造がスッキリ:
- 各シーンが自前で AudioStreamPlayer を何個も持つ必要がなく、BGM ロジックは1ノードに集約。
- プレイヤーや敵は「BGMの状態を知る必要がない」=責務が分離されてテストしやすい。
- 継承地獄からの解放:
BaseLevel.gdに BGM ロジックを書いて全ステージが継承…みたいな構造をやめられる。- 「このステージだけ別のBGM仕様にしたい」というときも、DynamicMusic を差し替えるだけでOK。
- レベルデザインが楽:
- レイヤーごとに別トラックを用意しておけば、敵配置やギミックに応じて
enter_combat()を呼ぶだけで演出が盛れる。 - フェード時間をいじるだけで、「じわっと緊張感が高まる」「一瞬で戦闘モードに切り替わる」といった演出調整が可能。
- レイヤーごとに別トラックを用意しておけば、敵配置やギミックに応じて
- オーディオ側の実験もしやすい:
- コンポーネントが「単に音量をミックスするだけ」なので、サウンド担当がトラックを差し替えてもコードはそのまま。
改造案: 「ステルス状態」の静かなレイヤーを追加する
例えば、プレイヤーが草むらに隠れているときだけ、静かなパッドを足したい…みたいな要望が出てきたとします。
そんなときは、DynamicMusic にもう1つレイヤー概念を足してもいいですが、まずは既存のAPIをちょっと拡張するくらいから始めるのがオススメです。
以下は、「一時的に全体の音量を下げる(ステルス時にBGMを抑える)」簡易ミュート機能を足す例です。
## DynamicMusic.gd の末尾あたりに追加
var _global_volume_db: float = 0.0
var _target_global_volume_db: float = 0.0
func set_global_volume_db(target_db: float) -> void:
## 全レイヤーに一括でかかるマスターボリューム。
## ステルス時に -10dB にする、ポーズ時に -20dB にする、等に使える。
_target_global_volume_db = target_db
func _apply_layer_volumes() -> void:
# 既存の _apply_layer_volumes を少し改造する想定
if fade_time <= 0.0:
_global_volume_db = _target_global_volume_db
else:
var t := get_process_delta_time() / fade_time
_global_volume_db = lerp(_global_volume_db, _target_global_volume_db, clamp(t, 0.0, 1.0))
var base_db := base_volume_db + _global_volume_db
if _base_player:
_base_player.volume_db = base_db
for p in _combat_players:
p.volume_db = _current_combat_db + _global_volume_db
for p in _boss_players:
p.volume_db = _current_boss_db + _global_volume_db
こうしておけば、プレイヤー側から:
- ステルス開始:
dynamic_music.set_global_volume_db(-10.0) - ステルス終了:
dynamic_music.set_global_volume_db(0.0)
と呼ぶだけで、全体の音量だけをふわっと下げるといった演出も簡単に追加できます。
まとめると、「DynamicMusic」のようなコンポーネントを1つ用意しておくだけで、どのシーンでも、どのゲームでも、同じAPIでリッチなBGM演出を再利用できるようになります。
深いノード階層や継承に頼らず、「BGMの責務を1ノードに閉じ込めて、シーンにはアタッチするだけ」という構成にしていくと、プロジェクトがかなりスッキリしますね。




