Unity游戏开发实用工具——音频管理器

Unity游戏开发实用工具——音频管理器


在使用Unity制作2D游戏的过程中,我实现了一套功能较为简单的音频管理系统,能够适用于快速原型开发和小型游戏项目。详细实现记录于此篇正文,为我自己提供一个技术积累,也希望能给有需要的人提供一个解决思路。

一、功能概述

该音频管理器实现以下功能:

  1. 使用单例模式实现全局的音频播放、停止、音量设置
  2. 支持Unity ScriptableObject配置音频,支持同一类型音频配置多个音频文件随机播放
  3. 简易对象池管理AudioSource
  4. 支持音频播放频率限制,防止多个音频短时间频繁播放造成鬼畜
  5. 支持了全局音量与类型音量倍率的双重控制架构,可以单独配置每个类型音量大小

该方案不支持Unity混音器(Audio Mixer)相关功能

该方案音源全部位于同一个根对象中,因此最好用于2D游戏

二、ScriptableObject配置脚本实现

采用音频类型枚举+AudioClip+音量的结构,并且希望在Unity Inspector界面中可以直接配置。

首先定义枚举类型,你可以为游戏中每种类型的音频都定义一个枚举,比如:按钮按下、界面弹出、界面关闭、BGM、怪物死亡...

public enum Sounds
{
    Upgrade = 0,
    ClickBtn = 1,
    GameSuccess = 2,
    GameFail = 3,
    Explosion = 4,
    MainMenuBGM = 5,
    InGameBGM = 6,
    Score = 7,
    ...,
}

接下来实现SO配置文件,使用自定义类型SoundEntry包装该类音频的各个属性。

我希望使用字典类型存储SoundEntry,将枚举类型作为键值,方便查找。但是字典类型无法序列化,于是我在so中使用List存储SoundEntry,方便在Inspector中配置,然后定义字段并配置get访问器,在获取字典时将list自动转为字典然后返回

由于每次访问都会新建一个字典然后返回,在频繁访问时会有性能隐患,因此建议将此so配置作为只读配置,不要动态更改,并且在访问后及时缓存,减少访问次数。

public class SoundsConfig : ScriptableObject
{
    [Serializable]
    public class SoundEntry
    {
        public Sounds key;
        public AudioClip[] value;

        [Range(0f, 1f)] [Tooltip("音量倍率(范围:0.0 ~ 1.0)\n0.0 = 静音,0.5 = 50%,1.0 = 100%")]
        public float volumeMultiplier = 1f;
    }

    [SerializeField] public List<SoundEntry> soundsList = new List<SoundEntry>();
    [Range(0f, 1f)] [Tooltip("全局音量(范围:0.0 ~ 1.0)\n0.0 = 静音,0.5 = 50%,1.0 = 100%")]
    public float soundsVolume = 1f;
    public float dequeDuration;
    public static double soundToleranceTime = 2.0;//频繁触发播放时的间隔限制
    
	//字典转换访问器
    private Dictionary<Sounds, SoundEntry> _soundsDictionary;
    public Dictionary<Sounds, SoundEntry> SoundsDictionary
    {
        get
        {
            if (_soundsDictionary == null)
            {
                _soundsDictionary = new Dictionary<Sounds, SoundEntry>();
                if (soundsList != null)
                {
                    foreach (var entry in soundsList)
                    {
                        if (!_soundsDictionary.ContainsKey(entry.key))
                        {
                            _soundsDictionary[entry.key] = entry;
                        }
                    }
                }
            }
            return _soundsDictionary;
        }
    }
}

在Unity中创建一个so对象,就可以配置音频了

image-20260327150936453

[!WARNING]

注意:如果配置了两条Key相同的配置,则只会读取其中之一,此处我没有做过多处理

三、SoundManager实现

这里我使用了单例模式,实现了一个音频管理器,任何模块都可以直接访问SoundManager来播放音效

1.初始化相关

#region 初始化

    //变量实例化以及从
    protected override void OnSingletonInit()
    {
        base.OnSingletonInit();

        InitializeDataFromSO();//从so读取数据
        m_audioSources = new();
        m_retentionTimers = new();
        m_loopAudioSources = new();
        m_completedSoundsCache = new();
        RegisterEvent();//事件注册
        m_gameObject = new GameObject();
        m_gameObject.name = GetType().ToString();
        DontDestroyOnLoad(m_gameObject);
    }

    private void RegisterEvent()
    {
        //注册总音量变更事件,更新全部AudioSource音量
        onSoundsvolumeChanged += UpdateAudioSourcesColume;
    }

    /// <summary>
    ///     从SO加载配置
    /// </summary>
    private void InitializeDataFromSO()
    {
        //这里是我自己的加载so对象的方法
        soundsManagerSO = this.GetUtility<GetConfigUtility>().GetConfig<SoundsConfig>();
        soundsAudioClips = soundsManagerSO.SoundsDictionary;
        // 确保 soundsVolume 在 0~1 范围内(防止手动输入错误值)
        soundsVolume = Mathf.Clamp01(soundsManagerSO.soundsVolume);
        dequeDuration = soundsManagerSO.dequeDuration;//暂时没用
    }

    #endregion

2.播放相关

正常在调用接口播放音频时,首先会请求一个AudioSource,如果没有空闲AudioSource就创建一个新的播放,然后设置计时器,加入循环播放或者非循环播放的缓存。但是PlayRequestBreakOff方法是我提供的一个特殊的播放方法,使用这个接口播放音频不会有播放频率限制,而且当有同类型音频正在播放时会直接打断移除旧音频并重新播放

也就是说其它的播放接口有播放频率约束而且在没有空闲AudioSource时优先新建;而PlayRequestBreakOff没有频率约束,在没有空闲AudioSource时优先替换同类型音频,适合用作点击音效

#region 播放相关

    /// <summary>
    ///     请求循环播放
    /// </summary>
    /// <param name="soundName"></param>
    public void PlayLoopRequest(Sounds soundName)
    {
        if (m_loopAudioSources.ContainsKey(soundName)) return;
        PlaySound(soundName, true);
    }

    /// <summary>
    ///     停止循环播放
    /// </summary>
    /// <param name="soundName"></param>
    public void StopLoopRequset(Sounds soundName)
    {
        RemoveSounds(soundName);
    }

    /// <summary>
    ///     停止所有音频(包括循环播放和单次播放)
    /// </summary>
    public void StopAllSounds()
    {
        // 停止所有循环播放的音频
        foreach (var kvp in m_loopAudioSources)
        {
            if (kvp.Value != null)
            {
                kvp.Value.Stop();
                kvp.Value.clip = null;
                kvp.Value.loop = false;
            }
        }
        m_loopAudioSources.Clear();

        // 停止所有正在播放的音源
        if (m_audioSources != null)
        {
            foreach (var audioSource in m_audioSources)
            {
                if (audioSource != null && audioSource.isPlaying)
                {
                    audioSource.Stop();
                    audioSource.clip = null;
                    audioSource.loop = false;
                }
            }
        }

        // 清空计时器
        if (m_retentionTimers != null)
        {
            m_retentionTimers.Clear();
        }

        // 清空缓存
        if (m_completedSoundsCache != null)
        {
            m_completedSoundsCache.Clear();
        }
    }

    /// <summary>
    ///     播放
    /// </summary>
    /// <param name="sounds"></param>
    /// <param name="loop"></param>
    private void PlaySound(Sounds sounds, bool loop = false)
    {
        var audioSource = GetAudioSource(sounds);
        if (loop)
        {
            RemoveSounds(sounds);
            m_loopAudioSources.Add(sounds, audioSource);
        }

        AudioClip clip = GetASound(sounds);
        if (clip == null)
        {
            return;
        }

        audioSource.clip = clip;
        audioSource.loop = loop;

        // 确保音量倍率被正确应用(在播放前设置)
        float multiplier = GetVolumeMultiplier(sounds);
        audioSource.volume = soundsVolume * multiplier;

        audioSource.Play();

        if (!loop)
        {
            var timer = new Timer((float)SoundsConfig.soundToleranceTime, false);
            timer.RestartTimer();
            m_retentionTimers.Add(sounds, timer);
        }
    }

    /// <summary>
    ///     请求播放音效(带频率限制)
    ///     同一音效在2秒内只会播放一次
    /// </summary>
    /// <param name="soundName">音效类型</param>
    public void PlayRequest(Sounds soundName)
    {
        if (m_retentionTimers.ContainsKey(soundName)) return; //限制播放频率
        PlaySound(soundName, false);
    }

    /// <summary>
    ///     强制播放请求
    /// </summary>
    /// <param name="soundName"></param>
    public void PlayRequestImmediate(Sounds soundName)
    {
        var audioSource = GetAudioSource(soundName);
        AudioClip clip = GetASound(soundName);
        if (clip == null)
        {
            return;
        }

        audioSource.clip = clip;

        // 确保音量倍率被正确应用(在播放前设置)
        float multiplier = GetVolumeMultiplier(soundName);
        audioSource.volume = soundsVolume * multiplier;

        audioSource.Play();
    }

    /// <summary>
    ///     打断播放(当有相同的音频正在播放,则直接中断该音频然后重新播放)
    /// </summary>
    /// <param name="soundName">要播放的音效类型</param>
    public void PlayRequestBreakOff(Sounds soundName)
    {
        // 1. 移除频率限制计时器,允许立即播放
        if (m_retentionTimers.ContainsKey(soundName))
        {
            m_retentionTimers.Remove(soundName);
        }

        // 2. 如果该音效正在循环播放,停止并移除
        if (m_loopAudioSources.ContainsKey(soundName))
        {
            RemoveSounds(soundName);
        }

        // 3. 获取目标音效的 AudioClip
        AudioClip targetClip = GetASound(soundName);
        if (targetClip == null)
        {
            return;
        }

        // 4. 中断正在播放的相同音效
        foreach (var audioSource in m_audioSources)
        {
            if (audioSource.isPlaying && audioSource.clip == targetClip)
            {
                audioSource.Stop();
                audioSource.clip = null;
            }
        }

        // 5. 播放新音效
        PlaySound(soundName, false);
    }

    #endregion

3.数据操作相关

数据操作部分主要提供一些对本地数据增删改查的方法

对于同一个音频类型多个音频资源随机播放的功能,将在获取音频时实现

[!NOTE]

AudioSource的管理使用了简易对象池系统。但是这个对象池系统只管创建不管清理且没有上限,因此在播放方法中我添加了不优先创建新AudioSource的打断播放方法。这种做法对于一般小游戏项目应该不会有什么影响,但是为了安全起见还是希望使用者能够引入更成熟的对象池进行管理

#region 数据操作相关

    /// <summary>
    ///     获取音频
    /// </summary>
    /// <param name="name"></param>
    /// <returns></returns>
    private AudioClip GetASound(Sounds name)
    {
        if (!soundsAudioClips.ContainsKey(name))
        {
            return null;
        }
        var clips = soundsAudioClips[name].value;
    	//如果该类音频有多个音频资源则随机返回一个
        return clips[Random.Range(0, clips.Length)];
    }

    /// <summary>
    ///     获取音源
    /// </summary>
    /// <returns></returns>
    private AudioSource GetAudioSource(Sounds soundType)
    {
        foreach (var audioSource in m_audioSources)
        {
            if (!audioSource.isPlaying)
            {
                audioSource.loop = false;
                // 更新音量
                float multiplier = GetVolumeMultiplier(soundType);
                audioSource.volume = soundsVolume * multiplier;
                return audioSource;
            }
        }
        return AddAudioSource(soundType);
    }

    /// <summary>
    ///     增加音源
    /// </summary>
    /// <returns></returns>
    private AudioSource AddAudioSource(Sounds soundType)
    {
        var audioSource = m_gameObject.AddComponent<AudioSource>();
        float multiplier = GetVolumeMultiplier(soundType);
        audioSource.volume = soundsVolume * multiplier;
        m_audioSources.Add(audioSource);
        return audioSource;
    }
    
    /// <summary>
    ///     更新音源音量
    /// </summary>
    private void UpdateAudioSourcesColume()
    {
        // 更新所有循环播放音频的音量
        foreach (var kvp in m_loopAudioSources)
        {
            if (kvp.Value != null)
            {
                float multiplier = GetVolumeMultiplier(kvp.Key);
                kvp.Value.volume = soundsVolume * multiplier;
            }
        }

        // 更新所有单次播放音频的音量
        foreach (var audioSource in m_audioSources)
        {
            if (audioSource.isPlaying)
            {
                // 查找该音频对应的 Sounds 类型
                Sounds? soundType = FindSoundTypeByClip(audioSource.clip);
                if (soundType.HasValue)
                {
                    float multiplier = GetVolumeMultiplier(soundType.Value);
                    audioSource.volume = soundsVolume * multiplier;
                }
            }
        }
    }

    /// <summary>
    ///     获取指定音频类型的音量倍率
    /// </summary>
    public float GetVolumeMultiplier(Sounds soundType)
    {
        // 从配置中获取默认倍率
        if (soundsAudioClips != null && soundsAudioClips.ContainsKey(soundType))
            return soundsAudioClips[soundType].volumeMultiplier;

        return 1.0f; // 默认倍率
    }

    /// <summary>
    ///     通过 AudioClip 查找对应的 Sounds 类型
    /// </summary>
    private Sounds? FindSoundTypeByClip(AudioClip clip)
    {
        if (clip == null || soundsAudioClips == null)
            return null;

        foreach (var kvp in soundsAudioClips)
        {
            if (kvp.Value != null && kvp.Value.value != null && Array.Exists(kvp.Value.value, c => c == clip))
            {
                return kvp.Key;
            }
        }
        return null;
    }
    
    
    /// <summary>
    ///     移除循环播放音频
    /// </summary>
    /// <param name="sounds"></param>
    private void RemoveSounds(Sounds sounds)
    {
        if (m_loopAudioSources.ContainsKey(sounds))
        {
            m_loopAudioSources[sounds].Stop();
            m_loopAudioSources[sounds].clip = null;
            m_loopAudioSources[sounds].loop = false;
            m_loopAudioSources.Remove(sounds);
        }
    }

    #endregion

4.其它操作

这里借助Unity生命周期函数Update()更新计时器

#region Unity Lifecycle

    private void Update()
    {
        UpdateTimers();
    }

    private void UpdateTimers()
    {
        if (m_retentionTimers == null || m_retentionTimers.Count == 0)
            return;

        m_completedSoundsCache.Clear();

        foreach (var kvp in m_retentionTimers)
        {
            var timer = kvp.Value;
            timer.UpdateTimer(Time.deltaTime);

            if (timer.IsCompleted)
            {
                m_completedSoundsCache.Add(kvp.Key);
            }
        }

        foreach (var sound in m_completedSoundsCache)
        {
            m_retentionTimers.Remove(sound);
        }
    }

    #endregion

    /// <summary>
    ///     销毁时操作
    /// </summary>
    protected override void OnSingletonDestroy()
    {
        base.OnSingletonDestroy();
        if (m_gameObject)
        {
            Destroy(m_gameObject);
            //TODO:事件注销
            onSoundsvolumeChanged -= UpdateAudioSourcesColume;
        }
    }

5.完整实现

完整代码示例如下,这里的PersistentMonoSingleton是我自己的单例基类

public class SoundsManager : PersistentMonoSingleton<SoundsManager>
{
    //全局音量设置
    public float soundsVolume
    {
        get => m_soundsVolume;
        set
        {
            m_soundsVolume = value;
            //可以添加音量变更事件

            onSoundsvolumeChanged?.Invoke();
        }
    }

    public event Action onSoundsvolumeChanged;
    private List<AudioSource> m_audioSources;//AudioSource音源缓存(对象池模式)
    private Dictionary<Sounds, Timer> m_retentionTimers;//音效计时器,用于记录音效播放时间,防止频繁触发
    private Dictionary<Sounds, AudioSource> m_loopAudioSources;//记录循环播放的音频
    private List<Sounds> m_completedSoundsCache;//记录已经超过频繁播放限制音频的缓存容器,用于统一移出m_retentionTimers
    private float m_soundsVolume;
    private GameObject m_gameObject;//所有音源的载体
    private Dictionary<Sounds, SoundsConfig.SoundEntry> soundsAudioClips;//音频配置字典缓存
    private float dequeDuration;
    private SoundsConfig soundsManagerSO;

    #region 初始化

    protected override void OnSingletonInit()
    {
        base.OnSingletonInit();

        InitializeDataFromSO();
        m_audioSources = new();
        m_retentionTimers = new();
        m_loopAudioSources = new();
        m_completedSoundsCache = new();
        RegisterEvent();
        //GlobalEventReceiver.instance.onSendEvent += Play;
        m_gameObject = new GameObject();
        m_gameObject.name = GetType().ToString();
        DontDestroyOnLoad(m_gameObject);
    }

    private void RegisterEvent()
    {
        onSoundsvolumeChanged += UpdateAudioSourcesColume;
    }

    /// <summary>
    ///     从SO加载配置
    /// </summary>
    private void InitializeDataFromSO()
    {
        soundsManagerSO = this.GetUtility<GetConfigUtility>().GetConfig<SoundsConfig>();
        soundsAudioClips = soundsManagerSO.SoundsDictionary;
        // 确保 soundsVolume 在 0~1 范围内(防止手动输入错误值)
        soundsVolume = Mathf.Clamp01(soundsManagerSO.soundsVolume);
        dequeDuration = soundsManagerSO.dequeDuration;
    }

    #endregion

    #region 播放相关

    /// <summary>
    ///     请求循环播放
    /// </summary>
    /// <param name="soundName"></param>
    public void PlayLoopRequest(Sounds soundName)
    {
        if (m_loopAudioSources.ContainsKey(soundName)) return;
        PlaySound(soundName, true);
    }

    /// <summary>
    ///     停止循环播放
    /// </summary>
    /// <param name="soundName"></param>
    public void StopLoopRequset(Sounds soundName)
    {
        RemoveSounds(soundName);
    }

    /// <summary>
    ///     停止所有音频(包括循环播放和单次播放)
    /// </summary>
    public void StopAllSounds()
    {
        // 停止所有循环播放的音频
        foreach (var kvp in m_loopAudioSources)
        {
            if (kvp.Value != null)
            {
                kvp.Value.Stop();
                kvp.Value.clip = null;
                kvp.Value.loop = false;
            }
        }
        m_loopAudioSources.Clear();

        // 停止所有正在播放的音源
        if (m_audioSources != null)
        {
            foreach (var audioSource in m_audioSources)
            {
                if (audioSource != null && audioSource.isPlaying)
                {
                    audioSource.Stop();
                    audioSource.clip = null;
                    audioSource.loop = false;
                }
            }
        }

        // 清空计时器
        if (m_retentionTimers != null)
        {
            m_retentionTimers.Clear();
        }

        // 清空缓存
        if (m_completedSoundsCache != null)
        {
            m_completedSoundsCache.Clear();
        }
    }

    /// <summary>
    ///     移除循环播放音频
    /// </summary>
    /// <param name="sounds"></param>
    private void RemoveSounds(Sounds sounds)
    {
        if (m_loopAudioSources.ContainsKey(sounds))
        {
            m_loopAudioSources[sounds].Stop();
            m_loopAudioSources[sounds].clip = null;
            m_loopAudioSources[sounds].loop = false;
            m_loopAudioSources.Remove(sounds);
        }
    }

    /// <summary>
    ///     播放
    /// </summary>
    /// <param name="sounds"></param>
    /// <param name="loop"></param>
    private void PlaySound(Sounds sounds, bool loop = false)
    {
        var audioSource = GetAudioSource(sounds);
        if (loop)
        {
            RemoveSounds(sounds);
            m_loopAudioSources.Add(sounds, audioSource);
        }

        AudioClip clip = GetASound(sounds);
        if (clip == null)
        {
            return;
        }

        audioSource.clip = clip;
        audioSource.loop = loop;

        // 确保音量倍率被正确应用(在播放前设置)
        float multiplier = GetVolumeMultiplier(sounds);
        audioSource.volume = soundsVolume * multiplier;

        audioSource.Play();

        if (!loop)
        {
            var timer = new Timer((float)SoundsConfig.soundToleranceTime, false);
            timer.RestartTimer();
            m_retentionTimers.Add(sounds, timer);
        }
    }

    /// <summary>
    ///     请求播放音效(带频率限制)
    ///     同一音效在2秒内只会播放一次
    /// </summary>
    /// <param name="soundName">音效类型</param>
    public void PlayRequest(Sounds soundName)
    {
        if (m_retentionTimers.ContainsKey(soundName)) return; //限制播放频率
        PlaySound(soundName, false);
    }

    /// <summary>
    ///     强制播放请求
    /// </summary>
    /// <param name="soundName"></param>
    public void PlayRequestImmediate(Sounds soundName)
    {
        var audioSource = GetAudioSource(soundName);
        AudioClip clip = GetASound(soundName);
        if (clip == null)
        {
            return;
        }

        audioSource.clip = clip;

        // 确保音量倍率被正确应用(在播放前设置)
        float multiplier = GetVolumeMultiplier(soundName);
        audioSource.volume = soundsVolume * multiplier;

        audioSource.Play();
    }

    /// <summary>
    ///     打断播放(当有相同的音频正在播放,则直接中断该音频然后重新播放)
    /// </summary>
    /// <param name="soundName">要播放的音效类型</param>
    public void PlayRequestBreakOff(Sounds soundName)
    {
        // 1. 移除频率限制计时器,允许立即播放
        if (m_retentionTimers.ContainsKey(soundName))
        {
            m_retentionTimers.Remove(soundName);
        }

        // 2. 如果该音效正在循环播放,停止并移除
        if (m_loopAudioSources.ContainsKey(soundName))
        {
            RemoveSounds(soundName);
        }

        // 3. 获取目标音效的 AudioClip
        AudioClip targetClip = GetASound(soundName);
        if (targetClip == null)
        {
            return;
        }

        // 4. 中断正在播放的相同音效
        foreach (var audioSource in m_audioSources)
        {
            if (audioSource.isPlaying && audioSource.clip == targetClip)
            {
                audioSource.Stop();
                audioSource.clip = null;
            }
        }

        // 5. 播放新音效
        PlaySound(soundName, false);
    }

    #endregion

    #region 数据操作相关

    /// <summary>
    ///     获取音频
    /// </summary>
    /// <param name="name"></param>
    /// <returns></returns>
    private AudioClip GetASound(Sounds name)
    {
        if (!soundsAudioClips.ContainsKey(name))
        {
            return null;
        }
        var clips = soundsAudioClips[name].value;
        return clips[Random.Range(0, clips.Length)];
    }

    /// <summary>
    ///     获取音源
    /// </summary>
    /// <returns></returns>
    private AudioSource GetAudioSource(Sounds soundType)
    {
        foreach (var audioSource in m_audioSources)
        {
            if (!audioSource.isPlaying)
            {
                audioSource.loop = false;
                // 更新音量
                float multiplier = GetVolumeMultiplier(soundType);
                audioSource.volume = soundsVolume * multiplier;
                return audioSource;
            }
        }
        return AddAudioSource(soundType);
    }

    /// <summary>
    ///     增加音源
    /// </summary>
    /// <returns></returns>
    private AudioSource AddAudioSource(Sounds soundType)
    {
        var audioSource = m_gameObject.AddComponent<AudioSource>();
        float multiplier = GetVolumeMultiplier(soundType);
        audioSource.volume = soundsVolume * multiplier;
        m_audioSources.Add(audioSource);
        return audioSource;
    }
    
     /// <summary>
    ///     更新音源音量
    /// </summary>
    private void UpdateAudioSourcesColume()
    {
        // 更新所有循环播放音频的音量
        foreach (var kvp in m_loopAudioSources)
        {
            if (kvp.Value != null)
            {
                float multiplier = GetVolumeMultiplier(kvp.Key);
                kvp.Value.volume = soundsVolume * multiplier;
            }
        }

        // 更新所有单次播放音频的音量
        foreach (var audioSource in m_audioSources)
        {
            if (audioSource.isPlaying)
            {
                // 查找该音频对应的 Sounds 类型
                Sounds? soundType = FindSoundTypeByClip(audioSource.clip);
                if (soundType.HasValue)
                {
                    float multiplier = GetVolumeMultiplier(soundType.Value);
                    audioSource.volume = soundsVolume * multiplier;
                }
            }
        }
    }

    /// <summary>
    ///     获取指定音频类型的音量倍率
    /// </summary>
    public float GetVolumeMultiplier(Sounds soundType)
    {
        // 从配置中获取默认倍率
        if (soundsAudioClips != null && soundsAudioClips.ContainsKey(soundType))
            return soundsAudioClips[soundType].volumeMultiplier;

        return 1.0f; // 默认倍率
    }

    /// <summary>
    ///     通过 AudioClip 查找对应的 Sounds 类型
    /// </summary>
    private Sounds? FindSoundTypeByClip(AudioClip clip)
    {
        if (clip == null || soundsAudioClips == null)
            return null;

        foreach (var kvp in soundsAudioClips)
        {
            if (kvp.Value != null && kvp.Value.value != null && Array.Exists(kvp.Value.value, c => c == clip))
            {
                return kvp.Key;
            }
        }
        return null;
    }


    #endregion

    #region Unity Lifecycle

    private void Update()
    {
        UpdateTimers();
    }

    private void UpdateTimers()
    {
        if (m_retentionTimers == null || m_retentionTimers.Count == 0)
            return;

        m_completedSoundsCache.Clear();

        foreach (var kvp in m_retentionTimers)
        {
            var timer = kvp.Value;
            timer.UpdateTimer(Time.deltaTime);

            if (timer.IsCompleted)
            {
                m_completedSoundsCache.Add(kvp.Key);
            }
        }

        foreach (var sound in m_completedSoundsCache)
        {
            m_retentionTimers.Remove(sound);
        }
    }

    #endregion

    /// <summary>
    ///     销毁时操作
    /// </summary>
    protected override void OnSingletonDestroy()
    {
        base.OnSingletonDestroy();
        if (m_gameObject)
        {
            Destroy(m_gameObject);
            //TODO:事件注销
            onSoundsvolumeChanged -= UpdateAudioSourcesColume;
        }
    }
}

四、计时器实现

封装一个计时器类型,用于计算音频播放时间,判断频繁触发冷却

public class Timer
    {
        private float m_duration;
        private float m_currentTime;
        private bool m_isLooping;
        private bool m_isRunning;

        public float CurrentTime => m_currentTime;
        public float Duration => m_duration;
        public bool IsRunning => m_isRunning;
        public bool IsCompleted => !m_isLooping && m_currentTime >= m_duration;
        public float Progress => m_duration > 0 ? m_currentTime / m_duration : 1f;

        public Timer(float duration, bool isLooping = false)
        {
            if (duration <= 0)
                throw new System.ArgumentException("Duration must be greater than 0", nameof(duration));

            m_duration = duration;
            m_isLooping = isLooping;
            m_currentTime = 0f;
            m_isRunning = false;
        }

    	//开始计时
        public void StartTimer()
        {
            m_isRunning = true;
        }

    	//重置时间
        public void ResetTimer()
        {
            m_currentTime = 0f;
            m_isRunning = false;
        }

    	//重启计时
        public void RestartTimer()
        {
            ResetTimer();
            StartTimer();
        }

    	//停止计时
        public void StopTimer()
        {
            m_isRunning = false;
        }

    	//更新时间
        public void UpdateTimer(float deltaTime)
        {
            if (!m_isRunning)
                return;

            m_currentTime += deltaTime;

            if (m_isLooping && m_currentTime >= m_duration)
            {
                m_currentTime = 0f;
            }
        }

    	//冷却时间是否已经达到
        public bool HasElapsed()
        {
            return m_currentTime >= m_duration;
        }

    	//获取剩余时间
        public float GetRemainingTime()
        {
            if (HasElapsed() && !m_isLooping)
                return 0f;
            return Mathf.Max(0, m_duration - m_currentTime);
        }

五、结

这是一个小型 Unity 音频管理解决方案,核心功能在于AudioShouce简易对象池优化、ScriptableObject配置分离和双层音量控制(全局音量 + 单个音频倍率),配合触发频率限制防止音效重叠鬼畜。适用于中小型 2D 游戏项目,特别是需要丰富音效和背景音乐的休闲、益智类游戏。

但是该解决方案不提供空间音频、音频效果、动态优先级调度、Unity AudioMixer等功能,对于大型 3D 游戏或需要复杂音频混音的场景比较乏力。

接下来拓展计划为事件驱动播放音频的功能,这样可以通过监听事件自动播放对应音频,省去在每个需要播放音频的地方调用单例的繁琐操作,还能降低与其它模块的耦合度。

posted @ 2026-03-27 16:31  CloverJoyi  阅读(16)  评论(0)    收藏  举报