Godotで爆発エフェクトを量産していると、テストプレイ中に「なんか耳が痛いぞ…?」となることがありますよね。
敵の自爆、弾のヒット音、破壊音…すべてが同時に鳴ると、音量が合計されて爆音地獄になりがちです。
よくある対処としては:
- 各シーンに
AudioStreamPlayer2Dを直付けして、適当にvolume_dbを下げる - 「爆発は1個だけ鳴らす」と決めて、前の音を強制ストップする
- シングルトンに「再生中フラグ」を持たせて、雑に制御する
…といった方法がありますが、どれも「シーンごとに実装がバラバラになりやすい」「制御ロジックが散らばる」「継承で作ると柔軟性がない」といった問題を抱えています。
そこで今回は、「どのノードにもポン付けできて、同時発音数だけをスマートに制限する」コンポーネント、
AudioPool コンポーネントを作っていきましょう。
「継承より合成」の思想で、プレイヤーにも敵にもエフェクトにも同じコンポーネントをアタッチするだけの設計です。
【Godot 4】爆音地獄を3発までに制限!「AudioPool」コンポーネント
AudioPool は、ざっくりいうと:
- 内部に複数の
AudioStreamPlayer(2D/3D)をプールしておき - 再生要求が来たら「空いているプレイヤー」を探して音を鳴らす
- 空きがなければそれ以上は鳴らさない(=発音数制限)
というコンポーネントです。
これを爆発エフェクトの親ノードなどにアタッチしておけば、どれだけ爆発しても同時発音数は最大3つに抑えられます。
フルコード:AudioPool.gd
extends Node
class_name AudioPool
## 爆発音など「同時に鳴りまくる音」の発音数を制限するコンポーネント。
## - 内部で AudioStreamPlayer / AudioStreamPlayer2D / AudioStreamPlayer3D をプール
## - 同時再生数を @export で制御
## - 空きがない場合は「それ以上は鳴らさない」ことで爆音を防ぐ
@export_category("Pool Settings")
## 同時に鳴らしてよい最大数(プールサイズ)
@export_range(1, 32, 1)
var max_voices: int = 3
## 2Dゲームなら AudioStreamPlayer2D、3Dなら AudioStreamPlayer3D を選ぶ
@export_enum("AudioStreamPlayer", "AudioStreamPlayer2D", "AudioStreamPlayer3D")
var player_type: String = "AudioStreamPlayer2D"
## デフォルトの音源。指定しない場合は毎回引数で渡す想定。
@export var default_stream: AudioStream
@export_category("Playback Defaults")
## デフォルト音量(dB)。0 が等倍、-6 で半分くらい。
@export_range(-80.0, 24.0, 0.1)
var default_volume_db: float = 0.0
## デフォルトのピッチスケール(1.0 = 等倍)
@export_range(0.25, 4.0, 0.01)
var default_pitch_scale: float = 1.0
## 2D/3D の場合にだけ意味を持つ「自動的に親ノードの位置を追従するか」
@export var follow_parent_transform: bool = true
## デバッグ用: プールの状態をログ出力するか
@export var debug_log: bool = false
# 内部で使うプレイヤーのリスト
var _players: Array[Node] = []
func _ready() -> void:
_create_pool()
## プールを作り直す(max_voices を動的に変えたいときに再呼び出し可能)
func _create_pool() -> void:
# 既存プレイヤーを掃除
for p in _players:
if is_instance_valid(p):
p.queue_free()
_players.clear()
for i in max_voices:
var player := _instantiate_player()
if player == null:
push_error("AudioPool: Failed to create audio player of type '%s'." % player_type)
return
player.name = "AudioPoolPlayer_%d" % i
player.volume_db = default_volume_db
player.pitch_scale = default_pitch_scale
# 親(このコンポーネントの親ノード)にぶら下げると分かりやすい
# ただし、この AudioPool 自身の子にしたい場合は add_child(player) に変えてもOK
var parent_node := get_parent() if get_parent() != null else self
parent_node.add_child(player)
player.owner = get_tree().edited_scene_root if Engine.is_editor_hint() else null
# 2D/3D の場合は、Transform を親と同期させる設定
if follow_parent_transform and parent_node != self:
if player is Node2D and parent_node is Node2D:
player.position = parent_node.position
elif player is Node3D and parent_node is Node3D:
player.transform = parent_node.transform
_players.append(player)
if debug_log:
print("AudioPool: created %d players of type %s" % [max_voices, player_type])
## プレイヤーを型に応じて生成する
func _instantiate_player() -> Node:
match player_type:
"AudioStreamPlayer":
return AudioStreamPlayer.new()
"AudioStreamPlayer2D":
return AudioStreamPlayer2D.new()
"AudioStreamPlayer3D":
return AudioStreamPlayer3D.new()
_:
push_error("AudioPool: Unknown player_type: %s" % player_type)
return null
## 空いているプレイヤーを1つ取得する。
## 見つからなければ null を返す(=これ以上鳴らさない)。
func _get_free_player() -> Node:
for player in _players:
# is_playing() が false なら空きとみなす
if not player.playing:
return player
return null
## シンプル版再生関数。
## - 引数 stream が null の場合は default_stream を使用
## - 空きがない場合は何もしない
func play(stream: AudioStream = null, volume_db: float = NAN, pitch_scale: float = NAN) -> void:
var s := stream if stream != null else default_stream
if s == null:
push_warning("AudioPool: No AudioStream provided and default_stream is null.")
return
var player := _get_free_player()
if player == null:
# 空きがないので何も鳴らさない(発音制限)
if debug_log:
print("AudioPool: No free voice. Skipping playback.")
return
# オプション引数が NAN のときはデフォルト値を使用
var v_db := default_volume_db if is_nan(volume_db) else volume_db
var p_scale := default_pitch_scale if is_nan(pitch_scale) else pitch_scale
player.stop()
player.stream = s
player.volume_db = v_db
player.pitch_scale = p_scale
# 位置追従が有効なら、再生直前に位置を同期しておく
if follow_parent_transform and get_parent() != null and player != self:
var parent_node := get_parent()
if player is Node2D and parent_node is Node2D:
player.position = parent_node.position
elif player is Node3D and parent_node is Node3D:
player.transform = parent_node.transform
player.play()
## すべての音を即座に止める
func stop_all() -> void:
for player in _players:
if player.playing:
player.stop()
## 現在再生中のボイス数を返す(デバッグ・UI表示用)
func get_active_voice_count() -> int:
var count := 0
for player in _players:
if player.playing:
count += 1
return count
## Editor 上で max_voices を変えたときにプールを作り直したい場合などに使う
func rebuild_pool() -> void:
_create_pool()
使い方の手順
ここでは 2D シューティングゲームを例にして、敵の爆発エフェクトに AudioPool をアタッチしてみます。
シーン構成例
EnemyExplosion (Node2D) ├── AnimatedSprite2D ├── AudioPool (Node) ← このコンポーネントをアタッチ └── Area2D(※当たり判定など任意)
手順①:スクリプトをプロジェクトに追加
res://components/AudioPool.gdなど、わかりやすい場所に上記コードを保存します。- Godotエディタを再読み込みすると、ノード追加時の「スクリプト付きノード」として
AudioPoolが選べるようになります。
手順②:シーンに AudioPool をアタッチ
EnemyExplosionシーンを開きます。- 子ノードとして
Nodeを追加し、スクリプトにAudioPool.gdをアタッチします。 - インスペクタで以下のように設定します:
- max_voices = 3(同時に鳴らす爆発音は最大3つ)
- player_type =
AudioStreamPlayer2D - default_stream = 爆発音の
.oggや.wav - default_volume_db = -4 ~ -8 くらい(お好みで)
- follow_parent_transform = ON(爆発位置に合わせて音が鳴る)
手順③:爆発時に AudioPool.play() を呼ぶ
次に、爆発アニメーションの開始時(または終了時)に音を鳴らします。
EnemyExplosion.gd 側で AudioPool を取得して play() を呼ぶだけです。
extends Node2D
@onready var audio_pool: AudioPool = $AudioPool
@onready var anim: AnimatedSprite2D = $AnimatedSprite2D
func _ready() -> void:
anim.play("explode")
# 爆発開始時に音を鳴らす
audio_pool.play()
# アニメーションが終わったら自分を消す
anim.animation_finished.connect(_on_animation_finished)
func _on_animation_finished() -> void:
queue_free()
この EnemyExplosion シーンを敵の死亡時にインスタンスしまくっても、
同時に鳴る爆発音は最大3つまでに抑えられます。
手順④:プレイヤー・敵・動く床にも使い回す
同じコンポーネントを、別のシーンにもポン付けできます。
例1:プレイヤーのショット音
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── ShotAudioPool (AudioPool)
# Player.gd
@onready var shot_audio: AudioPool = $ShotAudioPool
func shoot() -> void:
# 弾の生成など
_spawn_bullet()
# 発射音。連射しても同時発音数は制限される
shot_audio.play()
例2:動く床の「ガコン」音(連続で乗っても鳴りすぎない)
MovingPlatform (Node2D) ├── Sprite2D ├── CollisionShape2D └── AudioPool
# MovingPlatform.gd
@onready var audio_pool: AudioPool = $AudioPool
func on_player_landed() -> void:
# 何度も飛び乗っても、同時に鳴る音は制限される
audio_pool.play()
メリットと応用
AudioPool コンポーネントを使うと、以下のようなメリットがあります。
- シーン構造がシンプル:
- 「敵爆発用の AudioStreamPlayer2D を毎回置いて…」といったノード乱立を防げます。
- 音声制御ロジックがすべて
AudioPoolにまとまるので、他のスクリプトがスッキリします。
- 継承地獄からの解放:
EnemyWithSound.gdみたいな「音付き専用ベースクラス」を作らなくてOK。- プレイヤー、敵、ギミック…どのシーンにも同じコンポーネントを合成するだけです。
- 発音数の調整が一元管理:
- 「このシーンは爆発音 3 つまで」「このシーンは 5 つまで」といった制御をインスペクタで完結。
- 後から「やっぱり 2 つで十分だった」となっても、
max_voicesを変えるだけです。
- レベルデザインが安全になる:
- レベルデザイナーが「敵を100体並べてもOK」な安心感があります。
- 爆音チェックのためのテストプレイ時間が減ります。
改造案:古いボイスを優先的に潰して再生する
「空きがないときは鳴らさない」方針だと、場合によっては「重要な爆発音が鳴らない」こともあります。
そこで、一番古くから鳴っているボイスを止めて、新しい音を優先するモードを追加するのもアリですね。
例えばこんな関数を追加してみましょう:
## 空きがない場合は「一番長く鳴っているプレイヤー」を奪う版の play
func play_steal_oldest(stream: AudioStream = null, volume_db: float = NAN, pitch_scale: float = NAN) -> void:
var s := stream if stream != null else default_stream
if s == null:
push_warning("AudioPool: No AudioStream provided and default_stream is null.")
return
var player := _get_free_player()
if player == null:
# すべて埋まっているので、一番長く鳴っているプレイヤーを探す
var oldest_player: Node = null
var max_time: float = -INF
for p in _players:
if p.playing and p.get_playback_position() > max_time:
max_time = p.get_playback_position()
oldest_player = p
player = oldest_player
if player == null:
return
var v_db := default_volume_db if is_nan(volume_db) else volume_db
var p_scale := default_pitch_scale if is_nan(pitch_scale) else pitch_scale
player.stop()
player.stream = s
player.volume_db = v_db
player.pitch_scale = p_scale
player.play()
このように、コンポーネントとして独立していると、挙動の差分を関数単位で簡単に追加・切り替えできるのがうれしいところですね。
ぜひ自分のプロジェクトに合わせて、AudioPool を育ててみてください。
