// // SubGameViewController.swift // ylgamehall // // 子游戏 H5 承载容器:BridgedWebView 全屏铺满 + Splash + boot pipeline。 // 与 WebContainerViewController(大厅)共享同款 BridgedWebView + SplashOverlay 样式, // 但 boot 流程更短(不拉远端配置、不做 IPA 升级、不做 lobby zip 升级),仅: // 1) 确保子游戏 H5 已就绪(commit B: SubGameDownloader.ensureReady) // 2) 写 app_*.js(containerRole = .subGame) // 3) loadFileURL 子游戏 index.html // // 详见 docs/H5-Native-Implementation-Design.md §2.4.3 / §6.4。 // import UIKit import WebKit public final class SubGameViewController: UIViewController { // MARK: - 入参(从 SwitchOverGameData 解析) private let request: SubGameRequest // MARK: - 实际生效的子游戏目录名 // // init 时从 SubGameDirectoryStore 同步读:首次 = request.gameDir,已升级 = 末尾追加 // 若干 "1" 的持久化值(msext gameController.m:1414 等价的 advance 链)。 // boot pipeline 内 SubGameDownloader.ensureReady 若触发升级(远端 game_version > // 本地 version.xml),返回的 actualDirectory 会更新本属性。所有资源路径(loadFileURL / // AppDataWriter / srcIsloop assets/wav)必须走它,而不是 request.gameDir。 private var effectiveGameDir: String // MARK: - UI private let bridgedWebView = BridgedWebView() private let splash = SplashOverlay() // MARK: - Handlers private let shakeHandler = ShakeHandler() // MARK: - .subGameDidReturn 观察者(overlay pop 回子游戏时反向 callback getWebdata) private var subGameReturnObserver: NSObjectProtocol? // MARK: - Init public init(request: SubGameRequest) { self.request = request // 同步读持久化目录名,registerBridgeHandlers 的 closure 捕获 self 即可读到当前值 self.effectiveGameDir = SubGameDirectoryStore.current(forKey: request.gameDir) super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("SubGameViewController requires SubGameRequest") } // MARK: - Lifecycle public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black setupBridgedWebView() setupSplash() bridgedWebView.webView.navigationDelegate = self registerBridgeHandlers() Task { @MainActor in await runBootPipeline() } } /// 注册子游戏 handler。大部分与大厅共用同一注册器(无状态 enum register), /// 但 SwitchOverGameData 不再注册(子游戏不能 push 更深一层,符合 §2.4.3 栈深 ≤ 2)。 /// backgameData 在 commit C 接入。 private func registerBridgeHandlers() { let bridge = bridgedWebView.bridge VibratorHandler.register(on: bridge) ClipboardHandler.register(on: bridge) shakeHandler.register(on: bridge) VoicePlayingHandler.register(on: bridge) DeviceInfoHandler.register(on: bridge) BrowserHandler.register(on: bridge) OpenSaomaHandler.register(on: bridge) StartLocationHandler.register(on: bridge) // assetsRoot 闭包 = 子游戏 H5 根目录(msext gameController.m:364 等价)。 // 用 closure 是因为 effectiveGameDir 在 boot pipeline 升级路径下会变化, // 必须运行时读最新值,否则升级后 srcIsloop 仍指旧目录。 LocalAudioHandler.register( on: bridge, assetsRoot: { [weak self] in guard let self else { return SandboxPaths.caches } return SandboxPaths.subGameIndex(self.effectiveGameDir, self.request.gameStart) .deletingLastPathComponent() } ) RemoteAudioHandler.register(on: bridge) AccreditLoginHandler.register(on: bridge) FriendsShareHandler.register(on: bridge) // OpenurlTitleData 在子游戏中也可能调(threeView 弹层 push 自子游戏页),保留 stub OpenurlTitleDataHandler.register(on: bridge) // 子游戏专属:backgameData(退出回大厅 + 反向 callback getWebdata) BackGameDataHandler.register(on: bridge) // 视频房间 3 件套 stub(业务暂未启用,仅维持桥契约不让 H5 报 "no handler") // 未来接 Agora 时把 VideoRoomHandlers 内 3 个 stub 展开实现,注册点不变。 VideoRoomHandlers.register(on: bridge) } // MARK: - 摇一摇支持 public override var canBecomeFirstResponder: Bool { true } public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) setupExternalSubscriptions() } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) becomeFirstResponder() } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) teardownExternalSubscriptions() } public override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { shakeHandler.handleMotionEnded(motion) } public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .landscape } public override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { .landscapeRight } public override var prefersStatusBarHidden: Bool { false } // MARK: - WebView 全屏布局(与大厅同款,等价 daoqi msext gameController.m // 全屏 WKWebView 路径) private func setupBridgedWebView() { bridgedWebView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(bridgedWebView) NSLayoutConstraint.activate([ bridgedWebView.topAnchor.constraint(equalTo: view.topAnchor), bridgedWebView.bottomAnchor.constraint(equalTo: view.bottomAnchor), bridgedWebView.leadingAnchor.constraint(equalTo: view.leadingAnchor), bridgedWebView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) } private func setupSplash() { splash.translatesAutoresizingMaskIntoConstraints = false view.addSubview(splash) NSLayoutConstraint.activate([ splash.topAnchor.constraint(equalTo: view.topAnchor), splash.bottomAnchor.constraint(equalTo: view.bottomAnchor), splash.leadingAnchor.constraint(equalTo: view.leadingAnchor), splash.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) splash.update(text: "加载子游戏...", progress: nil) } // MARK: - Boot pipeline private func runBootPipeline() async { splash.isHidden = false splash.update(text: "加载子游戏...", progress: nil) do { try await runBootPipelineSteps() } catch { showFatalAlert(error: error) } } private func runBootPipelineSteps() async throws { // 0. H5 传过来的 SwitchOverGameData.gamedownloadurl **字段名是 url 但语义是 gameid** // (msext gameController.m:544 注释明确写"游戏ID")。真实 zip 下载地址要按 // gameid 去启动期缓存的 RemoteConfig 里查 `game_zip` 字段;msext 走 uplevel: // chulishengji 算法,新外壳直接复用 VersionResolver 同一套实现。 // // 需要做两次 resolve: // - lobbyResolved(gameId = bc.gameId)→ 给 AppDataWriter 算 app_appversion 审核标志 // 与 msext NewRootVC 算 result 后 initinfo 传给 gameController 等价 // - subGameResolved(gameId = H5 传的 gameid)→ 拿 game_version 和 game_zip URL // 与 msext gameController.uplevel: 等价 let (lobbyResolved, subGameResolved) = try await resolveBoot() let subGameZipURL = subGameResolved.gameZip ?? "" // 1. 准备子游戏 zip(msext gameController.m:1239-1255 等价): // - 目录不存在(首装):下载到 currentDir // - 目录存在 + 远端 > 本地:advance + 下载到新目录 // - 远端 ≤ 本地:复用 currentDir splash.update(text: "准备子游戏...", progress: nil) let outcome = try await SubGameDownloader.shared.ensureReady( directoryKey: request.gameDir, gameStart: request.gameStart, remoteVersion: subGameResolved.gameVersion, remoteZipURL: subGameZipURL, onProgress: { [weak self] p in Task { @MainActor in self?.splash.update( text: String(format: "下载子游戏 %d%%", Int(p * 100)), progress: p ) } } ) // ensureReady 内可能 advance 了目录名,同步到本地状态。 // 此后所有资源路径(loadFileURL / AppDataWriter / srcIsloop closure)都走它。 effectiveGameDir = outcome.actualDirectory // 2. 写 4 个 app_*.js(containerRole = .subGame)。BatteryMonitor / NetworkMonitor // 单例已由大厅启动,此处仅复用其 currentXxx。 // resolvedVersion 必须传大厅视角的版本判定结果,让 app_appversion 审核标志 // 与大厅一致(msext result_state 传递路径等价)。 // containerRole.dir 传 effectiveGameDir,让 app_gamedir = msext gamefilepath // (升级后的"xxx1"形式),与 msext app_data.js:2160 等价。 let writer = AppDataWriter( bundleConfig: .shared, resolvedVersion: lobbyResolved, containerRole: .subGame(name: request.gameStart, dir: effectiveGameDir) ) try writer.writeInitial() try writer.writeBattery(BatteryMonitor.shared.currentLevel) try writer.writeNetwork(NetworkMonitor.shared.currentCode) // 3. 加载子游戏 H5(allowingReadAccessTo 给 subGameRoot 才能跨目录引用资源) splash.update(text: "进入子游戏...", progress: nil) let indexURL = SandboxPaths.subGameIndex(effectiveGameDir, request.gameStart) bridgedWebView.webView.loadFileURL( indexURL, allowingReadAccessTo: SandboxPaths.subGameRoot(effectiveGameDir) ) } /// 子游戏启动期两次 resolve: /// - lobby 视角(gameId = bc.gameId)给 app_appversion 审核标志(msext result_state 等价) /// - sub-game 视角(gameId = H5 传的 gameid)给 game_version + game_zip URL(msext gameController.uplevel: 等价) private func resolveBoot() async throws -> (lobbyResolved: ResolvedVersion, subGameResolved: ResolvedVersion) { guard let cfg = await RemoteConfigClient.shared.current() else { throw SubGameBootError.remoteConfigUnavailable } let bc = BundleConfig.shared let lobbyResolved = VersionResolver.resolve( config: cfg, agentId: bc.agent, channelId: bc.channel, marketId: bc.market, gameId: bc.gameId ) let subGameResolved = VersionResolver.resolve( config: cfg, agentId: bc.agent, channelId: bc.channel, marketId: bc.market, gameId: request.downloadURL // ← H5 字段名 url,实为 gameid ) // 远端必须给出 game_zip URL,否则 SubGameDownloader 首装路径无法落地。 // 已有本地缓存且远端不需要升级时(≤本地版本),URL 空也允许 → 让 ensureReady 走复用分支。 let zip = subGameResolved.gameZip ?? "" let localVer = LocalVersionReader.subGameVersion( dir: SubGameDirectoryStore.current(forKey: request.gameDir), start: request.gameStart ) if zip.isEmpty && subGameResolved.gameVersion > localVer { throw SubGameBootError.zipURLNotFound(gameId: request.downloadURL) } return (lobbyResolved, subGameResolved) } enum SubGameBootError: Error, CustomStringConvertible { case remoteConfigUnavailable case zipURLNotFound(gameId: String) var description: String { switch self { case .remoteConfigUnavailable: return "远端配置未就绪,请返回大厅重试" case .zipURLNotFound(let id): return "未找到子游戏 \(id) 的下载地址" } } } // MARK: - 外部订阅生命周期(参 §2.4.2) // // 子游戏栈顶时挂钩 battery / network / appservice。大厅 push 子游戏后大厅 // viewWillDisappear 已 teardown,避免双发。子游戏 pop 时 teardown,大厅 // viewWillAppear 重新 setup。 // // 与大厅一致:业务期变化不重写文件,evaluateJavaScript 重新赋值 window.app_*。 private func setupExternalSubscriptions() { NetworkMonitor.shared.start() BatteryMonitor.shared.start() AppLifecycleObserver.shared.start() // 仅子游戏挂电话状态监听(大厅不订阅,与 msext gameController 同款边界) PhoneStateMonitor.shared.start() let bridge = bridgedWebView.bridge let webView = bridgedWebView.webView BatteryMonitor.shared.onChange = { level in let value = String(format: "%.2f", level) Task { @MainActor in _ = try? await webView.evaluateJavaScript("window.app_getbattery=\(value);") } bridge.call("getBattery", data: .string(value), callback: nil) } NetworkMonitor.shared.onChange = { code in Task { @MainActor in _ = try? await webView.evaluateJavaScript("window.app_getnetwork=\(code);") } bridge.call("getnetwork", data: .string("\(code)"), callback: nil) } AppLifecycleObserver.shared.onBackground = { bridge.call("appservice", data: .string("1"), callback: nil) } AppLifecycleObserver.shared.onForeground = { bridge.call("appservice", data: .string("2"), callback: nil) } // 电话状态 — 契约 §3.1 [14]:来电 "2" / 挂断 "0" PhoneStateMonitor.shared.onChange = { state in bridge.call("phonestate", data: .string(state), callback: nil) } // overlay(Phase 7)pop 回子游戏时反向 callback getWebdata。挂在子游戏栈顶 // 期间生效;overlay push 上来时 viewWillDisappear → teardown 自动解绑,避免双发。 subGameReturnObserver = NotificationCenter.default.addObserver( forName: .subGameDidReturn, object: nil, queue: .main ) { note in let data = (note.userInfo?["data"] as? String) ?? "" MainActor.assumeIsolated { bridge.call("getWebdata", data: .string(data), callback: nil) } } } private func teardownExternalSubscriptions() { BatteryMonitor.shared.onChange = nil NetworkMonitor.shared.onChange = nil AppLifecycleObserver.shared.onBackground = nil AppLifecycleObserver.shared.onForeground = nil PhoneStateMonitor.shared.onChange = nil PhoneStateMonitor.shared.stop() if let token = subGameReturnObserver { NotificationCenter.default.removeObserver(token) subGameReturnObserver = nil } } // MARK: - Error alert private func showFatalAlert(error: Error) { let alert = UIAlertController( title: "加载失败", message: "\(error)", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "返回大厅", style: .default) { [weak self] _ in self?.navigationController?.popViewController(animated: true) }) present(alert, animated: true) } } // MARK: - WKNavigationDelegate extension SubGameViewController: WKNavigationDelegate { public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { UIView.animate(withDuration: 0.3, animations: { [weak self] in self?.splash.alpha = 0 }, completion: { [weak self] _ in self?.splash.removeFromSuperview() }) } }