diff --git a/common/src/main/ets/event/NavEvents.ets b/common/src/main/ets/event/NavEvents.ets index 5c6f0dc..9e7dc4b 100644 --- a/common/src/main/ets/event/NavEvents.ets +++ b/common/src/main/ets/event/NavEvents.ets @@ -28,6 +28,8 @@ export interface SwitchGamePayload { webtype: string; /** 目标游戏目录名 */ dir: string; + /** 游戏 id(gamedownloadurl 字段):按需下载时经分层配置解析 game_zip 用。 */ + gameId: string; data: string; } diff --git a/domain_resource/src/main/ets/config/ConfigManager.ets b/domain_resource/src/main/ets/config/ConfigManager.ets index 9fdf626..07b6a95 100644 --- a/domain_resource/src/main/ets/config/ConfigManager.ets +++ b/domain_resource/src/main/ets/config/ConfigManager.ets @@ -88,6 +88,21 @@ export class ConfigManager { return VersionResolver.resolve(remote, this.matchKeys(fallbackGameId)); } + /** + * 子游戏版本决策:以**指定 gameid**(SwitchOverGameData 的 gamedownloadurl / 子游戏 version.xml 的 gameid) + * 经 agent→game→channel→market 分层解析 game_zip/game_version(agent/channel/market 取本地)。 + * 复刻 Android downloadgame2/downloadgame3 的层级遍历。 + */ + resolveGame(remote: RemoteConfig, gameid: string): VersionDecision { + const keys: MatchKeys = { + agentid: this.local.agent, + channelid: this.local.channel, + marketid: this.local.market, + gameid, + }; + return VersionResolver.resolve(remote, keys); + } + /** 便捷:拉取 + 解析一步到位(gameid 回退值由调用方提供)。 */ async resolve(fallbackGameId: string = ''): Promise { const remote: RemoteConfig = await this.fetchRemote(); diff --git a/domain_resource/src/main/ets/resource/ResourceManager.ets b/domain_resource/src/main/ets/resource/ResourceManager.ets index 7dc76f5..c90305b 100644 --- a/domain_resource/src/main/ets/resource/ResourceManager.ets +++ b/domain_resource/src/main/ets/resource/ResourceManager.ets @@ -102,6 +102,63 @@ export class ResourceManager { return this.readVersionXml().gameId; } + // ── 子游戏(§11.1 路径 A 按需下载)───────────────────────────────────────── + + /** 子游戏入口路径 `//index.html`。 */ + subgameIndexPath(dir: string): string { + return `${this.cachedPaths.urlpath}/${dir}/index.html`; + } + + /** 子游戏资源是否就绪(index.html 存在)。 */ + subgameInstalled(dir: string): boolean { + return FileSystem.exists(this.subgameIndexPath(dir)); + } + + /** 子游戏本地 version.xml 信息(缺失 → 空信息)。 */ + private subgameVersionXml(dir: string): VersionXmlInfo { + const p: string = `${this.cachedPaths.urlpath}/${dir}/version.xml`; + return FileSystem.exists(p) ? VersionXml.parse(FileSystem.readText(p)) : { version: 0, gameName: '', gameId: '' }; + } + + /** 子游戏本地版本号(缺失 0)。 */ + subgameLocalVersion(dir: string): number { + return this.subgameVersionXml(dir).version; + } + + /** 子游戏本地 version.xml 的 game id(查更新时优先用它解析远程版本;缺失 '')。 */ + subgameLocalGameId(dir: string): string { + return this.subgameVersionXml(dir).gameId; + } + + /** + * 下载并安装子游戏 zip 到 `urlpath`(复刻 Android runzip:删旧目录 → 解压到 urlpath)。 + * zip 顶层即 `/`,解压后得 `urlpath//...`。**原子化**:下载→解压暂存→校验 + * `/index.html`→删旧换入,任一步失败旧资源保持不动。 + */ + async updateSubgame(dir: string, downloadUrl: string, onProgress?: ProgressCallback): Promise { + const url: string = downloadUrl.includes('?') ? downloadUrl : `${downloadUrl}?a=${Date.now()}`; + const zipPath: string = `${this.cachedPaths.upurlpath}/dowlod_zip/${Date.now()}_${dir}.zip`; + const staging: string = `${this.cachedPaths.urlpath}/.staging_${dir}_${Date.now()}`; + const target: string = `${this.cachedPaths.urlpath}/${dir}`; + try { + await Downloader.download(url, zipPath, onProgress); + FileSystem.rmrf(staging); + FileSystem.ensureDir(staging); + await Unzipper.unzip(zipPath, staging); + const staged: string = `${staging}/${dir}`; + if (!FileSystem.exists(`${staged}/index.html`)) { + throw new Error(`subgame package missing ${dir}/index.html`); + } + FileSystem.rmrf(target); + FileSystem.ensureDir(this.cachedPaths.urlpath); + FileSystem.rename(staged, target); + ResourceManager.log.i(`subgame ${dir} updated from ${url}`); + } finally { + FileSystem.rmrf(zipPath); + FileSystem.rmrf(staging); + } + } + /** 持久化路径到 KvStore(供容器/重启读取)。 */ async persistPaths(): Promise { this.kv.putString(KEY_URLPATH, this.cachedPaths.urlpath); diff --git a/entry/src/main/ets/pages/BridgeGameContainer.ets b/entry/src/main/ets/pages/BridgeGameContainer.ets index dacead5..19cabaa 100644 --- a/entry/src/main/ets/pages/BridgeGameContainer.ets +++ b/entry/src/main/ets/pages/BridgeGameContainer.ets @@ -2,7 +2,7 @@ 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 } from 'domain_resource'; +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, @@ -35,6 +35,14 @@ export struct BridgeGameContainer { private cancels: Array<() => void> = []; /** 非空 → 子游戏 Web 显示(路径 A 切换)。 */ @State private subgameUrl: string = ''; + /** 子游戏资源下载中(按需下载期间显示启动页同款进度条)。 */ + @State private subgameDownloading: boolean = false; + /** 子游戏下载进度(0~100)。 */ + @State private subgamePercent: number = 0; + /** 远程配置 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)。 */ @@ -145,11 +153,14 @@ export struct BridgeGameContainer { }, false); } - /** 路径 A:大厅 → 子游戏(大厅去激活并保活,创建子游戏 Web)。 */ - private switchGame(p?: EventPayload): void { + /** 路径 A:大厅 → 子游戏(必要时先按需下载,再大厅去激活并保活、创建子游戏 Web)。 */ + private async switchGame(p?: EventPayload): Promise { if (p === undefined) { return; } + if (this.subgameDownloading) { + return; // 正在下载某子游戏,忽略重复触发 + } // 拓扑约束:仅 大厅↔子游戏,无 子游戏→子游戏。已在子游戏中时忽略—— // 游戏逻辑须先 backgameData 回大厅、再由大厅发 SwitchOverGameData 进新子游戏。 if (this.subgameUrl !== '') { @@ -162,6 +173,11 @@ export struct BridgeGameContainer { 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(), `${res.localVersion()}`, '1', res.localGameName()); @@ -172,6 +188,85 @@ export struct BridgeGameContainer { 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 === '') { @@ -246,6 +341,23 @@ export struct BridgeGameContainer { .onRenderExited((event: OnRenderExitedEvent) => this.slotS?.onRenderExited(event.renderExitReason)) } + // 子游戏按需下载进度(启动页同款:金色进度条 + 文案,覆盖全屏白底) + if (this.subgameDownloading) { + Column({ space: 14 }) { + Progress({ value: this.subgamePercent, total: 100, type: ProgressType.Linear }) + .width('56%') + .color('#D2A312') + .backgroundColor('#E6E6E6') + Row({ space: 8 }) { + LoadingProgress().width(20).height(20).color('#D2A312') + Text(`正在下载游戏… ${this.subgamePercent}%`).fontSize(15).fontColor('#1B2A4A') + } + } + .width('100%').height('100%') + .justifyContent(FlexAlign.Center) + .backgroundColor(Color.White) + } + // 分享面板(顶层叠加;用户选平台/取消 → 一次性回投 ShareProvider) if (this.sharePanelVisible) { SharePanel({ onPick: (platform: string) => this.emitShareResult(platform) }) diff --git a/feature_capabilities/src/main/ets/providers/NavProvider.ets b/feature_capabilities/src/main/ets/providers/NavProvider.ets index f48a480..082c20c 100644 --- a/feature_capabilities/src/main/ets/providers/NavProvider.ets +++ b/feature_capabilities/src/main/ets/providers/NavProvider.ets @@ -62,7 +62,9 @@ export class NavProvider implements CapabilityProvider { } // webtype 契约为 "2"竖/"3"横,但 H5 可能发数字(如 3)。统一转字符串, // 否则下游 `webtype !== '2'` 的严格比较对数字恒为真,会把竖屏子游戏错误强制横屏。 - const payload: SwitchGamePayload = { webtype: String(req.webtype), dir: req.Gamedirectory, data: req.data }; + const payload: SwitchGamePayload = { + webtype: String(req.webtype), dir: req.Gamedirectory, gameId: req.gamedownloadurl, data: req.data, + }; EventBus.emit(NavEvents.SWITCH_GAME, payload); }