使用说明 · 从零到能写业务

音效系统 · 使用说明

这份文档只回答一个问题:我该怎么用它?(一行代码就能出声)

读完你能做到:3 分钟放出声 · 分清 2D/3D 与"挂在哪" · 知道哪 9 个坑会让人白干半天 · 没声音时 5 分钟定位

〇它是干什么的

先花 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) 循环播放
① 请求播放策略 / 去重 / 上限
→
② 占槽位拿到句柄
→
③ 挂在目标下跟着它移动 / 出声
→
④ 播完回收播放器回池 / 句柄失效
③ 的"挂"是真的挂:播放器成为目标物体的子节点 —— 位置随父子关系自动更新(零每帧开销),物体被销毁时播放器一起销毁、声音自动收。

它不干什么(同样重要)

13 分钟跑起来

三步:把音频放对地方 → 一行播放 → (可选)调音量。

① 音频放哪:在打包工具窗口里选(代码里不写死)

菜单 Revolution.Tools/资源/RevAB 打包工具 → 「打包」页签 → 资源目录 → 音效目录:把文件夹拖进槽里,或点「选择…」,或点「默认」。

声音默认目录(相对"资源根目录")运行时读的常量
音效 / 语音 / 界面音<资源根目录>/Audio/Sfx/RevSoundPath.Sfx
背景音乐<资源根目录>/Audio/Bgm/RevSoundPath.Bgm
打包窗口里选目录→ 存进 ABBuildConfig.asset(团队共享)→ 自动生成 RevSoundPath.cs→ 音效系统读常量(零运行期 IO)
目录必须在「资源根目录」之内 放在外面的话:编辑器直读拼不出路径、RevResPath 也不会为它生成常量 → 会出现"真机有声、编辑器无声"的割裂。窗口会直接拒绝并提示。

子目录:随便建,名字里带上就行

音效名本身就是"相对音效根目录的资源路径",支持任意层子目录 —— 不需要为子目录做任何配置。

音频放在播放写法
<资源根目录>/Audio/Sfx/ui_click.wavRevSound.Play("ui_click")
<资源根目录>/Audio/Sfx/UI/ui_click.wavRevSound.Play("UI/ui_click")
<资源根目录>/Audio/Sfx/Voice/Hero_1001/vo_hello.wavRevSound.Play("Voice/Hero_1001/vo_hello", kind: RevSoundKind.Voice)
<资源根目录>/Audio/Bgm/Lobby/login.wavRevSound.PlayBgm("Lobby/login")
❌ 名字里又写了一遍根目录
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 / 换 BGMRevSound.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 必须说清楚"声音从哪发出"。

2D3D
写法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 个坑

十个坑,每个都对应一条"我明明写了却没反应"。

#坑正确做法 / 为什么会这样
13D 播放没给"挂在哪" 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。
NoTarget3D 播放没给"挂在哪"(PlayOn 的目标为 null)。检查传进去的物体是不是已经销毁了。
LoadFailed资源加载失败:路径不对 / 没打进包 / 名字写错。报的是实际加载的路径(名字登记过音效表时就是表里的路径)。见下面第 2 步。

按顺序查

  1. 看 Failed 事件:有原因就照着上表处理,不用猜。
  2. LoadFailed 时查路径:窗口里配的目录(常量值见 RevSoundPath.cs,RevSoundPath.Sfx / Bgm)+ 名字 拼出来的路径对不对? 编辑器下走 AssetDatabase,运行时走 AB 映射(ResMap)—— 音频文件有没有被打进包(AB 标记)?
  3. 看整体状态:RevSound.Core.ToString() → RevSoundCore(活跃 3/24,BGM=login)。
  4. 3D 听不到:确认用的是 PlayOn(目标有效、在场景里、没被隐藏)或 PlayAt(坐标对不对);听距范围默认 1~50 米(Set3DRange)。
  5. 2D 听着"有远近":不会 —— 2D 的 spatialBlend = 0。听起来有衰减,说明这条是走 3D 播的。
  6. 一切正常但就是不动:确认它真的在被推进 —— 如果你自己调过 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)
BGMRevSound.PlayBgm("login", fadeSeconds: 1f)
BGM 歌单RevSound.PlayBgmList("a", "b")
停一条 / 全部h.Stop() / RevSound.StopAll(0.3f)
停 BGMRevSound.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 = false
3D 听距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 音效 = 给后端正确的 emitterPlayOn 把播放器直接挂到目标物体下;物毁音停、位置零每帧开销
占位 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) 遍历所有 AudioSourceRevSound.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 / Mutebool 全局暂停:两个模块同时暂停,先恢复的那个会把别人的暂停也解掉
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 个坑 + 排障),内容与本文一致、更适合对着代码看。