diff --git a/modules/frameworks/资源管理/README.md b/modules/frameworks/资源管理/README.md index 0fa9fee..fb0509f 100644 --- a/modules/frameworks/资源管理/README.md +++ b/modules/frameworks/资源管理/README.md @@ -163,6 +163,7 @@ Package 完整成功后才记录请求包和依赖包。查询 Package 是否完 | 文档 | 关联原因 | |---|---| | [资源管理使用指南](使用指南.md) | 业务调用、卸载、内存和分包 API | +| [微信小游戏资源接入方案](微信小游戏资源接入方案.md) | FFCard_WeChat 的微信构建、CDN、缓存、加载与卸载接入设计 | | [模块索引](../../模块索引.md) | 模块入口和状态 | | [UI 窗口系统](../UI窗口系统/README.md) | UI Prefab 加载与卸载 | | [Hotfix 启动](../Hotfix启动.md) | Bootstrap 代码加载入口 | diff --git a/modules/frameworks/资源管理/微信小游戏资源接入方案.md b/modules/frameworks/资源管理/微信小游戏资源接入方案.md new file mode 100644 index 0000000..1a4000e --- /dev/null +++ b/modules/frameworks/资源管理/微信小游戏资源接入方案.md @@ -0,0 +1,351 @@ +# HF 框架微信小游戏 CDN 资源接入方案 + +## 一、目标与边界 + +本方案只设计 HF/StrayFog 框架自身的微信小游戏资源能力,不与任何具体业务工程绑定。 + +最终使用方式必须保持简单: + +1. 业务继续选择现有 `CDNAssets` 加载模式。 +2. 编译时启用 `WeChat` 宏,框架自动使用微信 CDN 实现。 +3. 未启用 `WeChat` 宏,框架继续使用原有 CDN 实现。 +4. 不新增 `isWeChat` 参数、项目启动代码、项目名路径或业务层初始化接口。 + +本阶段不处理微信登录、支付、广告、分享,也不负责安装、升级或转换 WXSDK。 + +## 二、微信官方约束 + +### 2.1 SDK 与宏 + +微信官方文档建议 Unity 2019 及以后版本通过 Package Manager 安装 WXSDK,并说明同时导出普通 WebGL 时应使用编译宏隔离微信专用接口。 + +框架采用已有的 `WeChat` 宏作为唯一开关: + +```csharp +#if WeChat +// 微信小游戏实现 +#else +// 原有实现 +#endif +``` + +`WeChat` 宏启用时,使用方必须已经导入提供 `WeChatWASM` 和运行时程序集 `Wx` 的微信插件。宏未启用时,框架不得编译或调用任何微信 API。 + +### 2.2 AssetBundle + +微信官方资源文档明确要求: + +- 非首包资源放在 CDN,通过异步接口按需加载。 +- 小游戏不能沿用普通 APP 的本地路径 AssetBundle 加载方式。 +- Bundle 应通过 CDN URL 使用 `UnityWebRequestAssetBundle` 或 `DownloadHandlerAssetBundle` 异步加载。 +- 微信转换插件在底层负责 Bundle 缓存和更新;业务层仍按远程请求编写。 +- 不应自行将普通 Bundle 写入 `WX.env.USER_DATA_PATH` 后再使用 `LoadFromMemoryAsync`。 +- 禁止使用 `WWW`、`WWW.LoadFromCacheOrDownload` 和 Unity 旧 Cache API。 +- Bundle 文件名应包含内容 Hash;推荐 LZ4,并在不需要跨 Unity 版本时关闭 TypeTree。 +- 不再使用的 Bundle 应及时卸载。 + +因此,框架的微信 CDN 后端负责“创建远程 Bundle 请求和管理生命周期”,不实现第二套 AssetBundle 磁盘缓存。 + +## 三、框架公开行为 + +### 3.1 选择矩阵 + +| 加载模式 | `WeChat` 宏 | 框架行为 | +|---|---:|---| +| `CDNAssets` | 开启 | 使用微信 CDN AssetBundle 后端 | +| `CDNAssets` | 关闭 | 使用现有 CDN 后端 | +| 非 `CDNAssets` | 开启或关闭 | 保持现有 Editor、PersistentData、StreamingAssets 等逻辑 | + +只有同时满足“当前为 `CDNAssets`”和“编译启用 `WeChat`”时才切换实现。业务调用方式、资源 ID、回调和 `HFFunCatalogue.Asset` 入口保持不变。 + +### 3.2 唯一开关 + +框架内部选择逻辑应等价于: + +```csharp +if (loadModel == enHFAbLoadModel.CDNAssets) +{ +#if WeChat + LoadAssetBundleByWeChatCdn(); +#else + LoadAssetBundleByDefaultCdn(); +#endif + return; +} + +LoadByExistingMode(); +``` + +不得再叠加以下开关: + +- `HF_WECHAT_MINIGAME` +- `UNITY_WEIXINMINIGAME` +- `useWechatCdn` +- `isMiniGame` +- 项目配置表中的微信布尔值 + +Unity 目标平台仍由原逻辑识别为 WebGL,资源平台目录继续使用 `ab_webgl`;`WeChat` 只决定 CDN 实现,不新增 `ab_wechat`。 + +## 四、框架内部设计 + +### 4.1 编译期分流 + +微信实现直接封装在现有框架资源调用链中,不新增业务初始化入口: + +```text +HFAssetLoader + -> CDNAssets + -> UnityWebRequestAssetBundle(仅 WeChat 宏编译) + -> 原有下载与本地缓存流程(未启用 WeChat 宏) + +HFABMgr Manifest + -> CDNAssets + -> UnityWebRequestAssetBundle(仅 WeChat 宏编译) + -> 原有 Manifest 下载流程(未启用 WeChat 宏) + +SFSubPackageRuntimeV2 + -> Bootstrap 与状态文件 + -> WeChatMiniGame / WXFileSystemManager(仅 WeChat 宏编译) + -> System.IO(未启用 WeChat 宏) + -> 普通 AssetBundle + -> UnityWebRequestAssetBundle 预热,由微信插件缓存 +``` + +微信桥接负责两项能力: + +- 创建微信环境下的远程 AssetBundle 请求。 +- 为 V2 状态和 Bootstrap 原始文件提供微信文件存储。 + +普通 AssetBundle 缓存仍由微信插件底层负责,不进入 `HF Byte Storage`。 + +### 4.2 无注册、无第二开关 + +不建立 `HFResourcePlatform.Register(...)` 一类注册流程,也不要求项目启动代码识别微信平台。现有 CDN 根地址、版本、渠道和平台参数仍使用框架原配置;`WeChat` 只在编译时替换 `CDNAssets` 的底层实现。 + +微信文件根目录由 `HFAssetBundleHelper.GetCdnSaveRootPath()` 在 `#if WeChat` 中直接返回 `WX.env.USER_DATA_PATH` 下的框架目录。V2 在第一次读取状态前等待下载配置与存储回调全部设置完成,避免先访问 `persistentDataPath` 再切换存储。 + +### 4.3 程序集边界 + +- 微信 API 只出现在 `#if WeChat` 包围的框架代码中。 +- `WeChatMiniGame` 使用插件提供的 `WeChatWASM` 文件系统类型。 +- Loader、Manifest 和 V2 普通 Bundle 分支只依赖 Unity 标准 `UnityWebRequestAssetBundle`。 +- 不新增项目程序集引用、项目名、业务配置或微信布尔字段。 +- 未启用 `WeChat` 宏时,原有 CDN 代码保持编译和运行。 +- 启用 `WeChat` 宏但没有导入微信插件时,`WeChatMiniGame` 会产生明确的编译错误,而不是静默使用普通 WebGL 文件系统。 + +## 五、CDN AssetBundle 实现 + +### 5.1 默认微信后端 + +第一阶段使用微信官方常规异步方案: + +```csharp +#if WeChat +UnityWebRequest request = UnityWebRequestAssetBundle.GetAssetBundle(remoteUrl); +yield return request.SendWebRequest(); + +if (request.result == UnityWebRequest.Result.Success) +{ + AssetBundle bundle = DownloadHandlerAssetBundle.GetContent(request); + // 交给 HF Loader 生命周期管理。 +} +else +{ + // 进入框架原有候选 CDN、重试和失败回调。 +} + +request.Dispose(); +#endif +``` + +实现规则: + +- 始终传入完整 CDN URL;热启动同样走该请求,由微信插件判断缓存命中。 +- 不调用 `ExistLocalFile`、`ReadLocalFile`、`File.Exists`、`FileInfo`、`File.ReadAllBytes`、`AssetBundle.LoadFromFileAsync` 或 `AssetBundle.LoadFromMemoryAsync` 加载微信普通 Bundle。 +- 不对插件 Bundle 缓存做业务层物理文件检查、删除和大小统计。 +- 不使用 `WWW`、旧 Cache API 或 IndexedDB 模拟缓存。 +- 请求进度通过 `UnityWebRequest.downloadProgress` 回传给现有 HF 回调。 +- 资源大小优先使用 Manifest 的 `Size`;没有可靠值时保持 `Size = 0`,不影响加载结果。 +- 相同 URL 的并发请求继续由框架合并。 +- 请求失败继续使用框架现有候选 CDN 地址与重试策略。 + +### 5.2 `WXAssetBundle` 扩展后端 + +微信插件还提供更节省内存的 `WXAssetBundle`。官方同时说明它可能增加磁盘读取成本,因此第一阶段不默认启用。 + +后续如果真机数据证明必须使用,可以在框架现有微信 CDN 请求方法内部整体替换实现,不改变业务接口和宏: + +```csharp +WXAssetBundle.GetAssetBundle(remoteUrl) +DownloadHandlerWXAssetBundle.assetBundle +bundle.WXUnload(false) +``` + +这仍然只由 `WeChat` 宏选择。不得为它再增加第二个业务开关;是否升级为该实现属于框架版本决策。 + +### 5.3 Hash 与更新 + +微信底层缓存依赖 URL 和 Bundle Hash。框架构建工具必须保证: + +- WebGL Bundle 物理文件名包含内容 Hash。 +- V2 Manifest 保存 CDN 上真实的 Hash 文件名。 +- 相同内容 URL 稳定,不同内容生成新 URL。 +- 使用 `BuildAssetBundleOptions.ChunkBasedCompression`。 +- 无跨 Unity 版本兼容要求时使用 `DisableWriteTypeTree`。 +- 如果现有构建器已经拼接内容 Hash,不再重复使用另一套 Hash 命名。 + +微信端不读取 Bundle 本地字节做 MD5 校验。V2 中的 MD5 保留用于构建审计、差异展示和非 Bundle 原始文件校验。 + +## 六、V2 状态与 Bootstrap 存储 + +### 6.1 存储范围 + +微信文件存储只处理: + +- `subpackage_state.json` +- 最近一次有效的 `subpackage.json` +- AOT DLL 原始字节 +- HotUpdate DLL 原始字节 +- 框架未来明确声明为原始文件的内容 + +普通 AssetBundle、场景 Bundle、UI、模型、贴图和音频 Bundle 不写入此存储。 + +### 6.2 框架通用路径 + +微信小游戏自身已经按应用隔离用户目录,因此框架路径不包含任何项目名: + +```text +WX.env.USER_DATA_PATH/hf_resource/v2/ab_webgl +``` + +路径由 `WeChatMiniGame.MiniGameSaveRootPath()` 内部生成。业务不能传入产品名或项目目录。 + +处理规则: + +- 使用 `WXFileSystemManager` 读写。 +- 路径统一为 `/`,禁止 `..` 和越过框架根目录。 +- 不使用 `FileInfo` 解析微信路径或统计大小。 +- 已持有完整原始字节时可以记录 `LongLength`,否则大小保持 `0`。 +- 读取为 `null`、空字节或 MD5 不一致时,当前流程重新下载并覆盖。 +- 写入或删除失败不影响下一次重新读取和恢复。 + +### 6.3 V2 初始化 + +V2 初始化由资源框架内部完成: + +1. 平台桥接已经自动注册。 +2. `SetLocalStorage` 只保存回调,不自行调用 `LoadState()`。 +3. CDN、版本、平台和 V2 Local Root 全部设置完成后,只调用一次 `LoadState()`。 +4. `WeChat` 宏下使用微信字节存储;`#else` 使用 `System.IO`。 +5. 禁止先访问 `Application.persistentDataPath`,再切换到微信存储。 + +V2 的 Bundle 历史下载状态不能作为微信插件缓存仍存在的证明。每次进程首次使用某个 Bundle 时仍创建远程请求,由插件决定缓存或网络;Package 的微信状态只表示本次进程请求成功。 + +## 七、卸载与失败恢复 + +### 7.1 卸载 + +第一阶段使用 `UnityWebRequestAssetBundle`: + +```csharp +assetBundle.Unload(false); +``` + +卸载顺序: + +1. 业务释放场景、窗口、音频或实例引用。 +2. HF Loader 清理资源引用。 +3. Bundle 执行 `Unload(false)`。 +4. 请求和 DownloadHandler 被释放。 + +如果框架以后内部切换为 `WXAssetBundle`,对应请求方法必须改用 `WXUnload(false)`。这一差异仍封装在框架内部,不暴露给业务。 + +`Resources.UnloadUnusedAssets` 只在场景切换或批量窗口关闭后的安全节点调用,不在每次资源卸载时执行,也不主动调用 `GC.Collect`。 + +### 7.2 失败恢复 + +- 请求失败按候选 CDN 和框架重试次数重新请求。 +- 新 Bundle 通过新 Hash URL 绕过旧版本缓存。 +- 不依赖微信插件缓存的物理路径,不主动删除插件缓存。 +- 请求成功但 `AssetBundle == null` 时最多重试一次,禁止无限循环。 +- V2 状态或 Bootstrap 原始文件损坏时重新下载覆盖。 +- 日志包含逻辑资源名、完整 URL、候选地址序号、重试次数和请求错误。 + +## 八、框架代码修改边界 + +| 内容 | 框架源位置 | +|---|---| +| CDN 地址、本地根目录与宏选择 | `Assets/StrayFog/Editor/CopyToX/CopyToGeneral/AssetBundle/ESF_AssetBundleHelper.cs` | +| 微信字节存储实现 | `Assets/StrayFog/Editor/CopyToX/CopyToGeneral/MinGameModel/WeChatMiniGame.cs` | +| Loader 请求、进度、重试和卸载 | `Assets/StrayFog/Editor/CopyToX/CopyToHF/AssetBundle/Class/` | +| CDN Manifest 请求 | `Assets/StrayFog/Editor/CopyToX/CopyToHF/AssetBundle/ESF_ABMgr_Manifest.cs` | +| V2 状态与 Bootstrap | `Assets/StrayFog/Core/SFSubPackageV2.cs` | +| WebGL Bundle 构建参数与 Hash Manifest | `Assets/StrayFog/Editor/Tools/AssestBundle/` | + +禁止把实现放入项目的 Game、Hotfix、启动入口、LoadingWindow 或其他业务管理器中。 + +修改 ESF 源后执行 CopyToX,并验证生成结果;不能只修改 CopyToHF、CopyToGeneral 等生成目录。 + +## 九、验收标准 + +### 9.1 公共行为 + +- 业务只设置 `CDNAssets`,不调用微信初始化 API。 +- 打开 `WeChat` 宏后自动走微信 CDN 后端。 +- 关闭 `WeChat` 宏后自动走原 CDN 后端。 +- 非 CDN 模式行为不变。 +- 框架代码中没有项目名、项目路径或业务程序集依赖。 + +### 9.2 微信 CDN + +- Bundle 使用完整 CDN URL 异步加载。 +- 相同 Hash URL 的后续请求由插件复用缓存,框架代码路径不变。 +- 框架不生成自定义微信 Bundle 缓存文件。 +- Bundle Loader 不持有用于 `LoadFromMemoryAsync` 的完整缓存字节。 +- 请求失败、重试、进度和完成回调符合现有公开 API。 +- Bundle 卸载后 Loader 和 Bundle 内存下降。 + +### 9.3 V2 原始文件 + +- V2 状态与 Bootstrap 写入框架通用微信目录。 +- 空文件、损坏文件和 MD5 不一致能够重新下载。 +- 微信路径不进入 `System.IO` 和 `FileInfo`。 +- V2 初始化只读取一次正确存储中的状态。 + +### 9.4 回归 + +- Editor、Android、iOS 和普通 WebGL 的原 CDN 行为不变。 +- 未启用 `WeChat` 宏时不编译微信 API 调用。 +- `WeChat` 宏启用但插件缺失时给出明确编译或初始化错误。 + +## 十、当前实施结果 + +截至 2026-07-22,框架第一阶段已经完成: + +1. `CDNAssets + WeChat` 直接使用 `UnityWebRequestAssetBundle` 加载主 Bundle,普通 Bundle 不再走自建文件缓存和 `LoadFromMemoryAsync`。 +2. CDN Manifest 使用同一 AssetBundle 请求方式,并支持按现有候选 CDN 根地址回退。 +3. 请求成功但 Bundle 为空时只重试一次;网络失败继续尝试候选 CDN,失败后正确结束 Loader 状态。 +4. 微信普通 Bundle 的 V2 下载改为请求预热,只记录本进程成功状态,不持久化伪缓存命中状态。 +5. V2 状态和 Bootstrap 原始字节写入 `WX.env.USER_DATA_PATH/hf_resource/v2/`,微信路径不再进入 `FileInfo`。 +6. 微信读取返回 `null` 或 MD5 不一致时沿用 V2 原流程重新下载;微信写入结果会向 V2 报告成功或失败。 +7. `SetDownloadConfig` 和 `SetLocalStorage` 只更新配置,状态在首次实际操作前延迟读取一次。 +8. ESF 源文件已按 CopyToX 规则同步到 HF 生成目录,并校验生成内容一致。 +9. 普通分支及临时追加 `WeChat` 宏的 `StrayFogCore`、`GameGeneral`、`GameHF`、`SFEditor` 均完成 0 error 编译验证。 + +仍需真机阶段验证微信插件缓存复用、弱网切换、内存峰值和 Bundle 卸载效果。真机数据不足前不启用 `WXAssetBundle`,也不增加第二个开关。 + +## 十一、外部依据 + +- [微信 SDK 安装与使用](https://developers.weixin.qq.com/minigame/dev/guide/game-engine/unity-webgl-transform/Design/SDKInstaller.html) +- [微信资源按需加载概述](https://developers.weixin.qq.com/minigame/dev/guide/game-engine/unity-webgl-transform/Design/ResourcesLoading.html) +- [微信使用 AssetBundle 进行资源按需加载](https://developers.weixin.qq.com/minigame/dev/guide/game-engine/unity-webgl-transform/Design/UsingAssetBundle.html) +- [微信 FileSystemManager.readFile](https://developers.weixin.qq.com/minigame/dev/api/file/FileSystemManager.readFile.html) + +## 十二、文档关联 + +| 文档 | 关联原因 | +|---|---| +| [资源管理](README.md) | 当前资源框架职责和实现边界 | +| [资源管理使用指南](使用指南.md) | 现有加载、卸载和资源更新公开 API | +| [Hotfix 启动](../Hotfix启动.md) | Bootstrap AOT 与 HotUpdate 启动顺序 | +| [工作流清单](../../../rules/40-工作流清单.md) | 实施、生成、验证和文档同步流程 | diff --git a/modules/frameworks/音效系统重构方案.md b/modules/frameworks/音效系统重构方案.md new file mode 100644 index 0000000..cbfc90f --- /dev/null +++ b/modules/frameworks/音效系统重构方案.md @@ -0,0 +1,634 @@ +# 音效系统重构方案 + +## 目的 + +使用 Unity 原生 `AudioSource + AudioMixer` 重写框架音效系统,移除业务层对 SoundManagerPro、`AudioSource`、`AudioClip`、`SongCallBack` 和插件配置概念的依赖,并建立适合微信小游戏的加载、播放、缓存与释放流程。 + +本文是共享框架的可实施设计。第一版只覆盖 2D 音频;CrystalBattle 中的旧 `HFSoundManager` 仅作为迁移参考,不作为新接口规范。 + +## 当前实施状态(2026-07-21) + +第一版已经在 `FFCard_WeChat` 落地,并于 2026-07-22 同步到 `CrystalBattle` 参考工程,两边均通过 Unity Editor 编译。已实现统一播放句柄、Music 双源交叉淡化、SFX/UI 共享池、同资源并发限制、并发加载合并、引用计数与 LRU、PlayerPrefs 设置存储、UI 按钮桥接及 `HFFunCatalogue.Audio` 入口;两个工程均已移除 SoundManagerPro、旧 `HFSoundManager` 封装和对应 asmdef 依赖,`CrystalBattle` 原有业务调用也已迁移到新入口。后续根据业务使用反馈继续补充预加载、优先级抢占和更细的诊断接口。 + +## 主要路径 + +| 类型 | 路径 | 说明 | +|---|---|---| +| 旧实现参考 | `Assets/Game/GameHFScripte/GameFunction/SoundManagerPro/New/` | CrystalBattle 中基于 SoundManagerPro 的旧封装 | +| 热更框架源码 | `Assets/StrayFog/Editor/CopyToX/CopyToHF/Audio/` | 音效管理器、播放句柄、缓存和资源适配器的真实源码 | +| 热更生成结果 | `Assets/Game/GameHFScripte/CopyToHF/Audio/` | 由 CopyToX 生成,禁止直接修改 | +| 非热更桥接源码 | `Assets/StrayFog/Editor/CopyToX/CopyToGeneral/Audio/` | UI 音效接口和桥接器的真实源码 | +| 非热更生成结果 | `Assets/Game/GameGeneralScripte/CopyToGeneral/Audio/` | 由 CopyToX 生成,禁止直接修改 | +| 项目播放入口 | `Assets/Game/GameHFScripte/GameFunction/GameRoot/HFFunCatalogue_Audio.cs` | 向业务统一暴露 `HFFunCatalogue.Audio` | +| UI 接入 | `Assets/Game/GameMono/Scripts/UIMono/Abs/AbsSFUI_Button.cs` | 默认、覆盖、禁用三种按钮点击音模式 | +| 混音资源 | `Assets/Game/GameMono/Resources/Audio/HFDefaultAudioMixer.mixer` | 首包内的 Master、Music、SFX、UI 四个混音总线 | +| 启动接入 | `Assets/Game/GameHFScripte/GameFunction/GameRoot/HFGameStart.cs` | Res 表加载完成后显式初始化音效系统 | + +## 已确认的设计决定 + +| 项目 | 决定 | +|---|---| +| 播放后端 | Unity 原生 `AudioSource + AudioMixer` | +| 框架范围 | 共享框架,具体项目只提供资源和启动参数 | +| 资源标识 | 公开接口直接接收 Res 表中的 `int` ID | +| 配置来源 | 不再读取 `Sounds.xlsx`;Res 表只负责 ID 到资源路径的解析 | +| 旧接口兼容 | 不保留兼容层,业务调用直接迁移到新接口 | +| 播放控制 | 每次播放立即返回统一的 `HFAudioHandle` | +| 初始化 | Res 表可用后,由 `HFGameStart` 显式初始化 | +| 混音 | 强制使用标准 `AudioMixer`,不提供代码音量回退 | +| BGM 切换 | 两个 Music AudioSource 交替执行交叉淡化 | +| SFX 并发 | SFX/UI 总数 24,同一 Res ID 默认最多 4 个实例 | +| 缓存 | 引用计数、并发加载合并、LRU 淘汰;默认 32 个、空闲 60 秒 | +| UI 点击音 | `AbsSFUI_Button` 自动播放,支持默认、覆盖和禁用 | +| 设置保存 | 通过 `IHFAudioSettingsStore` 注入,提供 PlayerPrefs 默认实现 | +| 插件退出 | 新系统验收后立即删除 SoundManagerPro 及 asmdef 引用 | + +## 源码与生成目录 + +`CopyToHF` 和 `CopyToGeneral` 是生成目录,不是框架源码目录。新音效系统必须遵循现有 CopyToX 规则: + +```text +Assets/StrayFog/Editor/CopyToX/CopyToHF/Audio/ + -> 编写 ESF_ / IESF_ 热更框架源码 + -> CopyToX + -> Assets/Game/GameHFScripte/CopyToHF/Audio/ + -> 生成 HF_ / IHF_ 运行时代码 + +Assets/StrayFog/Editor/CopyToX/CopyToGeneral/Audio/ + -> 编写 ESF_ / IESF_ 非热更桥接源码 + -> CopyToX + -> Assets/Game/GameGeneralScripte/CopyToGeneral/Audio/ + -> 生成 HF_ / IHF_ 运行时代码 +``` + +命名对应关系: + +| 真实源码 | 生成后的公开类型 | +|---|---| +| `ESF_AudioManager` | `HFAudioManager` | +| `ESF_AudioHandle` | `HFAudioHandle` | +| `IESF_AudioAssetLoader` | `IHFAudioAssetLoader` | +| `IESF_UISoundPlayer` | `IHFUISoundPlayer` | +| `ESF_UIAudioBridge` | `HFUIAudioBridge` | + +本文后续 API 示例统一使用业务实际调用到的 `HF_` 生成名称。实施时必须修改对应 `ESF_` 源码并运行 CopyToX,禁止在生成结果中直接实现或修复功能。 + +## 边界 + +### 本模块负责 + +- 根据 Res ID 加载和释放 `AudioClip`。 +- Music、SFX、UI 的播放、暂停、恢复、停止和音量控制。 +- BGM 循环与交叉淡化。 +- SFX/UI AudioSource 池、并发限制和实例抢占。 +- 播放句柄状态、结果和实例级控制。 +- 并发加载合并、引用计数、预加载和 LRU 缓存。 +- Master、Music、SFX、UI 设置的读取与保存。 +- 应用进入后台及恢复时的声音状态管理。 +- UI 按钮默认点击音桥接。 + +### 本模块不负责 + +- 不维护独立的声音 XLSX 配置表。 +- 不向业务公开 `AudioClip`、`AudioSource` 或 `AudioMixerGroup`。 +- 第一版不处理 3D 位置、距离衰减、跟随目标和空间混音。 +- 不负责音频资源制作;只规定推荐的 Unity 导入策略。 +- 不直接销毁资源系统加载的 `AudioClip`。 + +## 总体架构 + +```text +业务 / UI + -> HFFunCatalogue.Audio + -> HFAudioManager + -> HFAudioHandle:一次加载与播放的状态和控制 + -> 内部 Voice:Music 双源、SFX/UI 共享池 + -> 内部 ClipCache:合并加载、引用计数、LRU + -> HFResAudioAssetLoader + -> Resource 表:Res ID -> Path + -> HFABMgr:加载、等待下载、取消、失败、卸载 + -> AudioMixer:Master / Music / SFX / UI + -> IHFAudioSettingsStore:音量与静音持久化 +``` + +## 关键类与接口 + +| 类/接口 | 用途 | +|---|---| +| `HFAudioManager` | 音效系统唯一入口和生命周期管理器 | +| `HFAudioHandle` | 表示一次加载、排队和播放请求 | +| `HFAudioManager` 内部 ClipCache | 管理 Clip 加载合并、引用计数和 LRU | +| `HFAudioManager` 内部 Voice | 管理 AudioSource 的分配和归还 | +| `HFResAudioAssetLoader` | 使用 Res 表和 `HFABMgr` 加载/卸载音频资源 | +| `IHFAudioSettingsStore` | 音量和静音设置存储边界 | +| `HFPlayerPrefsAudioSettingsStore` | 默认设置存储实现 | +| `IHFUISoundPlayer` | GameMono 可依赖的 UI 音效最小接口 | +| `HFUIAudioBridge` | UI 按钮与 GameHF 音效实现之间的依赖倒置桥 | + +## HFFunCatalogue 音效播放入口 + +在项目层通过 `HFFunCatalogue` 的 partial 文件增加唯一业务入口: + +```csharp +public static partial class HFFunCatalogue +{ + public static HFAudioManager Audio => HFAudioManager.Instance; +} +``` + +业务代码统一通过该入口调用: + +```csharp +HFAudioHandle music = HFFunCatalogue.Audio.PlayMusic(musicResourceId); +HFAudioHandle sfx = HFFunCatalogue.Audio.PlaySfx(sfxResourceId); +HFAudioHandle ui = HFFunCatalogue.Audio.PlayUi(uiResourceId); +``` + +SFX 播放期间需要临时压低 Music 时,将 Duck 参数固定在播放参数中传入: + +```csharp +private static readonly HFAudioPlayOptions BattleSfxOptions = new HFAudioPlayOptions +{ + // SFX 播放期间将 Music 临时压到当前设置音量的 35%。 + musicDuckVolume = 0.35f, + musicDuckFadeIn = 0.08f, + musicDuckFadeOut = 0.25f, +}; + +HFFunCatalogue.Audio.PlaySfx(sfxResourceId, BattleSfxOptions); +``` + +- `musicDuckVolume = 1f` 表示不压低 Music,`0.35f` 表示临时保留 35% 音量。 +- `musicDuckFadeIn` 是压低所需时间,`musicDuckFadeOut` 是 SFX 结束后的恢复时间,单位均为秒。 +- 同时存在多个 Duck 音效时使用最低音量系数,所有相关音效结束后才恢复 Music。 +- Duck 只作用于当前播放过程,不会修改或持久化玩家的 Music 音量设置。 +- 播放参数会在管理器内部复制,因此可以安全复用同一个固定参数实例。 + +使用规则: + +- `HFFunCatalogue.Audio` 是业务层唯一允许使用的音效系统入口。 +- 业务代码禁止直接访问 `HFAudioManager.Instance`。 +- `HFFunCatalogue` 只负责暴露管理器,不重复包装 `PlayMusic`、`PlaySfx`、`PlayUi` 等方法,避免形成两套 API。 +- 入口文件属于项目集成层,不能放入 `CopyToHF` 或 `CopyToGeneral` 生成目录。 +- 初始化仍由 `HFGameStart` 在 XLSX 加载完成后调用 `HFFunCatalogue.InitializeAudio(...)`。 + +## 后续完整 API 目标 + +管理器公开接口: + +```csharp +public AbsHFTask Initialize( + HFAudioInitializeOptions options, + IHFAudioSettingsStore settingsStore, + Action onComplete = null); + +public HFAudioHandle PlayMusic( + int resourceId, + HFMusicOptions options = default); + +public HFAudioHandle PlaySfx( + int resourceId, + HFSfxOptions options = default); + +public HFAudioHandle PlayUi( + int resourceId, + HFUiAudioOptions options = default); + +public AbsHFTask Preload( + int resourceId, + Action onComplete = null); + +public void ReleasePreload(int resourceId); +public void ClearUnused(); + +public void PauseMusic(); +public void ResumeMusic(); +public void StopMusic(float fadeOutSeconds = -1f); +public void StopAll(enHFAudioBus bus = enHFAudioBus.All); + +public void SetVolume(enHFAudioBus bus, float normalizedVolume); +public void SetMuted(enHFAudioBus bus, bool muted); + +public void Shutdown(); +``` + +### 初始化参数 + +```csharp +public sealed class HFAudioInitializeOptions +{ + public int AudioMixerResourceId; + public int DefaultUiClickResourceId; + public float DefaultMusicCrossFadeSeconds = 0.5f; + public int MaxSfxVoices = 24; + public int MaxInstancesPerResource = 4; + public int MaxUnusedCachedClips = 32; + public float UnusedClipLifetimeSeconds = 60f; +} +``` + +- `AudioMixerResourceId` 必须是 Res 表中指向 `HFAudioMixer.mixer` 的有效 ID。 +- `DefaultUiClickResourceId == 0` 表示默认按钮点击音关闭。 +- 初始化必须验证 Mixer Group 和公开参数;缺失时整体失败,不静默降级。 + +### 播放参数 + +`HFMusicOptions` 默认值: + +- `Loop = true` +- `Volume = 1f` +- `FadeInSeconds = 0.5f` +- `CrossFadeSeconds = -1f`,负数表示使用初始化默认值 +- `RestartIfSame = false` + +`HFSfxOptions` 默认值: + +- `Loop = false` +- `Volume = 1f` +- `DelaySeconds = 0f` +- `Priority = 128` +- `MaxInstancesPerResource = -1`,负数表示使用全局默认值 + +`HFUiAudioOptions` 默认值: + +- `Loop = false` +- `Volume = 1f` +- `DelaySeconds = 0f` +- `Priority = 64`,优先级高于普通 SFX + +所有输入音量必须限制到 `0~1`,延迟和淡化时间不得小于零,非法参数返回明确失败结果或使用文档规定的默认值。 + +## 播放句柄 + +```csharp +public enum enHFAudioHandleState +{ + Loading, + WaitingDownload, + Playing, + Paused, + Completed, + Stopped, + Failed, + Canceled +} +``` + +`HFAudioHandle` 至少公开: + +- `long InstanceId` +- `int ResourceId` +- `enHFAudioBus Bus` +- `enHFAudioHandleState State` +- `float Progress` +- `HFAudioResult Result` +- `Stop(float fadeOutSeconds = 0f)` +- `Pause()` +- `Resume()` +- `SetVolume(float normalizedVolume)` +- `OnCompleted(Action callback)` + +规则: + +- `Play` 立即返回非空句柄;即使未初始化或 ID 非法,也返回已经失败的句柄。 +- 一个句柄只能进入一次终态,完成回调只能执行一次。 +- 加载期间调用 `Stop`,结果为 `Stopped`;调用 `Cancel`,结果为 `Canceled`。 +- 被并发策略抢占时进入 `Stopped`,结果原因标记为 `VoiceStolen`。 +- 不允许通过句柄取得底层 `AudioSource` 或 `AudioClip`。 + +建议结果码: + +```text +Success +NotInitialized +InvalidResourceId +ResourceConfigMissing +AssetTypeMismatch +LoadFailed +Canceled +MixerUnavailable +MixerLayoutInvalid +VoiceStolen +Shutdown +``` + +## AudioMixer 规范 + +Mixer 必须包含以下 Group: + +```text +Master + Music + SFX + UI +``` + +必须公开以下参数: + +```text +MasterVolumeDb +MusicVolumeDb +SfxVolumeDb +UiVolumeDb +``` + +运行时保存 `0~1` 的线性音量,写入 Mixer 时转换为分贝: + +```csharp +db = normalizedVolume <= 0.0001f + ? -80f + : 20f * Mathf.Log10(normalizedVolume); +``` + +静音使用 `-80 dB`。取消静音后恢复该总线最近一次保存的线性音量。 + +## BGM 行为 + +- Music 使用两个专用 AudioSource,不占用 SFX/UI 的 24 个实例。 +- 新 BGM 播放时,空闲 Music Source 淡入,旧 Source 同时淡出。 +- 淡化使用 `Time.unscaledDeltaTime`,不受游戏暂停或 `timeScale` 影响。 +- 相同 Res ID 且 `RestartIfSame == false` 时返回当前 Music 句柄,不重新加载或播放。 +- `RestartIfSame == true` 时按一次新的切换执行。 +- `PauseMusic` 只暂停当前 Music;`ResumeMusic` 只恢复由该接口暂停的 Music。 +- `StopMusic(-1)` 使用初始化默认淡出时间,`StopMusic(0)` 立即停止。 +- BGM 加载失败时旧 BGM 保持播放,不先停止旧音乐。 + +## SFX/UI 并发与抢占 + +- SFX 和 UI 共用最多 24 个 AudioSource。 +- 同一 Res ID 默认最多同时播放 4 个实例。 +- 达到同 ID 上限时,停止该 ID 中最早启动的实例,再复用其 Source。 +- 达到全局上限时: + 1. 优先归还已经完成但尚未清理的 Source。 + 2. 在可抢占实例中选择最低优先级。 + 3. 同优先级时选择最早启动的实例。 +- 数值越小优先级越高,UI 默认 `64`,普通 SFX 默认 `128`。 +- Music Source 永远不参与 SFX/UI 抢占。 +- 循环 SFX 也必须通过句柄停止,不能依赖 Res ID 查找最后一个实例。 + +## 加载、缓存与释放 + +### Res ID 加载 + +`HFResAudioClipLoader` 的固定流程: + +```text +校验 resourceId > 0 + -> 查询 Resource 表 + -> 取得并规范化 Path + -> HFABMgr.LoadAssetObject + -> 返回 AudioClip、底层任务和实际卸载路径 +``` + +加载器必须传递成功、失败、取消、进度和等待下载状态,不能只处理成功回调。 + +### 并发加载合并 + +- 同一 Res ID 同时只允许一个底层加载任务。 +- 后续播放或预加载请求加入等待列表。 +- 任一调用方取消时只移除自己的等待句柄;仍有等待者时不得取消共享底层任务。 +- 最后一个等待者取消后,才允许取消底层任务。 +- 成功、失败或取消后必须从“正在加载”字典移除。 + +### 引用计数 + +每个缓存项至少记录: + +```text +ResourceId +ResolvedPath +AudioClip +ActiveReferenceCount +PreloadPinCount +LastUsedRealtime +LoadState +``` + +- 活跃播放开始前增加引用,进入终态时减少引用。 +- `Preload` 增加固定引用;`ReleasePreload` 减少固定引用。 +- `ActiveReferenceCount > 0` 或 `PreloadPinCount > 0` 时禁止淘汰。 + +### LRU 默认策略 + +- 无引用 Clip 空闲超过 60 秒后可被淘汰。 +- 无引用缓存超过 32 个时,从最久未使用的条目开始淘汰。 +- 先清理超过空闲时间的条目,再按数量上限清理。 +- 卸载必须调用 `HFABMgr.UnloadAssetObject(resolvedPath)` 或等价资源接口。 +- 禁止 `Destroy(AudioClip)`。 +- 底层卸载失败时保留缓存记录并输出一次可定位日志,等待后续重试。 + +## UI 点击音与程序集边界 + +`GameHF` 已引用 `SFMono`。如果 `AbsSFUI_Button` 直接调用 `HFFunCatalogue.Audio`,将形成程序集循环依赖。 + +固定使用依赖倒置: + +```text +GameGeneral + -> 定义 IHFUISoundPlayer、HFUIAudioBridge + +SFMono + -> AbsSFUI_Button 调用 HFUIAudioBridge + +GameHF + -> HFAudioManager 实现 IHFUISoundPlayer + -> Initialize 时注册 + -> Shutdown 时注销 +``` + +按钮增加: + +```csharp +public enum enHFUIButtonAudioMode +{ + Default, + Override, + Disabled +} +``` + +序列化字段: + +```csharp +[SerializeField] +private enHFUIButtonAudioMode mClickAudioMode = enHFUIButtonAudioMode.Default; + +[SerializeField] +private int mClickAudioResourceId; +``` + +行为: + +- `Default`:使用初始化参数中的默认 UI 点击音 ID。 +- `Override`:使用按钮自身的 `mClickAudioResourceId`。 +- `Disabled`:不播放。 +- 仅在按钮通过 `interactable` 判断和现有 0.2 秒点击防抖后播放。 +- 音效未初始化时桥接调用直接忽略,只允许输出一次警告,不能每次点击刷日志。 + +## 设置持久化 + +```csharp +public interface IHFAudioSettingsStore +{ + bool TryLoad(out HFAudioVolumeSettings settings); + void Save(HFAudioVolumeSettings settings); +} +``` + +`HFAudioVolumeSettings` 保存 Master、Music、SFX、UI 四组音量和静音值。 + +默认实现使用独立版本键,例如 `hf.audio.settings.v1`。项目可替换为微信本地存储、账号设置或云存储实现,音效核心不得直接调用平台 SDK。 + +## 生命周期 + +### 初始化顺序 + +```text +HFGameStart.Init + -> 设置资源模式和语言 + -> 加载 XLSX/Res 表 + -> OnLoadDbFinish + -> HFFunCatalogue.Audio.Initialize + -> 初始化成功后进入业务场景 +``` + +Res 表未加载前无法使用 Res ID,因此 LoadingWindow 在数据库加载完成前默认没有 UI 点击音。如果启动界面未来需要声音,应单独设计首包固定资源通道,不能绕过当前 Res 加载约束。 + +### 应用后台 + +- 进入后台时记录由系统暂停的播放实例。 +- 返回前台时只恢复这些实例。 +- 原本由业务手动暂停的实例保持暂停。 + +### Shutdown + +`Shutdown` 必须: + +1. 拒绝新的播放请求。 +2. 取消或结束所有等待句柄。 +3. 停止 Music、SFX 和 UI Source。 +4. 注销 `HFUIAudioBridge`。 +5. 释放预加载固定引用。 +6. 通过资源管理器卸载可释放的 Clip 和 Mixer。 +7. 清空池、缓存、回调和运行状态。 + +## 迁移方案 + +### 调用替换 + +```csharp +// 旧 +HFFunCatalogue.SoundPool.PlayBgm(id); + +// 新 +HFFunCatalogue.Audio.PlayMusic(id); +``` + +```csharp +// 旧 +HFFunCatalogue.SoundPool.PlaySFX(id); + +// 新 +HFFunCatalogue.Audio.PlaySfx(id); +``` + +```csharp +// 旧 +HFFunCatalogue.SoundPool.SetPauseBGM(true); +HFFunCatalogue.SoundPool.SetPauseBGM(false); + +// 新 +HFFunCatalogue.Audio.PauseMusic(); +HFFunCatalogue.Audio.ResumeMusic(); +``` + +旧接口中返回或回调 `AudioClip`、`AudioSource` 的能力不迁移。业务如需停止循环声音,必须保存新接口返回的 `HFAudioHandle`。 + +### 实施顺序 + +1. 在 `Assets/StrayFog/Editor/CopyToX` 对应源码目录创建 ESF 核心类型、状态和结果模型,并通过 CopyToX 生成 HF 代码。 +2. 创建标准 AudioMixer 资源并登记 Res ID。 +3. 实现 Res 加载适配器和缓存。 +4. 实现 Music 双源和 SFX/UI Source 池。 +5. 接入设置存储和 UI 桥。 +6. 在项目层增加 `HFFunCatalogue.Audio` 播放入口。 +7. 在 `HFGameStart` 中通过该入口完成显式初始化。 +8. 一次性迁移业务调用,不增加旧 API 兼容层。 +9. 完成 Editor、AssetBundle、CDN/等待下载验证。 +10. 删除旧 `HFSoundManager`、`Sounds.xlsx` 音效依赖和 SoundManagerPro。 +11. 删除 GameHF 对 `SoundManagerProDefine` 的 asmdef 引用。 + +## 音频资源导入建议 + +针对微信小游戏: + +| 类型 | 建议 Load Type | 建议用途 | +|---|---|---| +| BGM | Streaming | 长音乐,避免整段解压常驻内存 | +| 短 UI/SFX | Compressed In Memory | 控制常驻内存,适合重复播放 | +| 极短且高频 SFX | 视性能测试选择 Decompress On Load | 只用于数量有限、确有解码性能瓶颈的资源 | + +其他建议: + +- 无空间定位需求的音频优先转 Mono。 +- 不建议所有资源统一使用质量 `1.0`,应按听感和包体测试设定。 +- BGM 不得使用 Decompress On Load 作为默认策略。 +- 平台 Override 必须通过 Unity 构建结果和运行时内存验证,而不是只看源文件大小。 + +## 验证 + +### EditMode + +- 非法、缺失 Res ID 返回正确失败结果。 +- 同 ID 并发请求只触发一次底层加载。 +- 成功、失败、取消时所有句柄都进入且只进入一次终态。 +- 单个等待者取消不影响其他等待者。 +- 活跃引用和预加载固定引用阻止错误淘汰。 +- 32 个/60 秒 LRU 按约定顺序释放。 +- 卸载通过资源管理器执行,没有直接销毁 Clip。 +- 音量设置能够保存、恢复并正确转换为分贝。 + +### PlayMode + +- BGM 双源交叉淡化正确,切换失败时旧 BGM 不停止。 +- 相同 BGM 默认不重播,`RestartIfSame` 可以强制重启。 +- SFX/UI 总 Source 不超过 24,同 ID 不超过 4。 +- 抢占顺序符合优先级和启动时间规则。 +- 循环 SFX 可以通过各自句柄独立停止。 +- UI 默认、覆盖、禁用三种模式正确。 +- 暂停、后台恢复和场景切换不会产生重复管理器或错误恢复。 + +### 资源模式 + +- Editor、AssetBundle、本地包、CDN 和等待下载路径均能闭环。 +- 下载失败、资源类型错误和取消不会留下永久等待回调。 +- `Shutdown` 后没有活动 Source、等待任务、UI 桥或缓存引用。 + +### 验收标准 + +- 工程内不存在业务侧 SoundManagerPro 类型引用。 +- 所有业务播放调用均通过 `HFFunCatalogue.Audio`,不存在直接访问 `HFAudioManager.Instance` 的调用点。 +- 音效框架的可维护实现全部位于 `Assets/StrayFog/Editor/CopyToX`,生成目录没有手工维护代码。 +- `GameHF.asmdef` 不再引用 `SoundManagerProDefine`。 +- 不再读取 `Sounds.xlsx` 播放音频。 +- 所有播放失败都可以通过句柄得到明确结果。 +- Music 固定使用两个 Source,SFX/UI Source 上限固定为 24。 +- 资源只由 `HFABMgr` 加载和释放。 +- SoundManagerPro 目录可以在不引发编译错误的情况下删除。 + +## 当前状态 + +- 待验证。 +- 已完成旧系统分析和新系统设计。 +- 尚未实现代码、Mixer 资源、业务迁移和运行时验证。 + +## 文档关联 + +| 文档 | 关联原因 | +|---|---| +| [模块索引](../模块索引.md) | 模块入口和状态 | +| [资源管理](资源管理/README.md) | AudioClip 的加载、等待下载和卸载边界 | +| [数据配置框架](数据配置框架.md) | Resource 表及 Res ID 解析 | +| [第三方插件](第三方插件.md) | SoundManagerPro 的退出和 asmdef 清理 | +| [Hotfix 启动](Hotfix启动.md) | `HFGameStart` 初始化时机 | +| [架构说明](../../rules/10-架构说明.md) | GameGeneral、SFMono、GameHF 依赖方向 | diff --git a/modules/模块索引.md b/modules/模块索引.md index 0153a03..dfa0aa4 100644 --- a/modules/模块索引.md +++ b/modules/模块索引.md @@ -10,6 +10,8 @@ | [对象池](frameworks/内存对象管理/README.md) | `CopyToHF/MemoryManager/` | GameObject、脚本和类实例池 | 已完成 | | [事件系统](frameworks/事件系统/README.md) | `CopyToHF/Event/` | CTC/STC 事件派发 | 已完成 | | [资源管理](frameworks/资源管理/README.md) | `CopyToHF/AssetBundle/`、`StrayFog/Core/SFSubPackageV2.cs` | AssetBundle、图集、卸载、分包 V2 和下载 | 已完成 | +| [微信小游戏资源接入方案](frameworks/资源管理/微信小游戏资源接入方案.md) | `CopyToGeneral/MinGameModel/`、`SFSubPackageV2.cs` | FFCard_WeChat 微信 CDN、缓存、加载、卸载和资源生命周期接入方案 | 待实施 | +| [音效系统重构方案](frameworks/音效系统重构方案.md) | `StrayFog/Editor/CopyToX/CopyToHF/Audio/`、`CopyToGeneral/Audio/` | 原生音频播放、混音、缓存、并发与资源生命周期设计 | 待验证 | | [相机系统](frameworks/相机系统/README.md) | `GameHFScripte/` | 多相机、堆栈和渲染顺序 | 已完成 | | [实体系统](frameworks/实体系统/README.md) | `GameFunction/GameEntity/` | 实体、System、Model 和实体池 | 已完成 | | [UI 窗口系统](frameworks/UI窗口系统/README.md) | `CopyToHF/UIWindowMgr/`、`GameFunction/UIWindows/` | 窗口生命周期、加载、卸载和列表 | 已完成 |