527 lines
17 KiB
Markdown
527 lines
17 KiB
Markdown
# 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<IAudioService>())─────────────
|
||
/// <summary>
|
||
/// 已废弃。请改用 ServiceLocator.Get<IAudioService>() 访问音频服务。
|
||
/// 保留此属性仅为历史层兴范过渡期兼容,将在下一个大版本移除。
|
||
/// </summary>
|
||
[System.Obsolete("Use ServiceLocator.Get<IAudioService>() 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 并应用所有音量
|
||
|
||
/// <summary>
|
||
/// 将指定混音器参数设置为 0-1 线性值(内部转换为 dB)。
|
||
/// 唯一音量写入入口——所有调用方均使用此方法。
|
||
/// </summary>
|
||
/// <param name="exposedParam">AudioMixerKeys.* 常量(Master / BGM / SFX / Ambient)</param>
|
||
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;
|
||
|
||
/// <summary>LiquidZone.OnTriggerEnter2D 时调用</summary>
|
||
public void EnterWater()
|
||
{
|
||
_mixer.FindSnapshot("Underwater")
|
||
.TransitionTo(_transitionDuration);
|
||
}
|
||
|
||
/// <summary>LiquidZone.OnTriggerExit2D 时调用</summary>
|
||
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 随速度变化)|
|