はじめに
今まで、BGMをほぼつけないで実装してきたので、音楽再生用の sound.js ざっとでですが実装してみたのでそのメモです。
ゾーン毎の情報をすでに取得できるようになってるので、そこでBGMを切り替えるようにして見ました。
今回も動画を撮ってみたのでアップして見ます。
フィードチェンジ時にBGMが変わる他、バトルモード切替時や、終了後にもBGMを戻すようにしています。
音楽が付くと一気にゲームっぽくなりますね…。
サウンド素材は、過去、カードゲームや、ブロック崩しゲームの際にも使用している「Yuli Audio Craft」さんのサイトからお借りしています。
Yuli Audio Craft
フリーBGM・オリジナル楽曲制作
http://yacft.com/
動画(パソコン)
Zone切り替えBGMを自然につなぐサウンド設計(フェード対応)
フィールドを歩き、Zone が変わり、バトルに入る。 この一連の流れで BGM が「ブツッ」と切り替わる違和感に一度は当たる。
最初は動いていた。 でも、完成に近づくほど「音だけがゲームっぽくない」状態になる。
この記事は、 BGM 切り替えの違和感を「設計の見直し」で解決した記録。
1. 最初は BGM を直接切り替えた
最初の実装は単純だった。 Zone が変わったら、その Zone 用の BGM を再生する。
function onZoneChanged(zone) {
playBgm(zone, config.sound);
}
衝突判定の中で Zone を更新し、そのまま BGM を切り替える。
if (config.player.currentZone !== hit.zone) {
config.player.currentZone = hit.zone;
onZoneChanged(hit.zone);
}
これで「動く」。 フィールドごとに音は変わるし、バトルに入れば戦闘BGMも鳴る。
2. Zone 境界で違和感が出た
しばらく遊んでいると、はっきり分かる違和感が出てきた。
- Zone 境界を跨いだ瞬間に 音がパッと切れる
- 境界を行き来すると BGM が連打される
- プレイ感が急に軽くなる
原因は単純で、 **音の切り替えが「ロジック上は正しいが、体験として雑」**だった。
3. 原因は「責務の位置」だった
最初はフェード処理を足そうとした。 でも、どこに書くかで手が止まった。
- Player?
- Zone 管理?
- Battle クラス?
ここで気づいた。
問題はフェードが無いことじゃない BGM を「どこが管理しているか」が壊れている
Zone 判定や Battle ロジックが 「どの音を、どう切り替えるか」まで知っている。
これが違和感の正体だった。
4. sound.js に集約した
やったことはシンプル。
- 音の状態管理を sound.js に集約
- Player や Battle は「通知するだけ」
まず、JSON で音を定義する。
{
"map": {
"wild": {
"src": "./assets/sound/field_wild.mp3",
"loop": true,
"volume": 0.6
},
"town": {
"src": "./assets/sound/field_town.mp3",
"loop": true,
"volume": 0.6
}
},
"bgm": {
"battle01": {
"src": "./assets/sound/battle.mp3",
"loop": true,
"volume": 0.7
}
},
"se": {
"enter": {
"src": "./assets/sound/enter.mp3",
"volume": 0.9
}
}
}
次に、sound.js に責務を集める。
// ./js/sound.js
let bgmAudio = null;
let currentBgmKey = null;
export function playBgm(key, config) {
if (currentBgmKey === key) return;
const data = config.map?.[key] ?? config.bgm?.[key];
if (!data) return;
if (!bgmAudio) {
bgmAudio = new Audio();
} else {
bgmAudio.pause();
}
bgmAudio.src = data.src;
bgmAudio.loop = !!data.loop;
bgmAudio.volume = data.volume ?? 1.0;
bgmAudio.currentTime = 0;
bgmAudio.play();
currentBgmKey = key;
}
export function playSe(key, config) {
const data = config.se?.[key];
if (!data) return;
const se = new Audio(data.src);
se.volume = data.volume ?? 1.0;
se.play();
}
この時点で、
- BGM は1本だけ
- SE は都度生成
- 多重再生しない
という 安定した土台ができた。
5. フェードを足したら全部解決した
違和感の最後の一押しが「フェード」。
ここで重要なのは、 フェードを Player や Zone 側に書かないこと。
sound.js に閉じる。
function fadeOut(audio, duration = 800) {
return new Promise((resolve) => {
const startVolume = audio.volume;
const startTime = performance.now();
function tick(now) {
const t = Math.min((now - startTime) / duration, 1);
audio.volume = startVolume * (1 - t);
if (t < 1) {
requestAnimationFrame(tick);
} else {
audio.pause();
audio.volume = startVolume;
resolve();
}
}
tick(startTime);
});
}
function fadeIn(audio, targetVolume, duration = 800) {
audio.volume = 0;
const startTime = performance.now();
function tick(now) {
const t = Math.min((now - startTime) / duration, 1);
audio.volume = targetVolume * t;
if (t < 1) {
requestAnimationFrame(tick);
}
}
tick(startTime);
}
切り替え処理はこうなる。
export async function changeBgmWithFade(key, config) {
if (currentBgmKey === key) return;
const data = config.map?.[key] ?? config.bgm?.[key];
if (!data) return;
if (bgmAudio) {
await fadeOut(bgmAudio);
} else {
bgmAudio = new Audio();
}
bgmAudio.src = data.src;
bgmAudio.loop = !!data.loop;
bgmAudio.currentTime = 0;
bgmAudio.play();
fadeIn(bgmAudio, data.volume ?? 1.0);
currentBgmKey = key;
}
Zone 側はこうなる。
function onZoneChanged(zone) {
changeBgmWithFade(zone, config.sound);
}
これで、
- Zone 境界でも違和感が出ない
- 行き来しても音が暴れない
- バトル → フィールド復帰も自然
音が一気に「ゲームの音」になる。
6. この設計が他でも使える理由
この設計は、
- Three.js 依存なし
- WebXR 依存なし
- Canvas / DOM でも同じ
必要なのは、
- 状態(Zone / Battle)
- 音の意味(wild / town / battle)
- 音の管理者(sound.js)
ロジックと音を分離しただけ。
だから、
- フェード時間を変えたくなったら sound.js だけ触る
- 音量調整を JSON に足せる
- ミュートやマスターボリュームも自然に足せる
完成後に手を入れても、壊れない。
おわりに
BGM の違和感は 「音の問題」じゃなく「設計の問題」だった。
音が気にならなくなった瞬間、 やっとゲーム全体が落ち着いた。
同じところで引っかかる人の近道になれば幸い。
💬 コメント