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(※当たり判定など任意)

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

  1. res://components/AudioPool.gd など、わかりやすい場所に上記コードを保存します。
  2. Godotエディタを再読み込みすると、ノード追加時の「スクリプト付きノード」として AudioPool が選べるようになります。

手順②:シーンに AudioPool をアタッチ

  1. EnemyExplosion シーンを開きます。
  2. 子ノードとして Node を追加し、スクリプトに AudioPool.gd をアタッチします。
  3. インスペクタで以下のように設定します:
    • 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 を育ててみてください。