見出し画像

【Unity】ゲームに音を入れる(AudioMixer・足音・3D定位・ミニマップ)


第1章 はじめに

主旨

Unity入門書は読み終えたけれど、その先の技術書がない!
そんな方のための「初級から中級への架け橋」を目指す連載記事です。
3Dアクションゲームを開発して行きます🎮

  1. ✅ [公開済み] 第1弾:『人型モデルを動かす(Blend Tree・CharacterController・Input Systemで作るTPS移動の基礎)』

  2. ✅ [公開済み] 第2弾:『バージョンアップ完全ガイド(移行手順・エラー対応・容量節約まで)』

  3. ✅ [公開済み] 第3弾:『ProBuilderで遺跡をブロックアウトする(中空トンネル・T字路・広間をメニュー操作だけで作る)』

  4. ✅ [公開済み] 第4弾:『遺跡に光と霧を灯す(暗所ライティング・ランタン・コルーチン・フォグ・環境音で作る探索の空気感)』

  5. ✅ [公開済み] 第5弾:『一人称視点と「調べる」を実装する(自作Input Actions・TPS⇄FPS切替・Raycastインタラクション)』

  6. ✅ [公開済み] 第6弾:『TextMeshProで日本語を表示する(フォントアセット作成・Localization多言語化・名前入力保存)』

  7. ✅ [公開済み] 第7弾:『データで仕掛けを量産する — ScriptableObjectカタログと抽選機(心臓部) 』

  8. ✅ [公開済み] 第8弾:『迷いの回廊を作る(ループ迷路・シームレステレポート・導きの精霊)』

  9. ✅ [公開済み] 第9弾:『番人ゴーレムを実装する(NavMesh巡回・視界判定・ランタンステルス)』

  10. ✅[公開済み]  第10弾(前編):『タイトル画面とセーブを実装する(Build Profiles・フェード遷移・ポーズ・ビットフラグ保存)』
    🆕 【今回の記事】 第10弾(後編):『AudioMixer・足音・3D定位・ミニマップ』
    ※ 第10弾は「前編」「後編」に分けます

  11. 📅 [執筆予定] 第11弾:『演出と結末 — コルーチン演出・推理ボード・マルチエンディング』

  12. 📅 [執筆予定] 第12弾:『見た目を仕上げる — 3Dアセットの探し方・ライセンス・組み込み』

  13. 📅 [執筆予定] 第13弾:『仕上げと公開 — 調整・ビルド・itch.io公開』

連載は12弾の予定でしたが、公開前に「プリミティブの遺跡を本物の3Dアセットに差し替える」回を1本挟むことにしました。第12弾がその回、公開は第13弾になります。

なお、筆者は入門書を読み終えたばかりの初心者です。
自身の備忘録も兼ね、調べながら書いておりますため、何卒、あたたかい目で見守っていただければと思います🐣

みなさんこんにちは。連載「初級から中級への架け橋」シリーズ、今回で第10弾(後編)です

前編で予告した「エディタでは鳴るのに、itch.ioのブラウザ版では鳴らない音がある」という話は、公開の弾(第13弾)に持っていきます。 ブラウザ版の検証はビルドと一緒にやるほうが確実だからです。今回はエディタで音を鳴らしきるところまで。ただし、後編の中にも「ここは公開の弾で効いてくる」という伏線をいくつか置いてあります。

👩‍💻 こんな方に向けた内容です
・AudioMixerを一度ちゃんと触ってみたい方
・音量スライダーを作ったら「0.5でほぼ無音」になって困った方(dB変換の話です)
・足音をアニメーションに同期させたい方(AnimationEventの実践)
・敵の位置を音で知らせる「3D定位」を作りたい方
・RenderTextureでミニマップを作りたい方
・第1〜10弾(前編)から続けて作ってくださっている方

🛠️ 動作検証環境
・macOS Tahoe 26.5.1
・Unity 6.5(6000.5.5f1)+ URP 17.5.0
・Input System 1.20.0(第5弾で自作した GameControls + Generate C# Class 方式)
・Cinemachine 3.1.7 / UniVRM 0.131.2(VRM0.xのVRoidアバター model_test)
・ProBuilder 6.1.2 / Unity Localization 1.5.12 / AI Navigation 2.0.14
・音素材:Starter Assets 同梱SE(足音、着地)/ Pixabay(BGM、霧、番人の駆動音。一部AI生成)/ 効果音ラボ(番人の状態音)/ Kenney(UI、宝箱、計数音、CC0)
・エディタは日本語化(メニュー名は日本語表記+必要に応じて英語併記)

※ 本記事は第10弾の後編です。前編(シーン構成・ポーズ・セーブ)が済んでいる前提で進めます。


今回の到達点

  • BGM・効果音・環境音・UIの音がAudioMixerの4グループに整理され、スライダーで個別に音量を変えられる

  • 調整した音量が保存され、次回起動時にも引き継がれる

  • 第1弾から空っぽだった OnFootstep/OnLand がついに鳴る(歩き・走り・着地)

  • 番人ゴーレムの駆動音が3D定位で聞こえ、姿が見える前に「気配」で気づける

  • 第9弾で置いた📢(警戒・発見・視線スイッチ点灯)がすべて音になる

  • クリスタルを見つめている間、間隔が詰まっていく音でゲージが「聞こえる」

  • 画面右上にミニマップが灯り、自分の位置と向きが分かる

  • ポーズ中は世界の音が止まり、UIの操作音だけが鳴る


本記事の構成

本記事は「配線盤を作る → 窓口を作る → 音を入れる → 地図を灯す」という流れの、7章構成です。今回もコード量が多めですが、章ごとに必ず動く状態で区切ってあるので、1章ずつ動作を確かめながら進めてください。

配線盤を作る → 窓口を作る → 音を入れる → 地図を灯す

📋「第1章 はじめに」は除いてます

  • 第2章「音の設計図とAudioMixer」 まず"音の配線盤"を作ります。BGM・効果音・環境音・UIの4系統に分け、スライダーで個別に音量を変えられるところまで。「0.5にしたのにほぼ無音」というdBの罠もここで。

  • 第3章「AudioManager — 音の受付窓口をつくる」 音を鳴らしたい場所が一気に増えるので、受付窓口を1つ作ります。前編で覚えたDontDestroyOnLoadの2回目の実戦です。

  • 第4章「足音を回収する」 第1弾で「将来ここに足音を」と書いた空の箱を、9本ぶんの連載を経てついに埋めます。歩きと走りが混ざると足音が二重になる罠も。

  • 第5章「3D定位 — 番人の気配をつくる」 番人に駆動音を持たせます。姿が見える前に音で気づける——ステルスが一段フェアになります。

  • 第6章「📢の回収と計数音」 第9弾のコードに置いた「📢ここに音を差し込む」コメントを全部回収します。既存ロジックは1行も変えません。

  • 第7章「ミニマップ」 RenderTextureで俯瞰図を作ります。第3弾の区画Prefabが、ここでもう一度効きます。


第2章 音の設計図とAudioMixer

まず設計図を見る

いきなりAudioMixerを作る前に、この遺跡にどんな音が鳴るのかを整理します。

ゲーム内のサウンドを4系統に整理・ルーティングする設計図

なぜ分けるのかというと、理由は3つあります。

  1. プレイヤーが個別に音量を変えたい(BGMだけ下げたい、は定番の要望)

  2. ポーズ中にUIの音だけ鳴らしたい(前編で AudioListener.pause = true にしたので、今は全部止まります)

  3. あとで系統ごとに演出したい(第13弾で「世界の音だけこもらせる」実験をします)

この「系統ごとにまとめて、まとめて操作する」ための道具が AudioMixer(オーディオミキサー) です。音楽スタジオの卓上にある、つまみが並んだ機械を想像してもらえば近いです。各AudioSourceは「自分の音を、どのつまみに送るか」を決めるだけ。音量の上げ下げは、つまみ側でまとめてやります。


AudioMixerアセットを作る

Projectウィンドウの Assets に Audio フォルダがあることを確認します(第4弾で環境音を入れた場所です)。その中に BGM / SE / Ambient の3フォルダを作っておくと、素材が増えても迷いません


Audio フォルダ内で右クリック ▸ 作成 ▸ オーディオ(Audio) ▸ オーディオミキサー(Audio Mixer)。

名前を GameAudioMixer にします
GameAudioMixer をダブルクリックすると、Audio Mixer ウィンドウが開きます。

Unity 6ではProjectウィンドウの「作成」メニューが整理され、音関連は「オーディオ」の下にまとまっています(第7弾のScriptableObjectが「Scripting」の下にあったのと同じ整理です)。古い記事の「作成 ▸ Audio Mixer」直下、という表記はこの再編前のものです。


Audio Mixerウィンドウの見方

初めて開くと、見慣れないパネルが並んでいます。役割だけ先に押さえておきましょう。

  1. ミキサー
    ミキサーアセットの一覧。今は GameAudioMixer の1つだけです

  2. スナップショット
    パラメータ一式の「スナップ写真」。実行中に写真同士を滑らかに切り替えられます(第13弾で使います)。★印が付いているものが、起動時に適用される状態です

  3. グループ
    グループの階層。いまは Master だけ。ここに BGM や SE を足していきます

  4. ビュー
    表示の絞り込み。動作には影響しません(グループが増えたときに「BGMまわりだけ表示」といった使い方をします)

  5. Master のストリップ(縦帯)
    右側に立っている縦長のパネル。音量スライダー、音の大きさを示すメーター、S / M / B ボタン、Attenuation、「追加...」が縦に並んでいます。

  6. Exposed Parameters
    スクリプトから触れるようにした項目の一覧。


グループを4つ作る

Masterの子として BGM / SE / Ambient / UI を作る
  1. 「グループ」パネルで Master を選択した状態で、パネル右上の 「+」 を押します

  2. 新しいグループが Master の子として生まれ、名前が編集できる状態になっているので、BGM と入力して Enter

  3. 同じ手順を繰り返して、SE / Ambient / UI を作ります


既存のAudioSourceを配線し直す

ミキサーを作っただけでは、まだ誰もそこに音を送っていません。第4弾で作った環境音を、Ambient グループへつなぎます。

  1. ヒエラルキー(Mainシーン)で Ambient オブジェクトを選択します

  2. インスペクターの Audio Source にある 出力(Output) 欄の右の ⊙ を押し、Ambient (GameAudioMixer) を選びます

これだけです。以後この記事では「AudioSourceを作ったら、必ずどこかのグループへ出力する」を合言葉にします。出力欄が「なし(None)」のAudioSourceは、ミキサーを素通りして直接スピーカーへ行くため、この章で作るスライダーが一切効きません。音の迷子を作らない、が今回いちばん大事な習慣です。

■ AudioSourceの項目名(日本語エディタ) 
この記事では何度もAudioSourceを作ります。名前がまぎらわしいものだけ、先に対応を載せておきます。


露出パラメータ — スクリプトから音量を触れるようにする

グループの音量は、Audio Mixerウィンドウのスライダーで変えられます。でもプレイヤーは、ゲーム内のスライダーで変えたいはずです。そのためには、ミキサーの音量をスクリプトから触れる状態にする必要があります。これを「パラメータを露出する(Expose)」と言います。

  1. Audio Mixerウィンドウで BGM のストリップをクリックして選択します(左の「グループ」パネルで BGM をクリックしても同じです)

  2. インスペクターに BGM の詳細が出ます。Attenuation の項目にある Volume の上で右クリック

  3. Expose 'Volume (of BGM)' to script を選びます

Audio Mixerウィンドウ右上の Exposed Parametersをクリックすると、一覧に MyExposedParam のような仮の名前で登録されています。右クリック ▸ Rename で BGMVolume に改名します

同じ手順で SE → SEVolume、Ambient → AmbientVolume を作ります。

■『UI』 グループは露出しません。
 
UIの操作音は「ゲームの雰囲気」ではなく「操作できたという手応え」なので、プレイヤーが音量を変える対象にしません。ボタンを押した音が聞こえないと、押せたのか分からなくなるからです。市販のゲームでも、音量設定はたいていBGM・効果音・環境音(+ボイス)で、UI音は独立していないか、効果音に含まれます。本作は「ポーズ中も鳴らす」という別扱いが必要なのでグループだけ分け、音量は固定にしました。

⚠️ 露出したパラメータは、スナップショットの制御から外れます
 
公式の仕様で、露出した瞬間から、その値は AudioMixer.SetFloat でしか変えられなくなります。「音量はスクリプト(スライダー)が担当、それ以外はスナップショットが担当」という役割分担だと覚えておいてください。第13弾のスナップショットの実験は、この分担があるから成り立ちます。


音量スライダーUIを作る(ポーズメニューの中に)

音量の調整画面は、前編で作ったポーズメニュー(PausePanel)の中に置きます。「つづける」ボタンが Y:40 にあるので、その上の空間にスライダー3本を積みます。

Slider(スライダー)

上記、Slider(スライダー)の項目名となります。

① 見出し

  1. ヒエラルキー(Mainシーン)で PausePanel を右クリック ▸ UI (Canvas) ▸ テキスト - TextMeshPro。名前を VolumeTitle に

  2. 文字は「おと」、Font Asset に日本語フォント(NotoSansJP-Medium SDF)を割り当て(合言葉:忘れると豆腐)、Font Size 24、Alignment 中央揃え(横・縦)

  3. Rect Transform:アンカープリセット middle center、位置 X:0 / Y:290、幅 240/高さ 40

② スライダー(BGM)

  1. PausePanel を右クリック ▸ UI (Canvas) ▸ スライダー(Slider)。名前を BgmSlider に

  2. Rect Transform:アンカー middle center、位置 X:60 / Y:240、幅 240/高さ 20

  3. インスペクターの Slider コンポーネントを、下の4つだけ確認します(項目名は日本語と英語が混ざっています)

    • 向き(Direction):左から右 のまま

    • Min Value:0 / Max Value:1(どちらも既定のまま)

    • Value:1(つまみが右端=最大音量の状態から始めます)

    • 総数:オフのまま

  4. スライダーは3つの子で出来ています。色を変えたいときは次の場所です

    • Background:溝の色(Image の色)

    • Fill Area ▸ Fill:溝の「埋まっている側」の色

    • Handle Slide Area ▸ Handle:つまみの色

③ スライダーの左にラベル

  1. PausePanel を右クリック ▸ UI (Canvas) ▸ テキスト - TextMeshPro。名前 BgmLabel

  2. 文字「BGM」、日本語フォント、Font Size 18、Alignment 右揃え(横)+中央(縦)

  3. アンカー middle center、位置 X:-120 / Y:240、幅 120/高さ 20

④ 残り2本を複製で作る

  1. BgmSlider と BgmLabel の2つを選択して Cmd + D で複製。名前を SeSlider / SeLabel に、位置Yを 190 に、ラベルの文字を「効果音」に

  2. もう一度複製して AmbientSlider / AmbientLabel、位置Y 140、文字「環境音」に


レイアウト問題

数値はあくまで目安です。 これらは画面の中央(middle center)を基準にしたピクセル指定なので、ゲームビューの解像度が変わると見え方が変わります。 実際、筆者の環境では次のようになりました。

ゲームビューが縦に広いとき。「ポーズ」→「おと」→スライダー3本→ボタン3つが、きれいに縦に並んでいる

ゲームビューが横長(縦が狭い)とき。「ポーズ」の見出しがスライダーに重なってしまっている

基準が違うと、画面の伸縮で「寄って」しまう

なぜ重なるのか?前編で置いた PauseTitle(「ポーズ」の見出し)は top center=画面上端から120pxの位置にあります。一方、今回のスライダーは middle center=画面中央から上へ240pxの位置です。基準にしている場所が違うので、画面が縦に縮むと、上から下りてくる「ポーズ」と、中央から上がっていくスライダーが近づいて、いずれぶつかります。

対処は2つあります。

2つの対処法(①手軽 / ②確実)

① 値を詰める(手軽) VolumeTitle を Y:250、スライダーを 210 / 170 / 130 のように、全体を少し下げて間隔を詰めます。すぐ直りますが、「今の画面比率でたまたま収まる」だけなので、別の解像度でまた崩れることがあります。

② 基準を揃える(確実) PauseTitle のアンカーも middle center に変え、位置Yを 340 のような中央基準の値にします。全員が同じ基準になるので、画面が縦に縮んでも全体が同じ比率で寄るだけになり、並び順が崩れません。前編で作ったものに手を入れることになりますが、根本的にはこちらが正解です

📝 本連載は学習効率を優先して、いまは①の「値を少し調整するだけ」で先へ進みます。 UIのレイアウトを画面サイズに追従させる話(Canvas Scaler の設定など)は、第12弾の仕上げでまとめて扱う予定です。


「0.5でほぼ無音」の正体 — dBという単位

ここで、スクリプトを書く前にこの章の目玉つまずきを先に知っておきます。

ミキサーの音量は dB(デシベル) という単位で、範囲は -80 〜 +20。0 dB が「元の音量そのまま」、-80 dB が「実質無音」です。一方、いま作ったスライダーは 0 〜 1。

「じゃあ、スライダーの値を -80〜0 に引き伸ばして渡せばいい」と思って value * 80 - 80 のように書くと、0.5 でもほぼ無音になります。dBは足し算ではなく掛け算の世界(対数)だからです。-6 dB でだいたい半分、-20 dB で10分の1、-40 dB で100分の1。-40 dB の時点で、人間の耳にはもう「消えた」も同然なのです。

スライダーの値をミキサーにそのまま渡してはいけない理由と、正しい変換式

正しい変換はこれです。

float db = Mathf.Log10(value01) * 20f;   // 1 → 0dB、0.5 → 約-6dB、0.1 → -20dB

Mathf.Log10 は「10を何乗するとその値になるか」を返す関数で、これに20を掛けるとdBになります。理屈は音響工学の定義そのものなので、初学者は「音量スライダーは Log10 × 20」と丸暗記で構いません。

⚠️ ただし Mathf.Log10(0) は負の無限大になります。スライダーを左端(0)まで動かした瞬間に -Infinity がミキサーに入り、そのまま音が戻らなくなることがあります。渡す前に 0.0001 などの小さい値でクランプしてください(次のコードの ToDb がそれです)。


VolumeOptionsUI.cs — スライダーとミキサーをつなぐ

スライダー側のスクリプトです。ミキサーへの書き込みと保存は、次の第3章で作る AudioManager に任せる設計にしておきます(ここでは「窓口に頼む」だけ)。第3章を作るまではコンパイルが通りませんが、章の順序として先に置いておきます。

// ==========================================================
// 📄 スクリプト:VolumeOptionsUI.cs
// ==========================================================
using UnityEngine;
using UnityEngine.UI;

// ポーズメニューの音量スライダー3本を、AudioManager(第3章)へ中継する。
// アタッチ先は常時アクティブな「PauseMenu」オブジェクト(理由は前編第5章と同じ:
// 非アクティブなパネルに載せると Start が呼ばれない)。
public class VolumeOptionsUI : MonoBehaviour
{
    [SerializeField] private Slider bgmSlider;
    [SerializeField] private Slider seSlider;
    [SerializeField] private Slider ambientSlider;

    private void Start()
    {
        var am = AudioManager.Instance;
        if (am == null) return; // Main単体再生などで窓口が無くても落ちない保険

        // 保存されている音量をスライダーの位置に反映する。
        // SetValueWithoutNotify は「イベントを発火させずに値だけ入れる」。
        // 普通に .value = に入れると onValueChanged が飛んで保存処理が走ってしまう。
        bgmSlider.SetValueWithoutNotify(am.GetVolume(VolumeChannel.Bgm));
        seSlider.SetValueWithoutNotify(am.GetVolume(VolumeChannel.Se));
        ambientSlider.SetValueWithoutNotify(am.GetVolume(VolumeChannel.Ambient));

        // スライダーが動いたら窓口へ伝える(第6弾NoteUIと同じAddListener方式)
        bgmSlider.onValueChanged.AddListener(v => am.SetVolume(VolumeChannel.Bgm, v));
        seSlider.onValueChanged.AddListener(v => am.SetVolume(VolumeChannel.Se, v));
        ambientSlider.onValueChanged.AddListener(v => am.SetVolume(VolumeChannel.Ambient, v));
    }

    // ポーズを閉じるときに PauseMenu から呼ぶ。ここで1回だけディスクへ書く
    public void SaveNow()
    {
        AudioManager.Instance?.SaveVolumes();
    }
}

Assets/Scripts内に VolumeOptionsUI.cs を作成します。
※ 本章の段階ではエラーとなります(想定内です)

v => am.SetVolume(...) はラムダ式で、第8弾の dropdown.onValueChanged.AddListener(index => ...) と同じ書き方です。「スライダーの値 v を受け取って、窓口に渡す」を1行で書いています。

■ なぜ動かすたびに保存しないのか?

操作中は「メモリ」だけ更新

スライダーは1回のドラッグで何十回も onValueChanged が飛びます。そのたびに PlayerPrefs.Save() でディスクへ書くのは無駄なので、値の更新(メモリ)とディスクへの保存を分け、保存は「ポーズを閉じたとき」の1回にします。第6弾・前編で決めた「Save() は区切りだけ」という規律の続きです。


PauseMenu.cs を改修する

前編の PauseMenu.cs に、音量を保存する呼び出しを足します。追加は4箇所で、しかも離れた位置に散らばるので、差分ではなく改修後の全体を載せます。PauseMenu.cs の中身をまるごと置き換えてください。💡が今回の追加分です。

// ==========================================================
// 📄 スクリプト:PauseMenu.cs(改修後・全体)
// ==========================================================
using UnityEngine;
using UnityEngine.InputSystem;

// Escキーでゲームを一時停止する管理役。
// パネル自身ではなく、常時アクティブな空オブジェクト「PauseMenu」にアタッチします
// (非アクティブなGameObjectでは Start も Update も呼ばれないため/前編第5章)。
public class PauseMenu : MonoBehaviour
{
    [SerializeField] private GameObject panelRoot;              // PausePanel
    [SerializeField] private CameraModeSwitcher cameraSwitcher; // カーソル復帰用(第6弾の口)

    // 💡 追加:音量UIへの参照。ポーズを抜けるときに保存を頼む相手
    [SerializeField] private VolumeOptionsUI volumeOptions;

    private GameControls controls;
    private InputAction pauseAction;
    private bool isPaused;

    private void Awake()
    {
        controls = new GameControls();
        pauseAction = controls.Player.Pause; // 型安全に取得(第5弾方式)
        panelRoot.SetActive(false);          // 最初は閉じておく
    }

    private void OnEnable()  { controls.Player.Enable(); }
    private void OnDisable() { controls.Player.Disable(); }

    private void Update()
    {
        if (!pauseAction.WasPressedThisFrame()) return;

        // 刻文や名簿など「別のUI」が開いている間は、ポーズを重ねて開かない。
        // (isPaused中は自分がUiGateを閉めている張本人なので、例外的に通します)
        if (!isPaused && UiGate.IsOpen) return;

        if (isPaused) Close();
        else          Open();
    }

    private void Open()
    {
        isPaused = true;
        UiGate.IsOpen = true;          // 入力系を一括停止(第6弾の仕組み)
        Time.timeScale = 0f;           // 世界の時間を止める
        AudioListener.pause = true;    // 音もまとめて一時停止
        Cursor.lockState = CursorLockMode.None; // ボタンを押せるようカーソル解放
        Cursor.visible = true;
        panelRoot.SetActive(true);
    }

    private void Close()
    {
        panelRoot.SetActive(false);
        Time.timeScale = 1f;           // 時間を戻す
        AudioListener.pause = false;   // 音を戻す
        UiGate.IsOpen = false;         // ゲートを開ける
        if (cameraSwitcher != null)
            cameraSwitcher.ApplyCursorForCurrentMode(); // FPSならロック/TPSなら解放

        volumeOptions?.SaveNow();      // 💡 追加:閉じるときに音量をディスクへ書く

        isPaused = false;
    }

    // --- ボタンから呼ばれる3つ ---

    public void OnClickResume() => Close();

    public void OnClickToTitle()
    {
        volumeOptions?.SaveNow();      // 💡 追加:Close()を通らない経路なので、ここでも保存

        // フェーダーの「後片付け3点セット」(前編第3章)が、暗転の裏で
        // timeScale・音・UiGate を必ず正常に戻してくれるので、Close()は呼ばない
        ScreenFader.Instance.FadeAndLoad("Title");
    }

    public void OnClickQuit()
    {
        volumeOptions?.SaveNow();      // 💡 追加:終了前に確実に書き出す

#if UNITY_EDITOR
        // エディタでは Application.Quit が「無視される」公式仕様のため、再生停止で代替
        UnityEditor.EditorApplication.isPlaying = false;
#else
        Application.Quit(); // ビルドではアプリ終了(Webでは効きません)
#endif
    }
}

※ Assets/Scripts内の PauseMenu.cs を上書き保存します。

■ なぜ3つの出口すべてに書くのか?

閉じる時の「3つの出口」で保存

ポーズを抜ける経路が「つづける(Close)」「タイトルへ」「おわる」の3つあるからです。前編で ScreenFader に「後片付け3点セット」を置いたときは、すべての遷移が必ずフェーダーを通るので1箇所で済みました。今回の保存はフェーダーを通らない経路(Close・Quit)があるため、出口ごとに書く必要があります。「共通の関所があるものは1箇所、無いものは出口ごと」という使い分けです。

📝 volumeOptions?.SaveNow() の ?. は、参照が割り当てられていなくてもエラーで止まらないための保険です(第6弾から使い続けている書き方)。

アタッチと割り当ては第3章の末尾でまとめて行います(AudioManager ができてからでないと動かないためです)。


この章の状態

コンパイルはまだ通りません。これは想定内です。

第3章で AudioManager.cs を作った瞬間にエラーが消え、新しい欄が現れます。
  1. VolumeOptionsUI.cs
    エラーが出ているのは VolumeOptionsUI.cs の中身
    で、まだ存在しない AudioManager と VolumeChannel を参照しているためです(error CS0246)。第8弾でFogアクションを消したときと同じで、「まだ無いものを呼んでいる」とコンパイラが教えてくれている状態です。

  2. PauseMenu.cs
    PauseMenu.cs の改修が、新しいエラーを増やすことはありません。
    VolumeOptionsUI という型も SaveNow() というメソッドも、この章ですでに書いてあるからです。エラーの発生源は VolumeOptionsUI.cs の1本だけです。

コンパイルが通るまで、インスペクターに新しい欄は現れません。
Unityはコンパイルに失敗している間、前回成功したときの状態を使い続けます。そのため、PauseMenu に足した Volume Options 欄も、いまはインスペクターに出てきません。 「コードに書いたのに欄がない」と焦らなくて大丈夫です。第3章で AudioManager.cs を作った瞬間にエラーが消え、欄が現れます。参照の割り当ては、そのあとまとめて行います。


第3章 AudioManager — 音の受付窓口をつくる

なぜ窓口が要るのか

この記事の後半では、番人・宝箱・霧・視線スイッチ・UIボタン……と、音を鳴らしたい場所が一気に10箇所以上に増えます。それぞれのスクリプトが自分でAudioSourceを持つと、必ず「出力欄の割り当て漏れ」が起きます(=スライダーが効かない音が混ざる)。

各スクリプトにAudioSourceを持たせない

そこで、音を鳴らす窓口を1つにします。各スクリプトは AudioManager.Instance.PlaySe(clip) と頼むだけ。出力先の割り当ても、3Dの設定も、窓口が引き受けます。

作りは、前編の ScreenFader と同じ シングルトン+DontDestroyOnLoad です。BGMはシーンをまたいで鳴り続けてほしいので、生き残る必要があります。


「Starter Assetsの足音」をそのまま使わない理由

先に、次章の伏線を1つ。Starter Assetsに同梱されている足音の実装は、AudioSource.PlayClipAtPoint という関数を使っています。指定した座標で音を鳴らしてくれる便利な関数なのですが、内部で一時的なオブジェクト("One shot audio")を自動生成して鳴らすため、AudioMixerのグループへの出力を指定できません。 つまり、これで鳴らした音には第2章のスライダーが効きません。

PlayClipAtPoint の罠

第1弾で「Starter Assetsはアニメーションだけ借りて、仕組みは自分で組む」と決めましたが、その方針がここでも同じ理由で通ります。窓口には「指定した座標で、Mixerに乗せて鳴らす」メソッドを自作します(PlaySeAt)。


AudioManager.cs(新規)

// ==========================================================
// 📄 スクリプト:AudioManager.cs
// ==========================================================
using System.Collections;
using UnityEngine;
using UnityEngine.Audio; // AudioMixer / AudioMixerGroup / AudioMixerSnapshot

// 音量チャンネル(スライダーとPlayerPrefsのキーに対応する)
public enum VolumeChannel { Bgm, Se, Ambient }

// 音の受付窓口。BGM・効果音・UI音の再生と、音量の保存を一手に引き受ける。
// 作りは前編のScreenFaderと同じ「シングルトン+DontDestroyOnLoad」。
public class AudioManager : MonoBehaviour
{
    public static AudioManager Instance { get; private set; }

    [Header("ミキサー")]
    [SerializeField] private AudioMixer mixer;          // GameAudioMixer

    [Header("再生用のAudioSource(子オブジェクト)")]
    [SerializeField] private AudioSource bgmSource;     // 2D・ループ・出力BGM
    [SerializeField] private AudioSource seSource;      // 2D・出力SE(PlayOneShot用)
    [SerializeField] private AudioSource uiSource;      // 2D・出力UI(ポーズ中も鳴る)

    [Header("3D効果音の設定(PlaySeAt用)")]
    [SerializeField] private AudioMixerGroup seGroup;   // GameAudioMixer/SE
    [SerializeField] private float seMinDistance = 1f;
    [SerializeField] private float seMaxDistance = 15f;

    [Header("BGMフェード秒数")]
    [SerializeField] private float bgmFadeDuration = 0.8f;

    // BGMの上限音量。環境音と重ねるので、控えめにしておく(本文の解説を参照)
    [Header("BGMの上限音量(環境音と重なるので控えめに)")]
    [SerializeField, Range(0f, 1f)] private float bgmMaxVolume = 0.5f;

    [Header("スナップショット(第13弾の実験用)")]
    [SerializeField] private AudioMixerSnapshot normalSnapshot;
    [SerializeField] private AudioMixerSnapshot muffledSnapshot;
    [SerializeField] private float snapshotTransition = 0.6f;

    private Coroutine bgmRoutine; // 走行中のBGM切り替え(多重起動防止)

    private void Awake()
    {
        // シングルトンガード(前編と同じ)。Boot経由でMainに入るとMain側の2体目が自滅する
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        Instance = this;
        DontDestroyOnLoad(gameObject);

        // UI音は AudioListener.pause(前編のポーズ)を無視して鳴らす。
        // この設定はインスペクターに項目が無く、スクリプトからしか変えられない
        uiSource.ignoreListenerPause = true;
    }

    private void Start()
    {
        // ★ミキサーへの書き込みは Start 以降で行う(Awake / OnEnable で SetFloat を
        //   呼ぶと想定外の挙動になり得る、と公式リファレンスに注意書きがある)
        ApplySavedVolumes();
    }

    // ---------------------------------------------------------
    // 音量(スライダーの0〜1 ⇄ ミキサーのdB)
    // ---------------------------------------------------------

    // 0〜1 を -80〜0 dB に変換する(第2章「0.5でほぼ無音」の対策そのもの)
    public static float ToDb(float value01)
    {
        value01 = Mathf.Clamp(value01, 0.0001f, 1f); // Log10(0) = -∞ を防ぐ
        return Mathf.Clamp(Mathf.Log10(value01) * 20f, -80f, 0f);
    }

    // チャンネル → PlayerPrefs のキー名(switch式。第7弾で覚えた書き方)
    private static string PrefsKey(VolumeChannel ch) => ch switch
    {
        VolumeChannel.Bgm     => "BgmVolume",
        VolumeChannel.Se      => "SeVolume",
        _                     => "AmbientVolume",
    };

    // チャンネル → 第2章で露出したミキサーのパラメータ名(1文字でも違うと無言で効かない)
    private static string MixerParam(VolumeChannel ch) => ch switch
    {
        VolumeChannel.Bgm     => "BGMVolume",
        VolumeChannel.Se      => "SEVolume",
        _                     => "AmbientVolume",
    };

    // スライダーから呼ばれる。ミキサーへ即反映し、値はメモリ上に控える(ディスクにはまだ書かない)
    public void SetVolume(VolumeChannel ch, float value01)
    {
        mixer.SetFloat(MixerParam(ch), ToDb(value01));
        PlayerPrefs.SetFloat(PrefsKey(ch), value01);
    }

    // 保存されている音量(未保存なら1=最大)
    public float GetVolume(VolumeChannel ch) => PlayerPrefs.GetFloat(PrefsKey(ch), 1f);

    // 区切りのタイミングでディスクへ書く(ポーズを閉じたとき等)
    public void SaveVolumes() => PlayerPrefs.Save();

    private void ApplySavedVolumes()
    {
        mixer.SetFloat(MixerParam(VolumeChannel.Bgm),     ToDb(GetVolume(VolumeChannel.Bgm)));
        mixer.SetFloat(MixerParam(VolumeChannel.Se),      ToDb(GetVolume(VolumeChannel.Se)));
        mixer.SetFloat(MixerParam(VolumeChannel.Ambient), ToDb(GetVolume(VolumeChannel.Ambient)));
    }

    // ---------------------------------------------------------
    // BGM
    // ---------------------------------------------------------

    // 曲をリクエストする。同じ曲が鳴っていれば何もしない(シーンをまたいでも途切れない)
    public void PlayBgm(AudioClip clip)
    {
        if (clip == null) return;
        if (bgmSource.clip == clip && bgmSource.isPlaying) return;

        if (bgmRoutine != null) StopCoroutine(bgmRoutine); // 多重起動防止(第4弾の定石)
        bgmRoutine = StartCoroutine(SwitchBgmRoutine(clip));
    }

    private IEnumerator SwitchBgmRoutine(AudioClip next)
    {
        // ① 鳴っている曲をフェードアウト
        if (bgmSource.isPlaying)
            yield return FadeSource(bgmSource, bgmSource.volume, 0f, bgmFadeDuration);

        // ② 差し替えて再生
        bgmSource.clip = next;
        bgmSource.loop = true;
        bgmSource.Play();

        // ③ フェードイン(1ではなく bgmMaxVolume まで上げる)
        yield return FadeSource(bgmSource, 0f, bgmMaxVolume, bgmFadeDuration);
        bgmRoutine = null;
    }

    // AudioSource.volume を from → to へ duration 秒で補間する。
    // 「timeを足す→tを求める→Lerp→yield return null」=第4弾の骨格。
    // deltaTime ではなく unscaledDeltaTime を使うのは、シーン遷移やポーズ中でも進めるため(前編ScreenFaderと同じ)
    private IEnumerator FadeSource(AudioSource src, float from, float to, float duration)
    {
        float time = 0f;
        while (time < duration)
        {
            time += Time.unscaledDeltaTime;
            src.volume = Mathf.Lerp(from, to, time / duration);
            yield return null;
        }
        src.volume = to;
    }

    // ---------------------------------------------------------
    // 効果音
    // ---------------------------------------------------------

    // 2Dで鳴らす(位置に関係なく同じ聞こえ方。自分に起きた出来事向け)
    public void PlaySe(AudioClip clip, float volume = 1f)
    {
        if (clip == null) return;
        seSource.PlayOneShot(clip, volume);
    }

    // UI音(ポーズ中も鳴る)
    public void PlayUi(AudioClip clip, float volume = 1f)
    {
        if (clip == null) return;
        uiSource.PlayOneShot(clip, volume);
    }

    // 指定した位置で3Dとして鳴らす(PlayClipAtPoint の「Mixerに乗る版」)。
    // 一時オブジェクトを作って再生し、鳴り終わったら自動で片付ける
    public void PlaySeAt(AudioClip clip, Vector3 position, float volume = 1f)
    {
        if (clip == null) return;

        var go = new GameObject("SE_" + clip.name);
        go.transform.position = position;

        var src = go.AddComponent<AudioSource>();
        src.clip = clip;
        src.outputAudioMixerGroup = seGroup;             // ★ PlayClipAtPoint にはできない指定
        src.spatialBlend = 1f;                           // 3D
        src.rolloffMode = AudioRolloffMode.Logarithmic;  // 距離減衰のカーブ(第5章で解説)
        src.minDistance = seMinDistance;
        src.maxDistance = seMaxDistance;
        src.volume = volume;
        src.Play();

        Destroy(go, clip.length + 0.1f); // 鳴り終わったら破棄
    }

    // ---------------------------------------------------------
    // スナップショット(第13弾で使う)
    // ---------------------------------------------------------

    // true で「こもった音」、false で通常へ、snapshotTransition 秒かけて移行する
    public void SetMuffled(bool muffled)
    {
        var target = muffled ? muffledSnapshot : normalSnapshot;
        if (target != null) target.TransitionTo(snapshotTransition);
    }
}

Assets/Scripts内に AudioManager.cs を作成します。保存した時点で、第2章のエラー(CS0246)が消えているはずです。

【 3つの「読みどころ」】
ただ音を鳴らすだけではない、ゲーム体験を支える裏側の仕組み

① ignoreListenerPause

ignoreListenerPause

前編のポーズは AudioListener.pause = true で「耳ごと止める」方式でした。UI用のAudioSourceだけ、この耳の一時停止を無視するように設定しています。この項目はインスペクターに出てこないので、Awake でコードから設定しているのがポイントです。

② PlaySeAt 

PlaySeAt

「一時オブジェクトを作って、AudioSourceを付けて、鳴らして、鳴り終わったら消す」。やっていることは PlayClipAtPoint と同じですが、自作なので出力グループを指定できます。Destroy(go, 秒数) の第2引数は「その秒数後に破棄」という意味で、第7弾の飾りの片付けと同じ関数の遅延版です。

③ bgmSource.clip == clip の判定

bgmSource.clip == clip の判定

タイトル→本編→タイトル、と行き来しても、同じ曲なら再生し直しません。「シーンをまたいでもBGMが途切れない」は、実は自分で仕組みを作らないと実現できません。DontDestroyOnLoadと、この1行の合わせ技です。


保存キーの追加(前編のキー設計表の続き)

📝 ここで新しく書くコードはありません
 
いま作った AudioManager が、どのキーで何を保存しているかを整理しておく節です。

前編で、セーブに使うキーの一覧(台帳)を作りました。今回そこに3つ増えます。

音量は「その周だけの状態」ではなく「プレイヤーの好み」なので、周回をまたいでもリセットしません。前編の ResetRunState()(フェーズと失敗数を巻き戻す処理)にも手を加える必要はありません。「何を残し、何を消すか」は、その値がプレイの結果なのかプレイヤーの設定なのかで決まります。


シーンに配置する(Prefab化してBootとMainへ)

前編の「管理役はBootとMainの両方に置く」方針をそのまま踏襲します。

■ AudioSource とは?
ひとことで言えばスピーカーです。音まわりの登場人物を並べると、役割がはっきりします。

AudioSource とは? — 音まわりの4つの登場人物

音を鳴らすには、音源ファイルを再生する装置が要ります。それがAudioSourceです。

音源ファイルを再生する『スピーカー』が、シーンの中に置かれている

シーンに置かれるので位置を持ち、3D設定をすれば「どこから鳴っているか」が耳との距離で決まります。第4弾で Ambient に付けたのが1台目で、ここでは用途ごとにスピーカーを増やしていく、というだけの話です。

① 入れ物とスピーカー1台目を作る

SeSourceとUiSourceは、次の「② 残り2台を複製で作る」で作成
  1. ヒエラルキー(Mainシーン)で右クリック ▸ 空のオブジェクトを作成。名前を AudioManager に(GameManager の隣が見つけやすいです)。位置はどこでも構いません(音は2Dなので座標と無関係です)

  2. AudioManager を右クリック ▸ 空のオブジェクトを作成。名前 BgmSource。「コンポーネントを追加」▸ Audio Source

    • Audio Generator 欄:空のまま(曲はスクリプトが bgmSource.clip = … で入れます)

    • 出力:BGM (GameAudioMixer)

    • ループ:オン/ゲーム開始時に再生:オフ(曲は窓口が指示したときに鳴らす)

    • 立体音響ブレンド(Spatial Blend):0(2D)

② 残り2台を複製で作る

同じ設定を3回入力するのは面倒なので、複製してしまいます。

  1. ヒエラルキーで BgmSource を選択し、Cmd + D(WindowsはCtrl + D)で複製

  2. 名前を SeSource に変更し、変えるのは2箇所だけ

    • 出力:BGM → SE

    • ループ:オン → オフ

  3. もう一度 BgmSource を複製して UiSource に。同じく 出力を UI、ループをオフ

■ AudioSource 設定の「なぜ?」

3つのこだわり
  1. ループを消す理由。 PlayOneShot で鳴らす効果音には、そもそもループ設定が効きません(実害はありません)。ただ「効果音のスピーカーがループになっている」のは、後から読んだときに混乱のもとです。設定は実際の役割と合わせておくのが、自分への親切になります。

  2. UiSource だけ特別な設定(ignoreListenerPause)が要りますが、インスペクターでの操作はありません。 あの項目はインスペクターに存在せず、AudioManager.cs の Awake でコードから設定しています。

  3. 3つとも Audio Generator 欄は空です。 PlayOneShot は「今から鳴らすクリップ」を引数で受け取るので、AudioSource側に事前登録する必要がありません。1台のスピーカーで、足音も宝箱も番人も鳴らせるのはこのためです。

③ スクリプトをアタッチして配線する

  1. AudioManager に AudioManager.cs をアタッチし、参照を割り当てます

    • Mixer:GameAudioMixer

    • Bgm Source / Se Source / Ui Source:それぞれの子オブジェクト

    • Se Group:GameAudioMixer を展開して中の SE を選ぶ(⊙ボタン → 検索欄に SE)

    • Normal Snapshot / Muffled Snapshot:いまは空のまま(第13弾で作ります)

  2. AudioManager を Assets/Prefabs へドラッグして Prefab化(シーンには残したまま)

【Boot シーン】を開き、AudioManager Prefabを配置する(GameManager Prefabの隣)

📝 Bootから再生するとBoot側の AudioManager が生き残り、Mainの同じものはAwakeの保険で自滅します。Mainから再生すればMain側がそのまま窓口になります。前編とまったく同じ仕組みです。


第2章のUIを結線する

  1. ヒエラルキーの PauseMenu オブジェクト(前編で作った、常時アクティブな空オブジェクト)を選択し、「コンポーネントを追加」▸ VolumeOptionsUI をアタッチ

  2. Bgm Slider / Se Slider / Ambient Slider に、第2章で作った3本のスライダーを割り当て

  3. 同じオブジェクトの PauseMenu (スクリプト) に現れた Volume Options 欄へ、PauseMenu オブジェクト自身をドラッグ(同じオブジェクトに付けた VolumeOptionsUI が入ります)

■ アタッチ先は PausePanel ではなく PauseMenu です

どこにアタッチするべきか?

前編で説明したとおり、PausePanel は閉じている間 SetActive(false) なので、そこに載せたスクリプトは Start も Update も呼ばれません。音量の復元は Start で行うので、常時アクティブな PauseMenu に載せる必要があります。

■ 非アクティブなスライダーでも、参照の割り当てはできます

「見えない=触れない」ではない

 PausePanel は今オフになっていますが、ヒエラルキーで展開すれば中のスライダーをドラッグできます(第7弾で非アクティブな Treasure を割り当てたときと同じです)。また SetValueWithoutNotify や AddListener は、対象が非アクティブでも問題なく動きます。「見えない=触れない」ではありません。


音素材はどこで手に入れるか

ここから先、素材を集める場面が何度も出てきます。ただし、いま全部を集める必要はありません。 次に必要なのはBGMの2曲だけです。とはいえ章ごとにサイトを探し直すのは手間なので、どこで何を探すかを先に決めておきます。

この記事で使う音は全部で18種類。そのうちすでに手元にあるもの(第4弾の環境音、Starter Assets同梱の足音・着地)を除くと、新しくダウンロードするのは13ファイルです。

サイトを3つに分けたのは、得意分野が違うからです。長い曲やループ素材はPixabay、短くて立ち上がりの速い効果音は効果音ラボ、ゲーム向けのUI音はKenney。1つのサイトで全部揃えることもできますが、分けたほうが早く、音の質も揃います。

■ おすすめ音源サイト3選と探し方のコツ

① Pixabay( https://pixabay.com/ja/sound-effects/ )
第4弾の環境音でお世話になったサイトです。会員登録なしでダウンロードでき、クレジット表記も不要。英語のキーワードで検索します。

  • 探し方の例:whoosh(風切り音)/wind gust(突風)/machine hum(機械の唸り)/low drone(低い持続音)

  • BGMを探すなら dungeon ambient dark exploration medieval mystery あたり。ループ素材かどうかと、1〜3分あるかを確認してください

⚠️ Pixabayは投稿型のサイトなので、素材ごとに作者が条件を追加していることがあります。気に入った音のページを一度開いて確認する習慣をつけてください。AI生成素材(ロボットアイコン付き)の扱いも含め、詳しくは第4弾の第9章「環境音ループ」で解説しています。

② 効果音ラボ( https://soundeffect-lab.info/ )
日本語の効果音サイトです。会員登録・クレジット表記ともに不要で、商用利用も可。検索するより、日本語のカテゴリを辿るほうが速いのが特徴です。

  • 探し方の例:「システム音」→ 決定音・キャンセル音/「装置・機械」→ 起動音・作動音/「戦闘」→ 打撃音・気配

  • 番人の警戒音・発見音は「システム音」や「戦闘」のカテゴリに、短くて使いやすいものが揃っています

③ Kenney( https://kenney.nl/assets/category:Audio )
ゲーム開発者向けに素材を配布している海外のサイトです。すべてCC0(著作権を放棄したパブリックドメイン)なので、クレジットも登録も、条件そのものがありません。

  • 個別の音を検索するのではなく、パック単位でダウンロードして、中から選ぶ形です

  • 今回に効くパック:UI Audio(50音/ボタン・スイッチ系)、Impact Sounds(130音/打撃・衝撃系)、Interface Sounds、Digital Audio

  • ダウンロード時に寄付を求める画面が出ますが、下の Continue without donating で無料ダウンロードできます

📝 UIクリックや宝箱の音を1つのパックから選ぶと、音色が自然に揃います。あちこちのサイトから寄せ集めると、音の質感がバラバラで「借りてきた感」が出やすいので、同系統の音はなるべく同じパックから取るのがコツです。


■ ライセンスについて(まとめ)
ライセンスはサイトごとに違います。
3つを並べると、性質がはっきり分かれます。

いずれも、ゲームに組み込んで公開するのは問題ありません(それが本来の使い道です)。逆に、素材をそのまま素材集として配布・販売するのは、多くのサイトで禁止されています。規約は変わることがあるので、使う前に各サイトの規約ページを一度読んでおきましょう。ライセンスの考え方とAI生成素材の扱いについては、第4弾の第9章「環境音ループ」で詳しく書いています。


■ (余談)Mixkit のライセンスの落とし穴

要注意:Mixkit のライセンスの落とし穴

ちなみに、海外の Mixkit も登録不要・クレジット不要で使いやすいのですが、効果音とBGMでライセンスが別です。効果音(Sound Effects Free License)はゲームでの利用が明記されている一方、音楽(Stock Music Free License)はビデオゲームでの利用が禁止されています。BGM探しでたどり着いたときは気をつけてください。


BGMをシーンごとにリクエストする

まず素材です。BGMは2曲用意します。

■ 本編曲は「環境音と重なる」ことを前提に選んでください
第4弾で作った Ambient(環境音)は、ゲーム開始から鳴りっぱなしです。そこに本編曲を足すので、Mainシーンでは環境音と本編曲が同時に鳴ります。 ここで普通のゲーム音楽——たとえば epic adventure のような勇ましい曲——を選ぶと、第4弾で作った「静かな遺跡」の空気が一気に壊れます。第9弾で番人の駆動音を「姿より先に届く気配」として設計しましたが、音数が増えるほど、その気配は埋もれます。

そこで本編曲は、環境音の一枚下に敷く薄い層として選びます。

  • 探すときのキーワード:dark ambient / dungeon atmosphere / mysterious drone / cave ambience

  • 選ぶ基準:メロディが立っていないこと(口ずさめる曲はNG)、急な盛り上がりがないこと

  • 音量:AudioManager の Bgm Max Volume(既定 0.5)で調整します。環境音と喧嘩するようなら 0.3 まで下げてください

📝 探索ゲームで「環境音+薄いアンビエントBGM」を重ねるのは一般的な作りです。層が増えるぶん空間に厚みが出ます。逆に、音が多いと感じたら、Mainシーンの BgmRequest を削除してしまってもかまいません。環境音だけの静かな遺跡に戻ります(タイトルとエンディングの曲は残るので、BGMスライダーの動作確認もできます)。


ファイルの置き場所と名前

■ Assets/Audio フォルダの構成

「これから作るもの」と「すでにあるもの」の整理

Assets/Audio/Ambient フォルダの作成は任意です、空のままで構いません。 第4弾の環境音は Assets/Audio の直下にあり、そのままで問題なく鳴っています(第2章でやったのはAudioSourceの出力先を繋いだだけで、ファイルの置き場所とは無関係です)。整理したい方は Ambient フォルダへ移動しても構いません。Unity内で移動すれば、参照は自動で追従します(第6弾でマテリアルを移動したときと同じです)。

■ この記事はリネームして進めます
ダウンロードした素材のファイル名は、たいてい dark-ambient-music-12345.mp3 のような、素材サイト側の都合で付いた名前になっています。1つ2つなら問題ありませんが、この記事では最終的に13ファイルを扱います。 インスペクターで割り当てるとき、この名前のままだと目的の音を探すだけでひと苦労です。

そこで本記事では、ダウンロードしたらすぐリネームする方針で進めます。まず今回の2曲から。

以降の章でも、同じ考え方で名前を付けていきます。先頭に用途の分類、そのあとに中身という形です。

BGMの2曲だけが「Assets/Audio/BGM」、効果音は11個すべて 「Assets/Audio/SE」 です。

■ ファイルの移動・リネームの鉄則

リネームはUnityの中で行ってください。 Projectウィンドウでファイルを選び、もう一度クリック(またはReturnキー)で名前を変えます。Finderで直接変えると、参照が切れることがあります(第6弾でマテリアルを移動したときと同じ理屈です)。

📝 リネームしないほうがよい場合もあります。 第4弾で入れた環境音は、Dawn Beyond the Ruins という元の曲名のままにしてあります。作者名や曲名が入ったファイル名は、出典を追いやすいからです。第12弾で使用素材を開示するとき、ファイル名から元の素材ページへ辿れると楽になります。

本記事でリネームを選んだのは、扱う数が13と多く、名前で用途が分からないと作業が滞るからです。そのかわり、どの素材をどこから取ってきたかは、別途メモに残しておいてください。 テキストファイルを1枚 Assets/Audio/ に置いておくだけでも十分です。


BgmRequest.cs(新規)

// ==========================================================
// 📄 スクリプト:BgmRequest.cs
// ==========================================================
using UnityEngine;

// シーンが始まったら、このシーンの曲を窓口にリクエストする。
// 各シーン(Title / Main / Ending)に1つ置く。
public class BgmRequest : MonoBehaviour
{
    [SerializeField] private AudioClip clip;

    private void Start()
    {
        // Title / Ending を直接再生すると窓口が居ないので、?. で落ちないようにする(前編の作法)
        AudioManager.Instance?.PlayBgm(clip);
    }
}

Assets/Scripts内に BgmRequest.cs を作成し、3つのシーンに1つずつ置きます。

※ Bootシーンには置きません

シーンをまたぐ作業なので、1シーンずつ順番に進めてください。
(※ 現在のシーンに未保存の変更があれば、保存しておいてください)

① Titleシーン

  1. 【Titleシーン】を開きます。空のオブジェクトを作成し、名前を 「BgmRequest」にします。

  2. 「コンポーネントを追加」▸ BgmRequest をアタッチ。インスペクターに現れた Clip 欄に、『タイトル曲』をドラッグ。

  3. 最後にCmd + S でシーンを保存します。

② Mainシーン

  1. 【Mainシーン】を開きます。空のオブジェクトを作成し、名前を 「BgmRequest」にします。

  2. 「コンポーネントを追加」▸ BgmRequest をアタッチ。インスペクターに現れた Clip 欄に、『本編曲』をドラッグ。
    ※ Mainシーンは本編曲となります

  3. 最後にCmd + S でシーンを保存します。

③ Endingシーン

  1. 【Endingシーン】を開きます。空のオブジェクトを作成し、名前を 「BgmRequest」にします。

  2. 「コンポーネントを追加」▸ BgmRequest をアタッチ。インスペクターに現れた Clip 欄に、『タイトル曲』をドラッグ。

  3. 最後にCmd + S でシーンを保存します。

📝 Title と Ending は、単体で再生しないでください。 前編で決めたとおり、管理役(ScreenFader / GameManager / AudioManager)を置いているのは Boot と Main の2つだけです。Titleを直接再生しても AudioManager.Instance が居ないので、?. のおかげでエラーにはなりませんが、曲は鳴りません。動作確認は必ず Boot か Main から始めてください。


動作確認

Bootシーンから「▶」。

・Titleでタイトル曲が鳴り、「はじめる」でMainに入ると本編曲へフェードで切り替わる
・Escでポーズを開くと、曲も環境音も止まる
・ポーズ中にBGMスライダーを動かす → 閉じたあと、曲の音量が変わっている
・環境音スライダーを下げると、第4弾の環境音だけが小さくなる(曲は変わらない)
・スライダーを左端(0)にしても、右へ戻せば音が復活する(-Infinity対策が効いている)
・再生を止めてもう一度Bootから再生 → スライダーの位置と音量が前回のまま
・「タイトルへ」→「はじめる」と一巡しても、音量設定が保たれている

■ スライダーを動かしても音量が変わらない場合

スライダーを動かしても音量が変わらない場合

①Exposed Parametersの名前が BGMVolume / SEVolume / AmbientVolume と一致しているか、②AudioSourceの出力欄が正しいグループか、③Audio Mixerウィンドウを開いたまま再生していて、上部の Edit in Play Mode がオンになっていないか(オンだと、エディタ側のミキサー編集が優先されて SetFloat の結果が見えなくなることがあります。確認中はウィンドウを閉じるのが確実です)、の順に疑ってください。


第4章 足音を回収する(OnFootstep/OnLand)

9本ぶりの回収

第1弾の PlayerLocomotion.cs に、こんな箱を置きました。

// アニメーションからの合図を受け取る箱(これがないとエラーが出ます)
private void OnFootstep(AnimationEvent animationEvent)
{
    // 将来、ここに「足音の音(SE)を鳴らす」プログラムを追加することができます
}

private void OnLand(AnimationEvent animationEvent)
{
    // 将来、ここに「着地のドスッという音を鳴らす」プログラムを追加することができます
}

あのとき「これがないとエラーが出ます」とだけ書いて、理由は説明しませんでした。種明かしをします。


AnimationEvent — アニメーションが「今だ」と合図する仕組み

Starter Assetsのアニメーションクリップ(Walk_N・Run_N・JumpLand など)には、「足が地面に着く瞬間に OnFootstep というメソッドを呼べ」という印が最初から埋め込まれています。 これを AnimationEvent(アニメーションイベント) と呼びます。

アニメーションファイル(FBX等)のインポート設定で、特定のフレームに「イベント(目印)」を打つことができます。

自分の目で確認してみましょう。

  1. Projectウィンドウで Assets/StarterAssets/ThirdPersonController/Character/Animations/ を開き、Locomotion--Walk_N を選択します(第1弾では中のクリップ名で「Walk_N」と呼んでいましたが、ファイル自体の名前は Locomotion--Walk_N です)。

  2. インスペクターの上部にあるタブから Animation を選びます(第1弾で「時間をループ」をオンにしたのと同じ画面です)

  3. クリップ の一覧に Walk_N が1つあります。これをクリックして選択します(FBXの中に複数の動きが入っている場合は、ここで対象を選ばないと下の設定が表示されません)

  4. その下のクリップ設定をかなり下までスクロールします。時間をループ → ルートトランスフォーム回転 → ルートトランスフォーム位置(Y) → ルートトランスフォーム位置(XZ) と続いた先に、イベント(Events) という折りたたみがあります

  5. 展開すると、タイムラインの上に 白い縦棒が数本立っています。縦棒をクリックすると、その下に 関数(Function):OnFootstep と表示されます

📝 Locomotion--Walk_Nの実体
拡張子が .anim に見えますが、実体はFBX(モデルファイル)です。インスペクターの見出しが「インポート設定」で、Model / Rig / Animation / Materials のタブが並んでいれば、それがFBXの証拠です

■ 実行時の伝言ゲーム

つまり、アニメーションが再生されて足が着地するフレームに来るたびに、Unityは「このオブジェクトに付いている全スクリプトの中から OnFootstep という名前のメソッドを探して呼ぶ」ということをしています。受け取るメソッドが1つも無いと、Unityは「呼び先が無い」とエラーを出します。 第1弾の空の箱は、このエラーを止めるためのものでした。

📝 再生中にそのフレームへ来ると、Unityはオブジェクトに付いているすべてのスクリプトに対して「OnFootstepを持ってるやつはいるか!」と一斉に呼びかけます。

■ Require Receiver — 「受け手がいなかったら、どうする?」の設定
イベントを選ぶと現れるチェックボックスで、イベント1つずつが個別に持っています(Unity 6.5でインスペクターに追加された項目です)。

  • オン(既定)
     呼び先のメソッドが見つからないと 'OnFootstep' AnimationEvent has no receiver! というエラーが出ます。歩くたびに発火するので、コンソールがこのエラーで埋まります。

  • オフ 
    見つからなくても何も起きません。静かになりますが、メソッド名を打ち間違えても気づけなくなります。

第1弾で空のメソッドを置く必要があったのは、この設定がオンだったからです。そして注意点がひとつ。この章では PlayerLocomotion から空メソッドを削除しますが、そのあと PlayerAudio のアタッチを忘れると、同じエラーが戻ってきます。 そのときのエラーは「受け手がいない」という正しい警告なので、チェックを外して消すのではなく、アタッチを確認してください。


歩きと走りが混ざると、足音が二重に鳴る

Starter Assetsの公式実装には、次のような判定があります。

if (animationEvent.animatorClipInfo.weight > 0.5f) { /* 鳴らす */ }

これが無いと何が起きるか。第1弾で作ったBlend Treeは、Speedの値に応じて歩きと走りを混ぜて再生しています。Speedが4のとき、Walk_N と Run_N が半々で混ざっているわけです。

すると、両方のクリップからイベントが飛んできて、足音が二重に鳴ります。 しかも歩きと走りではテンポが違うので、ドタドタと不揃いになります。

animatorClipInfo.weight は「いまこのクリップがどれくらい効いているか(0〜1)」です。0.5より大きい=いちばん強く効いているクリップのイベントだけ採用すれば、二重再生は起きません。第1弾のBlend Treeが、ここで技術的な副作用として返ってきた、というわけです。


PlayerAudio.cs(新規)— 受け手を別スクリプトに切り出す

足音の処理を PlayerLocomotion.cs に足すこともできますが、別スクリプト PlayerAudio.cs に切り出します。AnimationEventは「同じGameObjectに付いているすべてのスクリプト」を探して呼ぶので、Animatorが付いている model_test に新しいスクリプトを足せば、そちらでも受け取れます。移動の責務と音の責務を分けておく方が、後から読みやすくなります。

⚠️ ただし、両方に OnFootstep があると2回鳴ります。 PlayerLocomotion.cs の空メソッド2つは、この章で削除します(手順は後述)。ここが今回いちばん事故りやすい箇所なので、忘れずに。

■ PlayerAudio.cs(新規)

// ==========================================================
// 📄 スクリプト:PlayerAudio.cs
// ==========================================================
using UnityEngine;

// プレイヤーの足音・着地音。Starter Assetsのクリップに埋め込まれた
// AnimationEvent(OnFootstep / OnLand)から呼ばれる。第1弾の空メソッドの後継。
// アタッチ先:model_test(Animatorが付いているオブジェクト。ここでないと呼ばれない)
public class PlayerAudio : MonoBehaviour
{
    [Header("再生用AudioSource(子のFootstepSource)")]
    [SerializeField] private AudioSource footstepSource;

    [Header("足音のクリップ(複数登録するとランダムに選ぶ)")]
    [SerializeField] private AudioClip[] footstepClips;
    [SerializeField, Range(0f, 1f)] private float footstepVolume = 0.4f;

    [Header("着地音")]
    [SerializeField] private AudioClip landClip;
    [SerializeField, Range(0f, 1f)] private float landVolume = 0.6f;

    [Header("ピッチの揺らぎ(機械的な繰り返しを消す)")]
    [SerializeField] private float pitchMin = 0.95f;
    [SerializeField] private float pitchMax = 1.05f;

    // 足が着地するフレームで、アニメーション側から呼ばれる
    private void OnFootstep(AnimationEvent animationEvent)
    {
        // ブレンド中に歩きと走りの両方から飛んでくるので、強く効いている方だけ採用(二重再生の防止)
        if (animationEvent.animatorClipInfo.weight <= 0.5f) return;
        // UI表示中(第6弾のゲート)は鳴らさない。移動も止まっているので念のため
        if (UiGate.IsOpen) return;
        if (footstepClips == null || footstepClips.Length == 0) return;

        // 毎回違うクリップ・少し違う高さで鳴らすと「同じ音の繰り返し」感が消える
        var clip = footstepClips[Random.Range(0, footstepClips.Length)];
        footstepSource.pitch = Random.Range(pitchMin, pitchMax);
        footstepSource.PlayOneShot(clip, footstepVolume);
    }

    // 着地のフレームで呼ばれる(JumpLandクリップのイベント)
    private void OnLand(AnimationEvent animationEvent)
    {
        // ⚠️ ここには weight 判定を入れない(理由は後述。入れると着地音が鳴らなくなる)
        if (landClip == null) return;

        footstepSource.pitch = 1f;
        footstepSource.PlayOneShot(landClip, landVolume);
    }
}

Assets/Scripts内に PlayerAudio.cs を作成します。

OnLand には weight の判定を入れていません。足音で必要だったのは「歩きと走りが同時に混ざるから」で、着地のクリップは1つしかないので二重再生が起きないからです。

■ 短い効果音には PlayOneShot

PlayOneShot は「今鳴っている音を止めずに、重ねて1回だけ鳴らす」再生方法です。Play() だと前の足音を途中で切ってしまうので、短い効果音には PlayOneShot が定番です。第7弾の計画で「PlayOneShot×n回」と書いた計数音も、この関数を使います(第6章)。

■ ピッチの揺らぎと副作用

ピッチの揺らぎは「1行で効果が大きい」小技です。0.95〜1.05の範囲でランダムに高さを変えるだけで、同じ音源でも足音が「生きて」聞こえます。ただし pitch はAudioSource全体の設定なので、重なって鳴っている前の足音の高さも一緒に変わります。足音程度なら気になりませんが、厳密にやるならクリップごとに別のAudioSourceを使います。

⚠️ ここで pitch を正の範囲でしか使っていないことを覚えておいてください。第13弾で効いてきます。


PlayerLocomotion.cs から空メソッドを削除する

PlayerLocomotion.cs の末尾にある2つのメソッドを削除します。

// ❌ 削除する(PlayerAudio.cs に移譲した)
private void OnFootstep(AnimationEvent animationEvent) { }
private void OnLand(AnimationEvent animationEvent) { }

削除を忘れると、PlayerLocomotion と PlayerAudio の両方が呼ばれて足音が毎歩2回鳴ります。「なんだか足音が厚い/ダブって聞こえる」と感じたら、真っ先にここを疑ってください。


音源とAudioSourceを用意する

足音と着地音は、Starter Assetsに同梱されています。 新しくダウンロードするものはありません。

Assets/StarterAssets/ThirdPersonController/Character/Sfx/ を開くと、けっこうな数のファイルが並んでいます。

サイコロのアイコンは、AudioClip ではありません。 Player_Footstep と Player_Land(末尾に番号が付いていないほう)は Audio Random Container という別種のアセットです。このあと Footstep Clips の欄に割り当てるのは ♪マークの付いたクリップのほうで、サイコロはドラッグしても入りません。 正体については、この章の最後のコラムで説明します。

📝 Starter Assets の中身はリネームしません
 
第3章で「ダウンロードした素材はリネームする」方針にしましたが、こちらはもともと分かりやすい名前が付いているうえ、パッケージの一部なので触らないほうが安全です。リネームは「素材サイトから落としたファイル」だけに適用します。

  1. ヒエラルキーで model_test を右クリック ▸ 空のオブジェクトを作成。名前 FootstepSource、位置 (0, 0, 0)

  2. FootstepSource に「コンポーネントを追加」▸ Audio Source

    • Audio Generator 欄:空のまま(PlayOneShotで毎回クリップを渡すため)

    • 出力:SE (GameAudioMixer)

    • ゲーム開始時に再生:オフ/ループ:オフ

    • 立体音響ブレンド:0(2D)。自分の足音は、どこにいても同じ聞こえ方で構いません

  1. model_test(ルート)を選択し、「コンポーネントを追加」▸ PlayerAudio をアタッチ

  2. 参照を割り当て

    • Footstep Source:子の FootstepSource

    • Footstep Clips:Player_Footstep_…(♪マークのほう)を割り当てます。Projectウィンドウで複数選択して、配列の欄へまとめてドラッグすると、サイズも自動で決まって早いです

    • Land Clip:Player_Land_…(♪マークのほう)から1つ

■ 機械的な足音を消す「2段構え」

「クリップ抽選」×「ピッチ揺らぎ」による、無限のバリエーション

どれも「同じ床を踏む音」ですが、わずかに違うテイクを録り分けたものです。実際の足音は、体重のかけ方や着地の角度で毎回少しずつ違います。その微差を10個のファイルとして持っておき、毎回ランダムに選ぶことで「同じ音の繰り返し」を避ける——というのが定番の作り方です。

さきほど書いた PlayerAudio.cs の2行が、まさにこれをやっています。

var clip = footstepClips[Random.Range(0, footstepClips.Length)];  // 10個から1つ選ぶ
footstepSource.pitch = Random.Range(pitchMin, pitchMax);          // さらに高さを揺らす

クリップの選択とピッチの揺らぎ、2段構えなので、組み合わせは実質無限になります。同じ音が続いて聞こえることは、まずありません。

📝 10個すべて入れる必要はありません
聞き分けられるほどの差ではないので、3〜4個でも繰り返し感は十分に消えます。ピッチの揺らぎと併用するなら、なおさらです。もちろん全部入れても構いません。


材質で足音を変えるには(拡張の型だけ)

📝 この項は実装しません
本作では使わない拡張の「型」だけを示します。読み飛ばして次の章へ進んでも、動作に影響はありません。

本作の床はすべて石なので、今回は1種類で十分です。ただ「砂地では砂の音、水たまりでは水の音」は定番なので、拡張するときの型だけ示しておきます。

実は素材のほうは、もう用意されています。 さきほどのフォルダにあった Concrete_Footsteps が、コンクリート(=硬い床)用の足音です。Starter Assets は最初から「床の材質ごとに足音を分ける」ことを想定した作りになっているわけです。石造りの遺跡なら、汎用の Player_Footstep_… よりこちらのほうが合うかもしれません。両方をプレビューで聞き比べて、好きなほうを Footstep Clips に入れてください。

// プレイヤーの足元へ短いレイを撃ち、当たった床のタグで鳴らす音を切り替える例(本作では未使用)
if (Physics.Raycast(transform.position + Vector3.up * 0.1f, Vector3.down, out var hit, 0.5f))
{
    if (hit.collider.CompareTag("Sand")) { /* 砂の足音 */ }
    else                                  { /* 石の足音 */ }
}

マテリアル名で判定する方法(hit.collider.GetComponent<Renderer>().sharedMaterial.name)も見かけますが、名前を変えた瞬間に壊れるので、タグや専用コンポーネントで判定する方が堅いです。

■ あのサイコロは何だったのか — Audio Random Container
素材フォルダで見かけたサイコロのアイコン、あれの正体を説明します。

Audio Random Container(ARC) というアセットです。Unity 2023.2 で追加され、Unity 6 にも入っています。 ひとことで言うと、この章でスクリプトに書いた処理を、コードなしで実現するものです。

ファイルを選択して、インスペクターの Edit Audio Random Container を押すと専用ウィンドウが開きます。中身を今回のコードと並べると、対応がはっきりします。

やりたいことが、そのまま設定項目になっています。 AudioSourceの Audio Generator 欄にARCを割り当てれば、ランダム選択も揺らぎもUnityがやってくれます。

Avoid Repeating Last は「直前に鳴ったものを、次は選ばない」という設定です。第7弾で宝箱の見分けポイントに履歴Queueを実装しましたが、まったく同じ考え方が既製の機能として用意されています。連載を通して2度目の登場ですね。

ピッチの単位が ct(セント) なのも面白いところです。今回のコードは 0.95f 〜 1.05f と倍率で書きましたが、ARCは音楽の世界の単位(半音=100セント)を採用しています。

それでも本記事が配列方式を選んだ理由は、ひとつだけです。本連載の公開先が itch.io(ブラウザ) で、Web版での動作を確認できていないからです(第13弾で見るとおり、Web版の音は「基本機能のみ」で、公式の対応表にこの機能は載っていません)。確実に動くほうを取りました。

※ Windows/Mac向けに作る方は、ARCのほうが圧倒的に楽です。Player_Footstep(サイコロのほう)を FootstepSource の Audio Generator 欄に割り当てて、PlayerAudio から footstepSource.Play() を呼ぶだけで、この章のコードの大半が不要になります。

💡 Starter Assets自身が、この新機能を使っています
サイコロが3つも入っているのは、公式のサンプルがARCへ移行しているからです。「新機能があるらしい」ではなく、もう標準的な作り方になりつつある、と捉えたほうがよさそうです。


動作確認

  • 歩くと足音が鳴り、走ると間隔が詰まる

  • 足音が二重に鳴っていない(歩き⇄走りの切り替わりの瞬間も)

  • ジャンプして着地すると着地音が鳴る

  • ポーズを開き、効果音スライダーを下げて閉じる → 足音が小さくなる(=Mixerに乗っている証拠)

  • コンソールに「AnimationEvent 'OnFootstep' has no receiver」のようなエラーが出ていない


第5章 3D定位 — 番人の「気配」をつくる

2Dと3D(第4弾の続き)

第4弾で環境音を作ったとき、立体音響ブレンド(Spatial Blend)を0(2D)にしました。「どこにいても同じ音量で、空間全体に均等に鳴らしたい」からです。今回は逆に 1(3D) を使います。番人の位置によって、音量と左右の聞こえ方が変わるようにします。

■ 2Dと3D — 音が「どこで鳴っているか」の違い

立体音響ブレンド(Spatial Blend)の0と1で、聞こえ方はこのように変わります。


初心者が必ずハマる「3Dにしたのに減衰しない」

AudioSourceのインスペクターを一番下まで開くと 3D Sound Settings(ここだけ英語表記のままです)という折りたたみがあり、その中に 最小距離(Min Distance) と 最大距離(Max Distance) があります。既定値は 1 と 500 です。

・最小距離より近い:最大音量(それ以上近づいても大きくならない)
・最小〜最大距離の間:だんだん小さくなる
・最大距離より遠い:減衰が止まる(ほぼ聞こえない、または一定の小さい音量)

■ 最大距離500の罠 — 3Dにしたのに減衰しない

Min/Max Distance が決めているのは「どこからどこまで小さくなるか」

問題は 500 という既定値です。本作の迷路は入口から宝物庫まで70mほど。500mも減衰の範囲があると、迷路のどこにいても番人の音がほぼ最大で聞こえてしまいます。 「3Dにしたのに、どこにいても同じ音量」の正体は、たいていこの最大距離です。


音は、姿より先に届く

聞こえてから見つかるまでに、隠れる時間がある

本作では、番人の駆動音を 最小距離1/最大距離15 にします。この15という数字には遊びの意味があります。

つまり、姿が見える前に、音で気づける。第9弾のステルスに「フェアな警告」が加わります。「音が聞こえたらランタンを消して柱に隠れる」という遊びが、ここで初めて成立します。

📝 音量ロールオフ(Volume Rolloff) は減衰のカーブの形です。既定の Logarithmic Rolloff(対数)は、現実の音と同じく「近くで急に大きく、離れるとなだらかに小さく」なります。Linear(直線)は最大距離でぴったり0になるので「範囲外では完全に無音にしたい」ときに使います。今回は既定のLogarithmicのままで構いません。


番人に駆動音を付ける

■ 素材を用意する
まず音源です。番人の駆動音はループ素材が必要なので、第3章の表のとおり Pixabay から探します。

ダウンロードしたら Assets/Audio/SE に入れ、SE_GolemHum にリネームします(第3章で決めた命名の方針です)。サイトの選び方とライセンスの確認は、第3章「音素材はどこで手に入れるか」にまとめてあります。

📝 「唸り」は目立ちすぎない音を選ぶのがコツです。番人が近くにいる間ずっと鳴り続けるので、主張の強い音だと数分で耳が疲れます。低くて単調なくらいがちょうどいいです。

■ AudioSourceを付ける

  1. ヒエラルキーで GolemRoot を右クリック ▸ 空のオブジェクトを作成。名前 GolemHum、位置 (0, 1.2, 0)(胸のあたり。足元から鳴ると不自然なため)

  2. GolemHum に「コンポーネントを追加」▸ Audio Source

    • Audio Generator:番人の駆動音(低い唸り。ループ素材を選びます)

    • 出力:SE (GameAudioMixer)

    • ループ:オン/ゲーム開始時に再生:オン

    • ボリューム:0.5

    • 立体音響ブレンド:1(スライダーを右端の3Dへ)

    • 一番下の 3D Sound Settings を展開:音量ロールオフ 対数的なロールオフ(Logarithmic Rolloff)Logarithmic Rolloff/最小距離 1/最大距離 15

  3. ゲーム開始「▶」。番人から離れると音が消え、近づくと聞こえ始めることを確認します(聞こえない場合は、次項「音が小さくて聞こえないときは?」も参照)


音が小さくて聞こえないときは?

AudioSourceの「上限」と、根本的な2つの解決策

音が小さくて聞こえないときは。 まず『ボリュームを 1 まで上げて』みてください。それで足りるなら、それが一番簡単です。なお、AudioSourceのボリュームは、1が上限です。 1より大きい値は入りません。「元の音源が小さいから、2倍にして鳴らす」ということはできない仕組みです。1にしても足りない場合は、次のどちらかになります。

・素材そのものを大きくする(推奨):Audacityなどの音声編集ソフトで正規化(ノーマライズ)してから読み込みます。根本的な解決で、他の音とのバランスも取りやすくなります
・素材を選び直す:小さすぎる素材は、無理に大きくするとノイズも一緒に増幅されます。別のものを探すほうが早いこともあります


耳はカメラにある — 最長距離を 20 に直す

さて、いまの設定には問題があります。敵の視界(12m)より、音の届く範囲のほうが短いのです。本来は逆、音のほうが遠くまで届くはずでした。

3Dの音の距離は、「AudioListener(耳)」から測られます。そして耳は、既定で Main Camera に付いています。

Cinemachine

本連載はTPSなので、カメラはキャラクターの5m後ろ(Cinemachine の Radius 5)。つまり、さきほど設定した最長距離15は、プレイヤーから15mではなく、カメラから15mです。

これでは、さきほどの設計が崩れます。

見つかってから、さらに2m近づいて、ようやく音が聞こえる。 「姿より先に音が届く」が、逆転してしまっています。

最長距離を 20 に変更してください。

GolemHum
  1. GolemHum を選択

  2. 3D Sound Settings の 最長距離:15 → 20

💡 20 = 15 + 5。カメラのオフセットを足すだけです。これでカメラから20m=プレイヤーから15mになり、発見距離12mより3m手前で音が聞こえ始めます。

■ 動作確認

・番人から離れると駆動音が消え、近づくと聞こえ始める
・番人が画面の左にいると左から、右にいると右から聞こえる
・音が聞こえたあとで、番人に発見される(逆になっていない)
・ランタンを消していても音は聞こえる(=姿より先に気配が来る)
・FPS(Vキー)に切り替えても、聞こえ方が破綻しない
・効果音スライダーを下げると駆動音も小さくなる
・コンソールにAudio Listenerの警告が出ていない


第6章 📢の回収と計数音

第9弾に置いた「📢」を全部回収する

第9弾のコードには、こんなコメントを2箇所置いておきました。

// 📢 第10弾でここに警戒音・発見音を差し込みます
// 📢 第11弾でここを仕掛け(扉やギミック)に接続します

前者を今回回収します(後者は第11弾)。あわせて、宝箱・霧・UIにも音を入れます。差し込む場所と音の一覧です。

■ どこに、どんな音を差し込むか

既存のコードに1行ずつ。判定のロジックは何も変えません。

やり方は原則1行です。 AudioManager.Instance?.PlaySe(clip) か PlaySeAt(clip, 位置) を、既存の処理のあいだに挟むだけ。判定や状態遷移のロジックは1文字も変えません。 第9弾で「表示と判断を分けておくと、後で見た目を足すときにロジックを壊さない」と書きましたが、音でもまったく同じことが起きています。

📝 呼び出しに ?. を付けているのは、AudioManager を置き忘れたシーンや単体テストでも NullReferenceException で止まらないようにするためです(第6弾の FogController.Instance?.ToggleFog() と同じ保険)。


音素材を用意する

ここで必要になる素材は次の10種類です(足音・着地は第4章、番人の駆動音は第5章で用意済みなので、この表には含みません)。サイトの選び方とライセンスの確認は、第3章「音素材はどこで手に入れるか」を参照してください。

① 効果音ラボから(日本語のカテゴリを辿って探す)

SE_Alert、SE_Chase、SE_Lost、SE_Captureにリネーム。

② Kenneyから(パックを丸ごと落として中から選ぶ)

SE_UiClick、SE_ChestOpen、SE_ChestWrong、SE_Tick、SE_Lit
にリネーム。

③ Pixabayから(英語キーワードで検索)

SE_Fogにリネーム。

素材は Assets/Audio/SE に入れ、上の表のとおりリネームします。Kenneyのパックは中身が多いので、使う5つだけを取り出して名前を付け、残りは削除するか別フォルダにまとめておくと、Projectウィンドウが散らかりません。AI生成の効果音(Pixabayのロボットアイコン付き)を使った場合、第12弾で行う開示の対象は「Sound」の区分のまま変わりません(すでに環境音で該当しているため、区分が増えるわけではありません)。

■ 素材選びで「安っぽさ」を消す2つの鉄則

タイミングの遅れと、チグハグな質感を防ぐポイント
  1. 効果音は、先頭の無音を切ってある素材を選んでください。先頭に0.1秒の無音があるだけで、足音や計数音のような「タイミングが命」の音は遅れて聞こえます。波形の左端が音の立ち上がりになっているかを、インポート後にプレビューで確認しましょう。

  2. UI系をKenneyのパックで揃えたのには理由があります。 クリック音・正解音・不正解音・ティック音は、プレイヤーが1回の探索で何十回も聞く音です。バラバラのサイトから集めると音の質感が揃わず、「借りてきた素材を並べた」感じが出ます。同じパックから選べば、それだけで統一感が出ます。


GolemController.cs の差分

第9弾の完成形に、フィールド4つと呼び出し4行を足します。

// ==========================================================
// 📄 スクリプト:GolemController.cs(差分)
// ==========================================================
    [Header("効果音(第10弾後編)")]
    [SerializeField] private AudioClip alertClip;   // 警戒(?)
    [SerializeField] private AudioClip chaseClip;   // 発見(!)
    [SerializeField] private AudioClip lostClip;    // 見失い
    [SerializeField] private AudioClip captureClip; // 捕獲

    // --- 状態を変える瞬間に目の色を切り替え ---
    private void ChangeState(GuardState next)
    {
        state = next;
        ApplyEyeMaterial(next);

        // 📢→🔊 第10弾で回収。状態が「変わった瞬間」に、番人の位置で1回だけ鳴らす。
        // switch式で「次の状態→鳴らす音」を引く(Patrolへ戻るときは鳴らさない)
        AudioClip clip = next switch
        {
            GuardState.Alert => alertClip,
            GuardState.Chase => chaseClip,
            GuardState.Lost  => lostClip,
            _                => null,
        };
        if (clip != null)
            AudioManager.Instance?.PlaySeAt(clip, transform.position);
    }

    // --- 捕獲トリガー:プレイヤーに触れたら霧で戻す ---
    private void OnTriggerEnter(Collider other)
    {
        if (MazeDirector.Instance != null && MazeDirector.Instance.IsReturning) return;
        if (other.GetComponent<CharacterController>() == null) return;

        AudioManager.Instance?.PlaySe(captureClip); // 🔊 追加:自分に起きた出来事なので2D
        JudgeFeedbackUI.Instance.ShowMessage("見つかった……! 霧に包まれて戻される");
        MazeDirector.Instance.ReturnPlayer(golemReturnAnchor);
        ResetToPatrol();
    }

■ 音を鳴らすのは「Update」ではなく「ChangeState」

毎フレーム鳴り続ける罠を防ぐ、「変わった瞬間」だけの処理

音は ChangeState に置きます。Update に置いてはいけません。 Update は毎フレーム呼ばれるので、警戒中ずっと「キンキンキン……」と鳴り続けます。「状態が変わった瞬間に1回だけ」を実現できる場所が ChangeState です。第9弾で Debug.Log を置いた場所と同じで、あのログが「1回だけ出る」ことを確認していたのが、そのままここで効いています。

📝 第9弾で ChangeState に入れた Debug.Log($"番人の状態:{next}") は、HUDで状態が見えるようになった時点で役目を終えています。残しておいても害はありませんが、コンソールが流れて見づらければ消して構いません。

■ 割り当て(GolemRoot)

Alert Clip / Chase Clip / Lost Clip / Capture Clip に、用意した4つの音を割り当てます。


GazeSwitch.cs — 計数音(間隔が詰まる音)と点灯音

第9弾の視線スイッチは「1.2秒見つめると点く」仕組みでした。ゲージは内部の数字(gazeTime)として溜まっていますが、プレイヤーには見えません。これを音で聞かせます。

画面に何も出さずに、耳だけで「あと少し」を伝える設計

見つめている間「ティック、ティック……」と鳴らし、点灯に近づくほど間隔を詰めていく。すると「あと少しで点く」が耳で分かります。計画段階から「計数音」と呼んでいた仕掛けで、鳴らす間隔を Mathf.Lerp で縮めていくだけです。

改修箇所が Update の内側に散らばるので、全体を載せます。💡が今回の追加です。

// ==========================================================
// 📄 スクリプト:GazeSwitch.cs(改修後・全体)
// ==========================================================
using UnityEngine;

// プレイヤーが一定時間見つめると点灯するクリスタル(第9弾)。
// 第10弾後編:見つめている間の計数音と、点灯音を追加。
public class GazeSwitch : MonoBehaviour
{
    [SerializeField] private Renderer crystalRenderer;
    [SerializeField] private Material crystalOff;
    [SerializeField] private Material crystalOn;
    [SerializeField] private float gazeAngle = 30f;
    [SerializeField] private float gazeDistance = 8f;
    [SerializeField] private float gazeRequired = 1.2f;

    // 💡 追加:効果音
    [Header("効果音(第10弾後編)")]
    [SerializeField] private AudioClip tickClip;              // 計数音(見つめている間)
    [SerializeField] private AudioClip litClip;               // 点灯音
    [SerializeField] private float tickIntervalStart = 0.35f; // 見つめ始めの間隔(秒)
    [SerializeField] private float tickIntervalEnd = 0.08f;   // 点灯直前の間隔(秒)

    private LayerMask obstructionMask;
    private Camera cam;
    private float gazeTime = 0f;
    private bool isOn = false;
    private float tickTimer = 0f; // 💡 追加:次のティックまでの計測

    void Start()
    {
        cam = Camera.main;
        obstructionMask = LayerMask.GetMask("Default");
        ApplyMaterial();
    }

    void Update()
    {
        if (isOn) return;
        if (cam == null) return;

        Vector3 toCrystal = transform.position - cam.transform.position;
        bool near = toCrystal.sqrMagnitude <= gazeDistance * gazeDistance;
        bool facing = Vector3.Angle(cam.transform.forward, toCrystal) < gazeAngle;
        bool clear = !Physics.Linecast(
            cam.transform.position, transform.position,
            obstructionMask, QueryTriggerInteraction.Ignore);

        if (near && facing && clear)
        {
            gazeTime += Time.deltaTime;

            // 💡 計数音:進み具合(0→1)に応じて、鳴らす間隔を Start → End へ縮める
            float progress = gazeTime / gazeRequired;
            float interval = Mathf.Lerp(tickIntervalStart, tickIntervalEnd, progress);
            tickTimer += Time.deltaTime;
            if (tickTimer >= interval)
            {
                tickTimer = 0f;
                AudioManager.Instance?.PlaySeAt(tickClip, transform.position);
            }

            if (gazeTime >= gazeRequired)
            {
                isOn = true;
                ApplyMaterial();
                AudioManager.Instance?.PlaySeAt(litClip, transform.position); // 💡 📢→🔊 点灯音
                // 📢 第11弾でここを仕掛け(扉やギミック)に接続します
            }
        }
        else
        {
            // 見るのをやめたら、未点灯の進捗は少しずつ戻る。ティックも仕切り直し
            gazeTime = Mathf.MoveTowards(gazeTime, 0f, Time.deltaTime);
            tickTimer = 0f; // 💡 追加
        }
    }

    private void ApplyMaterial()
    {
        if (crystalRenderer == null) return;
        crystalRenderer.sharedMaterial = isOn ? crystalOn : crystalOff;
    }
}

Assets/Scripts内の GazeSwitch.cs を上書き保存します。

interval = Mathf.Lerp(0.35, 0.08, progress) の1行が計数音の心臓です。進み具合が0のとき0.35秒間隔、1に近づくと0.08秒間隔。第4弾で学んだ「tを求めてLerp」が、今回は時間ではなく間隔を補間しています。同じ道具を別の軸に使う、という例です。

この処理は Time.deltaTime で進むので、ポーズ中(timeScale=0)は自動的に止まります。前編で整理した「止まるもの/止まらないもの」の表どおりで、ポーズ中にティックが鳴り続ける心配はありません。

📝 ティックに PlaySeAt を使うことについて
PlaySeAt は呼ばれるたびに一時オブジェクトを作って捨てます。ティックは終盤で1秒に10回以上鳴るので、その間だけオブジェクトの生成・破棄が続きます。今回の規模では実害はありませんが、気になる場合は クリスタル自身に3D設定済みのAudioSourceを1つ付けておき、PlayOneShot で鳴らすほうが効率的です(第4章の足音と同じ作り)。「短い音を高頻度で鳴らすなら、専用のAudioSourceを1つ置く」——これは覚えておくと役に立つ判断基準です。

■ 割り当て(Crystal)

Tick Clip / Lit Clip に用意した音を割り当て。Tick Interval の2つは既定値のままでOK。


ChestPairController.cs の差分

// ==========================================================
// 📄 スクリプト:ChestPairController.cs(差分)
// ==========================================================
    [Header("効果音(第10弾後編)")]
    [SerializeField] private AudioClip correctClip; // 開錠
    [SerializeField] private AudioClip wrongClip;   // 失敗

    public void Judge(TreasureChest picked)
    {
        if (IsSolved || isResolving || MazeDirector.Instance.IsReturning) return;

        bool pickedHasTell = (picked == chestWithTell);
        bool inverted = GameManager.Instance.IsJudgementInverted;
        bool pickedClean = !pickedHasTell;
        bool correct = pickedClean ^ inverted;

        if (correct)
        {
            isResolving = true;
            picked.Open();
            AudioManager.Instance?.PlaySeAt(correctClip, picked.transform.position); // 🔊 追加:開けた箱の位置で
            IsSolved = true;
            GameManager.Instance.CollectTreasure(treasureIndex);
            MazeDirector.Instance.MarkSolved(junctionId);
            chestA.SetExaminable(false);
            chestB.SetExaminable(false);
            MazeDirector.Instance.NotifyProgress();
            JudgeFeedbackUI.Instance.Show(true, "あたり!");
        }
        else
        {
            AudioManager.Instance?.PlaySeAt(wrongClip, picked.transform.position); // 🔊 追加
            GameManager.Instance.AddMistake();
            JudgeFeedbackUI.Instance.Show(false, "はずれ……");
            MazeDirector.Instance.ReturnPlayer(returnAnchor);
        }
    }

割り当ては、シーンの3体ではなくPrefab本体に行います。


Projectウィンドウの Assets/Prefabs/ChestPair を選択し、インスペクターの Correct Clip / Wrong Clip に音を割り当てます。

Prefab本体を変えれば、3体のインスタンスすべてに行き渡ります。

📝 第8弾で「Prefabに適用は押さない」と書いたのは、junctionId のように1体ずつ違う値の話です。音のように全員同じでよい値は、Prefab本体に持たせるのが正解です。この使い分けは第7章のミニマップでもう一度出てきます。


MazeDirector.cs の差分

// ==========================================================
// 📄 スクリプト:MazeDirector.cs(差分)
// ==========================================================
    [Header("効果音(第10弾後編)")]
    [SerializeField] private AudioClip fogClip; // 霧に包まれる音

    private IEnumerator ReturnRoutine(Transform anchor)
    {
        isReturning = true;
        player.InputLocked = true;
        FogController.Instance.FadeToVeil();
        AudioManager.Instance?.PlaySe(fogClip); // 🔊 追加:霧が満ちるのと同時に
        yield return new WaitForSeconds(0.8f);
        player.TeleportTo(anchor.position, anchor.rotation);
        FogController.Instance.FadeToMin();
        yield return new WaitForSeconds(0.5f);
        player.InputLocked = false;
        isReturning = false;
    }

■ 割り当て(MazeDirector)

Fog Clip に霧の音。番人に捕まったときも同じ ReturnPlayer を通るので、捕獲時も自動で鳴ります(第8弾の「入口を1つに絞る設計」の恩恵です)。


UIのクリック音 — 小さな部品を1つ作って、全ボタンに貼る

ボタンは全部で11個あります(タイトル2・ポーズ3・手記3・名簿2・エンディング1)。それぞれのスクリプトに音を足していくと改修が11箇所に散らばるので、「ボタンに貼るだけで鳴る」小さな部品を作ります。

// ==========================================================
// 📄 スクリプト:UiClickSound.cs
// ==========================================================
using UnityEngine;
using UnityEngine.UI;

// ボタンに貼るだけで、クリック時にUI音を鳴らす部品。
// [RequireComponent] で、Buttonの無いオブジェクトには貼れないようにしておく(第1弾の作法)
[RequireComponent(typeof(Button))]
public class UiClickSound : MonoBehaviour
{
    [SerializeField] private AudioClip clip;

    private void Awake()
    {
        // 既存のクリック処理(インスペクター方式・AddListener方式のどちらでも)に、音の処理を「追加」する。
        // AddListenerは上書きではなく追加なので、元の処理は壊れない
        GetComponent<Button>().onClick.AddListener(() => AudioManager.Instance?.PlayUi(clip));
    }
}

Assets/Scripts内に UiClickSound.cs を作成します。

■ 貼り方(まとめて一括)

  1. ヒエラルキー(Mainシーン)で ResumeButton を選択し、Cmd を押しながら ToTitleButton PauseQuitButtonPrevButton NextButton CloseButton ConfirmButton NameCloseButton を順にクリック(複数選択)

  2. インスペクターの「コンポーネントを追加」▸ UiClickSound。選択中の全オブジェクトにまとめてアタッチされます

  3. 複数選択したまま、Clip 欄にUIクリック音をドラッグ。全員に同じ値が入ります(複数選択中のインスペクターは「共通の値」をまとめて編集できます)

■ Title / Ending シーン
Mainシーンに貼ったように、Title / Ending シーンにもしていきましょう。

  • Title シーンを開き、StartButton QuitButton に同様に。

  • Ending シーンの EndToTitleButton にも

📝 Escでポーズを開く瞬間にも音が欲しい場合は、PauseMenu.Open() の先頭に AudioManager.Instance?.PlayUi(clip); を1行足してください(フィールド [SerializeField] private AudioClip openClip; を追加して割り当てます)。閉じるときは「つづける」ボタンの音が鳴るので、Escで閉じた場合だけ無音になりますが、気になる方は Close() にも同じ1行を足せばそろいます。


動作確認

【Bootシーン】から再生。

  • 番人に見つかると、警戒音(目が黄)→ 発見音(目が赤)の順に1回ずつ鳴る(連続で鳴り続けない)

  • 柱に隠れて見失われると、下降する音が鳴って巡回に戻る

  • 捕まると捕獲音と霧の音が重なって、戻される

  • クリスタルを見つめると、ティックが少しずつ速くなり、点灯音で止まる。目を逸らすとティックも止まる

  • 正解の宝箱で開錠音、不正解で失敗音+霧の音

  • ポーズ中でも「つづける」等のクリック音は鳴る(UIグループ+ignoreListenerPause)

  • 効果音スライダーを下げると、UIクリック以外の音がまとめて小さくなる


第7章 ミニマップ(RenderTexture+直交カメラ)

方式を決める — 素朴に真上から撮ると、本作では2つ困る

📝 ここで手を動かす作業はありません。 どの方式で作るかを決める節です。実際の作業は次の「「Minimap」レイヤーを作る」から始まります。

ミニマップの定番の作り方は「もう1台のカメラを真上に置いて、その映像を画面の隅に貼る」です。カメラの映像を画面ではなく画像(テクスチャ)に描き出す仕組みを RenderTexture(レンダーテクスチャ) と呼び、これがミニマップの正体です。

ところが本作でこれを素直にやると、2つ困ります。

天井が邪魔をして、しかも床が暗すぎる
  1. 暗い。 第4弾で環境光をほぼ黒(#111015)まで落としました。真上から撮っても、映るのはほぼ真っ暗な床です

  2. 天井がある。 第3弾で区画を「1オブジェクトの中空メッシュ」にしたので、真上には天井が張ってあります

📝 ②については、第3弾で法線を内向きに反転した副作用——「外から見ると壁が透ける」——が、実は真上から見ると天井が透けて内部が見える方向に働きます(天井の外側は裏面なので描画されないためです)。とはいえ①の暗さは残ります。ライトを足して明るくする手もありますが、追加ライトの上限(第4弾)と負荷が気になります。

そこで本連載は、実景を映さない方式を採ります。

2台のカメラが、同じ空間の別々のものを見る

Minimap専用のレイヤーを作り、床の形をした「地図の板」だけをそのレイヤーに置いて、ミニマップカメラにはそれだけを映す。

利点は3つ。暗所ライティングの影響を受けない(板はライト不要のUnlitマテリアル)、天井の問題が消える(板しか映さない)、描画負荷が最小(板とアイコン数枚だけ)。そして何より、板は区画Prefabに1回仕込めば全15区画に行き渡ります。 第3弾の区画Prefab設計が、ここで3度目の仕事をします。

📝 実景をそのまま映す方式が向いている場面もあります(明るい屋外、天井が無い、地形が複雑で板を用意しづらい)。その場合は「カメラを天井より下に置く」「近クリップ面(Near Clip Plane)で天井を切る」といった手を使います。本記事の最後のコラムで触れます。


「Minimap」レイヤーを作る

  1. 編集 ▸ プロジェクト設定 ▸ タグとレイヤー(Tags and Layers)

  2. 「レイヤー」を展開し、User Layer 8 の欄に Minimap と入力

これで本連載のレイヤーは3つになりました。ここで一度整理しておきます。

第5弾では「レイの当たり先を絞る」ためにレイヤーを使いました(LayerMask)。今回は「カメラの映す対象を絞る」ために使います(Culling Mask)。同じレイヤーという道具の、別の使い方です。


地図の板のマテリアル(ライト不要のUnlit)

  1. Assets/Materials で右クリック ▸ 作成 ▸ マテリアル。名前 MinimapFloorMat

  2. インスペクター上部の シェーダー ドロップダウンから Universal Render Pipeline ▸ Unlit を選びます

  3. ベースマップ(Base Map) の色を明るいグレー(例:R:170 G:170 B:180)に

📝 Unlit(アンリット) は「ライティングを計算しないシェーダー」です。暗い遺跡の中にあっても、指定した色そのままで描かれます。第4弾で使った Lit(ライトの影響を受ける)との対比で覚えてください。ミニマップの板のように「常に同じ見え方でいてほしいもの」にぴったりです。

同様に、プレイヤーアイコン用の MinimapIconMat(Unlit、明るいオレンジ。例:R:255 G:150 B:40)も作っておきます。


区画Prefabに「地図の板」を仕込む(Prefabモード)

ここが本章の要です。シーン上のインスタンスではなく、Prefab本体を編集します。

  1. Projectウィンドウで Assets/Prefabs/Corridor_Straight をダブルクリック。ヒエラルキーが切り替わり、Prefabの中身だけが表示されます(これが Prefabモード です。左上に「<」ボタンが出ます)

  2. ルートの Corridor_Straight を右クリック ▸ 3Dオブジェクト ▸ クアッド(Quad)。名前 MinimapPlate

  3. Transform:

    • 位置:X:0 / Y:-1.45 / Z:0(区画の中心が原点で、床は高さの半分=-1.5の位置にあるので、そのすぐ上)

    • 回転:X:90 / Y:0 / Z:0(水平に寝かせる。Quadは表面が-Z向きなので、X:90で表面が上を向きます)

    • スケール:X:4 / Y:10 / Z:1(通路の内寸4×10。回転後は「Yがシーンの奥行き(Z)方向」になります)

  4. 付いている Mesh Collider を削除(当たり判定は不要。あると足元のレイや移動に干渉します)

  5. マテリアルに MinimapFloorMat をドラッグ

  6. インスペクター右上の Layer を Minimap に

  7. ヒエラルキー左上の「<」を押してPrefabモードを抜けます(自動保存されます)

■ 床の上に板を重ねるときの、2つの落とし穴

同じ高さだとチラつく/映らない板でも影は落ちる
  1. なぜ床と同じ -1.5 ではないのか。同じ位置に2枚の面があると、 どちらが手前か決まらずチラチラと交互に描かれます(Z-fighting)。 地面に何かを重ねるときは、少し浮かせるのが定番です。

  2. もし床が不自然に暗くなったら、MinimapPlate の Mesh Renderer で 投影(Cast Shadows)をオフに。板は本編カメラに映りませんが、 影の計算はカメラとは別だからです。

通路に MinimapPlate 設置

シーンに戻ると、配置済みの通路すべてに MinimapPlate が生えています。 第3弾で「あとから通路をもう1本足したいときはPrefabをドラッグするだけ」と書きましたが、その逆——「全部の通路をまとめて変えたい」——もPrefabなら1回で済む、というわけです。

さらにT字、L字、広場にも MinimapPlate 設置

同じ手順を、残り3種類のPrefabにも行います。

第8弾では「Prefabに適用は押さない」と注意しました。今回は逆で、Prefab本体に入れます。

■ Prefab本体に入れるもの、入れないもの

1体ずつ違う値はインスタンス、全員に配りたいものはPrefabモード

違いは「その値が全員同じかどうか」です。第8弾の junctionId や treasureIndex は1体ずつ違う値なので、Prefabに適用すると全員が同じ番号になってしまいます。今回の板は全員同じでよいので、本体に持たせるのが正解です。

1体ずつ違う値はインスタンスで、全員に配りたいものはPrefabモードで。 この使い分けが分かると、Prefabは怖くなくなります。

📝 板が壁とぴったり重なる位置にあると、ミニマップ上で端がちらつくことがあります(さきほどのZ-fightingと同じ現象です)。気になる場合は、スケールを0.1だけ小さくしてください。


RenderTextureを作る

RenderTextureとは、カメラの映像を、画面ではなく「絵」として受け取るための入れ物です。この絵をUIに貼ることで、画面の隅に地図を出せます。

行き先を画面からテクスチャへ切り替えるだけ

普通のカメラは、映した映像をそのまま画面に出します。ところが Target Texture に RenderTexture を割り当てると、映像の行き先が画面からこのファイルへ切り替わります。

  1. Projectウィンドウで Assets に Rendering フォルダを作成

  2. その中で右クリック ▸ 作成 ▸ レンダリング(Rendering) ▸ レンダーテクスチャ(Render Texture)(環境によっては 作成 ▸ レンダーテクスチャ 直下にあります)。名前 MinimapRT

  3. インスペクターの サイズ を 256 × 256 に。それ以外は既定のままで構いません

📝 なぜ256か。ミニマップは画面の隅に160ピクセル程度で表示するので、それより大きくしても見えません。一方でRenderTextureは毎フレーム、カメラ1台ぶんの描画コストがかかります。小さいほど軽いので、必要十分な大きさに留めます(公開の弾(第13弾)の負荷調整の布石です)。


ミニマップカメラを作る(Audio Listenerの回収)

  1. ヒエラルキー(Mainシーン)で右クリック ▸ カメラ。名前 MinimapCamera

  2. Audio Listener が付いていたら、右クリック ▸ コンポーネントを削除。 第5章で予告した「耳が2つ」問題の回収です。これを忘れると、コンソールに「There are 2 audio listeners in the scene」が出続け、3Dの音がどちらの耳で聞こえるか定まらなくなります

  3. Transform:位置はスクリプトで動かすのでそのまま。回転 X:90 / Y:0 / Z:0(真下を向く)

  4. Camera コンポーネント(URPの画面構成で説明します)

    • レンダータイプ(Render Type):基本(Base)(オーバーレイにしない。オーバーレイは別のカメラの上に重ねる用途です)

    • 投影(Projection):平行投影、サイズ:10(上下に10m=計20m四方が映る。迷路の分岐1つがちょうど収まる広さ)

    • レンダリング ▸ ポストプロセス:オフのまま(第4弾の技術メモで「ミニマップ用のカメラはポスプロ・オフが正解」と書いた、その回収です。ビネットや色調整は地図には不要)

    • レンダリング ▸ カリングマスク(Culling Mask):Minimap のみ(一度「Nothing」を選んでから Minimap にチェック)

    • 環境 ▸ 背景タイプ(Background Type):ソリッドカラー、背景の色を暗い紺(例:R:20 G:24 B:40)に

    • 出力 ▸ 出力テクスチャ(Output Texture):MinimapRT

平行投影は「遠近のない見え方」です。透視投影(本編のカメラ)だと遠くのものが小さく写るので、真上から撮っても端のほうは斜めから見た形に歪みます。平行投影ならどこを見ても真上からの形になり、通路は長方形、分岐はT字のまま、設計図のような見え方になります。地図にはこちらが向いています。


MinimapFollow.cs — 俯瞰カメラとアイコンをプレイヤーに追従させる

// ==========================================================
// 📄 スクリプト:MinimapFollow.cs
// ==========================================================
using UnityEngine;

// ミニマップ用の俯瞰カメラを、プレイヤーの真上に追従させる。
// あわせて、プレイヤーアイコンもプレイヤーの位置・向きに合わせる。
// アタッチ先:MinimapCamera
public class MinimapFollow : MonoBehaviour
{
    [Header("追いかける対象")]
    [SerializeField] private Transform target;      // model_test

    [Header("カメラの高さ")]
    [SerializeField] private float height = 30f;    // 地図の板より十分上

    [Header("向き")]
    [SerializeField] private bool rotateWithPlayer = false; // true:進行方向が常に上/false:北が常に上

    [Header("プレイヤーアイコン")]
    [SerializeField] private Transform playerIcon;  // MinimapIcon(シーン直下の空オブジェクト)
    [SerializeField] private float iconHeight = 2.5f;

    // カメラ系は LateUpdate(第1弾のFollowCameraと同じ理由:プレイヤーが動き終わった後に追う)
    private void LateUpdate()
    {
        if (target == null) return;

        Vector3 p = target.position;
        float yaw = target.eulerAngles.y;

        // カメラ:真上から見下ろす
        transform.position = new Vector3(p.x, p.y + height, p.z);
        transform.rotation = rotateWithPlayer
            ? Quaternion.Euler(90f, yaw, 0f)   // 進行方向が上
            : Quaternion.Euler(90f, 0f, 0f);   // 北(+Z)が上

        // アイコン:プレイヤーの頭上に置き、向きだけ合わせる
        if (playerIcon != null)
        {
            playerIcon.position = p + Vector3.up * iconHeight;
            playerIcon.rotation = Quaternion.Euler(0f, yaw, 0f);
        }
    }
}

Assets/Scripts内に MinimapFollow.cs を作成し、MinimapCamera にアタッチします。そのままインスペクターで参照を割り当てます。

  • Target:model_test(アバターのルート)

  • Player Icon:空のまま(次の節で作ります)

  • 高さ:20/Icon Height:0.2/Rotate With Player:オフのまま

⚠️ Target を割り当てないと、カメラは原点に置き去りになります。 再生してもミニマップが真っ暗なまま動かない、という症状になります。

■ 北固定か、進行方向固定か

地図が回るか、自分が回るか。本作は「形を覚えられる」ほうを選ぶ

rotateWithPlayer をオンにすると「自分の向きが常に上」になり直感的ですが、地図が回転するので形を覚えづらくなります。本作は「道を覚えるゲーム」(第8弾の設計方針)なので、北固定を既定にします。第8弾の全体マップは「上が+Z」で描きましたが、この設定ならミニマップもまったく同じ向きになります。


プレイヤーアイコン — キューブとクアッドだけで作る

アイコンは アバター(本記事の例ではmodel_test) の子にはしません。 第5弾の CameraModeSwitcher は、一人称に切り替えたとき「アバター配下の全Rendererを影だけ(ShadowsOnly)にする」処理を持っているので、子にするとFPS中にアイコンまで消えてしまいます。そこでシーン直下に置き、MinimapFollow が位置と向きを合わせる作りにしてあります。

■ MinimapIcon - Body

MinimapIcon - Body
  1. で右クリック ▸ 空のオブジェクトを作成。名前 MinimapIcon、位置 (0, 0, 0)

  2. MinimapIcon を右クリック ▸ 3Dオブジェクト ▸ クアッド。名前 Body

    • 位置 (0, 0, 0)/回転 X:90/スケール (1.2, 1.2, 1)

    • Mesh Collider を削除/マテリアル MinimapIconMat/Layer Minimap

■ MinimapIcon - Nose

MinimapIcon - Nose
  1. MinimapIcon を右クリック ▸ 3Dオブジェクト ▸ クアッド。名前 Nose(進行方向を示す「鼻」)

  2. 位置 (0, 0, 0.8)(親の前方。MinimapFollow が親の向きをプレイヤーに合わせるので、鼻は常に進行方向を指します)/回転 X:90/スケール (0.5, 0.6, 1)

  3. Mesh Collider を削除/マテリアル MinimapIconMat/Layer Minimap

■ MinimapCamera

MinimapCamera

MinimapCamera を選択し、さきほど空のままにした Player Icon に MinimapIcon を割り当て(Target はすでに入っています)


本編のカメラから「地図」を隠す(忘れやすい1手順)

ここまでで、ミニマップ側の準備は整いました。ただしこのまま再生すると、本編の画面にも地図の板とオレンジのアイコンが映り込みます。

理由は、Main Camera のカリングマスクが既定で Everything(すべて) だからです。「すべて」には、あとから作った Minimap レイヤーも含まれます。ミニマップ用のものは、ミニマップにだけ映ってほしいので、本編のカメラ側から除外します。

  1. ヒエラルキー(Mainシーン)で Main Camera を選択します

  2. インスペクターの Camera ▸ レンダリング(Rendering) ▸ カリングマスク(Culling Mask) のドロップダウンを開きます

  3. 一覧の Minimap のチェックだけを外します(他は触らない)。表示が「Everything」から「Mixed...」に変わります

⚠️ この1手順を忘れると、遺跡の床にオレンジの矢印が浮いて見えたり、床がやけに明るいグレーに見えたりします。「板やアイコンが本編に映る」ときは、まずMain Cameraのカリングマスクを疑ってください。

これで、レイヤーの使い分けが3通り出そろいました。第5弾の LayerMask(レイを当てる相手を絞る)、第9弾の Ignore Raycast(レイを素通りさせる)、そして今回の Culling Mask(カメラが映す相手を絞る)。同じ「レイヤー」という仕組みが、目的によって別の顔を見せます。


Raw Imageで画面に貼る

UIには、テクスチャをそのまま表示する Raw Image(Raw 画像) を使います。第9弾で使った Image(画像)はスプライト用、Raw Image はRenderTextureのような「生のテクスチャ」用です。

① 枠(先に作る)

  1. ヒエラルキーの Canvas(第5弾から使っているもの)を右クリック ▸ UI (Canvas) ▸ 画像(Image)。名前 MinimapFrame

  2. Rect Transform:アンカープリセット top right、位置 X:-100 / Y:-100、幅 168/高さ 168

  3. Image の色:暗い茶(例:R:60 G:45 B:30、A:255)

  4. レイキャストターゲット(Raycast Target)のチェックを外す(表示専用。第9弾のゲージと同じ理由。外し忘れると右上のクリックを吸います)

② 地図本体

  1. Canvas を右クリック ▸ UI (Canvas) ▸ Raw 画像(Raw Image)。名前 MinimapImage

  2. Rect Transform:アンカー top right、位置 X:-100 / Y:-100、幅 160/高さ 160(枠より4ピクセルずつ小さい=枠が4ピクセル見える)

  3. Raw Image の テクスチャ(Texture) に MinimapRT を割り当て

  4. レイキャストターゲットのチェックを外す

■ 描画の前後は、ヒエラルキーの並び順で決まります

下にあるものほど手前

MinimapFrame を先に作ったのは、MinimapImage より上(=奥)に置くためです。もし枠が地図を隠してしまったら、ヒエラルキーで MinimapFrame を MinimapImage の上へドラッグしてください。

📝 開発中は、Localizationパッケージが再生中の右上に言語切替のドロップダウンを出します(第6弾)。ミニマップと重なって見づらい場合は、編集 ▸ プロジェクト設定 ▸ Localization にある「ゲームビューにロケールメニューを表示(Show Locale Menu In Game View)」のチェックを外すと消せます。ビルドには元々含まれません。


地図に「どこまで見せるか」— 迷路ゲームでの注意

ミニマップは便利ですが、迷路ゲームでは作りすぎると迷路が成立しなくなります。 全体図をいつでも見られるなら、「迷う」という体験そのものが消えてしまうからです。

本作でカメラの サイズ(Orthographic Size)を 10 にしたのは、見た目の理由だけではありません。サイズ10は上下20m四方が映る設定で、これは「いま立っている区画と、その隣まで」にちょうど収まります。分岐に立つと左右の道が同じくらい伸びて見えるだけで、どちらが正解かは地図からは分かりません。 手がかりはあくまで宝箱の見分けと石碑(第8弾の設計方針)のままです。

💡 サイズを 30 や 50 まで上げると、遺跡の全体像が見えてしまいます。試しに一度上げてみると「迷路が謎解きでなくなる」感覚が分かるので、体験としておすすめです(確認したら10に戻してください)。

石碑・手記・宝箱といった仕掛けは、あえて地図に出していません。「地図では場所が分からない、自分の足で見つける」のが探索の面白さだからです。もし出したくなったら、それぞれに Minimap レイヤーの小さな板を子として付けるだけで実現できます(プレイヤーアイコンとまったく同じ手順です)。 「一度通った道だけ地図に出る(フォグ・オブ・ウォー)」も定番ですが、区画ごとに板の表示・非表示を切り替える仕組みが必要になるので、本連載では扱いません。MinimapPlate を最初は非アクティブにしておき、区画に入ったら SetActive(true) にする——という発想で作れます。第8弾の JunctionTrigger が、ちょうどその「区画に入った」を検知する部品です。


動作確認

  • 画面右上に、暗い紺の背景に明るいグレーの通路の形が表示される

  • オレンジのアイコンが中央にあり、鼻が自分の向いている方向を指す

  • 歩くとアイコンではなく地図のほうが流れる(=カメラが追従している)

  • 分岐(T字)や広間の形が地図で分かる

  • 本編の画面には、板もアイコンも映っていない

  • 暗い通路にいてもミニマップは明るいまま(Unlitの効果)

  • ミニマップの上をクリックしても、UIの操作が邪魔されない

  • コンソールにAudio Listenerの警告が出ていない

  • FPS(Vキー)に切り替えてもアイコンが消えない


おわりに

今回、実装したもの

第10弾(後編)で実装したもの
  • 4グループのAudioMixerと、dB変換つきの音量スライダー(保存つき)

  • 音の受付窓口 AudioManager(DontDestroyOnLoadの2回目。PlayClipAtPointの「Mixerに乗る版」)

  • 第1弾から9本ぶりの OnFootstep/OnLand 回収と、ブレンド中の二重再生を防ぐ weight 判定(ただし着地には入れない)

  • 番人の駆動音による3D定位=「姿より先に届く気配」(耳=カメラの5mを足して最長距離20m)

  • 第9弾の📢コメント全回収と、間隔の詰まる計数音

  • Minimap専用レイヤーで作るミニマップ(第3弾の区画Prefabが3度目の仕事)

  • ポーズ中はUI音だけが鳴る(ignoreListenerPause)

音が入ると、ゲームは急に「そこにある」感じがします。足音ひとつで遺跡に床ができ、番人の唸りひとつで角の向こうに誰かがいる。9本ぶんの無音を経て、ようやく遺跡が鳴りました。

そして、今回はあえて回収しなかった伏線があります。前編で予告した「エディタでは鳴るのに、ブラウザでは鳴らない音」です。AudioManager のスナップショット欄、pitch を正の値に留めた足音、Bootに置かなかった BgmRequest——後編のあちこちに置いた「公開の弾で効いてくる」という一言は、すべてそこにつながります。第4弾のフォグ、第7弾のEmissionに続く「エディタで動いたものが、配布先でも動くとは限らない」の3つ目として、公開の弾(第13弾)でビルドと一緒に確かめます。


第11弾予告

次回は『演出と結末 — コルーチン演出・推理ボード・マルチエンディング』です。

次回、第11弾『演出と結末』

今回入れた音の上に、演出を重ねます。強制的に歩かされる導入(第5弾の InputLocked が本領を発揮します)、まばたきのような画面の明滅、一度きりの演出、雨と粉塵。隊列をやり過ごす「隠れる」イベント。そして推理ボードと、3種類のエンディング——前編で作った LastEnding の伝言板を、ついに読み出します。第9弾の視線スイッチに残した最後の📢(仕掛けへの接続)も、そこで回収します。

その先には、プリミティブだった遺跡を本物の3Dアセットに差し替える第12弾と、いよいよitch.ioで公開する第13弾が控えています。

いいなと思ったら応援しよう!

辛島信芳 ここまで読んでくれてありがとうございます!記事は基本的に無料で出していくので、気が向いたら応援してもらえると嬉しいです☕