【Cocos Creator 3.8】UIGenericSoundの実装:アタッチするだけでシーン内のボタンHover/Click音を一括管理する汎用スクリプト

本記事では、シーン内のすべてのボタン(UI)に対して、Hover(マウスオーバー)音Click音を自動で接続してくれる汎用コンポーネント UIGenericSound を実装します。
このコンポーネントを任意のノード(たとえば Canvas)にアタッチし、Inspector で音声クリップを設定するだけで、個々のボタンにスクリプトを付けなくても一括でUIサウンドを管理できます。


コンポーネントの設計方針

要件整理

  • シーン内(または指定ルート配下)の Button コンポーネントを自動検出する。
  • 各ボタンに対し、Hover(マウスオーバー/フォーカス)時Click時に音を鳴らす。
  • 既にボタンに設定されている clickEvents などの挙動は壊さない(音だけ追加)。
  • 外部の GameManager やシングルトンに一切依存せず、このスクリプト単体で完結させる。
  • Inspector から以下を調整できるようにする:
    • 検索対象とするルートノード(未指定なら自ノード配下を検索)
    • Hover音 / Click音の AudioClip
    • 音量・ミュート・同時再生制御などの基本設定
    • 自動スキャンのタイミング(onLoad / start / 手動)
    • ボタンリストをログ出力するかどうか(デバッグ用途)
  • 防御的な実装
    • AudioSource が無ければ自動で追加し、ログで通知。
    • AudioClip が未設定のときは、そのイベントのサウンド再生をスキップし警告ログ。
    • エディタ上での誤操作に対してもエラーにならないようにする。

Inspector で設定可能なプロパティ設計

  • scanRoot: Node | null
    • 説明: ボタンを検索するルートノード。
    • 未設定(null)の場合は、このコンポーネントがアタッチされたノードをルートとして、その子孫をすべて検索。
    • Canvas 全体を対象にしたい場合は、Canvas ノードに本コンポーネントを付けて scanRoot = null のままでOK。
  • hoverClip: AudioClip | null
    • 説明: ボタンにカーソルが乗ったとき(またはフォーカス獲得時)に再生する効果音。
    • 未設定の場合、Hover音は再生されず、ログで警告を出す(Click音には影響なし)。
  • clickClip: AudioClip | null
    • 説明: ボタンがクリックされたときに再生する効果音。
    • 未設定の場合、Click音は再生されず、ログで警告を出す(Hover音には影響なし)。
  • volume: number
    • 説明: 再生音量 (0〜1)。
    • 全ての UI サウンドに共通で適用される。
  • mute: boolean
    • 説明: true の場合、音を鳴らさない(スクリプトは動作するが AudioSource は再生しない)。
    • 一時的に UI サウンドだけミュートしたいときに使用。
  • allowOverlap: boolean
    • 説明: true の場合、前の音が鳴っていてもそのまま新しい音を重ねて再生する。
    • false の場合、再生中の音を一旦停止してから新しい音を再生する(UIサウンドが混ざりすぎないようにする用途)。
  • autoScanOn: 'OnLoad' | 'Start' | 'None'
    • 説明: 自動でボタンスキャンを行うタイミング。
    • 'OnLoad': onLoad() で即スキャン。
    • 'Start': start() でスキャン(他のスクリプトがボタンを生成してから紐付けしたい場合に便利)。
    • 'None': 自動ではスキャンせず、他のスクリプトやエディタの Execute ボタンから rescanButtons() を呼び出す前提。
  • logFoundButtons: boolean
    • 説明: スキャンしたボタンの数と名前を console.log に出力するかどうか。
    • デバッグ時に有効にすると、どのボタンにサウンドが紐付けられているか確認しやすい。

TypeScriptコードの実装


import { _decorator, Component, Node, Button, AudioSource, AudioClip, EventHandler, Enum, sys } from 'cc';
const { ccclass, property, executeInEditMode, menu } = _decorator;

// 自動スキャンのタイミングを Enum 化
enum AutoScanTiming {
    OnLoad = 0,
    Start = 1,
    None = 2,
}

Enum(AutoScanTiming);

@ccclass('UIGenericSound')
@executeInEditMode()
@menu('Custom/UIGenericSound')
export class UIGenericSound extends Component {

    @property({
        type: Node,
        tooltip: 'ボタンを検索するルートノード。\n未設定の場合、このコンポーネントが付いているノード配下を検索します。'
    })
    public scanRoot: Node | null = null;

    @property({
        type: AudioClip,
        tooltip: 'ボタンにカーソルが乗ったとき(Hover)に再生する効果音。\n未設定の場合、Hover音は再生されません。'
    })
    public hoverClip: AudioClip | null = null;

    @property({
        type: AudioClip,
        tooltip: 'ボタンがクリックされたときに再生する効果音。\n未設定の場合、Click音は再生されません。'
    })
    public clickClip: AudioClip | null = null;

    @property({
        tooltip: 'UIサウンドの音量(0〜1)。\nすべての再生に共通で適用されます。',
        min: 0,
        max: 1,
        slide: true
    })
    public volume: number = 1.0;

    @property({
        tooltip: 'true の場合、音を再生しません(ミュート)。\n挙動の確認だけしたいときに便利です。'
    })
    public mute: boolean = false;

    @property({
        tooltip: 'true: 前の音が鳴っていても重ねて再生します。\nfalse: 再生中の音を停止してから新しい音を再生します。'
    })
    public allowOverlap: boolean = true;

    @property({
        type: AutoScanTiming,
        tooltip: 'ボタンを自動スキャンするタイミング。\nOnLoad: onLoad() で即スキャン\nStart: start() でスキャン\nNone: 自動ではスキャンしません'
    })
    public autoScanOn: AutoScanTiming = AutoScanTiming.Start;

    @property({
        tooltip: 'true の場合、検出したボタンの一覧をログに出力します。'
    })
    public logFoundButtons: boolean = false;

    // 内部で使用する AudioSource(自動追加される)
    private _audioSource: AudioSource | null = null;

    // すでにイベントを設定したボタンを記録(重複登録防止)
    private _registeredButtons: Set<Button> = new Set<Button>();

    onLoad() {
        this._ensureAudioSource();

        if (!sys.isBrowser && !sys.isNative) {
            // エディタなど特殊環境では挙動を変えたい場合はここで分岐可能
        }

        if (this.autoScanOn === AutoScanTiming.OnLoad) {
            this.rescanButtons();
        }
    }

    start() {
        if (this.autoScanOn === AutoScanTiming.Start) {
            this.rescanButtons();
        }
    }

    /**
     * AudioSource が存在しない場合は自動で追加する
     */
    private _ensureAudioSource() {
        let source = this.getComponent(AudioSource);
        if (!source) {
            source = this.addComponent(AudioSource);
            console.warn('[UIGenericSound] AudioSource が見つからなかったため、自動で追加しました。');
        }
        this._audioSource = source;
        this._applyAudioSettings();
    }

    /**
     * volume / mute の設定を AudioSource に反映
     */
    private _applyAudioSettings() {
        if (!this._audioSource) {
            return;
        }
        this._audioSource.volume = this.volume;
        this._audioSource.mute = this.mute;
    }

    /**
     * Inspector で volume / mute を変更したときにも反映されるように
     */
    update(deltaTime: number) {
        // 実行中にプロパティが変わっても適用されるようにする
        this._applyAudioSettings();
    }

    /**
     * ボタンを再スキャンし、Hover / Click イベントにサウンド再生処理を紐付ける
     * エディタの「Execute Method」からも呼び出せるよう public にしておく
     */
    public rescanButtons() {
        const root = this.scanRoot || this.node;

        if (!root) {
            console.error('[UIGenericSound] scanRoot も this.node も存在しません。シーンに正しく配置されているか確認してください。');
            return;
        }

        // 以前登録したボタンは一旦クリア(簡易実装)
        this._registeredButtons.clear();

        const buttons: Button[] = [];
        this._collectButtonsRecursive(root, buttons);

        if (this.logFoundButtons) {
            console.log(`[UIGenericSound] 検出したボタン数: ${buttons.length}`);
            buttons.forEach((btn) => {
                console.log(` - Button Node: ${btn.node.path}`);
            });
        }

        buttons.forEach((btn) => {
            this._registerButtonEvents(btn);
        });
    }

    /**
     * 指定ノード配下から Button コンポーネントをすべて収集
     */
    private _collectButtonsRecursive(node: Node, outButtons: Button[]) {
        const btn = node.getComponent(Button);
        if (btn) {
            outButtons.push(btn);
        }

        const children = node.children;
        for (let i = 0; i < children.length; i++) {
            this._collectButtonsRecursive(children[i], outButtons);
        }
    }

    /**
     * 1つの Button に対して Hover / Click イベントを登録
     */
    private _registerButtonEvents(button: Button) {
        if (this._registeredButtons.has(button)) {
            return; // 二重登録防止
        }

        // Hover (マウスオーバー/フォーカス) 用 EventHandler
        const hoverHandler = new EventHandler();
        hoverHandler.target = this.node;
        hoverHandler.component = 'UIGenericSound';
        hoverHandler.handler = 'onButtonHoverSound';

        // Click 用 EventHandler
        const clickHandler = new EventHandler();
        clickHandler.target = this.node;
        clickHandler.component = 'UIGenericSound';
        clickHandler.handler = 'onButtonClickSound';

        // pointerEnter と click イベントにハンドラを追加
        // 既存のイベントはそのまま残す
        button.pointerEnterEvents.push(hoverHandler);
        button.clickEvents.push(clickHandler);

        this._registeredButtons.add(button);
    }

    /**
     * Button の Hover 時に EventHandler 経由で呼ばれる
     */
    public onButtonHoverSound() {
        if (!this.hoverClip) {
            console.warn('[UIGenericSound] hoverClip が設定されていないため、Hover音は再生されません。');
            return;
        }
        this._playClip(this.hoverClip);
    }

    /**
     * Button の Click 時に EventHandler 経由で呼ばれる
     */
    public onButtonClickSound() {
        if (!this.clickClip) {
            console.warn('[UIGenericSound] clickClip が設定されていないため、Click音は再生されません。');
            return;
        }
        this._playClip(this.clickClip);
    }

    /**
     * 実際に AudioSource でクリップを再生する共通処理
     */
    private _playClip(clip: AudioClip) {
        if (this.mute) {
            return;
        }
        if (!this._audioSource) {
            console.error('[UIGenericSound] AudioSource が存在しません。onLoad() での初期化に失敗している可能性があります。');
            return;
        }

        if (!this.allowOverlap && this._audioSource.playing) {
            this._audioSource.stop();
        }

        this._audioSource.clip = clip;
        this._audioSource.play();
    }
}

コードのポイント解説

  • @executeInEditMode()
    • エディタ上でも rescanButtons() を呼べるようにしておくための指定です。
    • この記事のコードでは主にランタイムでの利用を想定していますが、必要に応じてエディタ拡張的に使うことも可能です。
  • AudioSource の自動追加
    • onLoad() 内の _ensureAudioSource() で、AudioSource が無ければ自動追加し、警告ログを出します。
    • これにより、「AudioSource を付け忘れたせいで音が鳴らない」という事故を防ぎます。
  • ボタンの自動スキャン
    • scanRoot が設定されていなければ this.node をルートに、子孫ノードから Button コンポーネントを再帰的に収集します。
    • 収集したボタンに対し、pointerEnterEventsclickEventsEventHandler を追加しています。
    • 既存のイベントは壊さずに「追加」するだけなので、既に設定済みのクリック処理はそのまま動作します。
  • Hover / Click の共通再生処理
    • onButtonHoverSound() / onButtonClickSound() は各ボタンから呼ばれる公開メソッドです。
    • 実際の再生は _playClip() に集約し、muteallowOverlap のロジックを一箇所で管理しています。
  • 防御的なログ出力
    • AudioClip 未設定時に警告ログを出すことで、「音が鳴らない理由」がすぐに分かるようにしています。
    • logFoundButtons を有効にすると、どのボタンが対象になっているかを一覧で確認できます。

使用手順と動作確認

1. スクリプトファイルの作成

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を UIGenericSound.ts にします。
  3. 自動生成された中身をすべて削除し、本記事の UIGenericSound のコードを貼り付けて保存します。

2. テスト用の UI シーンを用意する

  1. Hierarchy パネルで右クリック → Create → UI → Canvas を作成します(既にある場合はそのままでOK)。
  2. Canvas ノードを選択し、右クリック → Create → UI → Button をいくつか作成してみます。
    • 例: Button_Start, Button_Options, Button_Quit など。

3. UIGenericSound コンポーネントをアタッチ

  1. Hierarchy で Canvas ノードを選択します。
  2. Inspector の下部にある Add Component ボタンをクリックします。
  3. Custom → UIGenericSound を選択してアタッチします。

4. Inspector でプロパティを設定

Canvas ノードに追加された UIGenericSound の Inspector を確認し、以下のように設定します。

  1. Scan Root:
    • 空欄(null)のままで構いません。この場合、Canvas 自身がルートとなり、Canvas 配下のすべてのボタンが対象になります。
  2. Hover Clip:
    • UI のマウスオーバー用の効果音(例: ui_hover.wav)をドラッグ&ドロップで設定します。
  3. Click Clip:
    • クリック用の効果音(例: ui_click.wav)を設定します。
  4. Volume:
    • まずは 0.7〜1.0 程度に設定しておくと分かりやすいです。
  5. Mute:
    • 通常は オフ(false) にしておきます。UI サウンドを一時的に切りたい場合だけオンにします。
  6. Allow Overlap:
    • UI の連打で音が重なっても気にならない場合は オン(true)
    • 音が濁るのを避けたい場合は オフ(false) にして、前の音を止めてから新しい音を再生するようにします。
  7. Auto Scan On:
    • 基本的には Start を推奨します。シーン開始時にボタンを自動検出し、イベントを紐付けます。
    • ボタンをランタイムで生成し、生成後に自分で rescanButtons() を呼びたい場合は None にします。
  8. Log Found Buttons:
    • 動作確認中は オン(true) にして、Console に検出されたボタンの一覧が出るか確認すると便利です。
    • 問題なければ最終的には オフ(false) にしておくとログがすっきりします。

5. シーンを再生して動作確認

  1. 上部ツールバーの Play ボタンを押してシーンを再生します。
  2. Game ビュー上で、Canvas 配下のボタンにマウスカーソルを乗せてみます。
    • Hover 時に Hover Clip で設定した音が鳴ることを確認します。
  3. ボタンをクリックしてみます。
    • Click 時に Click Clip で設定した音が鳴ることを確認します。
  4. Console に [UIGenericSound] 検出したボタン数: ... といったログが出ていれば、ボタンスキャンも正常に動作しています。

6. ランタイムでボタンを追加する場合の使い方

もしゲーム中に動的にボタンを生成する場合は、生成後に UIGenericSoundrescanButtons() を呼び出すことで、新しいボタンにも自動でサウンドが紐付けられます。

例(別スクリプトから呼ぶ場合のイメージ):


// 例: Canvas に付いている UIGenericSound を取得して再スキャン
const canvas = director.getScene().getChildByName('Canvas');
if (canvas) {
    const uiSound = canvas.getComponent(UIGenericSound);
    uiSound?.rescanButtons();
}

※ 本記事のコンポーネント自体は他スクリプトに依存していません。
上記は「もし他スクリプトから呼び出すならこう書ける」という参考コードです。


まとめ

UIGenericSound コンポーネントを導入することで、以下のようなメリットがあります。

  • ボタン1つ1つにサウンド用スクリプトを付ける必要がない
    • Canvas などの上位ノードに本コンポーネントを 1 つアタッチし、音源を設定するだけで、配下の全ボタンに Hover/Click 音が自動で付与されます。
  • UI サウンドの一括調整が可能
    • 音量・ミュート・重なり具合などを 1 箇所の Inspector から変更できるため、調整コストが大幅に削減されます。
  • 外部依存なしでどのプロジェクトにも持ち込みやすい
    • GameManager やシングルトンに依存せず、この UIGenericSound.ts 単体で完結しているため、他のプロジェクトにもコピペで簡単に再利用できます。
  • 防御的な実装でトラブルを早期発見
    • AudioSource の自動追加、AudioClip 未設定時の警告ログ、ボタン一覧のログ出力などにより、設定ミスに気付きやすくなっています。

UI 周りの「Hover/Click 音をちゃんと全部に付ける」という作業は地味に手間がかかりますが、UIGenericSound を使えばその大部分を自動化できます。
まずはテストシーンで導入してみて、使い勝手を確認しつつ、自分のプロジェクトに合わせてプロパティや挙動をカスタマイズしてみてください。