[JavaScript] Zone切り替えBGMを自然につなぐサウンド設計(フェード対応)

はじめに

今まで、BGMをほぼつけないで実装してきたので、音楽再生用の sound.js ざっとでですが実装してみたのでそのメモです。

ゾーン毎の情報をすでに取得できるようになってるので、そこでBGMを切り替えるようにして見ました。

今回も動画を撮ってみたのでアップして見ます。

フィードチェンジ時にBGMが変わる他、バトルモード切替時や、終了後にもBGMを戻すようにしています。

音楽が付くと一気にゲームっぽくなりますね…。

サウンド素材は、過去、カードゲームや、ブロック崩しゲームの際にも使用している「Yuli Audio Craft」さんのサイトからお借りしています。

動画(パソコン)

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 の違和感は 「音の問題」じゃなく「設計の問題」だった。

音が気にならなくなった瞬間、 やっとゲーム全体が落ち着いた。

同じところで引っかかる人の近道になれば幸い。