游戏中的按钮点击、角色受击、技能释放和背景音乐,都需要加载并播放音频。如果每个业务对象自己创建AudioSource、加载AudioClip和管理销毁,容易出现重复加载、音量规则不统一以及大量临时节点。
MyFramework 使用AudioManager统一管理音频资源,并通过可复用的AudioHelper播放临时音效。
项目地址:
https://github.com/ZHOURUIH/MyFramework
一、AudioManager 负责什么
AudioManager主要管理:
音频路径和音效ID的映射 AudioClip 的加载与卸载 背景音乐播放对象 2D和3D音效播放 音乐与音效的全局音量 临时 AudioHelper 的复用 同时播放的音效数量业务层通常不需要自己创建AudioSource,只需要调用:
AT.MUSIC(...); AT.SOUND_2D(...); AT.SOUND_3D(...);二、注册音效
音频文件路径相对于:
Assets/GameResources并且需要带文件后缀。
例如:
public static class SOUND_DEFINE { public const int MAIN_BGM = 1; public const int BUTTON_CLICK = 2; public const int MONSTER_HIT = 3; }注册对应的文件:
mAudioManager.registeSoundDefine( SOUND_DEFINE.MAIN_BGM, "Audio/Music/Main.mp3"); mAudioManager.registeSoundDefine( SOUND_DEFINE.BUTTON_CLICK, "Audio/UI/ButtonClick.wav"); mAudioManager.registeSoundDefine( SOUND_DEFINE.MONSTER_HIT, "Audio/Battle/MonsterHit.wav");注册后,业务代码只需要使用音效 ID,不需要关心具体文件路径。
实际项目中通常会把音效配置放进 Excel,再统一注册:
using static FrameBaseHotFix; using static GBR; public class AudioRegister : FrameSystem { public override void init() { base.init(); if (mExcelSound == null) { return; } foreach (EDSound data in mExcelSound.queryAll()) { mAudioManager.registeSoundDefine( data.mID, data.mFileName); } } }这样修改音频文件时,只需要调整表格配置。
三、内部如何保存音频信息
AudioManager内部保存了两个主要字典:
protected Dictionary<string, AudioInfo> mAudioList = new(); protected Dictionary<int, string> mSoundDefineMap = new();它们分别负责:
mAudioList 音频路径 → AudioInfo mSoundDefineMap 音效ID → 音频路径AudioInfo中保存实际资源和加载状态:
public class AudioInfo : ClassObject { public ResourceRef<AudioClip> mClip; public AudioClip mRawClip; public string mAudioName; public bool mIsLocal; public LOAD_STATE mState; public AudioClip getClip() { if (mClip != null) { return mClip.getResource(); } return mRawClip; } }本地音频通过ResourceRef<AudioClip>持有,可以交给ResourceManager正确卸载。
通过网络地址加载的音频则直接保存AudioClip,销毁时使用 Unity 接口释放。
四、播放背景音乐
播放默认循环的背景音乐:
AT.MUSIC(SOUND_DEFINE.MAIN_BGM);指定是否循环:
AT.MUSIC( SOUND_DEFINE.MAIN_BGM, true);指定本次播放音量:
AT.MUSIC( SOUND_DEFINE.MAIN_BGM, true, 0.8f);也可以直接通过路径播放:
AT.MUSIC( "Audio/Music/Main.mp3", true, 0.8f);停止当前背景音乐:
AT.MUSIC();AudioManager内部只维护一个背景音乐播放对象:
protected AudioHelper mMusicHelper;初始化资源系统后,它会创建一个长期存在的AudioHelper,并将其设置为 2D 音频:
mMusicHelper = getOneUnusedHelper(-1.0f); mMusicHelper.setSpatialBlend(0.0f);因此同一时间只维护一条全局背景音乐播放通道。
五、播放 2D 音效
UI 点击、系统提示和奖励弹窗等音效与场景位置无关,适合使用 2D 音效:
AT.SOUND_2D( SOUND_DEFINE.BUTTON_CLICK);设置单次播放音量:
AT.SOUND_2D( SOUND_DEFINE.BUTTON_CLICK, 0.7f);也可以通过路径播放:
AT.SOUND_2D( "Audio/UI/ButtonClick.wav", 0.7f);例如按钮点击:
protected void onConfirmClick() { AT.SOUND_2D( SOUND_DEFINE.BUTTON_CLICK); confirmOperation(); }播放时,框架会获取一个临时AudioHelper,并设置:
helper.setSpatialBlend(0.0f);所以音效不会受到摄像机和场景距离影响。
六、播放 3D 音效
攻击、爆炸、怪物受击等声音通常来自场景中的指定位置:
AT.SOUND_3D( monster.getPosition(), SOUND_DEFINE.MONSTER_HIT);指定音量:
AT.SOUND_3D( monster.getPosition(), SOUND_DEFINE.MONSTER_HIT, 0.8f);内部会把播放对象移动到指定位置,并设置为完全 3D:
AudioHelper helper = mAudioManager.getAudioHelper(0.0f); helper.setSpatialBlend(1.0f); helper.setPosition(pos);最终的衰减范围、空间混合和其他 3D 参数,仍然由AudioSource的配置决定。
七、AudioHelper 为什么需要复用
如果每次播放音效都创建一个 GameObject:
创建GameObject 添加AudioSource 播放音效 等待结束 销毁GameObject短时间内大量战斗音效会频繁创建和销毁节点。
AudioManager使用两个集合管理辅助对象:
protected SafeHashSet<AudioHelper> mHelperList = new(); protected Queue<AudioHelper> mUnusedList = new();获取播放对象时,优先从未使用队列中取出:
if (mUnusedList.Count > 0) { helper = mUnusedList.Dequeue(); } else { CLASS(out helper); helper.setName("AudioHelper"); helper.init(); }音效播放结束后,不销毁对象,而是重新放回队列:
获取 AudioHelper ↓ 设置 AudioSource 并播放 ↓ 音效时间结束 ↓ 关闭 AudioHelper ↓ 放回未使用队列下一次播放音效时继续复用。
八、如何判断音效已经结束
非循环音效播放后,框架会记录剩余时间:
helper.mRemainTime = mAudioManager.getAudioLength(soundName);AudioManager.update()中持续更新:
using var a = new SafeHashSetReader<AudioHelper>(mHelperList); foreach (AudioHelper item in a.mReadList) { if (item != mMusicHelper && tickTimerOnce( ref item.mRemainTime, elapsedTime)) { unusedHelper(item); } }倒计时结束后,辅助对象自动回到缓存队列。
这里使用SafeHashSet,是因为遍历播放对象时,可能同时有对象被回收。
九、限制同时存在的音效数量
战斗中可能在一瞬间播放大量音效。可以设置辅助对象数量上限:
mAudioManager.setMaxAudioCount(32);当播放对象数量达到上限后,框架会找到剩余时间最短的音效,将其提前回收,再播放新的音效。
设置为:
mAudioManager.setMaxAudioCount(0);表示不限制数量。
这个上限适合根据项目规模和目标设备进行调整,不能设置得过小,否则重要音效可能被过早替换。
十、设置音乐和音效音量
设置全局音效音量:
mAudioManager.setSoundVolume(0.8f);设置全局背景音乐音量:
mAudioManager.setMusicVolume(0.5f);这两个值会在后续播放时乘到本次播放音量上:
最终音量 = 本次播放音量 × 全局分类音量修改当前正在播放的背景音乐音量,可以使用:
AT.MUSIC_VOLUME(0.5f);平滑淡出:
AT.MUSIC_VOLUME( 1.0f, 0.0f, 0.5f);平滑淡入:
AT.MUSIC_VOLUME( 0.0f, 1.0f, 0.5f);它会接入前面介绍的关键帧系统,不需要自己在update()中修改AudioSource.volume。
十一、预加载和卸载音频
注册音频并不会立即加载全部资源。
首次播放时,AudioManager会自动加载对应的AudioClip。
需要提前加载时:
mAudioManager.loadAudio( SOUND_DEFINE.BUTTON_CLICK);异步加载:
mAudioManager.loadAudioAsync( SOUND_DEFINE.BUTTON_CLICK, (info) => { if (info != null) { log("音效加载完成"); } });预加载全部已注册音频:
mAudioManager.loadAllAsync();不再使用时卸载:
mAudioManager.unload( SOUND_DEFINE.MAIN_BGM);或者通过路径卸载:
mAudioManager.unload( "Audio/Music/Main.mp3");卸载只是释放音频资源和重置加载状态,注册的 ID 与路径映射仍然保留,之后可以再次加载。
十二、总结
MyFramework 的音频使用流程可以概括为:
1. 注册音效ID与资源路径 2. 使用 AT.MUSIC 播放背景音乐 3. 使用 AT.SOUND_2D 播放界面音效 4. 使用 AT.SOUND_3D 播放场景音效 5. AudioManager 统一加载和卸载 AudioClip 6. AudioHelper 自动缓存并复用播放节点 7. 通过音量和数量上限控制整体表现业务层不需要反复创建AudioSource,也不需要为每个短音效编写销毁计时。
AudioManager负责资源和播放对象的生命周期,AT提供统一的播放入口,让背景音乐、界面音效和场景音效都使用同一套规则。