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

17 KiB
Raw Permalink Blame History

11 · 音频模块

命名空间 BaseGames.Audio
程序集 BaseGames.Audio
路径 Assets/Scripts/Audio/
依赖 BaseGames.Core.EventsUnity AudioMixer


目录

  1. AudioMixer 架构
  2. AudioManager
  3. BGMController
  4. AudioZone
  5. AudioEventSOSFX 集成)
  6. GlobalSFXPlayer
  7. AudioConfigSO
  8. 音频事件频道清单

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 dB1.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 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

// 路径: 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
// 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

// 路径: 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 BGMControllerBossHPBar
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
// 挂载到地面碰撞体所在 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 §15

进入 LiquidZone(见 21_LiquidPuzzleModule)时,全局音效自动应用水下 DSP 处理。

// 路径: 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 随速度变化)