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