【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()を呼んで再生を開始します。
- 戦闘状態に入る際に、レイヤーAudioSourceが停止している場合は
- _checkStopLayersIfSilent()
- フェードアウト完了後、レイヤー音量がほぼ0であれば
stop()を呼び出し、不要な無音ループを止めます。
- フェードアウト完了後、レイヤー音量がほぼ0であれば
使用手順と動作確認
1. TypeScript スクリプトを作成する
- エディタ下部の Assets パネルで、BGM用スクリプトを置きたいフォルダ(例:
assets/scripts)を選択します。 - フォルダ上で右クリック → Create → TypeScript を選択します。
- 新規スクリプト名を DynamicMusic.ts に変更します。
- 作成された
DynamicMusic.tsをダブルクリックして開き、既存のコードをすべて削除して、本記事のDynamicMusicのコードを貼り付けて保存します。
2. BGM用ノードと AudioSource を用意する
DynamicMusic は、1つのノードに「ベースBGM用 AudioSource」と「レイヤー用 AudioSource」をアタッチして使う想定です。
- Hierarchy パネルで右クリック → Create → Empty Node を選択し、ノード名を BGMRoot などに変更します。
- BGMRoot ノードを選択し、Inspector の Add Component ボタンをクリックします。
- Audio → AudioSource を選択して追加します。
- これがベースBGM用の AudioSource になります。
Clipに通常BGMの AudioClip(例:bgm_base)を設定します。Loopは ON にしておくと便利ですが、DynamicMusic 側のloopAllSourcesを true にしておけば自動でONになります。
- 同じ BGMRoot ノードに、レイヤー用の AudioSource を必要な数だけ追加します。
- 再度 Add Component → Audio → AudioSource を選択し、2つ目の AudioSource を追加します。
- 2つ目の AudioSource の
Clipに「戦闘用ドラムトラック」の AudioClip を設定します。 - さらにギタートラックなどがあれば、同様に3つ目、4つ目の AudioSource を追加し、それぞれに別のトラックを設定します。
- 音量(Volume)は、とりあえず 1.0 のままで構いません。DynamicMusic 側で調整します。
3. DynamicMusic コンポーネントをアタッチする
- BGMRoot ノードを選択します。
- Inspector の Add Component → Custom → DynamicMusic を選択します。
- 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 のメソッドを呼び出してみます。
- Assets パネルで右クリック → Create → TypeScript を選択し、TestBattleToggle.ts という名前で作成します。
- 以下のような簡単なテストコードを貼り付けます(完全に任意・サンプル用)。
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);
}
}
}
- Hierarchy で右クリック → Create → Empty Node を作成し、BattleTester と名付けます。
- BattleTester ノードを選択し、Add Component → Custom → TestBattleToggle を追加します。
- BattleTester の Inspector で、Dynamic Music プロパティに先ほど作成した BGMRoot ノード上の DynamicMusic コンポーネントをドラッグ&ドロップで設定します。
- シーンを保存し、プレビュー再生します。
- ゲームウィンドウが表示されたら、キーボードの 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演出を簡単に追加・調整でき、ゲーム全体の演出力と開発効率が大きく向上します。
