# 11 · 音频模块 > **命名空间** `BaseGames.Audio` > **程序集** `BaseGames.Audio` > **路径** `Assets/Scripts/Audio/` > **依赖** `BaseGames.Core.Events`、`Unity AudioMixer` --- ## 目录 1. [AudioMixer 架构](#1-audiomixer-架构) 2. [AudioManager](#2-audiomanager) 3. [BGMController](#3-bgmcontroller) 4. [AudioZone](#4-audiozone) 5. [AudioEventSO(SFX 集成)](#5-audioeventsso) 6. [GlobalSFXPlayer](#6-globalsfxplayer) 7. [AudioConfigSO](#7-audioconfigso) 8. [音频事件频道清单](#8-音频事件频道清单) --- ## 1. AudioMixer 架构 **资产路径**:`Assets/Audio/MainMixer.mixer` ### 混音组层级 ``` Master ├── BGM (背景音乐) ├── SFX (所有音效) │ ├── SFX_Player │ ├── SFX_Enemy │ └── SFX_World └── Ambient (环境音) ``` ### Exposed Parameters(代码用字符串常量) ```csharp // 路径: Assets/Scripts/Audio/AudioMixerKeys.cs public static class AudioMixerKeys { public const string Master = "MasterVolume"; public const string BGM = "BGMVolume"; public const string SFX = "SFXVolume"; public const string Ambient = "AmbientVolume"; } ``` 所有参数范围:`-80 ~ 0 dB` ### AudioMixer 快照 | 快照名 | 触发条件 | 主要差异 | |--------|---------|---------| | `Default` | 正常 Gameplay | 全组正常 | | `Paused` | `GameState.Paused` | BGM/SFX -12 dB + 低通滤波 | | `Dead` | 玩家死亡 | BGM 渐出 -80 dB(1.5s)| | `BossFight` | Boss 战开始 | Ambient -20 dB | 切换方式: ```csharp _mixer.TransitionToSnapshots(new[] { snapshot }, new[] { 1f }, transitionTime); ``` --- ## 2. AudioManager ```csharp // 路径: Assets/Scripts/Audio/AudioManager.cs [DefaultExecutionOrder(-500)] public class AudioManager : MonoBehaviour { [Header("AudioMixer")] [SerializeField] private AudioMixer _mixer; [Header("BGM Sources(双 Source 交叉淡入淡出)")] [SerializeField] private AudioSource _bgmSourceA; [SerializeField] private AudioSource _bgmSourceB; [Header("SFX Pool(4~8 源轮转,防高密度战斗音效戳断)")] [SerializeField] private AudioSource[] _sfxSources; // Inspector 预挂,建议 6 个,均路由到 SFX MixerGroup private int _sfxRoundRobin; // 轮转指针(无锁) [Header("Event Channels - Subscribe")] [SerializeField] private GameStateEventChannelSO _onGameStateChanged; [SerializeField] private StringEventChannelSO _onBossFightStarted; // ⚠️ bossId: string(与 02_EventSystem §4 / §3 BGMController 一致) // ── Singleton(已废弃:新代码请使用 ServiceLocator.Get())───────────── /// /// 已废弃。请改用 ServiceLocator.Get<IAudioService>() 访问音频服务。 /// 保留此属性仅为历史层兴范过渡期兼容,将在下一个大版本移除。 /// [System.Obsolete("Use ServiceLocator.Get() instead. AudioManager.Instance will be removed in a future version.")] public static AudioManager Instance { get; private set; } private void Awake() { if (Instance != null) { Destroy(gameObject); return; } Instance = this; _activeBGMSource = _bgmSourceA; _inactiveBGMSource = _bgmSourceB; } private AudioSource _activeBGMSource; private AudioSource _inactiveBGMSource; private Coroutine _crossfadeCoroutine; // ── 音量(统一入口;SettingsManager / 设置面板调用)──────────── public void Initialize(); // 读取 GlobalSettings 并应用所有音量 /// /// 将指定混音器参数设置为 0-1 线性值(内部转换为 dB)。 /// 唯一音量写入入口——所有调用方均使用此方法。 /// /// AudioMixerKeys.* 常量(Master / BGM / SFX / Ambient) public void SetVolume(string exposedParam, float linear) => _mixer.SetFloat(exposedParam, LinearToDecibel(linear)); // BGM 播放(带两段淡变时长) public void PlayBGM(AudioClip clip, float fadeOutDur = 1f, float fadeInDur = 1f); public void StopBGM(float fadeDuration = 1f); // SFX 一次性播放(轮转多源,避免高密度战斗时音效相互戳断) public void PlaySFX(AudioClip clip, float volumeScale = 1f) { var src = NextSFXSource(); src.volume = volumeScale; src.PlayOneShot(clip); // PlayOneShot 不打断当前正在播放的其他音效 } // 2D 游戏中位置参数不做 3D 衰减,统一个多源池路由 public void PlaySFXAtPosition(AudioClip clip, Vector2 pos, float volumeScale = 1f) => PlaySFX(clip, volumeScale); // 轮转返回下一个 AudioSource;PlayOneShot 下无需检查 isPlaying private AudioSource NextSFXSource() => _sfxSources[_sfxRoundRobin++ % _sfxSources.Length]; // 快照切换 public void TransitionToSnapshot(string snapshotName, float transitionTime = 0.5f); private IEnumerator CrossfadeCoroutine(AudioClip newClip, float fadeOutDur, float fadeInDur); private static float LinearToDecibel(float linear) => linear > 0.0001f ? 20f * Mathf.Log10(linear) : -80f; } ``` --- ## 3. BGMController ```csharp // 路径: Assets/Scripts/Audio/BGMController.cs // 订阅世界 / Boss 事件,指挥 AudioManager 切换 BGM public class BGMController : MonoBehaviour { [SerializeField] private AudioManager _audioManager; [SerializeField] private AudioConfigSO _config; [Header("Event Channels - Subscribe")] [SerializeField] private GameStateEventChannelSO _onGameStateChanged; [SerializeField] private BoolEventChannelSO _onBossFightToggled; // true=开始, false=结束 [SerializeField] private StringEventChannelSO _onRegionEntered; // region id private MusicState _musicState = MusicState.Exploration; private string _currentRegion = "Forest"; private void OnEnable() { _onBossFightToggled.OnEventRaised += OnBossFightToggled; _onRegionEntered.OnEventRaised += OnRegionEntered; _onGameStateChanged.OnEventRaised += HandleStateChanged; } private void OnDisable() { /* -= */ } private void OnBossFightToggled(bool started) { if (started) { _musicState = MusicState.Boss; var clip = _config.GetBossBGM(_currentRegion); _audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 0.5f); _audioManager.TransitionToSnapshot("BossFight", 0.5f); } else { StartCoroutine(PlayVictoryThenRestore()); } } private IEnumerator PlayVictoryThenRestore() { _musicState = MusicState.Victory; _audioManager.PlayBGM(_config.VictoryStingBGM, fadeOutDur: 0.3f, fadeInDur: 0.1f); yield return new WaitForSecondsRealtime(_config.VictoryStingDuration); _musicState = MusicState.Exploration; OnRegionEntered(_currentRegion); _audioManager.TransitionToSnapshot("Default", 1.0f); } private void OnRegionEntered(string regionId) { if (regionId == _currentRegion) return; _currentRegion = regionId; if (_musicState == MusicState.Exploration) { var clip = _config.GetZoneBGM(regionId); _audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 1f); } } private void HandleStateChanged(GameStateId state) { // ⚠️ GameStateId 是 struct,不能用 switch;使用 if/else + GameStates 常量(架构 03_CoreModule §2) if (state == GameStates.MainMenu) _audioManager.PlayBGM(_config.MainMenuBGM, fadeOutDur: 0.5f, fadeInDur: 1.0f); else if (state == GameStates.Paused) _audioManager.TransitionToSnapshot("Paused", 0.2f); else if (state == GameStates.Dead) _audioManager.TransitionToSnapshot("Dead", 1.5f); else if (state == GameStates.Gameplay) _audioManager.TransitionToSnapshot("Default", 0.3f); } } ``` --- ## 3.5 MusicState 音乐状态机 ```csharp // 路径: Assets/Scripts/Audio/BGMController.cs // 由 BGMController 内部维护,控制 BGM 切换逻辑 public enum MusicState { Exploration, // 默认:区域探索 BGM Boss, // Boss 战:Boss 主题 BGM Victory, // Boss 击败后短暂胜利音乐 None, // 过场/死亡/主菜单时由 BGMController 直接切换 } ``` **状态转换**: ``` Exploration ──[开始 Boss 战]─────────────► Boss ◄─[结束 Boss 战]────────────── Boss ──[Boss 击败]────────────────► Victory Victory ──[VictorySting 播放完毕]─────► Exploration Exploration ──[OnRegionEntered]─────────► Exploration(切换同状态内不同曲目) ``` --- ## 4. AudioZone ```csharp // 路径: Assets/Scripts/Audio/AudioZone.cs // 触发器:进入区域时切换 BGM [RequireComponent(typeof(Collider2D))] public class AudioZone : MonoBehaviour { [SerializeField] private string _zoneId; // 与 AudioConfigSO 中的 key 对应 [Header("Event Channel")] [SerializeField] private StringEventChannelSO _onRegionEntered; private void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag("Player")) _onRegionEntered.Raise(_zoneId); } } ``` --- ## 5. AudioEventSO ```csharp // 路径: Assets/Scripts/Audio/AudioEventSO.cs // 可在 Inspector 配置的 SFX 数据:支持随机音量/音调、随机片段 [CreateAssetMenu(menuName = "Audio/AudioEvent")] public class AudioEventSO : ScriptableObject { public AudioClip[] Clips; // 随机挑选一个播放 [Range(0f, 1f)] public float VolumeMin = 0.9f; [Range(0f, 1f)] public float VolumeMax = 1.0f; [Range(0.5f, 2f)] public float PitchMin = 0.95f; [Range(0.5f, 2f)] public float PitchMax = 1.05f; public AudioMixerGroup MixerGroup; // 指定路由到哪个子混音组(如 SFX_Player) public void Play(AudioSource source) { if (Clips == null || Clips.Length == 0) return; source.outputAudioMixerGroup = MixerGroup; source.clip = Clips[Random.Range(0, Clips.Length)]; source.volume = Random.Range(VolumeMin, VolumeMax); source.pitch = Random.Range(PitchMin, PitchMax); source.Play(); } public void PlayOneShot(AudioSource source) { if (Clips == null || Clips.Length == 0) return; var clip = Clips[Random.Range(0, Clips.Length)]; source.outputAudioMixerGroup = MixerGroup; source.PlayOneShot(clip, Random.Range(VolumeMin, VolumeMax)); } } ``` **资产路径**:`Assets/ScriptableObjects/Audio/` **命名规范**:`AUD_{Category}_{Name}.asset`(例 `AUD_Player_SwordSlash.asset`) --- ## 6. GlobalSFXPlayer ```csharp // 路径: Assets/Scripts/Audio/GlobalSFXPlayer.cs // 提供静态方法入口,配合 AudioEventSO 在任何地方播放 SFX // Feel(MMF_Player)的 MMSoundManagerSoundSO 也通过此路由 public class GlobalSFXPlayer : MonoBehaviour { private static GlobalSFXPlayer _instance; [SerializeField] private AudioMixerGroup _sfxGroup; private void Awake() { if (_instance != null) { Destroy(gameObject); return; } _instance = this; } // 路由到 AudioManager.PlaySFX(多源轮转池,见 §2) // AudioEventSO.GetClip() 返回对应音频片段(含随机变体支持) public static void Play(AudioEventSO audioEvent, Vector2? worldPos = null) { // 2D 游戏不需要 3D 空间音效衰减;统一委托 AudioManager 多源池播放 var clip = audioEvent.GetClip(); if (clip != null) AudioManager.Instance.PlaySFX(clip); } } ``` --- ## 7. AudioConfigSO ```csharp // 路径: Assets/Scripts/Audio/AudioConfigSO.cs [CreateAssetMenu(menuName = "Audio/AudioConfig")] public class AudioConfigSO : ScriptableObject { [System.Serializable] public struct ZoneBGMEntry { public string ZoneId; public AudioClip BGMClip; public float FadeDuration; } [System.Serializable] public struct BossBGMEntry { public string BossId; public AudioClip BGMClip; } public ZoneBGMEntry[] ZoneBGMs; public BossBGMEntry[] BossBGMs; public AudioClip MainMenuBGM; public AudioClip GameOverSting; // 死亡时短音乐片段 public AudioClip VictoryStingBGM; // Boss 击败后胜利音乐片段 public float VictoryStingDuration = 4f; // 胜利音乐播放时长(秒) public AudioClip GetZoneBGM(string zoneId) { foreach (var e in ZoneBGMs) if (e.ZoneId == zoneId) return e.BGMClip; return null; } public AudioClip GetBossBGM(string bossId) { foreach (var e in BossBGMs) if (e.BossId == bossId) return e.BGMClip; return null; } } ``` **资产路径**:`Assets/ScriptableObjects/Audio/AUD_Config.asset` --- ## 8. 音频事件频道清单 | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_RegionEntered` | `StringEventChannelSO` | `AudioZone` | `BGMController` | | `EVT_BossFightStarted` | `StringEventChannelSO` | `BossOrchestrator` | `BGMController`、`BossHPBar` | | `EVT_BossFightEnded` | `BoolEventChannelSO` | `BossBase` | `BGMController` | | `EVT_GameStateChanged` | `GameStateEventChannelSO` | `GameManager` | `BGMController`(暂停/恢复)、`AudioManager`(快照) | | `EVT_PlayerDied` | `VoidEventChannelSO` | `PlayerStats` | `AudioManager`(播放 GameOverSting、切 Dead 快照) | --- ## 9. 脚步声材质分层(Footstep Material System) > **Design 来源**:[12_AudioSystem](../Design/12_AudioSystem.md) §14 脚步声根据脚下地面材质动态切换,不使用单一 SFX 以增强环境真实感。 ```csharp // 路径: Assets/Scripts/Audio/FootstepMaterial.cs public enum FootstepMaterial { Stone, // 石板地(默认) Dirt, // 泥土/草地 Wood, // 木板 Metal, // 金属格栅 Water, // 浅水区(溅水声) Sand, // 沙地 Grass, // 草丛 Cave, // 洞穴(回响加强) } // 路径: Assets/Scripts/Audio/FootstepAudioConfigSO.cs [CreateAssetMenu(menuName = "BaseGames/Audio/FootstepAudioConfig")] public class FootstepAudioConfigSO : ScriptableObject { [System.Serializable] public struct MaterialEntry { public FootstepMaterial material; public AudioClip[] clips; // 随机选一个,防止重复感 [Range(0f, 1f)] public float volume; [Range(0.8f, 1.2f)] public float pitchVariance; // 每次随机 pitch 偏移范围 } public MaterialEntry[] entries; public MaterialEntry? GetEntry(FootstepMaterial mat) { foreach (var e in entries) if (e.material == mat) return e; return null; } } // 路径: Assets/Scripts/Audio/FootstepMaterialMarker.cs // 挂载到地面碰撞体所在 GameObject(Tilemap 图层 or 单体地形 Prefab) public class FootstepMaterialMarker : MonoBehaviour { public FootstepMaterial material; } ``` **播放时机**: - **落地**:`PlayerController.OnLanded()` 触发(同 MaterialEntry,音量 ×1.5) - **行走**:Animancer 动画事件 `FootstepL` / `FootstepR`(见 `24_AnimEventModule`)触发 - **冲刺起步**:Dash 动画第 2 帧触发专属 `DashSFX`(不走 Footstep 通道) 玩家若脚下 GameObject 无 `FootstepMaterialMarker`,默认使用 `Stone`。 --- ## 10. 水下音效处理(Underwater Audio) > **Design 来源**:[12_AudioSystem](../Design/12_AudioSystem.md) §15 进入 `LiquidZone`(见 `21_LiquidPuzzleModule`)时,全局音效自动应用水下 DSP 处理。 ```csharp // 路径: Assets/Scripts/Audio/UnderwaterAudioController.cs // 挂载于 PlayerController 所在 GameObject;LiquidZone 调用 EnterWater/ExitWater public class UnderwaterAudioController : MonoBehaviour { [SerializeField] AudioMixer _mixer; [SerializeField] float _transitionDuration = 0.3f; /// LiquidZone.OnTriggerEnter2D 时调用 public void EnterWater() { _mixer.FindSnapshot("Underwater") .TransitionTo(_transitionDuration); } /// LiquidZone.OnTriggerExit2D 时调用 public void ExitWater() { _mixer.FindSnapshot("Default") .TransitionTo(_transitionDuration); } } ``` **Underwater Snapshot DSP 配置**(AudioMixer 中预设): | Bus | 处理 | |-----|------| | BGM | Low-Pass 800 Hz(水下声音沉闷)| | SFX | Low-Pass 1200 Hz + Volume ×0.7 | | Ambient | Volume ×0,替换为水下环境音(气泡声)| | PlayerSFX | Low-Pass 1000 Hz | **水下专属 SFX 对照**: | 动作 | 水上 SFX | 水下 SFX | |------|---------|---------| | 攻击 | `sfx_player_slash` | `sfx_player_slash_underwater` | | 浮出水面 | — | `sfx_splash_exit` | | 入水 | — | `sfx_splash_enter` | | 游泳移动 | — | `sfx_swim_loop`(循环,pitch 随速度变化)|