diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index 3729605..27adad0 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -589,11 +589,20 @@ Contract Design Plan(本文档) - [x] **6.1** `Source/Coordinator/AppCoordinator.swift`:2s 节流(`lastSwitchAt`)+ 栈深约束(`viewControllers.count < 2`)+ `.subGameDidReturn` 通知名(Design §2.4.3) - [x] **6.2** `Source/WebView/SubGameViewController.swift`:与大厅同款 BridgedWebView + Splash + 16:9 letterbox + ExternalSubscriptions;`AppDataWriter(containerRole: .subGame)` 写 4 个 app_*.js;H5 未安装则抛 `SubGameBootError.subGameNotInstalled`(待 6.7 接入下载) -- [ ] **6.3** `Source/Bridge/Handlers/SubGameHandlers.swift`:19 共享 + `backgameData` + 3 个视频房间 stub(commit C 接入 backgameData) +- [x] **6.3** 子游戏 handler 注册(拆散到独立 enum,不抽 `SubGameHandlers` 聚合类): + - 14 个与大厅共享 handler 直接复用各自 enum.register + - 新增 `Source/Bridge/Handlers/BackGameDataHandler.swift`,仅 SubGameViewController 注册 + - exitRoom / getVideoinfo / createRoom 等视频房间 handler 留待 Phase 8 - [x] **6.4** `SwitchOverGameData` handler(大厅 + 子游戏共用):解参 `Gamedirectory` / `gamedownloadurl` / `data` → `AppCoordinator.shared.showSubGame(...)`;节流 / 栈深守门移到 Coordinator,handler 始终回 cb `"SwitchOverGameData"`(msext NewRootVC.m:541-566 等价) - [x] **SceneDelegate**:以 `UINavigationController` 承载大厅,`AppCoordinator.shared.navigationController` 持引;导航栏隐藏 + 禁用边缘 pop 手势 -- [ ] **6.5** `backgameData` handler(仅子游戏注册):停 audio → 发 `.subGameDidReturn` 通知 → coordinator.popSubGame -- [ ] **6.6** 大厅监听 `.subGameDidReturn` → `bridge.call("getWebdata", data)` +- [x] **6.5** `backgameData` handler(仅子游戏 `BackGameDataHandler.swift` 注册): + - `AudioPlayer.shared.stopAllBackground()`(新增方法,msext 无条件 stop+nil 行为等价) + - `AppCoordinator.shared.popSubGame(returningData:)` → 内部 popViewController + 发 `.subGameDidReturn` + - callback 字面 `"backgameData"` + - data 入参兼容 string / object(非字符串走 JSONSerialization 透传) +- [x] **6.6** 大厅监听 `.subGameDidReturn` → `bridge.call("getWebdata", data)` + - 挂钩点:`WebContainerViewController.setupExternalSubscriptions`(与 battery/network/appservice + 同一生命周期,避免双发);`teardownExternalSubscriptions` removeObserver - [x] **6.7** 子游戏 zip 下载 / 解压(H5 端通过 SwitchOverGameData 传 `gamedownloadurl`) - `Source/Resource/SubGameDownloader.swift`:actor 单例,URLSession + ZIPFoundation - 缓存策略:`{Caches}/{gameDir}/{gameStart}/index.html` 已存在 → `.alreadyExists` 直接复用; @@ -924,10 +933,10 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin ### Phase 6 子游戏 - [x] 6.1 AppCoordinator 栈深节流(2s + viewControllers.count < 2) - [x] 6.2 SubGameViewController 框架(commit B 接入 SubGameDownloader 后真实下载/缓存命中) -- [ ] 6.3 SubGameHandlers(backgameData 留待 commit C) +- [x] 6.3 子游戏 handler 拆散注册(含 BackGameDataHandler;视频房间留 Phase 8) - [x] 6.4 SwitchOverGameData → AppCoordinator.showSubGame -- [ ] 6.5 backgameData -- [ ] 6.6 getWebdata 通知链 +- [x] 6.5 backgameData(停 audio + popSubGame + JSON 序列化兼容) +- [x] 6.6 getWebdata 通知链(.subGameDidReturn 观察者挂在 ExternalSubscriptions 生命周期) - [x] 6.7 SubGameDownloader(actor,URLSession + ZIPFoundation + staging + 原子 rename) ### Phase 7 弹层 diff --git a/ylgamehall/Source/Audio/AudioPlayer.swift b/ylgamehall/Source/Audio/AudioPlayer.swift index 28dc414..1e1134c 100644 --- a/ylgamehall/Source/Audio/AudioPlayer.swift +++ b/ylgamehall/Source/Audio/AudioPlayer.swift @@ -82,4 +82,15 @@ public final class AudioPlayer { backgroundType = nil Self.log.debug("stopBackground done type=\(type, privacy: .public)") } + + /// 无条件停止当前背景音。子游戏 `backgameData` 退出时统一调一次 + /// (msext gameController.m:693-697 行为:不看 type,直接 stop + nil)。 + public func stopAllBackground() { + guard backgroundPlayer != nil else { return } + let oldType = backgroundType ?? "nil" + backgroundPlayer?.stop() + backgroundPlayer = nil + backgroundType = nil + Self.log.debug("stopAllBackground done (was type=\(oldType, privacy: .public))") + } } diff --git a/ylgamehall/Source/Bridge/Handlers/BackGameDataHandler.swift b/ylgamehall/Source/Bridge/Handlers/BackGameDataHandler.swift new file mode 100644 index 0000000..e0099f8 --- /dev/null +++ b/ylgamehall/Source/Bridge/Handlers/BackGameDataHandler.swift @@ -0,0 +1,51 @@ +// +// BackGameDataHandler.swift +// ylgamehall +// +// H5 → Native handler:backgameData — 子游戏退出并把数据回传给大厅。 +// 契约:docs/H5-Native-Contract.md §3.1 [18] +// +// 与 msext gameController.m:691-709 等价: +// 1) 停背景音(无条件,与 stopBackground 不同 — msext 不看 type 直接 nil) +// 2) AppCoordinator.shared.popSubGame(returningData: data) +// 内部 popViewController + 发 .subGameDidReturn 通知,由大厅监听后 +// 回调 H5 `getWebdata` 完成反向闭环 +// 3) responseCallback("backgameData") 无论 data 是否解析成功都回(msext 字面) +// +// ⚠️ 仅注册到子游戏 SubGameViewController;大厅 WebContainerViewController 不注册。 +// H5 大厅页若误调 backgameData 会落空(bridge 无对应 handler → JS 端的 callback 直接卡), +// msext 同款行为 — 这是契约边界,不在原生侧兜底。 +// + +import Foundation + +public enum BackGameDataHandler { + + public static func register(on bridge: any BridgeProtocol) { + // 【18】backgameData + // 入参 data(字符串,子游戏 H5 自定义透传给大厅 getWebdata) + // cb: "backgameData" + bridge.register("backgameData") { data, callback in + await MainActor.run { + // data 可能是 string / object,统一序列化为 string(msext NSString 透传等价)。 + // - 直接是 .string → 取字面 + // - 其它类型 → JSON 序列化(保留对象结构传给大厅 H5) + let payload = serialize(data) + AudioPlayer.shared.stopAllBackground() + AppCoordinator.shared.popSubGame(returningData: payload) + } + callback?(.string("backgameData")) + } + } + + private static func serialize(_ data: BridgeData?) -> String { + if let s = data?.asString { return s } + guard let data else { return "" } + let any = data.jsonObject + if let bytes = try? JSONSerialization.data(withJSONObject: any, options: [.fragmentsAllowed]), + let s = String(data: bytes, encoding: .utf8) { + return s + } + return "" + } +} diff --git a/ylgamehall/Source/WebView/SubGameViewController.swift b/ylgamehall/Source/WebView/SubGameViewController.swift index 535bdf3..0e340d9 100644 --- a/ylgamehall/Source/WebView/SubGameViewController.swift +++ b/ylgamehall/Source/WebView/SubGameViewController.swift @@ -83,8 +83,10 @@ public final class SubGameViewController: UIViewController { // OpenurlTitleData 在子游戏中也可能调(threeView 弹层 push 自子游戏页),保留 stub OpenurlTitleDataHandler.register(on: bridge) - // backgameData / exitRoom / getVideoinfo / createRoom 等子游戏专属 handler - // 留待 commit C / 后续 Phase 接入。 + // 子游戏专属:backgameData(退出回大厅 + 反向 callback getWebdata) + BackGameDataHandler.register(on: bridge) + + // exitRoom / getVideoinfo / createRoom 等视频房间 handler 留待 Phase 8 接入 } // MARK: - 摇一摇支持 diff --git a/ylgamehall/Source/WebView/WebContainerViewController.swift b/ylgamehall/Source/WebView/WebContainerViewController.swift index 4782375..a027db5 100644 --- a/ylgamehall/Source/WebView/WebContainerViewController.swift +++ b/ylgamehall/Source/WebView/WebContainerViewController.swift @@ -21,6 +21,10 @@ public final class WebContainerViewController: UIViewController { private let shakeHandler = ShakeHandler() + // MARK: - .subGameDidReturn 观察者(子游戏 backgameData pop 后回大厅,反向 callback getWebdata) + + private var subGameReturnObserver: NSObjectProtocol? + // MARK: - Boot pipeline 内部错误分类 // // 用 enum 区分"终态阻塞"与"系统级错误": @@ -307,6 +311,21 @@ public final class WebContainerViewController: UIViewController { AppLifecycleObserver.shared.onForeground = { bridge.call("appservice", data: .string("2"), callback: nil) } + + // §3.2 [12] getWebdata — 子游戏 pop 回大厅,反向把 backgameData 入参透传给 H5 + // (msext NewRootVC 监听 "backgameDatatwo" 通知 → callHandler:"getWebdata" 等价) + // 大厅栈顶时挂钩,pop 进子游戏时由 viewWillDisappear teardown 解绑,避免双发。 + subGameReturnObserver = NotificationCenter.default.addObserver( + forName: .subGameDidReturn, + object: nil, + queue: .main + ) { note in + // queue=.main 保证 closure 在主线程;显式 hop 让 Swift 6 actor 推断通过 + let data = (note.userInfo?["data"] as? String) ?? "" + MainActor.assumeIsolated { + bridge.call("getWebdata", data: .string(data), callback: nil) + } + } } private func teardownExternalSubscriptions() { @@ -319,6 +338,11 @@ public final class WebContainerViewController: UIViewController { NetworkMonitor.shared.onChange = nil AppLifecycleObserver.shared.onBackground = nil AppLifecycleObserver.shared.onForeground = nil + + if let token = subGameReturnObserver { + NotificationCenter.default.removeObserver(token) + subGameReturnObserver = nil + } } // MARK: - Alerts (terminal / retry states)