RigidBody で「ガシャーン!」と気持ちいい衝突音を鳴らしたいのに、毎回同じようなスクリプトを書いていませんか?
Godot 標準だと、
- 各 RigidBody ごとに
body_entered/contact_monitor/max_contacts_reportedの設定をしたり - 衝突強度に応じて音量を変えたいけど、どこで速度差を計算するか悩んだり
- プレイヤー、敵、オブジェクト…それぞれに似たようなコードをコピペしてメンテ地獄
みたいな「ちょっとした面倒」が積み重なりがちです。
そこで今回は、「衝突音だけ」を責務にしたコンポーネント ImpactSound を用意して、
RigidBody にポンとアタッチするだけで「衝撃力に応じた音量の衝突音」を鳴らせるようにしてみましょう。
継承ベースで Player, Enemy, BreakableCrate それぞれにロジックを書くのではなく、
「音の振る舞い」は ImpactSound コンポーネント にまとめて合成(Composition)していくスタイルですね。
【Godot 4】衝突の気持ちよさを一括管理!「ImpactSound」コンポーネント
フルコード(GDScript)
extends Node
class_name ImpactSound
"""
ImpactSound コンポーネント
親の RigidBody2D / RigidBody3D が何かに衝突したとき、
衝撃の強さに応じた音量で効果音を再生するコンポーネント。
・親には RigidBody2D か RigidBody3D を想定
・自分の子として AudioStreamPlayer(2D/3D) を持つ構成がおすすめ
"""
# === 基本設定 ===
@export var enabled: bool = true:
set(value):
enabled = value
@export var min_impact_speed: float = 1.0:
## この速度差(衝突の強さ)未満では音を鳴らさない
## 「カツン」レベルの小さな当たりを無視するための下限
set(value):
min_impact_speed = max(value, 0.0)
@export var max_impact_speed: float = 20.0:
## この速度差を「最大インパクト」とみなし、ここを超えたら常に最大音量
set(value):
max_impact_speed = max(value, 0.01)
@export_range(0.0, 1.0, 0.01)
var min_volume: float = 0.1:
## 鳴らすときの最小音量(0.0〜1.0)
set(value):
min_volume = clamp(value, 0.0, 1.0)
@export_range(0.0, 1.0, 0.01)
var max_volume: float = 1.0:
## 最大インパクト時の音量(0.0〜1.0)
set(value):
max_volume = clamp(value, 0.0, 1.0)
@export var cooldown_time: float = 0.05:
## 連続衝突で「ババババッ」と鳴りすぎないようにするクールダウン秒数
set(value):
cooldown_time = max(value, 0.0)
@export var random_pitch_variation: float = 0.05:
## ピッチにランダム揺らぎを加える範囲
## 0.05 なら 0.95〜1.05 の範囲でランダム
set(value):
random_pitch_variation = max(value, 0.0)
# === 高度な設定 ===
@export var use_parent_linear_velocity: bool = true:
## true: RigidBody の linear_velocity を使って衝撃を推定
## false: contact の normal, local_velocity から計算(3D向け)
set(value):
use_parent_linear_velocity = value
@export var debug_print_impact: bool = false
# === 内部状態 ===
var _rigid_body: RigidBody2D = null
var _rigid_body_3d: RigidBody3D = null
var _audio_player: AudioStreamPlayer = null
var _audio_player_2d: AudioStreamPlayer2D = null
var _audio_player_3d: AudioStreamPlayer3D = null
var _last_velocity: Vector2 = Vector2.ZERO
var _last_velocity_3d: Vector3 = Vector3.ZERO
var _time_since_last_sound: float = 0.0
func _ready() -> void:
# 親が RigidBody2D か RigidBody3D かを判定
if owner is RigidBody2D:
_rigid_body = owner as RigidBody2D
elif owner is RigidBody3D:
_rigid_body_3d = owner as RigidBody3D
else:
push_warning("ImpactSound: 親ノードは RigidBody2D か RigidBody3D を想定しています。現在: %s" % [owner])
# 子ノードから AudioStreamPlayer を探す(2D / 3D 両対応)
_audio_player = get_node_or_null("AudioStreamPlayer")
_audio_player_2d = get_node_or_null("AudioStreamPlayer2D")
_audio_player_3d = get_node_or_null("AudioStreamPlayer3D")
if not _audio_player and not _audio_player_2d and not _audio_player_3d:
push_warning("ImpactSound: 子に AudioStreamPlayer / AudioStreamPlayer2D / AudioStreamPlayer3D が見つかりません。音は鳴りません。")
# 初期速度を記録
if _rigid_body:
_last_velocity = _rigid_body.linear_velocity
elif _rigid_body_3d:
_last_velocity_3d = _rigid_body_3d.linear_velocity
set_process(true)
func _process(delta: float) -> void:
if not enabled:
return
_time_since_last_sound += delta
if _rigid_body:
_process_2d()
elif _rigid_body_3d:
_process_3d()
func _process_2d() -> void:
if not _rigid_body:
return
# RigidBody2D は direct に衝突コールバックが取りにくいので、
# 速度の変化量(減速の大きさ)を「疑似インパクト」として扱う。
var current_velocity := _rigid_body.linear_velocity
var delta_v := current_velocity - _last_velocity
var impact_speed := delta_v.length()
if impact_speed > 0.0:
_handle_impact(impact_speed)
_last_velocity = current_velocity
func _process_3d() -> void:
if not _rigid_body_3d:
return
# RigidBody3D も同様に、速度変化から衝撃を推定
var current_velocity := _rigid_body_3d.linear_velocity
var delta_v := current_velocity - _last_velocity_3d
var impact_speed := delta_v.length()
if impact_speed > 0.0:
_handle_impact(impact_speed)
_last_velocity_3d = current_velocity
func _handle_impact(impact_speed: float) -> void:
# クールダウン中は無視
if _time_since_last_sound < cooldown_time:
return
# 一定以下のインパクトは無視
if impact_speed < min_impact_speed:
return
# 衝撃強度を 0.0〜1.0 に正規化
var t := (impact_speed - min_impact_speed) / (max_impact_speed - min_impact_speed)
t = clamp(t, 0.0, 1.0)
# 音量を補間(線形)
var volume := lerp(min_volume, max_volume, t)
if debug_print_impact:
print("ImpactSound: impact_speed=%.2f, t=%.2f, volume=%.2f" % [impact_speed, t, volume])
_play_sound(volume)
_time_since_last_sound = 0.0
func _play_sound(volume: float) -> void:
var player := _get_any_audio_player()
if not player:
return
# 0.0〜1.0 の音量を dB に変換
# 0.0 → -80dB (ほぼ無音), 1.0 → 0dB
var db := linear_to_db(clamp(volume, 0.0001, 1.0))
# ピッチにランダム変化を加える(少しだけ毎回違う音に)
var pitch := 1.0
if random_pitch_variation > 0.0:
var r := randf_range(-random_pitch_variation, random_pitch_variation)
pitch += r
if player is AudioStreamPlayer:
player.volume_db = db
player.pitch_scale = pitch
player.play()
elif player is AudioStreamPlayer2D:
player.volume_db = db
player.pitch_scale = pitch
player.play()
elif player is AudioStreamPlayer3D:
player.volume_db = db
player.pitch_scale = pitch
player.play()
func _get_any_audio_player() -> Object:
# 優先順位: 2D → 3D → 汎用
if _audio_player_2d:
return _audio_player_2d
if _audio_player_3d:
return _audio_player_3d
if _audio_player:
return _audio_player
return null
使い方の手順
ここでは 2D 物理(RigidBody2D)を例にしますが、3D でも同じ考え方で使えます。
- ImpactSound.gd を用意する
上記コードをそのままImpactSound.gdとして保存し、
Godot エディタで開いて「スクリプト」タブからclass_name ImpactSoundが認識されていることを確認します。 - シーン構成にコンポーネントを追加する
例として、物理で転がる木箱(Crate)を作るとします。Crate (RigidBody2D)
├── Sprite2D
├── CollisionShape2D
├── ImpactSound (Node)
└── AudioStreamPlayer2D
Crate: RigidBody2DImpactSound: 通常のNodeを追加して、スクリプトにImpactSound.gdをアタッチAudioStreamPlayer2D: 衝突音の AudioStream(効果音)を設定
3D の場合は
RigidBody3DとAudioStreamPlayer3Dを使うだけでほぼ同じです。 - インスペクタでパラメータを調整する
ImpactSoundノードを選択すると、以下のようなパラメータが見えます。min_impact_speed: この値未満の衝突は音を鳴らさない(小さなコツンは無視)max_impact_speed: この値以上の衝突は常に最大音量min_volume/max_volume: 音量レンジcooldown_time: 連続ヒット時のクールダウン(0.05〜0.1 くらいが無難)random_pitch_variation: 音に個性を出すためのピッチ揺らぎdebug_print_impact: 衝撃値をコンソールに出したいときに ON
- 実際に動かしてみる
シーンに床を用意して、Crate を高い位置に置いて落下させてみましょう。- 高いところから落とす → 衝撃が大きくなり、音量も大きく
- 少しだけ転がす → ほとんど音が鳴らない or 小さい音
プレイヤーや敵にも同じコンポーネントをポン付けできます。
Player (RigidBody2D or CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
├── ImpactSound (Node)
└── AudioStreamPlayer2D
たとえば、プレイヤーが壁に激突したときに「ドンッ」と鳴らしたい場合も、
プレイヤー本体のスクリプトを汚さずに、ImpactSound コンポーネントだけで完結します。
メリットと応用
ImpactSound コンポーネントを使うメリットはかなりシンプルですが強力です。
- シーン構造がスッキリする
衝突音のロジックを RigidBody のスクリプトから完全に分離できるので、
「物理挙動」と「演出(サウンド)」が混ざってカオスになるのを防げます。 - あらゆるオブジェクトにコピペなしで適用できる
Player、Enemy、Crate、MovingPlatform…など、
「衝突したら音が欲しいもの」に共通のコンポーネントとして使い回せます。 - パラメータだけで個性を出せる
同じコンポーネントでも、重い鉄球ならmin_impact_speedを小さめに、
軽いゴムボールならmax_volumeを低めに、など、インスペクタ上の調整だけで差別化できます。 - 「継承ツリー」を増やさずに済む
「SoundRigidBody」「PlayerWithImpactSound」みたいなクラスを増やさなくていいので、
継承地獄を避けて、合成(Composition)で機能を積み上げていけます。
さらに、ImpactSound をベースにして、例えば「一定以上の衝撃でだけ壊れるオブジェクト」を作ることもできます。
改造案として、_handle_impact の中でシグナルを飛ばすようにしておくと、
「大きな衝突があったらパーティクルを出す」「HP を減らす」など、別コンポーネントと連携できます。
signal big_impact(impact_speed: float)
func _handle_impact(impact_speed: float) -> void:
if _time_since_last_sound < cooldown_time:
return
if impact_speed < min_impact_speed:
return
var t := (impact_speed - min_impact_speed) / (max_impact_speed - min_impact_speed)
t = clamp(t, 0.0, 1.0)
var volume := lerp(min_volume, max_volume, t)
_play_sound(volume)
_time_since_last_sound = 0.0
# 例えば、かなり大きい衝撃だった場合だけシグナル発火
if impact_speed >= max_impact_speed * 0.8:
emit_signal("big_impact", impact_speed)
このシグナルを別の「破壊コンポーネント」や「エフェクトコンポーネント」とつなげれば、
さらに「継承より合成」な設計に育てていけますね。
