〇它是干什么的
先花 30 秒建立直觉,再动手写。
只学 3 个东西就能干活(真的)
RevSound.Play("ui_click"); // ① 2D:界面音、提示音、语音 —— 直接播 RevSound.PlayOn("skill_cast", hero.gameObject); // ② 3D:挂到物体上(跟着它,物毁音停) RevSound.PlayBgm("login", fadeSeconds: 1f); // ③ 背景音乐(自动替换上一首,可淡入)
就这 3 个入口覆盖 90% 的需求。要停就拿着返回的句柄:var h = RevSound.Play("vo_hello"); h.Stop();
剩下的(音量总线、作用域、预加载、策略、事件……)都是用到再看的增值项。
人话
音效系统 = 你只说"播哪条音、从哪发出",剩下的(找资源、加载、找播放器、播完回收)它全包。
它甚至不需要你配置:第一次播放时,它自己创建一个隐藏宿主每帧干活;音频还没加载好也不会"播丢",加载完自动补上。
一条声音的一生
RevSound.PlayOn("skill_cast", hero.gameObject) 循环播放
① 请求播放策略 / 去重 / 上限
→
② 占槽位拿到句柄
→
③ 挂在目标下跟着它移动 / 出声
→
④ 播完回收播放器回池 / 句柄失效
③ 的"挂"是真的挂:播放器成为目标物体的子节点 —— 位置随父子关系自动更新(零每帧开销),物体被销毁时播放器一起销毁、声音自动收。
它不干什么(同样重要)
- 不做查询:没有"现在有几条在播""卡在哪一步"这类 API —— 想知道就订阅
RevSound.Failed/RevSound.VoiceFinished自己记。 - 不打日志:框架自身一条日志都不打,失败一律走
Failed事件(等框架的日志系统出来再统一接)。 - 不管资源生命周期:音效片段加载后会常驻,直到你
RevSound.Unload(...)/UnloadAll()。
13 分钟跑起来
三步:把音频放对地方 → 一行播放 → (可选)调音量。
① 音频放哪:在打包工具窗口里选(代码里不写死)
菜单 Revolution.Tools/资源/RevAB 打包工具 → 「打包」页签 → 资源目录 → 音效目录:把文件夹拖进槽里,或点「选择…」,或点「默认」。
| 声音 | 默认目录(相对"资源根目录") | 运行时读的常量 |
|---|---|---|
| 音效 / 语音 / 界面音 | <资源根目录>/Audio/Sfx/ | RevSoundPath.Sfx |
| 背景音乐 | <资源根目录>/Audio/Bgm/ | RevSoundPath.Bgm |
打包窗口里选目录→
存进 ABBuildConfig.asset(团队共享)→
自动生成 RevSoundPath.cs→
音效系统读常量(零运行期 IO)
目录必须在「资源根目录」之内
放在外面的话:编辑器直读拼不出路径、
RevResPath 也不会为它生成常量 → 会出现"真机有声、编辑器无声"的割裂。窗口会直接拒绝并提示。
子目录:随便建,名字里带上就行
音效名本身就是"相对音效根目录的资源路径",支持任意层子目录 —— 不需要为子目录做任何配置。
| 音频放在 | 播放写法 |
|---|---|
<资源根目录>/Audio/Sfx/ui_click.wav | RevSound.Play("ui_click") |
<资源根目录>/Audio/Sfx/UI/ui_click.wav | RevSound.Play("UI/ui_click") |
<资源根目录>/Audio/Sfx/Voice/Hero_1001/vo_hello.wav | RevSound.Play("Voice/Hero_1001/vo_hello", kind: RevSoundKind.Voice) |
<资源根目录>/Audio/Bgm/Lobby/login.wav | RevSound.PlayBgm("Lobby/login") |
- 子目录名直接进 同帧去重 / 上限淘汰 / 句柄 的判定:
"UI/click"与"Battle/click"是两条不同的声音; - 手写习惯会被自动规范化:
Play("UI\click")(Windows 反斜杠)、Play("/UI//click/")(多余斜杠)都能正确加载 (规范化实现在资源系统的RevResPathUtil.NormalizeResName,纯 C#、其它模块也能复用); - 打包窗口里「音效目录」下面会列出当前已有的子目录(含多级),照着抄就行。
❌ 名字里又写了一遍根目录
RevSound.Play("Audio/Sfx/UI/click"); // 拼成 Audio/Sfx/Audio/Sfx/UI/click // → Failed(LoadFailed)
✅ 名字永远相对"音效根目录"
RevSound.Play("UI/click"); // 根目录由打包窗口配置,不写进名字
② 一行播放
// 2D:直接播(界面音、提示音、语音、播报 —— 不会因为距离变小声) RevSound.Play("ui_click"); RevSound.Play("vo_hello", kind: RevSoundKind.Voice); // 3D:挂到物体上(技能、脚步、引擎 —— 有距离衰减,跟着物体走) RevSound.PlayOn("skill_cast", hero.gameObject); RevSound.PlayOn("run_loop", transform, loop: true); // 3D:挂在世界坐标点(爆炸、落地 —— 不跟随任何物体) RevSound.PlayAt("explosion", hitPoint); // 背景音乐 RevSound.PlayBgm("login", fadeSeconds: 1f);
零配置
不需要摆场景物体、不需要挂脚本、不需要写
Update —— 第一次播放时框架会自动创建一个隐藏宿主每帧驱动它。
(想自己驱动就每帧调 RevSound.Tick(Time.deltaTime),之后自动宿主永久让位。)
③ 要停就拿着句柄
RevSoundHandle h = RevSound.Play("vo_hello"); h.Stop(); // 停掉这一条 if (h.IsEmpty) { /* 这次播放没发生:被策略拦下 / 超上限 / 系统关闭 / 加载失败 */ } RevSound.StopAll(fadeSeconds: 0.3f); // 停掉所有(切场景/退战斗)
句柄会"自己过期"
一条声音结束后它的句柄自动失效,过期的句柄绝不会误停一条新的声音(每个槽位带代际号)。所以你不用像早期实现那样维护"这个播放器还能不能用"。
2"我要做 X" 对照表
照着抄就行。这一页覆盖 95% 的日常需求。
| 我想做的 | 怎么写 |
|---|---|
| 放一个界面音(2D) | RevSound.Play("ui_click", kind: RevSoundKind.Ui) |
| 技能音挂在释放者身上(3D) | RevSound.PlayOn("skill_cast", hero.gameObject) |
| 脚步 / 引擎这类循环音 | RevSound.PlayOn("run_loop", transform, loop: true) |
| 爆炸 / 落地(3D 固定点) | RevSound.PlayAt("explosion", position) |
| 播语音 / 台词 | RevSound.Play("vo_hello", kind: RevSoundKind.Voice) |
| 播 BGM / 换 BGM | RevSound.PlayBgm("login", fadeSeconds: 1f) |
| 播歌单(播完自动下一首) | RevSound.PlayBgmList("login_1", "login_2") |
| 停一条 / 停全部 | h.Stop() / RevSound.StopAll(fadeSeconds: 0.3f) |
| 一块播、一块停(切界面/一局) | using (var s = RevSound.OpenScope()) { s.Play(...); s.PlayBgm(...); } |
| 调音量(总 / 分类) | RevSound.MasterVolume = 0.8f; RevSound.SetVolume(RevSoundKind.Bgm, 0.6f); |
| 静音 / 总开关 | RevSound.Mute = true; / RevSound.Enabled = false; |
| 提前加载(点了就有声) | RevSound.Preload("ui_click", "ui_close"); |
| 让代码只写"逻辑名"(表驱动) | RevSound.Register("ui_click", "UI/Button/click", kind: RevSoundKind.Ui, volume: 0.8f); |
| 预加载表里所有音效 | RevSound.PreloadAll(); |
| 注销 / 查询 / 清空音效表 | RevSound.Unregister("ui_click") / IsRegistered("ui_click") / ClearCatalog() |
| 卸载 | RevSound.Unload("ui_click") / RevSound.UnloadAll() |
| 限制同时播放数 | RevSound.MaxVoices = 24;(默认就是 24) |
| 同帧同名只播一次(防连点) | 默认开;关掉:RevSound.FrameDedupe = false; |
| 战斗中不播某些音 | RevSound.Policy = new MyBattleSoundPolicy();(实现 IRevSoundPolicy) |
| 知道"哪些音没播出去 / 为什么" | RevSound.Failed += (name, reason) => YourLog.Warn($"{name} 没播:{reason}"); |
| 知道"播完了"(接台词、BGM 轮播) | RevSound.VoiceFinished += h => ... |
| 自己驱动(自定义宿主) | 每帧 RevSound.Tick(Time.deltaTime); |
四类声音的默认行为
| 分类 | 同帧去重 | 默认循环 | 适用 |
|---|---|---|---|
RevSoundKind.Ui | ✅ | ✗ | 按钮、弹窗、页签 |
RevSoundKind.Sfx | ✅ | ✗ | 技能、打击、脚步 |
RevSoundKind.Voice | ✗(连续两句同台词是合理的) | ✗ | 角色语音、播报 |
RevSoundKind.Bgm | ✗ | ✅ | 背景音乐 |
2D / 3D 不由分类决定
由你调哪个方法决定(
Play = 2D;PlayOn / PlayAt = 3D)—— 没有"某个分类悄悄变 3D"的隐藏魔法。
可选增强:音效表(逻辑名 → 真实路径)
磁盘目录怎么整理、美术怎么改名,调用点都不想跟着改 —— 那就把映射登记到表里(做了这件事,代码里就只出现"逻辑名")。
// ① 登记一次(建议在初始化 / 加载界面时;同名重复登记 = 覆盖) RevSound.Register("ui_click", "UI/Button/click", kind: RevSoundKind.Ui, volume: 0.8f); RevSound.Register("vo_hello", "Voice/Hero_1001/vo_hello", kind: RevSoundKind.Voice); RevSound.Register("run_loop", "Sfx/Run/loop", loop: true); RevSound.Register("login_bgm", "Lobby/login", kind: RevSoundKind.Bgm); // ② 之后代码只认逻辑名 RevSound.Play("ui_click"); // → Audio/Sfx/UI/Button/click RevSound.PlayOn("run_loop", transform); // → Sfx/Run/loop,并且自动循环 RevSound.PlayBgm("login_bgm", fadeSeconds: 1f); RevSound.PreloadAll(); // 表里所有音效一次性预加载(各按自己的分类选根目录)
| 规则 | 说明 |
|---|---|
| 解析优先级 | 表里的值只补齐调用点没写的参数;调用点显式传的永远优先(Play("ui_click", volume: 0.3f) → 用 0.3) |
| 没登记的名字 | 原样当路径用(= 以前的写法,子目录照样能用)—— 音效表是可选增强,不是必须的开关 |
| 同名重复登记 | 覆盖(后登记的生效);名字 / 路径为空返回 false,不改动表 |
| 同帧去重按什么判 | 按真实路径 + 分类:两个逻辑名指向同一条音效时也算重复 |
| 分类决定什么 | 表里的 kind 决定用哪个根目录(Bgm → BGM 根目录)与是否同帧去重 —— 登记背景音乐别忘写 kind: RevSoundKind.Bgm |
LoadFailed 报什么 | 报的是表里的真实路径(它就是加载失败的那个路径);其它失败报的是逻辑名 |
这就是参考实现"元数据驱动"那条哲学的轻量版
参考实现的表是二进制(
SoundBankInfoSet.bytes,由工具生成);这里是一张代码注册的小表 ——
代价是"表与代码不同步"换成了"表就是代码"(可 diff、可 Code Review、零加载解析成本)。
32D 还是 3D:三种播放方式
先记一句话:2D 直接播;3D 必须说清楚"声音从哪发出"。
| 2D | 3D | |
|---|---|---|
| 写法 | RevSound.Play("ui_click") | RevSound.PlayOn("cast", hero.gameObject)RevSound.PlayAt("boom", hitPoint) |
| 要不要给位置 | 不用给(给了也没意义) | 必须给:挂在哪个物体上 / 哪个世界坐标点 |
| 距离衰减 | 没有,永远听得清 | 有(默认 1~50 米,RevSound.Set3DRange 可调) |
| 典型用途 | 界面音、提示音、语音、BGM、播报 | 技能、打击、脚步、爆炸、环境声、角色身上的循环音 |
三种写法逐个讲透
①
RevSound.Play(name) —— 2D:直接播界面音、提示音、语音、播报、BGM —— 你只关心"播哪条",不关心它在哪。
RevSound.Play("ui_click"); // 默认 Sfx 分类 RevSound.Play("ui_click", kind: RevSoundKind.Ui); // 界面音:同帧去重更严 RevSound.Play("vo_hello", kind: RevSoundKind.Voice); // 语音:不去重 RevSound.Play("ui_click", volume: 0.6f); // 这一次小一点声
2D 的播放器留在框架根节点下、
spatialBlend = 0:不会因为相机远近变小声。②
RevSound.PlayOn(name, 目标) —— 3D:挂到 GameObject 上技能音挂在释放者身上、脚步挂在角色身上、引擎声挂在车上 —— 声音要"跟着它"。
RevSound.PlayOn("skill_cast", hero.gameObject); // 挂在英雄身上(一次性) RevSound.PlayOn("run_loop", transform, loop: true); // 挂在 transform 上(循环) RevSound.PlayOn("npc_talk", npc.transform, volume: 0.8f, kind: RevSoundKind.Voice);
它是真的把播放器挂到那个物体下面(作为子节点)—— 不是每帧抄坐标:
位置由父子关系自动维护(零每帧开销),物体被销毁时播放器一起销毁、声音自动收。
③
RevSound.PlayAt(name, 坐标) —— 3D:挂在世界坐标点爆炸、落地、脚步点播、开箱 —— 声音在那个位置响完就完事,不跟着任何东西走。
RevSound.PlayAt("explosion", hitPoint); RevSound.PlayAt("footstep", transform.position, volume: 0.5f);
播放器留在框架根节点下,但位置设成了你给的那个点 —— 一样有距离衰减。
层级差别(一眼看懂)
2D:播放器在框架根节点下
RevSoundRoot
├── RevSoundVoice ← 2D:spatialBlend = 0
└── RevSoundVoice
(与场景物体无关,永远听得清)
3D:播放器挂在目标物体下
Hero(目标物体)
└── RevSoundVoice ← 3D:spatialBlend = 1
(localPosition = 0)
(跟着英雄跑;英雄没了,声音就停)
3D 的 4 条行为(必须知道)
| 场景 | 会发生什么 |
|---|---|
PlayOn 的目标是 null(物体已销毁) | 触发 Failed(NoTarget) 并返回空句柄,不会偷偷按 2D 播(那会变成"贴脸满音量",更难查) |
目标被隐藏(activeInHierarchy == false) | 播放器随之停 → 框架按"播完"回收,并触发 VoiceFinished |
| 目标被销毁 | 播放器一起销毁 → 框架自动回收槽位(对象池取出时会自动跳过空壳,你不用管) |
循环音(loop: true) | 不会因为"播完"回收;要停就 h.Stop()(或用作用域) |
最容易写错的一处
❌ 3D 却忘了给目标
RevSound.Play("skill_hit"); // 技能音变成了 2D:不管距离 // 永远满音量(还以为出了 bug)
✅ 3D 就明确"挂在哪"
RevSound.PlayOn("skill_hit", enemy.gameObject); // 或只是某个点: RevSound.PlayAt("skill_hit", hitPoint);
目标为空时不会被静默吞掉
如果
PlayOn 的目标为 null,会收到 Failed(NoTarget) —— 立刻就知道"哦,物体先没了"。
4BGM / 音量 / 开关 / 预加载
把"手感"调好的四个旋钮。
背景音乐
// 播(2D、循环;再调一次会替换上一首) RevSound.PlayBgm("login"); // 硬切 RevSound.PlayBgm("battle", fadeSeconds: 1.5f); // 旧歌淡出、新歌淡入(同时进行,不突兀) // 歌单:一首播完自动下一首,最后一首播完停止 RevSound.PlayBgmList("login_1", "login_2", "login_3"); // 停(可淡出;同时结束歌单) RevSound.StopBgm(fadeSeconds: 0.8f);
音量:最终音量 = 单次音量 × 分类音量 × 主音量
| 旋钮 | 作用 | 改完的效果 |
|---|---|---|
RevSound.MasterVolume | 主音量(0~1) | 立刻对正在播的所有声音生效 |
RevSound.SetVolume(kind, v) | 某一类(Ui / Sfx / Voice / Bgm) | 立刻生效;做"音乐 60%、音效 100%"的设置界面就用它 |
RevSound.Mute | 静音 | 声音继续走、音量按 0 处理;取消静音立刻接上(不会"从头再播") |
RevSound.Enabled | 总开关 | false 之后新的 Play 直接不发生(返回空句柄);已经在播的不受影响 |
Mute 和 Enabled 不是一回事
"静音"用 Mute(可随时接上),"这一段我不想让它响"用 Enabled = false(连"播"这个动作都不发生)。
预加载:把"首次晚一点"提前解决
RevSound.Preload("ui_click", "ui_close", "ui_open"); // 进界面前调一次 RevSound.PreloadBgm("battle"); // BGM 也可以预加载 RevSound.Unload("ui_click"); // 不用了:还引用,下次播放会重新加载 RevSound.UnloadAll(); // 回登录 / 大版本切换
为什么首次播放会"晚一点"?
本框架只走异步加载:资源没到 → 立刻返回句柄,加载完自动播(业务写法完全同步,不用管异步窗口)。
代价是首次可能晚几十毫秒 —— 想零延迟就
Preload。这么做是为了避开"同步加载命中加载中的句柄会拿到空内容"以及 WebGL/小游戏不支持同步加载这两个真坑。
策略:把"该不该播"留在业务
public sealed class BattleSoundPolicy : IRevSoundPolicy
{
// 返回 false:这次播放被拦下,并触发 Failed(PolicyRejected)
public bool CanPlay(string name, RevSoundKind kind)
=> kind != RevSoundKind.Bgm || name.StartsWith("battle_"); // 举例:战斗中只放战斗 BGM
}
RevSound.Policy = new BattleSoundPolicy();
内核不知道任何业务规则(英雄、皮肤、玩法都不认识)—— "该不该播"只在这一个地方回答。
5一块播、一块停(作用域)
把"切界面/一局结束时该收声"从散落各处的 Stop 变成一块边界。
using (var s = RevSound.OpenScope())
{
s.Play("ui_open_big"); // 2D:弹窗音
s.PlayOn("npc_talk", npc.gameObject); // 3D:挂在 NPC 身上
s.PlayBgm("battle_prepare", fadeSeconds: 0.5f); // 这一段的 BGM
s.StopAll(); // (可选)中途一键停,作用域还能接着用
} // ← 出块:上面播的(含 BGM)全部停掉
它解决的现实问题
| 旧写法(靠人记) | 作用域 |
|---|---|
| 切页面前记得把自己播的声音都停掉(忘了就"切页面还在响") | using 一块,出块自动全停 |
切场景前 ClearSound()(早期实现注释写了三遍"重要的事情说三遍") | 类型层面的边界:忘了写 using 才是例外 |
作用域只管"声音",不管"资源"
它不会卸载音效片段 —— 因为两个作用域可能用同一个片段,谁也别替谁释放。资源由
Preload / Unload / UnloadAll 显式管理。
6新手最容易白干的 10 个坑
十个坑,每个都对应一条"我明明写了却没反应"。
| # | 坑 | 正确做法 / 为什么会这样 |
|---|---|---|
| 1 | 3D 播放没给"挂在哪" | PlayOn 的目标为 null(物体已销毁)→ Failed(NoTarget) + 空句柄。这是刻意的:偷偷降级成 2D 会让技能音"贴脸满音量",更难排查。 |
| 2 | 挂在物体上的音效"跟着没了" | 这是设计:目标隐藏 → 播放器停(按"播完"回收);目标销毁 → 播放器一起销毁、声音自动收。要"物体没了声音还继续"就别用 PlayOn,用 PlayAt。 |
| 3 | 首次播放听起来"慢半拍" | 只用异步加载(避开同步加载的竞态与 WebGL 限制)。进界面前 RevSound.Preload(...) 即可。 |
| 4 | 以为 Unload 会把正在响的声音掐断 |
不会:播放器自己还持有片段引用,只是"不再常驻",下次播放会重新加载。 |
| 5 | 把 Mute 和 Enabled 搞混 |
Mute = 音量按 0(继续走,可随时接上);Enabled = false = 后续播放直接不发生。 |
| 6 | 指望作用域帮你卸资源 | 作用域只停声音。资源用 Preload / Unload / UnloadAll 管。 |
| 7 | 音效片段"分组卸载"没生效 | 资源分组只认第一次加载(RevResourceSystem 的规定):先被别的系统以别的分组加载过,你传 RevResGroup.Sound 是无效的。公共音效想"永不参与分组卸载"就 AddFlag(RevResInstanceFlag.Resident)。 |
| 8 | 团战"丢了几个音" | 同时在播超过 MaxVoices(默认 24):先淘汰最旧的一次性音效;如果全是循环音就会拒绝新播放并触发 Failed(TooManyVoices)。调大上限,或用 Policy 提前裁剪。 |
| 9 | 想改音频目录,却去改代码 | 去打包窗口改:Revolution.Tools/资源/RevAB 打包工具 → 「打包」页签 → 资源目录 → 音效目录。改完自动重新生成常量,目录写错编译期就报错(运行期零 IO)。 |
| 10 | 名字里又写了一遍根目录 | Play("Audio/Sfx/UI/click") 会拼成 Audio/Sfx/Audio/Sfx/UI/click → Failed(LoadFailed)。名字永远相对音效根目录:子目录写进去("UI/click"),根目录不写(根目录由打包窗口配置)。 |
还有一条"看不见的保险"
同帧同名同分类只播一次(默认开):按钮连点、同帧重复触发都不会叠声。真需要叠声就
RevSound.FrameDedupe = false。
7没声音怎么查(5 分钟定位)
先接上 Failed 事件 —— 这一条能定位 90% 的问题。
第 0 步:把失败原因接出来(一行)
RevSound.Failed += (name, reason) => YourLog.Warn($"[音效] {name} 没播:{reason}");
失败原因表
| 原因 | 意思 / 怎么办 |
|---|---|
Disabled | 总开关关了(RevSound.Enabled = false)。 |
PolicyRejected | 被你的 IRevSoundPolicy 拦下 —— 去策略里看条件。 |
Duplicated | 同一帧、同一个名字(同分类)已经播过一次。确认是不是连点/重复触发;确实要叠就关 FrameDedupe。 |
TooManyVoices | 同时在播数量到上限,且没有可淘汰的一次性音效(都是循环音)。调大 MaxVoices。 |
NoTarget | 3D 播放没给"挂在哪"(PlayOn 的目标为 null)。检查传进去的物体是不是已经销毁了。 |
LoadFailed | 资源加载失败:路径不对 / 没打进包 / 名字写错。报的是实际加载的路径(名字登记过音效表时就是表里的路径)。见下面第 2 步。 |
按顺序查
- 看
Failed事件:有原因就照着上表处理,不用猜。 LoadFailed时查路径:窗口里配的目录(常量值见RevSoundPath.cs,RevSoundPath.Sfx/Bgm)+ 名字 拼出来的路径对不对? 编辑器下走 AssetDatabase,运行时走 AB 映射(ResMap)—— 音频文件有没有被打进包(AB 标记)?- 看整体状态:
RevSound.Core.ToString()→RevSoundCore(活跃 3/24,BGM=login)。 - 3D 听不到:确认用的是
PlayOn(目标有效、在场景里、没被隐藏)或PlayAt(坐标对不对);听距范围默认 1~50 米(Set3DRange)。 - 2D 听着"有远近":不会 —— 2D 的
spatialBlend = 0。听起来有衰减,说明这条是走 3D 播的。 - 一切正常但就是不动:确认它真的在被推进 —— 如果你自己调过
RevSound.Tick,就要保证每帧都调(调过一次,自动宿主就永久让位了)。
没有日志是正常的
框架自身不打任何日志(这是设计),所以"没声音又没日志"时,请先看
Failed / VoiceFinished 两个事件,而不是去 Console 里找线索。
8一页速查卡
打印出来贴在显示器旁边,写代码时不用回来翻文档。
播放
2D 直接播
RevSound.Play("ui_click")3D 挂物体
RevSound.PlayOn("cast", hero.gameObject)3D 挂坐标点
RevSound.PlayAt("boom", hitPoint)循环音
RevSound.PlayOn("run", transform, loop: true)BGM
RevSound.PlayBgm("login", fadeSeconds: 1f)BGM 歌单
RevSound.PlayBgmList("a", "b")停一条 / 全部
h.Stop() / RevSound.StopAll(0.3f)停 BGM
RevSound.StopBgm(0.8f)登记一条音效(逻辑名 → 路径)
RevSound.Register("ui_click", "UI/Button/click")注销 / 查询
RevSound.Unregister("ui_click") / IsRegistered("ui_click")预加载表里全部
RevSound.PreloadAll()音量与开关
主音量
RevSound.MasterVolume = 0.8f分类音量
RevSound.SetVolume(RevSoundKind.Bgm, 0.6f)静音(可接回)
RevSound.Mute = true总开关(不播)
RevSound.Enabled = false上限 / 去重
RevSound.MaxVoices = 32 / FrameDedupe = false3D 听距
RevSound.Set3DRange(1f, 60f)策略
RevSound.Policy = new MyPolicy()预加载 / 卸载
RevSound.Preload("a","b") / UnloadAll()观察(框架不做查询,都在回调里)
哪条音没播出去 / 为什么
RevSound.Failed += (name, reason) => ...播完了(接台词 / 轮播)
RevSound.VoiceFinished += h => ...整体状态
RevSound.Core.ToString()自己驱动
RevSound.Tick(Time.deltaTime)一块播一块停
using (var s = RevSound.OpenScope()) { ... }业务预加载也能用常量
RevResManager.LoadAsync<AudioClip>(RevSoundPath.Sfx, "ui_click", cb)目录配置(不进代码)
改音效 / BGM 目录打包窗口 「打包」页签 → 资源目录 → 音效目录(拖拽 / 选择… / 默认)
查看生成结果
RevSoundSystem/Generated/RevSoundPath.cs(工具生成,勿手改)默认值
Audio/Sfx/、Audio/Bgm/(相对资源根目录)硬约束目录必须在「资源根目录」之内(否则编辑器直读与真机行为会割裂)
子目录(任意层)名字里带子目录即可:
Play("UI/click")、PlayBgm("Lobby/login");不用改配置子目录的反斜杠/多余斜杠自动规范化:
"UI\\click"、"/UI//click/" 都能加载9附:设计来源与早期实现对照
给"想改它 / 想评审它"的人:这套东西是从哪来的、丢掉了什么。
向参考实现音效系统学的(精华)
| 参考实现哲学 | 本框架怎么落 |
|---|---|
| 异步是常态:播放立即返回、业务看不见异步窗口 | 资源没到 → 立即返回句柄,加载完自动播("等资源中"的槽位) |
| 3D 音效 = 给后端正确的 emitter | PlayOn 把播放器直接挂到目标物体下;物毁音停、位置零每帧开销 |
| 占位 ID + 播放现场缓存 | 只留一个槽位表 + 代际号:停止就是校验代际后释放槽位(O(1)) |
| 生命周期即作用域(14 个 Bank 域 + 一行批量回收) | RevSoundScope:using 一块,退出全停 |
| 筛选先于提交(最便宜的调用是不调用) | 策略 → 同帧去重 → 上限淘汰,三步都在"取播放器"之前 |
| 分类即语义 | RevSoundKind 四类 + 各自默认行为(替掉事件名后缀 _Hit_/_VO_ 的魔法字符串) |
| 元数据驱动(代码不认识任何具体音效名,只认识 ID 和表) | 默认:音效名 = 相对根目录的路径(走资源系统的路径映射);要"代码只写逻辑名"就用音效表 RevSound.Register |
| 策略可插拔、内核无知 | IRevSoundPolicy:内核不知道任何业务规则 |
| 性能内建 | 播放器池化(RevObjectPool)+ 只遍历活跃槽位 + 稳态零分配(无 LINQ、无装箱) |
| 可观测性 | Failed(带原因枚举)/ VoiceFinished 两个事件 + 内核 ToString() |
丢掉的(糟粕)
| 参考实现的问题 | 本框架的处理 |
|---|---|
CSoundManager 3539 行上帝类 | 内核 587 行,门面与内核分家 |
到处 Singleton(5 个都带 GetInstance) | 静态门面只是便捷;内核 RevSoundCore 是普通实例(可 new 多个) |
空 catch { } 吞异常、失败无日志 | 失败一律走 RevSoundErrorReason + Failed 事件 |
| 接口爆炸(PostEvent 5 重载 + RTPC 10 重载) | 播放 5 个 + 停止 3 个;音量/开关 9 个;高级能力各 1 个 |
默认参数陷阱(forceUnload = true 语义反直觉) | 默认值都取"安全侧"(不卸载、不停播、不静音) |
公开 API 里留 NotImplementedException | 不写未实现的接口 |
| 静态临时容器(重入就串数据) | 不用共享临时容器 |
平台宏分支爆炸(#if SGAME_LITE / IS_CE / …) | 零 #if 分支:平台差异由资源系统的后端策略吸收 |
| 业务要自己移除回调、自己判空 | 句柄自动失效;3D 目标销毁自动收声 |
| 客户端混用同步/异步加载(同步命中"加载中"会拿到空内容) | 只走异步,首次晚一点出声换零竞态 |
早期实现 MusicMgr(714 行)迁移对照
| 旧写法 | 新写法 | 旧实现的坑 |
|---|---|---|
MusicMgr.Instance.PlaySound("click") | RevSound.Play("ui_click") | 单例 + 依赖 6 个其它管理器 |
没有 3D 概念(所有 AudioSource 都堆在 SoundRoot 下) | RevSound.PlayOn("cast", hero.gameObject) | 想挂在角色身上只能业务自己 SetParent,且没有"物毁音停" |
StopSound(AudioSource) | h.Stop() | 业务要攥着 AudioSource;列表里找不到就静默 return("停不掉") |
ChangeSoundValue(v) 遍历所有 AudioSource | RevSound.SetVolume(RevSoundKind.Sfx, v) | 只有 2 个全局音量,没有主音量与分类 |
PlayBKMusic(ab, name) | RevSound.PlayBgm("name", fadeSeconds: 1f) | BGM 切换是硬切 |
PlayBKMusicList(...) + 完成事件 | RevSound.PlayBgmList("a", "b") | 能力保留,去掉 Timer 轮询与手工接线 |
PlaySoundSafe(...)(场景切换延迟 100ms 重试) | 无需对应物 | 异步回调被拦掉后不释放片段(泄漏);延迟靠定时器轮询 |
PlayOrPauseSound(bool) | RevSound.Enabled / Mute | bool 全局暂停:两个模块同时暂停,先恢复的那个会把别人的暂停也解掉 |
ClearSound()(注释写了三遍"切场景前记得清") | using (var s = RevSound.OpenScope()) | 靠人记,忘了就"切页面还在响" |
duration 定时停止:(int)duration * 1000 | 自己 h.Stop()(或用作用域) | 小于 1 秒的 duration 被截断成 0(旧代码的真 bug) |
规模与验证
| 项 | 结果 |
|---|---|
| 规模 | 10 个 .cs / 1640 行(注释 453 + 净代码 907 + 空行 280) |
| 小白必读 | 1 个文件:Facade\RevSound.cs(296 行) |
| 目录配置 | 在打包工具窗口里选(ABBuildConfig → RevSoundPath.cs),代码里零硬编码 |
| 编译 | Debug 0 错 0 警;Release 0 错 |
| 内核行为验证(纯 C#,脱离 Unity) | 26 / 26 通过:句柄代际、槽位复用不误停、轮转分配、同帧去重、上限淘汰、重复释放幂等、分类默认表 |
| 路径 / 规范化 / 音效表验证(纯 C#) | 43 / 43 通过:一层与多级子目录拼接、"两段键 == 完整路径键"不变量、名字规范化 12 项、音效表(登记/覆盖/注销/解析优先级)—— 与内核合计 69 / 69 |
| 名字规范化归属 | RevResPathUtil.NormalizeResName(资源系统的纯 C# 工具,其它模块也能复用) |
| 依赖 | RevResourceSystem(音频片段)+ RevObjectPool(播放器池,不需要预制体) |
配套文档
代码目录里还有一份
Assets/Revolution/Runtime/RevSoundSystem/README.md(3 分钟上手 + 9 个坑 + 排障),内容与本文一致、更适合对着代码看。