import { webview } from '@kit.ArkWeb'; import { common } from '@kit.AbilityKit'; import { window } from '@kit.ArkUI'; import { BusinessError } from '@kit.BasicServicesKit'; import { ConfigManager, ResourceManager, AppDataInjector, AppDataValues, RemoteConfig, VersionDecision } from 'domain_resource'; import { KvStore, LocalUploadServer } from 'platform'; import { OutboundHandlers } from 'contracts'; import { AppEnv, EventBus, EventPayload, NavEvents, OpenGenericWebPayload, SwitchGamePayload, BackGamePayload, ShareEvents, SharePanelRequest, SharePanelResult, Logger } from 'common'; import { BridgeGameParams, GenericWebParams, GenericWebResult, RouteName, AppEvents } from '../routes/AppRoutes'; import { WebSlot } from '../web/WebSlot'; import { SharePanel } from '../components/SharePanel'; import { KEY_SPLASH_VISIBLE } from './Index'; /** * 大厅/子游戏容器(对应 webviewActivity,框架 §7.1/§7.5)。**双 Web 槽模型**: * - 大厅 Web 常驻(始终在 Stack);子游戏 Web 进入创建/返回销毁(subgameUrl 空/非空驱动)。 * - 同时只有一个激活:进子游戏 → 大厅 deactivate(appservice'2'+停能力+桥关+onInactive+隐藏)、子游戏 activate; * 返回大厅 → 销毁子游戏、大厅 resumeLoaded(appservice'1'+getWebdata)。 * - 每槽独立桥+能力(结构隔离,接口不串);cookie 全局共享、localStorage file:// 同源共享(已验证)。 */ @Component export struct BridgeGameContainer { pathStack: NavPathStack = new NavPathStack(); params: BridgeGameParams = { entryUrl: '', launchType: '0' }; private controllerL: webview.WebviewController = new webview.WebviewController(); // 子游戏 controller 不复用:每次进子游戏新建(随 slotS),避免复用同一 controller 时 // onControllerAttached 不再触发导致第二个子游戏建不出桥(review 发现)。存于 slotS.controller。 private uploadServer: LocalUploadServer = new LocalUploadServer(); private hostCtx: common.UIAbilityContext | undefined = undefined; private config: ConfigManager | undefined = undefined; private kv: KvStore | undefined = undefined; private resource: ResourceManager | undefined = undefined; private slotL: WebSlot | undefined = undefined; private slotS: WebSlot | undefined = undefined; private cancels: Array<() => void> = []; /** 非空 → 子游戏 Web 显示(路径 A 切换)。 */ @State private subgameUrl: string = ''; /** 当前子游戏目录名(switchGame 记录,供 setupSubgame 给能力上下文定位资源,如音效)。 */ private currentSubgameDir: string = ''; /** 子游戏资源下载中(按需下载期间显示启动页同款进度条)。 */ @State private subgameDownloading: boolean = false; /** 子游戏下载进度(0~100)。 */ @State private subgamePercent: number = 0; /** 子游戏首帧是否已绘制:下载完到子游戏 H5 上屏间用启动图盖住,消除白屏。 */ @State private subgamePainted: boolean = false; /** 远程配置 10 分钟缓存(对齐 Android:避免每次进子游戏都重拉 config)。 */ private cachedRemote: RemoteConfig | undefined = undefined; private cachedRemoteAt: number = 0; private static readonly REMOTE_TTL: number = 10 * 60 * 1000; /** 分享面板显隐(叠在 Stack 顶层,对齐 Android SharePanelHelper)。 */ @State private sharePanelVisible: boolean = false; /** 当前分享面板的一次性回投事件名(用户选定平台后 emit 回 ShareProvider)。 */ private shareResultEvent: string = ''; aboutToAppear(): void { if (AppEnv.isDebug()) { webview.WebviewController.setWebDebuggingAccess(true); } } aboutToDisappear(): void { // 先退订事件,再 dispose——避免销毁期 SWITCH_GAME/FOREGROUND 等事件又触发建槽/回调 for (const cancel of this.cancels) { cancel(); } this.cancels = []; this.slotS?.dispose(); this.slotL?.dispose(); this.uploadServer.stop(); this.slotS = undefined; this.slotL = undefined; this.hostCtx = undefined; this.config = undefined; this.kv = undefined; this.resource = undefined; } /** 懒初始化共享依赖(两槽共用 config/kv/resource)+ 订阅事件。 */ private ensureDeps(): void { if (this.hostCtx !== undefined) { return; } const hostCtx: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext; this.hostCtx = hostCtx; this.config = ConfigManager.load(hostCtx); this.kv = KvStore.create(hostCtx); this.resource = new ResourceManager(hostCtx, this.config.getLocal(), this.kv); this.uploadServer.start(); this.subscribeEvents(); } private resourceRoot(): string { return ResourceManager.resourceRoot(this.hostCtx as common.UIAbilityContext); } private setupLobby(): void { this.ensureDeps(); if (this.slotL === undefined) { this.slotL = new WebSlot('lobby', this.controllerL, true, this.uploadServer); } // 大厅内容目录 = gamestart(gamehall):音效等按此定位 /gamehall/assets/wav/* this.slotL.setup(this.hostCtx!, this.config!, this.kv!, this.resourceRoot(), this.config!.getLocal().gamestart); } private setupSubgame(): void { this.ensureDeps(); // 子游戏内容目录 = 当前进入的子游戏目录(switchGame 时记录),音效定位 //assets/wav/* this.slotS?.setup(this.hostCtx!, this.config!, this.kv!, this.resourceRoot(), this.currentSubgameDir); } private activeSlot(): WebSlot | undefined { return this.subgameUrl !== '' ? this.slotS : this.slotL; } private subscribeEvents(): void { this.cancels.push(EventBus.on(AppEvents.FOREGROUND, () => this.activeSlot()?.onAppForeground())); this.cancels.push(EventBus.on(AppEvents.BACKGROUND, () => this.activeSlot()?.onAppBackground())); this.cancels.push(EventBus.on(NavEvents.OPEN_GENERIC_WEB, (p?: EventPayload) => this.openGenericWeb(p))); this.cancels.push(EventBus.on(NavEvents.SWITCH_GAME, (p?: EventPayload) => this.switchGame(p))); this.cancels.push(EventBus.on(NavEvents.BACK_GAME, (p?: EventPayload) => this.backToLobby(p))); this.cancels.push(EventBus.on(ShareEvents.SHOW_PANEL, (p?: EventPayload) => this.showSharePanel(p))); } /** 收到 ShareProvider 的 SHOW_PANEL:记下一次性回投事件名并弹出面板。 */ private showSharePanel(p?: EventPayload): void { if (p === undefined) { return; } // 已有面板未关闭:先按取消回投旧请求,避免上一个 once 永不触发致 ShareProvider 悬挂。 if (this.sharePanelVisible && this.shareResultEvent !== '') { this.emitShareResult('cancel'); } const req = p as SharePanelRequest; this.shareResultEvent = req.resultEvent; this.sharePanelVisible = true; } /** 用户选定平台/取消:回投并关闭面板。 */ private emitShareResult(platform: string): void { const ev: string = this.shareResultEvent; this.shareResultEvent = ''; this.sharePanelVisible = false; if (ev !== '') { const result: SharePanelResult = { platform }; EventBus.emit(ev, result); } } /** 路径 B:打开通用网页容器;关闭回传 → 当前激活槽 callHandler('getWebdata')。 */ private openGenericWeb(p?: EventPayload): void { if (p === undefined) { return; } const d = p as OpenGenericWebPayload; const params: GenericWebParams = { url: d.url, title: d.title, data: d.data, orientation: d.orientation }; this.pathStack.pushPathByName(RouteName.GENERIC_WEB, params, (info: PopInfo) => { const result = info.result as GenericWebResult; this.activeSlot()?.bridge()?.callHandler(OutboundHandlers.GetWebData, result !== undefined ? result.data : ''); }, false); } /** 路径 A:大厅 → 子游戏(必要时先按需下载,再大厅去激活并保活、创建子游戏 Web)。 */ private async switchGame(p?: EventPayload): Promise { if (p === undefined) { return; } if (this.subgameDownloading) { return; // 正在下载某子游戏,忽略重复触发 } // 拓扑约束:仅 大厅↔子游戏,无 子游戏→子游戏。已在子游戏中时忽略—— // 游戏逻辑须先 backgameData 回大厅、再由大厅发 SwitchOverGameData 进新子游戏。 if (this.subgameUrl !== '') { Logger.tag('BridgeGameContainer').w('SwitchOverGameData ignored: already in subgame (route via lobby)'); return; } const d = p as SwitchGamePayload; const res = this.resource; const cfg = this.config; if (d.dir === '' || res === undefined || cfg === undefined) { return; } // §11.1:子游戏 H5 按需下载——目录缺失先下载解压;已装则比对远程版本,更高才更新。 const ready: boolean = await this.prepareSubgame(d, res, cfg); if (!ready) { return; // 下载失败/无地址:留在大厅(已记录日志/弹提示) } const subDir: string = `${res.paths().urlpath}/${d.dir}`; // 子游戏目录自带 app_data.js(H5 同步读),launchtype='1' const values: AppDataValues = AppDataInjector.buildValues(cfg.getLocal(), '1'); AppDataInjector.inject(subDir, values); this.slotL?.deactivate(); this.currentSubgameDir = d.dir; // 供 setupSubgame 注入能力上下文(音效资源定位) this.slotS = new WebSlot('subgame', new webview.WebviewController(), true, this.uploadServer); this.applyOrientation(d.webtype !== '2'); // "3" 横 / "2" 竖 this.subgamePainted = false; // 子游戏首帧前用启动图盖住,避免 下载完→子游戏上屏 间白屏 this.subgameUrl = `file://${subDir}/index.html?Launchtype=1`; // 触发子游戏 Web 渲染 → setupSubgame } /** * 确保子游戏资源就绪(复刻 Android NewwebviewActivity 进入时的 updategamezip): * - 目录缺失:经分层配置解析 game_zip → 下载解压(首装)。 * - 目录已装:比对远程 game_version 与本地 version.xml,更高才更新;否则直接用。 * - 取配置/下载失败时:已装则容忍离线用旧版,未装则提示并放弃(留在大厅)。 * 返回 true 表示可加载子游戏。 */ private async prepareSubgame(d: SwitchGamePayload, res: ResourceManager, cfg: ConfigManager): Promise { const installed: boolean = res.subgameInstalled(d.dir); let remote: RemoteConfig; try { remote = await this.remoteConfig(cfg); } catch (e) { Logger.tag('BridgeGameContainer').w(`subgame config fetch failed: ${(e as Error).message}`); if (installed) { return true; // 离线容忍:已装直接用 } this.toast('网络异常,无法下载游戏'); return false; } // gameid:已装优先用本地 version.xml 的 gameid(对齐 downloadgame3),否则用 H5 传来的 gamedownloadurl const localGameId: string = installed ? res.subgameLocalGameId(d.dir) : ''; const gameId: string = localGameId !== '' ? localGameId : d.gameId; const decision: VersionDecision = cfg.resolveGame(remote, gameId); const localVer: number = installed ? res.subgameLocalVersion(d.dir) : -1; if (installed && decision.gameVersion <= localVer) { return true; // 已是最新 } if (decision.gameDownload === '') { if (installed) { return true; // 无下载地址但已装:用旧的 } Logger.tag('BridgeGameContainer').w(`subgame ${d.dir} no game_zip (gameId=${gameId})`); this.toast('未找到该游戏的下载地址'); return false; } // 下载(缺目录=首装 / 远端更高=更新) this.subgamePercent = 0; this.subgameDownloading = true; try { await res.updateSubgame(d.dir, decision.gameDownload, (pct: number) => { if (pct >= 0) { this.subgamePercent = pct; } }); return true; } catch (e) { Logger.tag('BridgeGameContainer').w(`subgame ${d.dir} download failed: ${(e as Error).message}`); if (installed) { return true; // 更新失败但旧版可用 } this.toast('游戏下载失败,请重试'); return false; } finally { this.subgameDownloading = false; } } /** 远程配置(10 分钟缓存):对齐 Android 的 gameconfigtime 节流,避免每次进子游戏都重拉。 */ private async remoteConfig(cfg: ConfigManager): Promise { const now: number = Date.now(); const cached = this.cachedRemote; if (cached !== undefined && now - this.cachedRemoteAt < BridgeGameContainer.REMOTE_TTL) { return cached; } const remote: RemoteConfig = await cfg.fetchRemote(); this.cachedRemote = remote; this.cachedRemoteAt = now; return remote; } private toast(msg: string): void { try { this.getUIContext().getPromptAction().showToast({ message: msg, duration: 2000 }); } catch (e) { Logger.tag('BridgeGameContainer').w(`toast failed: ${(e as Error).message}`); } } /** 子游戏 → 大厅(销毁子游戏 Web,大厅恢复并带回数据)。 */ private backToLobby(p?: EventPayload): void { if (this.subgameUrl === '') { return; } const data: string = p !== undefined ? (p as BackGamePayload).data : ''; this.slotS?.dispose(); this.slotS = undefined; this.subgameUrl = ''; // 移除子游戏 Web this.applyOrientation(true); // 大厅横屏 if (this.slotL !== undefined) { this.slotL.pendingWebdata = data; this.slotL.resumeLoaded(); } } private applyOrientation(landscape: boolean): void { const ctx = this.hostCtx; if (ctx === undefined) { return; } const target: window.Orientation = landscape ? window.Orientation.LANDSCAPE : window.Orientation.PORTRAIT; window.getLastWindow(ctx).then((win: window.Window) => win.setPreferredOrientation(target)) .catch((e: BusinessError) => Logger.tag('BridgeGameContainer').w(`orientation failed: ${e.message}`)); } /** 大厅入口 URL:params 非空用之,否则回退最小回显页(V1)。 */ private lobbySrc(): string | Resource { return this.params.entryUrl !== '' ? this.params.entryUrl : $rawfile('test_echo.html'); } build() { NavDestination() { Stack() { // 大厅 Web(常驻) Web({ src: this.lobbySrc(), controller: this.controllerL }) .javaScriptAccess(true).domStorageAccess(true).fileAccess(true) .mixedMode(MixedMode.All).cacheMode(CacheMode.None) .geolocationAccess(true).zoomAccess(false) .width('100%').height('100%') // 铺满系统安全区(消除底部导航手势区白边),与原 Android 全屏 WebView 一致 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) .visibility(this.subgameUrl !== '' ? Visibility.Hidden : Visibility.Visible) .onControllerAttached(() => this.setupLobby()) .onLoadIntercept((event: OnLoadInterceptEvent) => this.slotL !== undefined ? this.slotL.onLoadIntercept(event.data.getRequestUrl()) : false) .onPageEnd(() => { this.slotL?.onPageEnd(); // 大厅页面加载完成(已稳定上屏)→ 撤根层启动页覆盖,直接显示大厅 H5。 AppStorage.setOrCreate(KEY_SPLASH_VISIBLE, false); }) .onProgressChange((event: OnProgressChangeEvent) => { if (event.newProgress === 100) { this.slotL?.onFirstProgress(); } }) .onRenderExited((event: OnRenderExitedEvent) => this.slotL?.onRenderExited(event.renderExitReason)) // 子游戏 Web(临时,subgameUrl 非空时存在) if (this.subgameUrl !== '' && this.slotS !== undefined) { Web({ src: this.subgameUrl, controller: this.slotS.controller }) .javaScriptAccess(true).domStorageAccess(true).fileAccess(true) .mixedMode(MixedMode.All).cacheMode(CacheMode.None) .geolocationAccess(true).zoomAccess(false) .width('100%').height('100%') .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) .onControllerAttached(() => this.setupSubgame()) .onLoadIntercept((event: OnLoadInterceptEvent) => this.slotS !== undefined ? this.slotS.onLoadIntercept(event.data.getRequestUrl()) : false) .onPageEnd(() => { this.slotS?.onPageEnd(); this.subgamePainted = true; // 子游戏页面加载完、稳定上屏 → 撤启动图覆盖层 }) .onProgressChange((event: OnProgressChangeEvent) => { if (event.newProgress === 100) { this.slotS?.onFirstProgress(); } }) .onRenderExited((event: OnRenderExitedEvent) => this.slotS?.onRenderExited(event.renderExitReason)) } // (大厅首帧前的启动图覆盖已上移到 Index.ets 根层,避免导航空隙白闪 + 二次 splash) // 子游戏进入覆盖层(与启动页 SplashPage 完全一致):覆盖「下载中」与「下载完→子游戏首帧上屏」 // 两段,启动图铺底 + 底部金色进度条 + 文案,直到子游戏 onPageEnd 才撤——消除两段间白屏。 if (this.subgameDownloading || (this.subgameUrl !== '' && !this.subgamePainted)) { Stack() { Image($r('app.media.launch_image')) .width('100%').height('100%') .objectFit(ImageFit.Cover) Column({ space: 14 }) { Progress({ value: this.subgameDownloading ? this.subgamePercent : 100, total: 100, type: ProgressType.Linear }) .width('56%') .color('#D2A312') .backgroundColor('#E6E6E6') Row({ space: 8 }) { LoadingProgress().width(20).height(20).color('#D2A312') Text(this.subgameDownloading ? `正在下载游戏… ${this.subgamePercent}%` : '正在进入游戏…') .fontSize(15).fontColor('#1B2A4A') } } .width('100%').height('100%') .justifyContent(FlexAlign.End) .padding({ bottom: 24 }) } .width('100%').height('100%') .backgroundColor(Color.White) .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) } // 分享面板(顶层叠加;用户选平台/取消 → 一次性回投 ShareProvider) if (this.sharePanelVisible) { SharePanel({ onPick: (platform: string) => this.emitShareResult(platform) }) } } .width('100%').height('100%') // 垫白底:Web 首帧前/页面切换间隙显白色(与启动页白底无缝),避免黑屏闪烁 .backgroundColor(Color.White) } .hideTitleBar(true) .onBackPressed(() => { // 分享面板打开时,返回键关闭面板并按取消回投(对齐 Android 点空白关闭) if (this.sharePanelVisible) { this.emitShareResult('cancel'); return true; } return false; }) } }