UnityでUIを作り始めたころ、Update() に「ボタンの入力チェック」「サウンド再生」「画面遷移」など、なんでもかんでも詰め込んでしまいがちですよね。
でもそれを続けると、

  • ボタンごとに条件分岐が増えて、if の地獄になる
  • サウンドの差し替えや調整のたびに巨大スクリプトを開いて修正する羽目になる
  • Prefab 化しても、別のシーンで再利用しづらい

そこでこの記事では、「ボタンが押された / ホバーされたときにだけ SE を鳴らす」という役割だけを持つ、小さなコンポーネント 「SoundButton」 を作ってみます。
UI ボタンのサウンドを「ボタンごと」に完結させることで、Godクラス化を防ぎつつ、プレハブ単位で気持ちよく管理できるようにしていきましょう。

【Unity】ボタンにサウンドを生やす!「SoundButton」コンポーネント

今回のコンポーネントのゴールはシンプルです。

  • 親の Button(Unity UI / uGUI)の
    • 押されたとき(pressed / clicked)
    • ホバーされたとき(hover / pointer enter)

    に反応して SE を鳴らす

  • それ以外の責務(画面遷移、ゲームロジック)は一切持たない
  • ボタン Prefab にアタッチしておけば、シーンに配置するだけで音付きボタンになる

入力検出やイベント処理は ButtonEventSystem に任せて、
このコンポーネントは「イベントを受けて SE を鳴らすだけ」に徹するのがポイントですね。

フルコード:SoundButton.cs


using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;

namespace Examples.UI
{
    /// <summary>
    /// 親の Button の pressed / hover シグナルに反応して SE を鳴らすコンポーネント
    /// 
    /// - 押下時(クリック時)に SE 再生
    /// - ホバー開始時(ポインタが乗った瞬間)に SE 再生
    /// 
    /// Button 自体の OnClick などには一切干渉せず、
    /// あくまで「音を鳴らす」ことだけに責務を絞っています。
    /// </summary>
    [RequireComponent(typeof(AudioSource))]
    public class SoundButton : MonoBehaviour,
        IPointerEnterHandler,     // ホバー開始検知
        IPointerClickHandler      // クリック検知(マウス / タップ)
    {
        [Header("参照")]
        [SerializeField]
        private Button targetButton;
        // 対象となるボタン。未指定の場合は親から自動取得

        [SerializeField]
        private AudioSource audioSource;
        // SE 再生用の AudioSource
        // RequireComponent により必ず同じ GameObject に付いています

        [Header("サウンド設定")]
        [SerializeField]
        private AudioClip hoverClip;
        // ホバー時に鳴らす SE(未設定なら鳴らさない)

        [SerializeField]
        private AudioClip pressedClip;
        // 押下時に鳴らす SE(未設定なら鳴らさない)

        [SerializeField, Range(0f, 1f)]
        private float volume = 1.0f;
        // 全体の音量

        [Header("挙動設定")]
        [SerializeField]
        private bool ignoreIfButtonNotInteractable = true;
        // Button.interactable == false のときは音を鳴らさないかどうか

        [SerializeField]
        private bool playHoverOnlyFirstTime = false;
        // true の場合、ホバー SE は「最初に乗ったときだけ」鳴らす

        private bool _hoverPlayedOnce = false;

        private void Reset()
        {
            // コンポーネント追加時に、よく使う参照を自動で埋める
            audioSource = GetComponent<AudioSource>();

            // 自分、もしくは親から Button を探してみる
            if (targetButton == null)
            {
                targetButton = GetComponentInParent<Button>();
            }

            // SE 用の AudioSource なので、空間位置の影響を受けないように 2D にしておく
            if (audioSource != null)
            {
                audioSource.playOnAwake = false;
                audioSource.loop = false;
                audioSource.spatialBlend = 0f; // 2D サウンド
            }
        }

        private void Awake()
        {
            // Inspector で設定し忘れていても、できる範囲で自動取得する
            if (audioSource == null)
            {
                audioSource = GetComponent<AudioSource>();
            }

            if (targetButton == null)
            {
                targetButton = GetComponentInParent<Button>();
            }

            // Button が見つからない場合は警告を出しておく
            if (targetButton == null)
            {
                Debug.LogWarning(
                    $"[SoundButton] Button が見つかりませんでした。親階層に Button を追加するか、targetButton を手動で設定してください。",
                    this
                );
            }
        }

        /// <summary>
        /// ポインタがボタンに乗った瞬間(ホバー開始)に呼ばれる
        /// </summary>
        public void OnPointerEnter(PointerEventData eventData)
        {
            if (!CanPlaySound())
            {
                return;
            }

            if (playHoverOnlyFirstTime && _hoverPlayedOnce)
            {
                // 「最初の一回だけ鳴らす」設定の場合、2回目以降は無視
                return;
            }

            if (hoverClip != null)
            {
                PlayClip(hoverClip);
                _hoverPlayedOnce = true;
            }
        }

        /// <summary>
        /// クリック(押下)されたときに呼ばれる
        /// 
        /// ※ Button.onClick よりも低レベルのイベントですが、
        ///   ここでは「音を鳴らすだけ」に使っています。
        /// </summary>
        public void OnPointerClick(PointerEventData eventData)
        {
            if (!CanPlaySound())
            {
                return;
            }

            if (pressedClip != null)
            {
                PlayClip(pressedClip);
            }
        }

        /// <summary>
        /// サウンド再生の可否を判定する共通関数
        /// </summary>
        private bool CanPlaySound()
        {
            if (audioSource == null)
            {
                // AudioSource がなければ何もできないので false
                return false;
            }

            if (ignoreIfButtonNotInteractable && targetButton != null)
            {
                // Button が無効(interactable == false)のときは鳴らさない
                if (!targetButton.interactable)
                {
                    return false;
                }
            }

            return true;
        }

        /// <summary>
        /// 実際に SE を鳴らす処理
        /// 
        /// OneShot 再生なので、同じ AudioSource から
        /// 連続で鳴らしても途中で途切れません。
        /// </summary>
        private void PlayClip(AudioClip clip)
        {
            if (clip == null)
            {
                return;
            }

            audioSource.PlayOneShot(clip, volume);
        }

        /// <summary>
        /// 外部からホバー SE を手動で鳴らしたいとき用の公開メソッド。
        /// 例えば、ゲームパッド操作でボタンフォーカスが移動したときなどに呼ぶ想定。
        /// </summary>
        public void PlayHoverSoundManually()
        {
            if (!CanPlaySound())
            {
                return;
            }

            if (hoverClip != null)
            {
                PlayClip(hoverClip);
                _hoverPlayedOnce = true;
            }
        }

        /// <summary>
        /// 外部から押下 SE を手動で鳴らしたいとき用の公開メソッド。
        /// 例えば、キーボードの Enter キーでボタン決定したときなどに呼ぶ想定。
        /// </summary>
        public void PlayPressedSoundManually()
        {
            if (!CanPlaySound())
            {
                return;
            }

            if (pressedClip != null)
            {
                PlayClip(pressedClip);
            }
        }
    }
}

使い方の手順

ここでは、典型的な UI メニューの「スタートボタン」に SE を付ける例で説明します。
プレイヤーのメニュー画面や、オプション画面など、どのボタンにも同じ手順で使えます。

  1. ① UI ボタンと EventSystem を用意する
    • Hierarchy で 右クリック > UI > Button (TextMeshPro) などからボタンを作成します。
    • シーンに EventSystem が存在しない場合は、自動で追加されるか、
      右クリック > UI > Event System から追加してください。
  2. ② ボタン用の SE 再生コンポーネントを追加する
    • ボタンの GameObject(例: StartButton)を選択します。
    • Add Component ボタンから SoundButton を検索して追加します。
    • 自動で AudioSource が追加され、親から Button も探してくれます。
  3. ③ SE 用の AudioClip を割り当てる
    • Project ウィンドウに SE 用の .wav / .mp3 などの AudioClip を用意しておきます。
    • SoundButton コンポーネントのインスペクターで
      • Hover Clip に「カーソルが乗ったときの SE」
      • Pressed Clip に「クリックしたときの SE」

      をドラッグ&ドロップで設定します。

    • ホバー音を最初の一回だけにしたい場合は、
      Play Hover Only First Time にチェックを入れます。
  4. ④ 実行して確認する
    • 再生ボタンを押して Play モードに入ります。
    • マウスカーソルをボタンに乗せると、Hover Clip が鳴ります。
    • ボタンをクリックすると、Pressed Clip が鳴ります。
    • ボタンの Button.interactable を false にすると、
      Ignore If Button Not Interactable が有効な場合は SE が鳴らなくなります。

このコンポーネントは「ボタンに紐づく SE 再生」だけを担当しているので、
画面遷移やゲーム開始処理は、別のスクリプトや Button.onClick イベントに任せておくと、
責務が分離されてメンテナンスしやすくなります。

メリットと応用

メリット:

  • Prefab 単位で完結する
    「ボタン本体」「ラベル」「SE 再生」をまとめて 1 つの Prefab にしておけば、
    別シーンでその Prefab を置くだけで「見た目 + 機能 + 音」がそろったボタンになります。
  • 巨大な UI 管理スクリプトが不要になる
    以前は「メインメニュー管理クラス」の中で StartButtonOptionButton を全部参照して、
    if (startButtonClicked) { 再生 } みたいに書いていたかもしれません。
    こういったロジックをボタン側のコンポーネントに分離することで、
    メニュー管理クラスは「遷移」だけに集中できるようになります。
  • サウンド差し替えがレベルデザイナーでもできる
    SoundButton はインスペクターで Clip を差し替えるだけで動きます。
    プログラマがコードを変更しなくても、サウンド担当やレベルデザイナーが自由に音を変えられるのが嬉しいですね。

応用アイデア:

  • 「決定ボタンは重めの音」「キャンセルボタンは軽めの音」など、ボタン種別ごとに SE を変える
  • オプション画面で「UI SE 音量」のスライダーを作り、SoundButtonvolume をまとめて変更する
  • ゲームパッド操作時に、EventSystem.current.currentSelectedGameObject を監視して、
    選択が変わったタイミングで PlayHoverSoundManually() を呼ぶ

最後に、簡単な「改造案」として、
ボタンが押されたときに「ランダムなピッチで SE を鳴らす」機能を追加する例を載せておきます。


/// <summary>
/// 押下 SE をランダムなピッチで鳴らす例
/// ちょっとしたバリエーションを付けたいときに使えます。
/// (SoundButton クラス内に追記する想定)
/// </summary>
private void PlayPressedWithRandomPitch()
{
    if (!CanPlaySound() || pressedClip == null)
    {
        return;
    }

    // 元のピッチを退避
    float originalPitch = audioSource.pitch;

    // 0.95 ~ 1.05 の範囲でランダムにピッチを変える
    audioSource.pitch = Random.Range(0.95f, 1.05f);

    audioSource.PlayOneShot(pressedClip, volume);

    // 再生後にピッチを元に戻しておく
    audioSource.pitch = originalPitch;
}

このように、小さな責務のコンポーネントを積み重ねていくと、
UI 全体の設計がすっきりして、プレハブ管理やレベルデザインもぐっと楽になります。
「ボタンの音は SoundButton に任せる」というルールをチーム内で統一しておくのもおすすめですね。