// // SubGameViewController.swift // ylgamehall // // 子游戏 H5 承载容器:BridgedWebView + Splash + 16:9 letterbox + 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: - UI private let bridgedWebView = BridgedWebView() private let splash = SplashOverlay() // MARK: - Handlers private let shakeHandler = ShakeHandler() // MARK: - Init public init(request: SubGameRequest) { self.request = request 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) LocalAudioHandler.register(on: bridge) RemoteAudioHandler.register(on: bridge) AccreditLoginHandler.register(on: bridge) FriendsShareHandler.register(on: bridge) // OpenurlTitleData 在子游戏中也可能调(threeView 弹层 push 自子游戏页),保留 stub OpenurlTitleDataHandler.register(on: bridge) // backgameData / exitRoom / getVideoinfo / createRoom 等子游戏专属 handler // 留待 commit C / 后续 Phase 接入。 } // 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: - 16:9 letterbox(与大厅同款,未来可抽 LetterboxLayout 复用) private func setupBridgedWebView() { bridgedWebView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(bridgedWebView) let aspect = bridgedWebView.widthAnchor.constraint( equalTo: bridgedWebView.heightAnchor, multiplier: 16.0 / 9.0 ) let widthMax = bridgedWebView.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor) let heightMax = bridgedWebView.heightAnchor.constraint(lessThanOrEqualTo: view.heightAnchor) let widthFill = bridgedWebView.widthAnchor.constraint(equalTo: view.widthAnchor) let heightFill = bridgedWebView.heightAnchor.constraint(equalTo: view.heightAnchor) widthFill.priority = .defaultLow heightFill.priority = .defaultLow NSLayoutConstraint.activate([ bridgedWebView.centerXAnchor.constraint(equalTo: view.centerXAnchor), bridgedWebView.centerYAnchor.constraint(equalTo: view.centerYAnchor), aspect, widthMax, heightMax, widthFill, heightFill ]) } 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 { // 1. 确保子游戏 zip 已下载解压(缓存命中直接复用,未命中下载 + 解压 + 原子 rename) splash.update(text: "准备子游戏...", progress: nil) _ = try await SubGameDownloader.shared.ensureReady( request: request, onProgress: { [weak self] p in Task { @MainActor in self?.splash.update( text: String(format: "下载子游戏 %d%%", Int(p * 100)), progress: p ) } } ) // 2. 写 4 个 app_*.js(containerRole = .subGame)。BatteryMonitor / NetworkMonitor // 单例已由大厅启动,此处仅复用其 currentXxx。 let writer = AppDataWriter( bundleConfig: .shared, resolvedVersion: nil, containerRole: .subGame(name: request.gameStart, dir: request.gameDir) ) 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(request.gameDir, request.gameStart) bridgedWebView.webView.loadFileURL( indexURL, allowingReadAccessTo: SandboxPaths.subGameRoot(request.gameDir) ) } // 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() 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) } } private func teardownExternalSubscriptions() { BatteryMonitor.shared.onChange = nil NetworkMonitor.shared.onChange = nil AppLifecycleObserver.shared.onBackground = nil AppLifecycleObserver.shared.onForeground = 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() }) } } // MARK: - SplashOverlay(与 WebContainerViewController 同款,先就地复制; // Phase 1.B 后续抽公共组件时合并到 Source/UI/SplashOverlay.swift) private final class SplashOverlay: UIView { private let imageView = UIImageView() private let progressView = UIProgressView(progressViewStyle: .default) private let label = UILabel() init() { super.init(frame: .zero) backgroundColor = .black imageView.contentMode = .scaleAspectFit imageView.image = UIImage(named: "SplashImage") imageView.clipsToBounds = true imageView.translatesAutoresizingMaskIntoConstraints = false addSubview(imageView) label.textColor = .white label.font = .systemFont(ofSize: 14, weight: .medium) label.textAlignment = .center label.numberOfLines = 1 label.translatesAutoresizingMaskIntoConstraints = false addSubview(label) progressView.translatesAutoresizingMaskIntoConstraints = false progressView.isHidden = true addSubview(progressView) NSLayoutConstraint.activate([ imageView.topAnchor.constraint(equalTo: topAnchor), imageView.bottomAnchor.constraint(equalTo: bottomAnchor), imageView.leadingAnchor.constraint(equalTo: leadingAnchor), imageView.trailingAnchor.constraint(equalTo: trailingAnchor), label.centerXAnchor.constraint(equalTo: centerXAnchor), label.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -32), label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 24), label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -24), progressView.centerXAnchor.constraint(equalTo: centerXAnchor), progressView.bottomAnchor.constraint(equalTo: label.topAnchor, constant: -12), progressView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.45) ]) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("SplashOverlay does not support init(coder:)") } func update(text: String, progress: Double?) { label.text = text if let p = progress { progressView.isHidden = false progressView.setProgress(Float(p), animated: true) } else { progressView.isHidden = true progressView.setProgress(0, animated: false) } } }