diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index 30a0992..91d957d 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -587,10 +587,11 @@ Contract Design Plan(本文档) #### 任务清单 -- [ ] **6.1** `Source/Coordinator/AppCoordinator.swift`:path 节流 + 栈深约束(Design §2.4.3) -- [ ] **6.2** `Source/Containers/SubGameViewController.swift`:继承 WebContainer,注入 SubGameHandlers -- [ ] **6.3** `Source/Bridge/Handlers/SubGameHandlers.swift`:19 共享 + `backgameData` + 3 个视频房间 stub -- [ ] **6.4** `SwitchOverGameData` handler(仅大厅注册):解参 → AppCoordinator.showSubGame → 节流 2s +- [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.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)` - [ ] **6.7** 子游戏 zip 下载 / 解压(H5 端通过 SwitchOverGameData 传 `gamedownloadurl`) @@ -918,10 +919,10 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin - [ ] 5.6 失败回包 errorCode 12 ### Phase 6 子游戏 -- [ ] 6.1 AppCoordinator 栈深节流 -- [ ] 6.2 SubGameViewController -- [ ] 6.3 SubGameHandlers -- [ ] 6.4 SwitchOverGameData +- [x] 6.1 AppCoordinator 栈深节流(2s + viewControllers.count < 2) +- [x] 6.2 SubGameViewController 框架(commit A:H5 未安装则抛错,待 6.7 接入) +- [ ] 6.3 SubGameHandlers(backgameData 留待 commit C) +- [x] 6.4 SwitchOverGameData → AppCoordinator.showSubGame - [ ] 6.5 backgameData - [ ] 6.6 getWebdata 通知链 - [ ] 6.7 SubGameDownloader diff --git a/ylgamehall/SceneDelegate.swift b/ylgamehall/SceneDelegate.swift index 7f4eb45..1b3c5bb 100644 --- a/ylgamehall/SceneDelegate.swift +++ b/ylgamehall/SceneDelegate.swift @@ -14,7 +14,17 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = scene as? UIWindowScene else { return } let window = UIWindow(windowScene: windowScene) - window.rootViewController = WebContainerViewController() + + // 用 UINavigationController 承载大厅,便于 Phase 6 push 子游戏 / Phase 7 push 弹层。 + // 栈深约束 ≤ 3(Design §2.4.3),由 AppCoordinator 在 push 处守门。 + let lobby = WebContainerViewController() + let nav = UINavigationController(rootViewController: lobby) + nav.setNavigationBarHidden(true, animated: false) + nav.interactivePopGestureRecognizer?.isEnabled = false + + AppCoordinator.shared.navigationController = nav + + window.rootViewController = nav self.window = window window.makeKeyAndVisible() } diff --git a/ylgamehall/Source/Bridge/Handlers/SwitchOverGameHandler.swift b/ylgamehall/Source/Bridge/Handlers/SwitchOverGameHandler.swift index 870b2b0..142d497 100644 --- a/ylgamehall/Source/Bridge/Handlers/SwitchOverGameHandler.swift +++ b/ylgamehall/Source/Bridge/Handlers/SwitchOverGameHandler.swift @@ -2,12 +2,19 @@ // SwitchOverGameHandler.swift // ylgamehall // -// H5 → Native handler stub:SwitchOverGameData — 切换到子游戏 +// H5 → Native handler:SwitchOverGameData — 切换到子游戏。 // 契约:docs/H5-Native-Contract.md §3.1 [17] // -// Phase 6.4 完整实现需要 AppCoordinator + SubGameViewController + -// SubGameDownloader(子游戏 zip 下载解压)。当前为 stub(仅 cb 维持契约), -// 让 H5 调时不报 "no handler",避免业务流程卡住。 +// 与 msext NewRootVC.m:541-566 等价: +// - 解析 Gamedirectory / gamedownloadurl / data +// - 2s 节流(msext one_Time / canbackone;本项目挪到 AppCoordinator.showSubGame) +// - 调 AppCoordinator.shared.showSubGame(...) push 子游戏 VC +// - 无论 push 是否成功,都回 callback "SwitchOverGameData"(msext 行为) +// +// ⚠️ 入参字段名严格保持字面: +// - "Gamedirectory" G 大写 +// - "gamedownloadurl" 全小写 +// - "data" 透传给子游戏 H5(commit C 通过 .subGameDidReturn 反向 callback getWebdata) // import Foundation @@ -18,8 +25,23 @@ public enum SwitchOverGameHandler { // 【17】SwitchOverGameData // 入参 type / Gamedirectory / gamedownloadurl / data // cb: "SwitchOverGameData"(驼峰首字母大写,沿用 msext 字面) - // 完整实现需 push SubGameViewController + zip 下载解压 + 加载子游戏 H5 - bridge.register("SwitchOverGameData") { _, callback in + bridge.register("SwitchOverGameData") { data, callback in + await MainActor.run { + let dir = data?["Gamedirectory"]?.asString ?? "" + let url = data?["gamedownloadurl"]?.asString ?? "" + let web = data?["data"]?.asString ?? "" + + // msext 行为:gameStart 沿用 Gamedirectory(同名同目录), + // 后续若契约出现独立 gamestart 字段再扩展。 + let request = SubGameRequest( + gameDir: dir, + gameStart: dir, + downloadURL: url, + webData: web + ) + AppCoordinator.shared.showSubGame(request) + } + // 与 msext 一致:节流命中 / 入参缺失也回 cb,避免 H5 业务期等待 callback?(.string("SwitchOverGameData")) } } diff --git a/ylgamehall/Source/Coordinator/AppCoordinator.swift b/ylgamehall/Source/Coordinator/AppCoordinator.swift new file mode 100644 index 0000000..6aa52fb --- /dev/null +++ b/ylgamehall/Source/Coordinator/AppCoordinator.swift @@ -0,0 +1,83 @@ +// +// AppCoordinator.swift +// ylgamehall +// +// 应用导航协调器:管理 Lobby ↔ SubGame ↔ Overlay 三层栈。 +// +// 契约约束(Design §2.4.3 栈深 ≤ 3): +// - 大厅 + 子游戏 + 弹层(threeView)最多 3 层 +// - 防止 H5 误调 SwitchOverGameData 死循环 push(msext 同款 first_Time 节流) +// +// 与 msext 一对一行为: +// - SwitchOverGameData 节流:2s 内不重复触发 +// - subGameDidReturn 通知:子游戏 pop 时发,大厅监听并 callback getWebdata +// + +import UIKit + +/// 子游戏 push 请求(从 H5 SwitchOverGameData 入参解析) +public struct SubGameRequest: Sendable { + public let gameDir: String // Gamedirectory,子游戏在 Caches 下的目录名 + public let gameStart: String // 子游戏入口(通常等于 gameDir,msext 行为) + public let downloadURL: String // gamedownloadurl 子游戏 zip 下载地址 + public let webData: String // 子游戏初始数据(H5 透传) + + public init(gameDir: String, gameStart: String, downloadURL: String, webData: String) { + self.gameDir = gameDir + self.gameStart = gameStart + self.downloadURL = downloadURL + self.webData = webData + } +} + +/// 子游戏 pop 回大厅通知(msext .subGameDidReturn 等价) +public extension Notification.Name { + /// 子游戏 pop 返回时发出,userInfo["data"] = 子游戏 backgameData 入参字符串 + static let subGameDidReturn = Notification.Name("ylgamehall.subGameDidReturn") +} + +@MainActor +public final class AppCoordinator { + + public static let shared = AppCoordinator() + + /// 节流:2 秒内连续 SwitchOverGameData 视为重复(msext first_Time 等价) + private var lastSwitchAt: Date? + + /// 当前可见 WebContainer(大厅)— 用于 push 子游戏 + public weak var navigationController: UINavigationController? + + public init() {} + + /// 大厅触发 SwitchOverGameData。节流命中或栈深 ≥ 2 时不触发。 + /// 返回 true 表示 push 成功(或调度中),false 表示被忽略。 + @discardableResult + public func showSubGame(_ request: SubGameRequest) -> Bool { + // 节流:2s 内重复调用直接吞 + if let last = lastSwitchAt, Date().timeIntervalSince(last) < 2 { + return false + } + // 栈深约束:当前最多大厅 + 子游戏 = 2 层 + guard let nav = navigationController, + nav.viewControllers.count < 2 + else { + return false + } + lastSwitchAt = Date() + + let subGame = SubGameViewController(request: request) + nav.pushViewController(subGame, animated: true) + return true + } + + /// 子游戏 pop 回大厅。data 是 H5 调 backgameData 时传入的字符串, + /// 通过通知派发给大厅 WebContainer,由大厅触发 `getWebdata` 反向 callback。 + public func popSubGame(returningData data: String) { + navigationController?.popViewController(animated: true) + NotificationCenter.default.post( + name: .subGameDidReturn, + object: nil, + userInfo: ["data": data] + ) + } +} diff --git a/ylgamehall/Source/WebView/SubGameViewController.swift b/ylgamehall/Source/WebView/SubGameViewController.swift new file mode 100644 index 0000000..102fb52 --- /dev/null +++ b/ylgamehall/Source/WebView/SubGameViewController.swift @@ -0,0 +1,340 @@ +// +// 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 已下载解压(commit B 接入 SubGameDownloader) + // commit A 暂时直接走 FileManager 检查,不存在则抛错让 commit B 接入下载。 + let indexURL = SandboxPaths.subGameIndex(request.gameDir, request.gameStart) + guard FileManager.default.fileExists(atPath: indexURL.path) else { + throw SubGameBootError.subGameNotInstalled( + dir: request.gameDir, + downloadURL: request.downloadURL + ) + } + + // 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) + 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: - Errors + + private enum SubGameBootError: LocalizedError { + case subGameNotInstalled(dir: String, downloadURL: String) + + var errorDescription: String? { + switch self { + case .subGameNotInstalled(let dir, _): + return "子游戏未安装(\(dir)),下载流程待 Phase 6.B 接入" + } + } + } + + private func showFatalAlert(error: Error) { + let alert = UIAlertController( + title: "加载失败", + message: "\(error.localizedDescription)", + 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) + } + } +}