Files
zeling_v2/Docs/Architecture/11_AudioModule.md
2026-05-08 11:04:00 +08:00

527 lines
17 KiB
Markdown
Raw Permalink 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.
# 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. [AudioEventSOSFX 集成)](#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 dB1.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 Pool4~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&lt;IAudioService&gt;() 访问音频服务。
/// 保留此属性仅为历史层兴范过渡期兼容,将在下一个大版本移除。
/// </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);
// 轮转返回下一个 AudioSourcePlayOneShot 下无需检查 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
// FeelMMF_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
// 挂载到地面碰撞体所在 GameObjectTilemap 图层 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 所在 GameObjectLiquidZone 调用 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 随速度变化)|