17 KiB
17 KiB
11 · 音频模块
命名空间
BaseGames.Audio
程序集BaseGames.Audio
路径Assets/Scripts/Audio/
依赖BaseGames.Core.Events、Unity AudioMixer
目录
- AudioMixer 架构
- AudioManager
- BGMController
- AudioZone
- AudioEventSO(SFX 集成)
- GlobalSFXPlayer
- AudioConfigSO
- 音频事件频道清单
1. AudioMixer 架构
资产路径:Assets/Audio/MainMixer.mixer
混音组层级
Master
├── BGM (背景音乐)
├── SFX (所有音效)
│ ├── SFX_Player
│ ├── SFX_Enemy
│ └── SFX_World
└── Ambient (环境音)
Exposed Parameters(代码用字符串常量)
// 路径: 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 |
切换方式:
_mixer.TransitionToSnapshots(new[] { snapshot }, new[] { 1f }, transitionTime);
2. AudioManager
// 路径: 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
// 路径: 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 音乐状态机
// 路径: 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
// 路径: 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
// 路径: 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
// 路径: 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
// 路径: 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 §14
脚步声根据脚下地面材质动态切换,不使用单一 SFX 以增强环境真实感。
// 路径: 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 §15
进入 LiquidZone(见 21_LiquidPuzzleModule)时,全局音效自动应用水下 DSP 处理。
// 路径: 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 随速度变化) |