实现子游戏按需下载,修复点子游戏白屏(§11.1 路径A)

根因:整包 gamehall.zip 顶层只含 gamehall,子游戏需按需单独下载;原 switchGame
直接 loadUrl 不存在的目录 → ArkWeb invalidFileUrl → 白屏。

对齐 Android NewwebviewActivity 的 updategamezip:
- ConfigManager.resolveGame(remote, gameid):以 gamedownloadurl 为 gameid 经
  agent→game→channel→market 分层解析子游戏 game_zip/game_version(复用 VersionResolver)。
- ResourceManager.updateSubgame:下载→暂存解压→校验 <dir>/index.html→原子换入 urlpath/<dir>。
  另加 subgameInstalled/subgameLocalVersion/subgameLocalGameId。
- BridgeGameContainer.switchGame 改 async + prepareSubgame:目录缺失先下载、已装比对远程
  版本更高才更新;下载期复用启动页同款金色进度条;远程配置 10 分钟缓存(对齐 gameconfigtime)。
- SwitchGamePayload 增 gameId(gamedownloadurl)。

真机验证:点三个老K → 下载 sangelaok.zip(9.4M) → 解压加载,子游戏正常渲染
(V1.155 与配置 game_version=155 一致);日志不再 invalidFileUrl。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lanterngamescn
2026-06-27 02:33:55 +08:00
co-authored by Claude Opus 4.8
parent e75f567948
commit de5b950d9b
5 changed files with 192 additions and 4 deletions
+2
View File
@@ -28,6 +28,8 @@ export interface SwitchGamePayload {
webtype: string; webtype: string;
/** 目标游戏目录名 */ /** 目标游戏目录名 */
dir: string; dir: string;
/** 游戏 idgamedownloadurl 字段):按需下载时经分层配置解析 game_zip 用。 */
gameId: string;
data: string; data: string;
} }
@@ -88,6 +88,21 @@ export class ConfigManager {
return VersionResolver.resolve(remote, this.matchKeys(fallbackGameId)); return VersionResolver.resolve(remote, this.matchKeys(fallbackGameId));
} }
/**
* 子游戏版本决策:以**指定 gameid**SwitchOverGameData 的 gamedownloadurl / 子游戏 version.xml 的 gameid
* 经 agent→game→channel→market 分层解析 game_zip/game_versionagent/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 回退值由调用方提供)。 */ /** 便捷:拉取 + 解析一步到位(gameid 回退值由调用方提供)。 */
async resolve(fallbackGameId: string = ''): Promise<VersionDecision> { async resolve(fallbackGameId: string = ''): Promise<VersionDecision> {
const remote: RemoteConfig = await this.fetchRemote(); const remote: RemoteConfig = await this.fetchRemote();
@@ -102,6 +102,63 @@ export class ResourceManager {
return this.readVersionXml().gameId; return this.readVersionXml().gameId;
} }
// ── 子游戏(§11.1 路径 A 按需下载)─────────────────────────────────────────
/** 子游戏入口路径 `<urlpath>/<dir>/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 顶层即 `<dir>/`,解压后得 `urlpath/<dir>/...`。**原子化**:下载→解压暂存→校验
* `<dir>/index.html`→删旧换入,任一步失败旧资源保持不动。
*/
async updateSubgame(dir: string, downloadUrl: string, onProgress?: ProgressCallback): Promise<void> {
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(供容器/重启读取)。 */ /** 持久化路径到 KvStore(供容器/重启读取)。 */
async persistPaths(): Promise<void> { async persistPaths(): Promise<void> {
this.kv.putString(KEY_URLPATH, this.cachedPaths.urlpath); this.kv.putString(KEY_URLPATH, this.cachedPaths.urlpath);
@@ -2,7 +2,7 @@ import { webview } from '@kit.ArkWeb';
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI'; import { window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit'; 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 { KvStore, LocalUploadServer } from 'platform';
import { OutboundHandlers } from 'contracts'; import { OutboundHandlers } from 'contracts';
import { AppEnv, EventBus, EventPayload, NavEvents, OpenGenericWebPayload, SwitchGamePayload, import { AppEnv, EventBus, EventPayload, NavEvents, OpenGenericWebPayload, SwitchGamePayload,
@@ -35,6 +35,14 @@ export struct BridgeGameContainer {
private cancels: Array<() => void> = []; private cancels: Array<() => void> = [];
/** 非空 → 子游戏 Web 显示(路径 A 切换)。 */ /** 非空 → 子游戏 Web 显示(路径 A 切换)。 */
@State private subgameUrl: string = ''; @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)。 */ /** 分享面板显隐(叠在 Stack 顶层,对齐 Android SharePanelHelper)。 */
@State private sharePanelVisible: boolean = false; @State private sharePanelVisible: boolean = false;
/** 当前分享面板的一次性回投事件名(用户选定平台后 emit 回 ShareProvider)。 */ /** 当前分享面板的一次性回投事件名(用户选定平台后 emit 回 ShareProvider)。 */
@@ -145,11 +153,14 @@ export struct BridgeGameContainer {
}, false); }, false);
} }
/** 路径 A:大厅 → 子游戏(大厅去激活并保活创建子游戏 Web)。 */ /** 路径 A:大厅 → 子游戏(必要时先按需下载,再大厅去激活并保活创建子游戏 Web)。 */
private switchGame(p?: EventPayload): void { private async switchGame(p?: EventPayload): Promise<void> {
if (p === undefined) { if (p === undefined) {
return; return;
} }
if (this.subgameDownloading) {
return; // 正在下载某子游戏,忽略重复触发
}
// 拓扑约束:仅 大厅↔子游戏,无 子游戏→子游戏。已在子游戏中时忽略—— // 拓扑约束:仅 大厅↔子游戏,无 子游戏→子游戏。已在子游戏中时忽略——
// 游戏逻辑须先 backgameData 回大厅、再由大厅发 SwitchOverGameData 进新子游戏。 // 游戏逻辑须先 backgameData 回大厅、再由大厅发 SwitchOverGameData 进新子游戏。
if (this.subgameUrl !== '') { if (this.subgameUrl !== '') {
@@ -162,6 +173,11 @@ export struct BridgeGameContainer {
if (d.dir === '' || res === undefined || cfg === undefined) { if (d.dir === '' || res === undefined || cfg === undefined) {
return; return;
} }
// §11.1:子游戏 H5 按需下载——目录缺失先下载解压;已装则比对远程版本,更高才更新。
const ready: boolean = await this.prepareSubgame(d, res, cfg);
if (!ready) {
return; // 下载失败/无地址:留在大厅(已记录日志/弹提示)
}
const subDir: string = `${res.paths().urlpath}/${d.dir}`; const subDir: string = `${res.paths().urlpath}/${d.dir}`;
// 子游戏目录自带 app_data.jsH5 同步读),launchtype='1' // 子游戏目录自带 app_data.jsH5 同步读),launchtype='1'
const values: AppDataValues = AppDataInjector.buildValues(cfg.getLocal(), `${res.localVersion()}`, '1', res.localGameName()); 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 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<boolean> {
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<RemoteConfig> {
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,大厅恢复并带回数据)。 */ /** 子游戏 → 大厅(销毁子游戏 Web,大厅恢复并带回数据)。 */
private backToLobby(p?: EventPayload): void { private backToLobby(p?: EventPayload): void {
if (this.subgameUrl === '') { if (this.subgameUrl === '') {
@@ -246,6 +341,23 @@ export struct BridgeGameContainer {
.onRenderExited((event: OnRenderExitedEvent) => this.slotS?.onRenderExited(event.renderExitReason)) .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 // 分享面板(顶层叠加;用户选平台/取消 → 一次性回投 ShareProvider
if (this.sharePanelVisible) { if (this.sharePanelVisible) {
SharePanel({ onPick: (platform: string) => this.emitShareResult(platform) }) SharePanel({ onPick: (platform: string) => this.emitShareResult(platform) })
@@ -62,7 +62,9 @@ export class NavProvider implements CapabilityProvider {
} }
// webtype 契约为 "2"竖/"3"横,但 H5 可能发数字(如 3)。统一转字符串, // webtype 契约为 "2"竖/"3"横,但 H5 可能发数字(如 3)。统一转字符串,
// 否则下游 `webtype !== '2'` 的严格比较对数字恒为真,会把竖屏子游戏错误强制横屏。 // 否则下游 `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); EventBus.emit(NavEvents.SWITCH_GAME, payload);
} }