Files
zeling_v2/Docs/Design/12_AudioSystem.md
2026-05-08 11:04:00 +08:00

623 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 12 · 音频系统
> **命名空间** `BaseGames.Audio`
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
> **依赖** `BaseGames.Core.Events` · Unity Audio · AudioMixer
---
## 目录
1. [设计原则](#1-设计原则)
2. [AudioMixer 架构](#2-audiomixer-架构)
3. [AudioManager](#3-audiomanager)
4. [BGMController — 自适应音乐](#4-bgmcontroller--自适应音乐)
5. [音乐状态机](#5-音乐状态机)
6. [AudioZone — 区域音乐触发](#6-audiozone--区域音乐触发)
7. [AudioEventSO — SFX 集成](#7-audioeventsso--sfx-集成)
8. [GlobalSFXPlayer](#8-globalsfxplayer)
9. [音频资产规范](#9-音频资产规范)
10. [AudioConfigSO](#10-audioconfigso)
11. [SettingsManager — 音量持久化](#11-settingsmanager--音量持久化)
12. [事件频道](#12-事件频道)
13. [编辑器友好设计](#13-编辑器友好设计)
---
## 1. 设计原则
- **分层混音**:所有音频通过 AudioMixer 路由,统一控制音量分组,不直接设置 `AudioSource.volume`
- **自适应音乐**BGM 根据游戏状态(探索/战斗/Boss动态切换保持氛围沉浸感
- **零耦合**`BGMController` 只订阅事件频道,不持有 `GameManager``EnemyBase` 等引用
- **像素风音效**:优先使用复古 8-bit / 16-bit 风格音效,随机 Pitch 变化±5%~10%)防止重复感
- **2D 空间音频**:本游戏为 2D 横版,不使用 3D 音效空间化(`AudioSource.spatialBlend = 0`),仅靠左右声道区分近远
---
## 2. AudioMixer 架构
`MainMixer.mixer` 资产路径:`Assets/Audio/MainMixer.mixer`
### 混音组层级
```
Master
├── BGM (背景音乐探索BGM、Boss BGM、主菜单BGM)
├── SFX (音效战斗、UI、环境互动)
│ ├── SFX_Player (玩家动作音效,子混音组)
│ ├── SFX_Enemy (敌人音效,子混音组)
│ └── SFX_World (世界互动音效,子混音组)
└── Ambient (环境音:风声、水声、洞穴回声等)
```
### Exposed Parameters暴露给代码控制的参数名
| 参数名 | 对应混音组 | 范围 | 说明 |
|--------|-----------|------|------|
| `MasterVolume` | Master | -80 ~ 0 dB | 总音量 |
| `BGMVolume` | BGM | -80 ~ 0 dB | 背景音乐音量 |
| `SFXVolume` | SFX | -80 ~ 0 dB | 音效总音量 |
| `AmbientVolume` | Ambient | -80 ~ 0 dB | 环境音音量 |
**线性值转分贝**Settings 滑条映射):
```csharp
public static float LinearToDecibel(float linear)
=> linear > 0.0001f ? 20f * Mathf.Log10(linear) : -80f;
```
### AudioMixer 快照Snapshots
| 快照名 | 用途 | 关键差异 |
|--------|------|---------|
| `Default` | 正常游玩 | 所有组正常音量 |
| `Paused` | 游戏暂停 | BGM / SFX 均降低 -12 dB添加低通滤波Cutoff 500Hz|
| `Dead` | 玩家死亡 | BGM 渐出 -80 dB1.5s 过渡) |
| `BossFight` | Boss 战 | Ambient 降低 -20 dB突出 Boss BGM|
快照切换:`_mixer.TransitionToSnapshots(new[] { snapshot }, new[] { 1f }, transitionTime)`
---
## 3. AudioManager
`AudioManager` 常驻 Persistent 场景,管理 BGM AudioSource 和 SFX 全局播放:
```csharp
namespace BaseGames.Audio
{
[DefaultExecutionOrder(-500)]
public class AudioManager : MonoBehaviour
{
[Header("AudioMixer")]
[SerializeField] AudioMixer _mixer;
[Header("BGM Sources")]
[SerializeField] AudioSource _bgmSourceA; // 双 Source 交叉淡入淡出
[SerializeField] AudioSource _bgmSourceB;
[Header("SFX Source")]
[SerializeField] AudioSource _globalSFXSource; // 一次性 SFX 播放
[Header("Config")]
[SerializeField] AudioConfigSO _config;
// 对外接口
public void Initialize(); // SettingsManager 读取 → 应用音量
public void SetVolume(string exposedParam, float linear); // 滑条回调
public void PlayBGM(AudioClip clip, float fadeOutDur = 1f, float fadeInDur = 1f);
public void StopBGM(float fadeOutDur = 1f);
public void PlaySFX(AudioClip clip, float volumeScale = 1f);
public void TransitionToSnapshot(string snapshotName, float transitionTime);
}
}
```
### BGM 交叉淡入淡出
使用双 `AudioSource`A/B实现无缝交叉淡变
```
当前播放 → Source A淡出 fadeOutDur
新曲目 → Source B淡入 fadeInDur
淡出结束 → 交换 A/B 角色,下次切换时复用
```
```csharp
IEnumerator CrossFade(AudioClip newClip, float fadeOutDur, float fadeInDur)
{
var outSource = _activeSource;
var inSource = _inactiveSource;
inSource.clip = newClip;
inSource.volume = 0f;
inSource.Play();
float t = 0f;
while (t < Mathf.Max(fadeOutDur, fadeInDur))
{
t += Time.unscaledDeltaTime;
outSource.volume = Mathf.Lerp(1f, 0f, t / fadeOutDur);
inSource.volume = Mathf.Lerp(0f, 1f, t / fadeInDur);
yield return null;
}
outSource.Stop();
(_activeSource, _inactiveSource) = (inSource, outSource);
}
```
---
## 4. BGMController — 自适应音乐
`BGMController` 订阅各种游戏事件,自动切换 BGM 曲目:
```csharp
public class BGMController : MonoBehaviour
{
[SerializeField] AudioManager _audioManager;
[SerializeField] AudioConfigSO _config;
// 监听的事件频道
[SerializeField] GameStateEventChannelSO _onGameStateChanged;
[SerializeField] StringEventChannelSO _onRegionEntered;
[SerializeField] BoolEventChannelSO _onBossFightToggled;
string _currentRegion = "Forest";
void OnGameStateChanged(GameState state)
{
switch (state)
{
case GameState.MainMenu:
_audioManager.PlayBGM(_config.mainMenuBGM, fadeOut: 0.5f, fadeIn: 1.0f);
break;
case GameState.BossFight:
// BGMController 不直接切换,等待 OnBossFightToggled
break;
case GameState.Dead:
_audioManager.TransitionToSnapshot("Dead", 1.5f);
break;
case GameState.Paused:
_audioManager.TransitionToSnapshot("Paused", 0.2f);
break;
case GameState.Gameplay:
_audioManager.TransitionToSnapshot("Default", 0.3f);
PlayRegionBGM(_currentRegion);
break;
}
}
void OnRegionEntered(string regionId)
{
_currentRegion = regionId;
if (_musicState == MusicState.Exploration)
PlayRegionBGM(regionId);
}
void OnBossFightToggled(bool started)
{
if (started)
{
_musicState = MusicState.Boss;
_audioManager.PlayBGM(_config.GetBossBGM(_currentRegion), fadeOut: 1f, fadeIn: 0.5f);
_audioManager.TransitionToSnapshot("BossFight", 0.5f);
}
else
{
// Boss 击败:播放胜利 Sting然后恢复探索 BGM
StartCoroutine(PlayVictoryThenRestore());
}
}
IEnumerator PlayVictoryThenRestore()
{
_musicState = MusicState.Victory;
_audioManager.PlayBGM(_config.victoryStingBGM, fadeOut: 0.3f, fadeIn: 0.1f);
yield return new WaitForSecondsRealtime(_config.victoryStingDuration);
_musicState = MusicState.Exploration;
PlayRegionBGM(_currentRegion);
_audioManager.TransitionToSnapshot("Default", 1.0f);
}
}
```
---
## 5. 音乐状态机
```
MusicState 枚举:
Exploration ← 默认:区域探索 BGM
Boss ← Boss 战Boss 主题 BGM
Victory ← Boss 击败后短暂胜利音乐
None ← 过场/死亡/主菜单时由 BGMController 直接切换
```
**状态转换**
```
Exploration ──[OnBossFightToggled(true)]──► Boss
◄─[OnBossFightToggled(false)]──
Boss ──[Boss 击败动画完成]──────────► Victory
Victory ──[VictorySting 播放完毕]───────► Exploration
Exploration ──[OnRegionEntered]────────────► Exploration切换同状态内不同曲目
```
---
## 6. AudioZone — 区域音乐触发
`AudioZone` 挂载在每个区域的入口触发器上,通知 BGMController 切换对应 BGM
```csharp
public class AudioZone : MonoBehaviour
{
[SerializeField] string _regionId; // 如 "Forest", "Cave", "Ruins"
[SerializeField] StringEventChannelSO _onRegionEntered;
void OnTriggerEnter2D(Collider2D other)
{
if (other.CompareTag("Player"))
_onRegionEntered.Raise(_regionId);
}
}
```
**区域 BGM 配置**在 `AudioConfigSO` 中(见 §10`BGMController` 通过 regionId 查表获取对应 AudioClip。
---
## 7. AudioEventSO — SFX 集成
`AudioEventSO` 已在 [07_FeedbackSystem.md](./07_FeedbackSystem.md) 中定义,本系统扩展其与 AudioMixer 的集成:
```csharp
[CreateAssetMenu(menuName = "Audio/AudioEvent")]
public class AudioEventSO : ScriptableObject
{
[SerializeField] AudioClip[] _clips; // 随机选取
[SerializeField] AudioMixerGroup _mixerGroup; // 指定路由到哪个子混音组
[Range(0f, 1f)]
[SerializeField] float _baseVolume = 1f;
[SerializeField] Vector2 _pitchRange = new(0.9f, 1.1f); // 随机 Pitch 范围
public void Play(AudioSource source)
{
if (_clips.Length == 0) return;
source.outputAudioMixerGroup = _mixerGroup;
source.clip = _clips[Random.Range(0, _clips.Length)];
source.volume = _baseVolume;
source.pitch = Random.Range(_pitchRange.x, _pitchRange.y);
source.Play();
}
public void PlayOneShot(AudioSource source)
{
if (_clips.Length == 0) return;
source.outputAudioMixerGroup = _mixerGroup;
float pitch = Random.Range(_pitchRange.x, _pitchRange.y);
source.pitch = pitch;
source.PlayOneShot(_clips[Random.Range(0, _clips.Length)], _baseVolume);
}
}
```
Feel 的 `MMF_AudioSource` 持有 `AudioEventSO` 引用,调用 `Play()` 即可。
---
## 8. GlobalSFXPlayer
对于不依附于具体 GameObject 的一次性 SFX如 UI 按钮音效),使用 `GlobalSFXPlayer`
```csharp
// AudioManager 的便捷方法(内部持有专用 AudioSource
public void PlaySFXGlobal(AudioEventSO audioEvent)
=> audioEvent.PlayOneShot(_globalSFXSource);
```
UI 按钮的 `onClick.AddListener(() => AudioManager.Instance.PlaySFXGlobal(btnClickSFX))`
---
## 9. 音频资产规范
### 文件路径
```
Assets/Audio/
├── BGM/
│ ├── BGM_MainMenu.ogg
│ ├── BGM_Forest_Exploration.ogg
│ ├── BGM_Cave_Exploration.ogg
│ ├── BGM_Ruins_Exploration.ogg
│ ├── BGM_Boss_Forest.ogg
│ ├── BGM_Boss_Cave.ogg
│ └── BGM_Victory_Sting.ogg
├── SFX/
│ ├── Player/
│ │ ├── SFX_Player_Jump.wav
│ │ ├── SFX_Player_Dash.wav
│ │ ├── SFX_Player_Attack_01/02/03.wav (连击三段各一个)
│ │ ├── SFX_Player_Hurt.wav
│ │ ├── SFX_Player_Death.wav
│ │ └── SFX_Parry_Success.wav
│ ├── Enemy/
│ │ ├── SFX_Enemy_Hit.wav
│ │ ├── SFX_Enemy_Death.wav
│ │ └── SFX_Enemy_Alert.wav
│ ├── World/
│ │ ├── SFX_Collectible_Geo.wav
│ │ ├── SFX_SavePoint_Activate.wav
│ │ ├── SFX_Door_Open.wav
│ │ └── SFX_Platform_Crumble.wav
│ └── UI/
│ ├── SFX_UI_Confirm.wav
│ ├── SFX_UI_Cancel.wav
│ └── SFX_UI_Navigate.wav
└── Ambient/
├── AMB_Forest_Wind.ogg
├── AMB_Cave_Drip.ogg
└── AMB_Ruins_Echo.ogg
```
### 音频导入设置规范
| 类型 | 格式 | Load Type | 说明 |
|------|------|-----------|------|
| BGM | `.ogg` | Streaming | 大文件流式读取,节省内存 |
| SFX短促| `.wav` | Decompress on Load | 最低延迟,内存占用接受 |
| SFX循环环境| `.ogg` | Compressed in Memory | 中等大小,压缩存储 |
| Ambient | `.ogg` | Streaming | 长音频流式读取 |
---
## 10. AudioConfigSO
所有 BGM 和关键 SFX 引用集中配置,避免硬编码引用:
```csharp
[CreateAssetMenu(menuName = "Config/AudioConfig")]
public class AudioConfigSO : ScriptableObject
{
[Header("BGM")]
public AudioClip mainMenuBGM;
public AudioClip victoryStingBGM;
public float victoryStingDuration = 3f;
[Header("Region BGMs")]
public RegionBGM[] regionBGMs; // RegionBGM { string regionId; AudioClip clip; AudioClip bossBGM; }
[Header("Fade Settings")]
public float defaultFadeOut = 1.0f;
public float defaultFadeIn = 1.0f;
public AudioClip GetRegionBGM(string regionId)
=> Array.Find(regionBGMs, r => r.regionId == regionId)?.clip;
public AudioClip GetBossBGM(string regionId)
=> Array.Find(regionBGMs, r => r.regionId == regionId)?.bossBGM;
}
```
资产路径:`Assets/ScriptableObjects/Config/AudioConfigSO.asset`
---
## 11. SettingsManager — 音量持久化
设置独立于存档系统,写入 `Application.persistentDataPath/settings.json`
```csharp
[Serializable]
public class SettingsData
{
public float masterVolume = 1.0f;
public float bgmVolume = 0.8f;
public float sfxVolume = 1.0f;
public float ambientVolume = 0.6f;
public bool haptics = true;
// P1: keybindings[]
}
public class SettingsManager : MonoBehaviour
{
public void Initialize()
{
_data = Load() ?? new SettingsData();
Apply(_data);
}
public void Apply(SettingsData data)
{
_audioManager.SetVolume("MasterVolume", data.masterVolume);
_audioManager.SetVolume("BGMVolume", data.bgmVolume);
_audioManager.SetVolume("SFXVolume", data.sfxVolume);
_audioManager.SetVolume("AmbientVolume",data.ambientVolume);
}
public void Save() => File.WriteAllText(_savePath, JsonUtility.ToJson(_data));
SettingsData Load() => File.Exists(_savePath) ? JsonUtility.FromJson<SettingsData>(File.ReadAllText(_savePath)) : null;
}
```
---
## 12. 事件频道
新增频道(`Assets/ScriptableObjects/Events/Audio/`
| 资产名 | 类型 | 用途 |
|--------|------|------|
| `OnRegionEntered.asset` | `StringEventChannelSO` | AudioZone 触发,传递 regionId |
| `OnBGMChangeRequested.asset` | `StringEventChannelSO` | P1显式请求切换 BGM过场等|
> `OnBossFightToggled.asset`、`OnGameStateChanged.asset` 在其他频道组已定义BGMController 直接订阅。
---
## 13. 编辑器友好设计
- `AudioManager` Custom Inspector实时显示当前 BGM 名称、Source A/B 音量、当前 Snapshot
- AudioConfigSO 提供 `[播放预览]` 按钮Editor Only在 Inspector 中点击可直接预听对应 BGM/SFXUI Toolkit `Button``CreateInspectorGUI()` 中添加)
- `AudioZone` Gizmo在 Scene View 显示音频区域范围(半透明绿色圆圈 + regionId 文字标签)
- `BGMController` Inspector显示当前 `MusicState` 枚举状态 + 当前区域 ID
---
## 14. 脚步声材质分层Footstep Material System
脚步声不使用单一 SFX而是根据脚下地面材质动态切换增加环境真实感。
### 14.1 地面材质枚举
```csharp
public enum FootstepMaterial
{
Stone, // 石板地(默认)
Dirt, // 泥土/草地
Wood, // 木板
Metal, // 金属格栅
Water, // 浅水区(溅水声)
Sand, // 沙地(细碎声)
Grass, // 草丛(沙沙声)
Cave, // 洞穴(回响加强)
}
```
### 14.2 FootstepAudioConfigSO
```csharp
[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;
}
```
### 14.3 地面标记
在 Tilemap 的每个 Tile 上(或碰撞体所在 GameObject 上)添加 `FootstepMaterialMarker` 组件:
```csharp
public class FootstepMaterialMarker : MonoBehaviour
{
public FootstepMaterial material;
}
```
玩家落地/行走时,通过当前碰撞的地面 GameObject 获取 `FootstepMaterialMarker.material`,驱动脚步声选择。若地面无标记,默认使用 `Stone`
### 14.4 播放时机
- **落地**`PlayerController.OnLanded()` 播放较响的落地音效(同一 MaterialEntry但音量×1.5
- **行走**Animancer 动画事件(`FootstepL` / `FootstepR` event tag触发脚步声播放
- **冲刺起步**`Dash` 动画第2帧触发 `DashSFX`(不用 Footstep用单独 SFX
---
## 15. 水下音效处理Underwater Audio
进入 `LiquidZone`(见 40_LiquidSwimSystem全局音效自动应用水下 DSP 处理。
### 15.1 水下 AudioMixer Snapshot
```
Snapshot: "Underwater"
BGM Bus: Low-Pass Filter 切割频率 800 Hz水下声音沉闷
SFX Bus: Low-Pass Filter 1200 Hz + Volume ×0.7
Ambient Bus: Volume ×0 + 替换为水下环境音(气泡声)
Reverb Bus: Room Size 增大Decay Time 1.8s(水下混响长)
PlayerSFX Bus: Low-Pass Filter 1000 Hz攻击声变闷
```
```csharp
// 进入/退出液体区域时过渡
public class UnderwaterAudioController : MonoBehaviour
{
[SerializeField] AudioMixer _mixer;
[SerializeField] float _transitionDuration = 0.3f;
public void EnterWater()
{
_mixer.FindSnapshot("Underwater")
.TransitionTo(_transitionDuration);
// 同时降低 BGM 音量,增强代入感
}
public void ExitWater()
{
_mixer.FindSnapshot("Default")
.TransitionTo(_transitionDuration);
}
}
```
### 15.2 水下专属 SFX
| 动作 | 水上 SFX | 水下 SFX |
|------|---------|---------|
| 攻击 | `sfx_player_slash` | `sfx_player_slash_underwater`(低频混响版)|
| 跳跃(浮出水面)| — | `sfx_splash_exit` |
| 入水 | — | `sfx_splash_enter` |
| 游泳移动 | — | `sfx_swim_loop`(循环,随速度调整 pitch|
---
## 16. 距离衰减曲线Distance Attenuation
所有世界空间中的 3D 音源(非 2D UI 音效)使用统一的衰减配置。
### 16.1 衰减模式
**游戏是 2D 横版卷轴,使用 `AudioSource.spatialBlend = 0`纯2D为主**,仅以下场景例外:
| 场景 | 实现方式 |
|------|---------|
| 大型场景中的远景环境音(瀑布/机械声)| `AudioSource.spatialBlend = 1.0`,自定义 Volume Rolloff 曲线 |
| NPC 的近身对话语音 | `spatialBlend = 0.5`,增加位置感但不做完整 3D |
| 远处 Boss 的警示音效(在门口听到 Boss 移动声)| `spatialBlend = 0.7`,最大距离 30 个世界单位 |
### 16.2 推荐衰减曲线参数(用于 3D 音源)
| 参数 | 值 |
|------|---|
| Min Distance | 22单位内全音量|
| Max Distance | 25超过25单位静音|
| Rolloff Mode | Custom Curve非线性靠近时迅速增大见下方|
| Volume Rolloff | 25: ×1.0 → 515: ×0.6 → 1525: ×0.2 → >25: ×0 |
```
Volume
1.0 |───╮
| ╲
0.6 | ╲──╮
| ╲
0.2 | ╲──╮
0.0 | ╲────
+─────────────────────> Distance
0 5 10 15 20 25
```
### 16.3 Reverb Zone区域混响
每个主区域在 Unity 的 `AudioReverbZone` 组件中配置环境混响:
| 区域 | ReverbPreset | 额外调整 |
|------|-------------|---------|
| 森林 | `Forest` | DecayTime 0.8s |
| 地穴 | `Cave` | DecayTime 2.0s,高频较少 |
| 废墟 | `Room` | DecayTime 1.2s |
| 深渊 | `Cave`(更大)| DecayTime 3.5sBass 增强 |
| 核心 | `Hallway` | DecayTime 1.8s,金属感 |