【Cocos Creator 3.8】DynamicMusic(BGMレイヤー)の実装:アタッチするだけで「通常BGMに戦闘用トラックを重ねる」ダイナミックBGMを実現する汎用スクリプト

本記事では、1つのノードにアタッチするだけで「通常シーンではベースBGMだけ」「戦闘中はドラム・ギターなどのトラックを音量フェードインして重ねる」ダイナミックBGM演出を実現する汎用コンポーネント DynamicMusic を実装します。

戦闘シーンやボス戦だけでなく、「緊張状態」「タイムリミット」「HPが少ないとき」など、ゲーム内状態に応じてBGMのレイヤー数を変える用途にもそのまま使える構成です。外部のGameManagerなどに依存せず、このスクリプト単体を任意のノードにアタッチして、インスペクタからAudioSourceを設定するだけで動作します。


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

機能要件の整理

  • ベースとなるBGM(例: 通常時のループBGM)を常に再生する。
  • 戦闘状態などの「緊張状態」になったら、追加トラック(ドラム・ギターなど)をフェードインして重ねる。
  • 戦闘状態が解除されたら、追加トラックをフェードアウトし、ベースBGMのみの状態に戻す。
  • フェード時間・音量などをインスペクタから調整可能にする。
  • 外部のスクリプトに依存せず、DynamicMusic コンポーネント単体で完結させる。
  • ゲーム側からは、「戦闘開始」「戦闘終了」を簡単に呼び出せるように enterBattle() / exitBattle() などの公開メソッドを用意する。
  • 必要な AudioSource が足りない場合は、エラーログを出して防御的に動作する。

外部依存をなくすためのアプローチ

  • ゲーム全体の状態管理(戦闘中かどうか)は、このコンポーネントの外で管理される前提とし、「状態変更の通知」だけを受ける形にする。
  • 通知は、他のスクリプトから getComponent(DynamicMusic) で取得して enterBattle() / exitBattle() を呼び出すだけにする。
  • 内部では、「現在は通常状態か、戦闘状態か」を自前で保持し、Updateでフェード処理を行う。
  • AudioSource の参照はすべてインスペクタの @property 経由で設定する。

インスペクタで設定可能なプロパティ設計

DynamicMusic コンポーネントに用意するプロパティと役割は以下の通りです。

  • baseBgmSource: AudioSource
    ベースとなる通常BGM用の AudioSource。常に再生され、戦闘時も鳴り続ける。
  • layerSources: AudioSource[]
    戦闘時に重ねる追加トラック(ドラム、ギター、シンセなど)の AudioSource 配列。配列の長さは任意。
  • baseBgmVolume: number
    ベースBGMの標準音量(0.0〜1.0)。ゲーム開始時や状態遷移時に、この値を基準に設定される。
  • layerMaxVolume: number
    追加トラックの最大音量(0.0〜1.0)。戦闘時にフェードインして到達する目標音量。
  • fadeDuration: number
    通常状態⇔戦闘状態の切り替えにかけるフェード時間(秒)。0.5〜3.0秒程度が扱いやすい。
  • playOnLoad: boolean
    true の場合、コンポーネントがロードされたタイミングでベースBGMを自動再生する。
  • startAsBattle: boolean
    true の場合、シーン開始時から「戦闘状態」とみなし、追加トラックをフェードインさせる。
  • loopAllSources: boolean
    true の場合、ベースBGMとすべてのレイヤーAudioSourceの loop を true に設定する。
  • logWarnings: boolean
    true の場合、設定不足(AudioSource 未設定など)の際に console.warn を出力する。

公開メソッド(他スクリプトから呼び出せるAPI):

  • enterBattle(): void
    戦闘状態に移行し、追加トラックをフェードインする。
  • exitBattle(): void
    通常状態に戻し、追加トラックをフェードアウトする。
  • setBattleState(isBattle: boolean): void
    true で戦闘状態、false で通常状態。内部的には enterBattle / exitBattle を呼ぶ。

TypeScriptコードの実装

以下が完成版の DynamicMusic コンポーネントです。


import { _decorator, Component, AudioSource, clamp01, warn } from 'cc';
const { ccclass, property } = _decorator;

/**
 * DynamicMusic
 * - ベースBGM + 複数レイヤーのトラックを状態に応じてフェードイン/アウトするコンポーネント
 * - 他スクリプトに依存せず、このコンポーネント単体で動作
 */
@ccclass('DynamicMusic')
export class DynamicMusic extends Component {

    @property({
        type: AudioSource,
        tooltip: 'ベースとなるBGM用の AudioSource。\n通常時も戦闘時も常に再生されます。'
    })
    public baseBgmSource: AudioSource | null = null;

    @property({
        type: [AudioSource],
        tooltip: '戦闘時などに重ねる追加トラックの AudioSource 配列。\nドラム・ギターなどをここに登録します。'
    })
    public layerSources: AudioSource[] = [];

    @property({
        tooltip: 'ベースBGMの標準音量(0.0〜1.0)。\nシーン開始時や状態遷移時にこの値を基準に設定されます。',
        min: 0.0,
        max: 1.0,
        slide: true
    })
    public baseBgmVolume: number = 0.8;

    @property({
        tooltip: 'レイヤートラックの最大音量(0.0〜1.0)。\n戦闘状態でフェードインして到達する音量です。',
        min: 0.0,
        max: 1.0,
        slide: true
    })
    public layerMaxVolume: number = 0.8;

    @property({
        tooltip: '通常状態 <-> 戦闘状態の切り替えにかけるフェード時間(秒)。',
        min: 0.0
    })
    public fadeDuration: number = 1.0;

    @property({
        tooltip: 'true の場合、コンポーネントのロード時にベースBGMを自動再生します。'
    })
    public playOnLoad: boolean = true;

    @property({
        tooltip: 'true の場合、シーン開始時から戦闘状態として扱い、レイヤーをフェードインさせます。'
    })
    public startAsBattle: boolean = false;

    @property({
        tooltip: 'true の場合、ベースBGMとレイヤーの AudioSource の loop を自動的に true に設定します。'
    })
    public loopAllSources: boolean = true;

    @property({
        tooltip: 'true の場合、設定不足(AudioSource 未設定など)の際に警告ログを出します。'
    })
    public logWarnings: boolean = true;

    // 内部状態
    private _isBattle: boolean = false;          // 現在が戦闘状態かどうか
    private _targetLayerVolume: number = 0.0;    // レイヤーの目標音量(0 or layerMaxVolume)
    private _currentLayerVolume: number = 0.0;   // レイヤーの現在音量
    private _fadeElapsed: number = 0.0;          // フェード経過時間
    private _isFading: boolean = false;          // 現在フェード中かどうか

    onLoad() {
        // ベースBGMの存在チェック
        if (!this.baseBgmSource) {
            if (this.logWarnings) {
                warn('[DynamicMusic] baseBgmSource が設定されていません。このコンポーネントは正常に動作しません。');
            }
        }

        // レイヤーの存在チェック
        if (!this.layerSources || this.layerSources.length === 0) {
            if (this.logWarnings) {
                warn('[DynamicMusic] layerSources が空です。戦闘状態にしても重ねるトラックがありません。');
            }
        }

        // ループ設定
        if (this.loopAllSources) {
            if (this.baseBgmSource) {
                this.baseBgmSource.loop = true;
            }
            for (const src of this.layerSources) {
                if (src) {
                    src.loop = true;
                }
            }
        }

        // ベースBGMの初期音量
        if (this.baseBgmSource) {
            this.baseBgmSource.volume = clamp01(this.baseBgmVolume);
        }

        // レイヤーの初期音量は 0 にしておく(通常状態スタート想定)
        for (const src of this.layerSources) {
            if (src) {
                src.volume = 0.0;
            }
        }

        // 戦闘状態で開始するかどうか
        this._isBattle = this.startAsBattle;
        this._currentLayerVolume = this.startAsBattle ? this.layerMaxVolume : 0.0;
        this._targetLayerVolume = this._currentLayerVolume;
        this._fadeElapsed = 0.0;
        this._isFading = false;
    }

    start() {
        // ベースBGMの自動再生
        if (this.playOnLoad && this.baseBgmSource) {
            if (!this.baseBgmSource.playing) {
                this.baseBgmSource.play();
            }
        }

        // 戦闘状態で開始する場合は、レイヤーも再生しておく
        if (this.startAsBattle) {
            this._ensureLayerPlaying();
            this._applyLayerVolumeImmediate();
        }
    }

    update(deltaTime: number) {
        // フェード処理
        if (!this._isFading) {
            return;
        }

        if (this.fadeDuration <= 0) {
            // フェード時間が0以下なら即時反映
            this._currentLayerVolume = this._targetLayerVolume;
            this._applyLayerVolumeImmediate();
            this._isFading = false;
            this._checkStopLayersIfSilent();
            return;
        }

        this._fadeElapsed += deltaTime;
        const t = clamp01(this._fadeElapsed / this.fadeDuration);

        // 線形補間で音量を更新
        const startVolume = this._isBattle ? 0.0 : this.layerMaxVolume;
        const endVolume = this._targetLayerVolume;
        this._currentLayerVolume = startVolume + (endVolume - startVolume) * t;

        this._applyLayerVolumeImmediate();

        if (t >= 1.0) {
            this._isFading = false;
            this._checkStopLayersIfSilent();
        }
    }

    /**
     * 戦闘状態に入る(レイヤートラックをフェードイン)
     */
    public enterBattle(): void {
        if (this._isBattle) {
            return; // すでに戦闘状態
        }
        this._isBattle = true;
        this._targetLayerVolume = clamp01(this.layerMaxVolume);
        this._fadeElapsed = 0.0;
        this._isFading = true;

        // レイヤーを再生開始
        this._ensureLayerPlaying();
    }

    /**
     * 戦闘状態から通常状態に戻る(レイヤートラックをフェードアウト)
     */
    public exitBattle(): void {
        if (!this._isBattle) {
            return; // すでに通常状態
        }
        this._isBattle = false;
        this._targetLayerVolume = 0.0;
        this._fadeElapsed = 0.0;
        this._isFading = true;
    }

    /**
     * 戦闘状態を直接指定するユーティリティ。
     * true: 戦闘状態 / false: 通常状態
     */
    public setBattleState(isBattle: boolean): void {
        if (isBattle) {
            this.enterBattle();
        } else {
            this.exitBattle();
        }
    }

    /**
     * レイヤーAudioSourceを必要に応じて再生開始する
     */
    private _ensureLayerPlaying(): void {
        for (const src of this.layerSources) {
            if (!src) {
                continue;
            }
            if (!src.playing) {
                src.play();
            }
        }
    }

    /**
     * 現在の _currentLayerVolume を全レイヤーに即時反映する
     */
    private _applyLayerVolumeImmediate(): void {
        const vol = clamp01(this._currentLayerVolume);
        for (const src of this.layerSources) {
            if (src) {
                src.volume = vol;
            }
        }
    }

    /**
     * レイヤー音量が 0 になったら、必要に応じて停止する
     * (停止しないと無音でループし続けるため)
     */
    private _checkStopLayersIfSilent(): void {
        if (this._currentLayerVolume > 0.0001) {
            return;
        }
        for (const src of this.layerSources) {
            if (src && src.playing) {
                src.stop();
            }
        }
    }
}

コードの主要ポイント解説

  • onLoad()
    • インスペクタで設定された baseBgmSource / layerSources の存在をチェックし、不足していれば警告ログを出します。
    • loopAllSources が true の場合、ベースBGMとレイヤーの loop を true に設定します。
    • ベースBGMの音量を baseBgmVolume に設定し、レイヤーは 0 に初期化します。
    • startAsBattle に応じて内部状態(_isBattle, _currentLayerVolume など)を初期化します。
  • start()
    • playOnLoad が true で baseBgmSource が設定されていれば、自動でベースBGMを再生します。
    • startAsBattle が true の場合、レイヤーも再生し、音量を即時反映させます。
  • update(deltaTime)
    • _isFading が true のときのみ、フェード処理を行います。
    • fadeDuration が 0 以下なら、フェードせずに即時に _targetLayerVolume に設定します。
    • 線形補間で _currentLayerVolume を更新し、_applyLayerVolumeImmediate() で全レイヤーに反映します。
    • フェード完了後、音量が 0 なら _checkStopLayersIfSilent() でレイヤーを停止します。
  • enterBattle() / exitBattle()
    • enterBattle() は、戦闘状態へ遷移し、レイヤーの目標音量を layerMaxVolume に設定してフェードを開始します。
    • exitBattle() は、通常状態へ戻し、レイヤーの目標音量を 0 に設定してフェードアウトします。
    • どちらも、すでにその状態であれば何もしません(多重呼び出しに対する防御)。
  • _ensureLayerPlaying()
    • 戦闘状態に入る際に、レイヤーAudioSourceが停止している場合は play() を呼んで再生を開始します。
  • _checkStopLayersIfSilent()
    • フェードアウト完了後、レイヤー音量がほぼ0であれば stop() を呼び出し、不要な無音ループを止めます。

使用手順と動作確認

1. TypeScript スクリプトを作成する

  1. エディタ下部の Assets パネルで、BGM用スクリプトを置きたいフォルダ(例: assets/scripts)を選択します。
  2. フォルダ上で右クリック → CreateTypeScript を選択します。
  3. 新規スクリプト名を DynamicMusic.ts に変更します。
  4. 作成された DynamicMusic.ts をダブルクリックして開き、既存のコードをすべて削除して、本記事の DynamicMusic のコードを貼り付けて保存します。

2. BGM用ノードと AudioSource を用意する

DynamicMusic は、1つのノードに「ベースBGM用 AudioSource」と「レイヤー用 AudioSource」をアタッチして使う想定です。

  1. Hierarchy パネルで右クリック → CreateEmpty Node を選択し、ノード名を BGMRoot などに変更します。
  2. BGMRoot ノードを選択し、InspectorAdd Component ボタンをクリックします。
  3. AudioAudioSource を選択して追加します。
    • これがベースBGM用の AudioSource になります。
    • Clip に通常BGMの AudioClip(例: bgm_base)を設定します。
    • Loop は ON にしておくと便利ですが、DynamicMusic 側の loopAllSources を true にしておけば自動でONになります。
  4. 同じ BGMRoot ノードに、レイヤー用の AudioSource を必要な数だけ追加します。
    • 再度 Add ComponentAudioAudioSource を選択し、2つ目の AudioSource を追加します。
    • 2つ目の AudioSource の Clip に「戦闘用ドラムトラック」の AudioClip を設定します。
    • さらにギタートラックなどがあれば、同様に3つ目、4つ目の AudioSource を追加し、それぞれに別のトラックを設定します。
    • 音量(Volume)は、とりあえず 1.0 のままで構いません。DynamicMusic 側で調整します。

3. DynamicMusic コンポーネントをアタッチする

  1. BGMRoot ノードを選択します。
  2. InspectorAdd ComponentCustomDynamicMusic を選択します。
  3. DynamicMusic コンポーネントのプロパティを設定します。
    • Base Bgm Source: 先ほどベースBGM用に追加した AudioSource をドラッグ&ドロップで設定します。
    • Layer Sources:
      • 右側の「+」ボタンをクリックして要素を追加します。
      • 要素0 に ドラムトラック用 AudioSource をドラッグ&ドロップ。
      • 要素1 に ギタートラック用 AudioSource をドラッグ&ドロップ。
      • …必要な数だけ追加します。
    • Base Bgm Volume: 0.7〜0.9 くらいを目安に設定します(例: 0.8)。
    • Layer Max Volume: 0.5〜0.9 くらいでお好みのバランスに(例: 0.7)。
    • Fade Duration: とりあえず 1.0 秒程度から試すとわかりやすいです。
    • Play On Load: シーン開始と同時にBGMを流したい場合は ON。
    • Start As Battle: シーン開始時から戦闘状態にしたい場合のみ ON。通常は OFF。
    • Loop All Sources: BGMとレイヤーをループさせたい場合は ON(通常は ON 推奨)。
    • Log Warnings: 設定ミスに気づきやすくするために、開発中は ON を推奨。

4. 戦闘状態の切り替えをテストする

エディタ上で手軽に動作確認するため、簡単なテストスクリプトから DynamicMusic のメソッドを呼び出してみます。

  1. Assets パネルで右クリック → CreateTypeScript を選択し、TestBattleToggle.ts という名前で作成します。
  2. 以下のような簡単なテストコードを貼り付けます(完全に任意・サンプル用)。

import { _decorator, Component, Node, input, Input, KeyCode, EventKeyboard } from 'cc';
import { DynamicMusic } from './DynamicMusic';
const { ccclass, property } = _decorator;

@ccclass('TestBattleToggle')
export class TestBattleToggle extends Component {

    @property({ type: DynamicMusic, tooltip: 'テスト対象の DynamicMusic コンポーネント' })
    public dynamicMusic: DynamicMusic | null = null;

    private _isBattle = false;

    onLoad() {
        input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    }

    onDestroy() {
        input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    }

    private onKeyDown(event: EventKeyboard) {
        if (!this.dynamicMusic) {
            return;
        }

        if (event.keyCode === KeyCode.SPACE) {
            this._isBattle = !this._isBattle;
            this.dynamicMusic.setBattleState(this._isBattle);
        }
    }
}
  1. Hierarchy で右クリック → CreateEmpty Node を作成し、BattleTester と名付けます。
  2. BattleTester ノードを選択し、Add ComponentCustomTestBattleToggle を追加します。
  3. BattleTester の Inspector で、Dynamic Music プロパティに先ほど作成した BGMRoot ノード上の DynamicMusic コンポーネントをドラッグ&ドロップで設定します。
  4. シーンを保存し、プレビュー再生します。
  5. ゲームウィンドウが表示されたら、キーボードの Spaceキー を押してみます。
    • 1回目の Space: 戦闘状態に入り、レイヤートラックがフェードインしてBGMが厚くなります。
    • 2回目の Space: 通常状態に戻り、レイヤートラックがフェードアウトしてベースBGMだけになります。
    • 連打しても、フェード処理は状態に応じて安全に制御されます。

実際のゲームでは、プレイヤーが敵に接触したタイミングや、ボス戦シーンへの遷移時などに、任意のスクリプトから以下のように呼び出すだけでOKです。


// 例: あるスクリプトから戦闘開始を通知する
const dynamicMusic = this.node.scene.getChildByName('BGMRoot')?.getComponent(DynamicMusic);
if (dynamicMusic) {
    dynamicMusic.enterBattle();
}

まとめ

  • DynamicMusic コンポーネントは、1つのノードにアタッチしてインスペクタから AudioSource を設定するだけで、
    「通常時はベースBGMのみ」「戦闘時はドラム・ギターなどのトラックをフェードインして重ねる」ダイナミックBGM演出を実現します。
  • 外部の GameManager などには一切依存せず、状態変更のトリガーとして enterBattle() / exitBattle() / setBattleState() を呼び出すだけのシンプルなAPIになっています。
  • ベース音量・レイヤー最大音量・フェード時間・ループ設定などはすべてインスペクタから調整できるため、サウンドデザイナーやレベルデザイナーがコードを触らずにバランス調整できます。
  • 応用例として:
    • 「残り時間が少なくなったら、シンセパッドを重ねて緊張感を出す」
    • 「ボスのHPが半分を切ったら、ギターソロトラックを追加する」
    • 「ステージ進行度に応じてレイヤー数を増やして盛り上げる」

    など、さまざまな演出にそのまま使い回せます。

この DynamicMusic コンポーネントをプロジェクトの標準BGM制御として組み込んでおけば、シーンごとにBGM演出を簡単に追加・調整でき、ゲーム全体の演出力と開発効率が大きく向上します。