chore: initial commit

This commit is contained in:
2026-05-08 11:04:00 +08:00
commit f55d2a57c3
6278 changed files with 866081 additions and 0 deletions

View File

@@ -0,0 +1,526 @@
# 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 随速度变化)|