Unityを触り始めると、つい何でもかんでも Update() に書いてしまいがちですよね。移動処理、入力処理、エフェクト、サウンド、UI 更新……全部ひとつのスクリプトに押し込んでしまうと、以下のような問題が出てきます。

  • どこで何が起きているのか追いづらい(デバッグがつらい)
  • ちょっとした仕様変更でも巨大なスクリプトを開いて修正しないといけない
  • 別のゲーム・別のオブジェクトで「再利用」しにくい

音周りも同じで、「プレイヤーの移動スクリプトの中で衝突音も鳴らす」といった書き方をすると、だんだんスクリプトが太っていきます。そこで今回は、

「衝突音だけ」を担当する小さなコンポーネントとして、ImpactSound を作ってみましょう。親の Rigidbody が何かにぶつかったとき、その衝撃の強さに応じて音量を自動調整してくれるコンポーネントです。

【Unity】物理衝突の迫力アップ!「ImpactSound」コンポーネント

以下が、Unity6(C#)でそのまま使えるフルコードです。


using UnityEngine;

/// <summary>
/// 親の Rigidbody が何かに衝突したとき、
/// 衝撃の強さ(相対速度)に応じて音量を変えて再生するコンポーネント。
/// </summary>
[RequireComponent(typeof(AudioSource))]
[DisallowMultipleComponent]
public class ImpactSound : MonoBehaviour
{
    // --- インスペクター設定項目 ---

    [Header("参照コンポーネント")]
    [Tooltip("衝突元となる Rigidbody。未指定の場合は親階層から自動取得します。")]
    [SerializeField] private Rigidbody targetRigidbody;

    [Header("基本サウンド設定")]
    [Tooltip("衝突時に再生する AudioClip。ランダム再生したい場合は下の配列を使います。")]
    [SerializeField] private AudioClip defaultClip;

    [Tooltip("複数の衝突音からランダムに選びたい場合に使用します。")]
    [SerializeField] private AudioClip[] randomClips;

    [Tooltip("衝突音の最大音量(0〜1)。物理量から算出された値にこの倍率をかけます。")]
    [Range(0f, 1f)]
    [SerializeField] private float maxVolume = 1.0f;

    [Header("感度設定")]
    [Tooltip("この相対速度未満の衝突は無音にします。(小さなコツンは無視)")]
    [SerializeField] private float minImpactSpeed = 1.0f;

    [Tooltip("この相対速度以上の衝突は常に最大音量になります。")]
    [SerializeField] private float maxImpactSpeed = 10.0f;

    [Header("再生制御")]
    [Tooltip("短時間に連続して衝突したときのクールダウン時間(秒)。")]
    [SerializeField] private float cooldownSeconds = 0.05f;

    [Tooltip("同じフレーム内で複数の衝突イベントが来た場合に、最も強い衝突だけを再生するかどうか。")]
    [SerializeField] private bool useStrongestImpactPerFrame = true;

    [Tooltip("一度に再生される衝突音のピッチをランダムに揺らす幅。例: 0.1 なら 0.9〜1.1 の範囲。")]
    [Range(0f, 0.5f)]
    [SerializeField] private float randomPitchRange = 0.05f;

    [Header("デバッグ")]
    [Tooltip("衝突強度と最終音量をコンソールにログ出力します。")]
    [SerializeField] private bool enableDebugLog = false;

    // --- 内部状態 ---

    private AudioSource audioSource;
    private float lastPlayTime = -999f;

    // 「フレーム内で最も強い衝突」を扱うためのバッファ
    private float pendingImpactSpeed = 0f;
    private bool hasPendingImpact = false;


    private void Awake()
    {
        // 必須コンポーネントの取得
        audioSource = GetComponent<AudioSource>();

        // Rigidbody が未設定なら、親階層から探す
        if (targetRigidbody == null)
        {
            targetRigidbody = GetComponentInParent<Rigidbody>();
        }

        if (targetRigidbody == null)
        {
            Debug.LogWarning(
                $"[ImpactSound] Rigidbody が見つかりませんでした。親オブジェクトに Rigidbody を追加してください。 (GameObject: {gameObject.name})",
                this
            );
        }

        // 衝突音なので 3D サウンドを前提に設定しておく(必要に応じて調整)
        audioSource.playOnAwake = false;
        audioSource.spatialBlend = 1.0f; // 3D サウンド
    }

    private void Update()
    {
        // 「1フレームの中で最も強い衝突だけ再生する」モードの処理
        if (!useStrongestImpactPerFrame) return;

        if (hasPendingImpact)
        {
            PlayImpactIfAllowed(pendingImpactSpeed);
            // 処理が終わったらリセット
            pendingImpactSpeed = 0f;
            hasPendingImpact = false;
        }
    }

    /// <summary>
    /// Unity の物理衝突イベント。
    /// Rigidbody が他の Collider と衝突したときに呼ばれます。
    /// </summary>
    /// <param name="collision">衝突情報</param>
    private void OnCollisionEnter(Collision collision)
    {
        if (targetRigidbody == null) return;

        // 相対速度(どれだけの速さでぶつかったか)を取得
        float impactSpeed = collision.relativeVelocity.magnitude;

        if (useStrongestImpactPerFrame)
        {
            // このフレーム内で最も強い衝突だけを再生するためにバッファする
            if (!hasPendingImpact || impactSpeed > pendingImpactSpeed)
            {
                pendingImpactSpeed = impactSpeed;
                hasPendingImpact = true;
            }
        }
        else
        {
            // 衝突ごとに即時再生モード
            PlayImpactIfAllowed(impactSpeed);
        }
    }

    /// <summary>
    /// 衝突強度から音量を計算し、条件を満たす場合にのみ再生する。
    /// </summary>
    /// <param name="impactSpeed">衝突時の相対速度の大きさ</param>
    private void PlayImpactIfAllowed(float impactSpeed)
    {
        // まずは小さすぎる衝突は無視
        if (impactSpeed <= minImpactSpeed) return;

        // クールダウン中なら再生しない
        if (Time.time - lastPlayTime < cooldownSeconds) return;

        // 音量を 0〜1 の範囲に正規化
        float t = Mathf.InverseLerp(minImpactSpeed, maxImpactSpeed, impactSpeed);
        float volume = Mathf.Clamp01(t) * maxVolume;

        // 最終的な音量がほぼ 0 なら再生しない
        if (volume <= 0.001f) return;

        // 再生するクリップを決定
        AudioClip clipToPlay = ChooseClip();
        if (clipToPlay == null)
        {
            if (enableDebugLog)
            {
                Debug.LogWarning("[ImpactSound] 再生する AudioClip が設定されていません。", this);
            }
            return;
        }

        // ランダムピッチの適用
        float basePitch = 1.0f;
        if (randomPitchRange > 0f)
        {
            float offset = Random.Range(-randomPitchRange, randomPitchRange);
            audioSource.pitch = basePitch + offset;
        }
        else
        {
            audioSource.pitch = basePitch;
        }

        // 実際に再生
        audioSource.PlayOneShot(clipToPlay, volume);
        lastPlayTime = Time.time;

        if (enableDebugLog)
        {
            Debug.Log($"[ImpactSound] impactSpeed={impactSpeed:F2}, volume={volume:F2}, clip={clipToPlay.name}", this);
        }
    }

    /// <summary>
    /// 再生するクリップを 1 つ選ぶ。
    /// randomClips が設定されていればランダムで選択し、
    /// なければ defaultClip を返す。
    /// </summary>
    private AudioClip ChooseClip()
    {
        // ランダム候補があればそこから選ぶ
        if (randomClips != null && randomClips.Length > 0)
        {
            // null が混じっている可能性もあるので、null でないものだけ抽出
            int safety = 0;
            while (safety < 10)
            {
                safety++;
                int index = Random.Range(0, randomClips.Length);
                AudioClip candidate = randomClips[index];
                if (candidate != null) return candidate;
            }
        }

        // ランダム候補が使えない場合は defaultClip
        return defaultClip;
    }

    // --- 便利な公開メソッド(任意で呼び出せる) ---

    /// <summary>
    /// スクリプトから手動で「衝突したことにする」ためのメソッド。
    /// 物理衝突ではないイベント(例: スキル発動時のヒット音)にも使えます。
    /// </summary>
    /// <param name="fakeImpactSpeed">疑似的な衝突強度(相対速度と同じスケールを想定)</param>
    public void PlayFakeImpact(float fakeImpactSpeed)
    {
        PlayImpactIfAllowed(fakeImpactSpeed);
    }
}

使い方の手順

ここからは、実際にシーンに組み込む手順を見ていきましょう。例として「プレイヤーのキャラクター」「転がる岩(敵やギミック)」「動く床」に適用するケースを挙げます。

  1. 衝突させたいオブジェクトに Rigidbody と Collider を用意する

    • 例1: プレイヤー
      • GameObject に Rigidbody(もしくは Rigidbody を持つ親)と CapsuleCollider などを追加。
    • 例2: 転がる岩(敵やギミック)
      • 岩のプレハブに RigidbodySphereCollider を追加。
    • 例3: 動く床
      • 床を物理挙動させたい場合は Rigidbody(IsKinematic でもOK)と BoxCollider を追加。
  2. ImpactSound コンポーネントを追加する

    • 衝突音を鳴らしたいオブジェクト(例: プレイヤー、岩、床)の GameObject に ImpactSound スクリプトをアタッチします。
    • 同じオブジェクトに AudioSource が自動で追加されます([RequireComponent] のおかげですね)。
    • Rigidbody が子オブジェクト側にある場合は、ImpactSound を親に付けても自動で親階層から探してくれます。
  3. サウンドや感度をインスペクターで調整する

    • Default Clip に基本となる衝突音の AudioClip を設定。
    • 複数のバリエーションをランダム再生したい場合は Random Clips 配列にクリップを登録。
    • Min Impact Speed
      • この値より小さい衝突は無音になります。0.5〜2.0 くらいで調整すると「小さなコツン音」を消しやすいです。
    • Max Impact Speed
      • この値以上の衝突は常に最大音量になります。プレイヤーの最高速度や落下速度を見ながら決めるとよいです。
    • Max Volume
      • ゲーム全体の音量バランスを見ながら 0.3〜1.0 くらいで調整しましょう。
    • Random Pitch Range
      • 0.05〜0.15 くらいにすると、同じ音でも微妙に変化して耳障りが良くなります。
    • Use Strongest Impact Per Frame
      • ON: 同じフレーム内に複数の衝突があっても、最も強いものだけ再生。
      • OFF: 衝突イベントごとに毎回再生(多重に鳴りやすいので注意)。
  4. プレハブ化して量産する

    • 設定が気に入ったら、そのオブジェクトを Prefab にしておきます。
    • プレイヤー用、敵用、ギミック用など、用途ごとに少しずつパラメータを変えたプレハブを作っておくと、レベルデザイン時に「置くだけでそれっぽい音」が鳴るようになります。

例えば「プレイヤーが壁にぶつかったときだけ音を鳴らしたい」場合は、プレイヤーのルートオブジェクトに ImpactSound を付けておけば、壁側はただの Collider でOKです。逆に「敵がプレイヤーにぶつかったときに敵側から音を鳴らしたい」なら、敵のプレハブにだけ ImpactSound を付けておけば、どのシーンに配置しても自動で衝突音が鳴るようになります。

メリットと応用

ImpactSound を使うメリットは、単に「衝突音が鳴る」だけではありません。

  • 責務が分離される
    • 移動スクリプトや AI スクリプトから「音再生」のコードを切り離せるので、各コンポーネントがシンプルになります。
    • 「音量のチューニング」や「音の差し替え」は ImpactSound だけを触ればよく、他のロジックに影響を与えません。
  • プレハブの再利用性が上がる
    • プレイヤー、敵、ギミックなど、どのオブジェクトにも同じコンポーネントを付けるだけで「物理的な衝撃音」が手に入ります。
    • 別プロジェクトに持っていくときも、このスクリプトとプレハブをコピペするだけで同じ挙動を再現できます。
  • レベルデザインが楽になる
    • 「この岩は大きいから maxImpactSpeed を少し下げて、同じ速度でもドンと鳴りやすくしよう」といった調整が、シーンビューでパラメータをいじるだけで完結します。
    • 動く床やエレベーターにも付けておけば、プレイヤーが乗り降りしたときに自然な着地音・衝突音を自動で付与できます。
  • サウンドデザイナーとの分業がしやすい
    • プログラマーは「ImpactSound を付けておく」だけにしておき、サウンド担当が後から AudioClip やパラメータを差し替える、といったワークフローが取りやすくなります。

さらに応用として、「一定以上の衝突強度のときだけパーティクルを出す」「プレイヤーの HP を減らす」「カメラを揺らす」など、衝突の強さをトリガーにした演出にもつなげられます。

例えば、ImpactSound に軽く手を加えて、強い衝突のときだけカメラを揺らす処理を追加する改造案はこんな感じです。


    /// <summary>
    /// 強い衝突時に簡易的なカメラシェイクを発生させる例。
    /// メインカメラの Transform を少しだけ揺らします。
    /// (本格的なカメラシェイクは専用コンポーネント化するとさらに良いです)
    /// </summary>
    private void ShakeCameraOnStrongImpact(float impactSpeed)
    {
        // 例えば maxImpactSpeed の 80% 以上を「強い衝突」とみなす
        float threshold = maxImpactSpeed * 0.8f;
        if (impactSpeed < threshold) return;

        Camera mainCam = Camera.main;
        if (mainCam == null) return;

        // 衝突の強さに応じて揺れ幅を少し変える
        float t = Mathf.InverseLerp(threshold, maxImpactSpeed, impactSpeed);
        float shakeAmount = Mathf.Lerp(0.02f, 0.08f, t);

        // ここでは簡易的に、単発でランダム方向にズラすだけ
        Vector3 randomOffset = Random.insideUnitSphere * shakeAmount;
        mainCam.transform.position += randomOffset;
    }

このように、まずは「衝突音」という一つの責務に絞ったコンポーネントを作っておき、必要に応じて別コンポーネントや軽い改造で演出を足していくと、Godクラス化を防ぎつつ拡張しやすいプロジェクト構成に近づけます。ぜひプロジェクトの標準パーツとして育ててみてください。