实现子游戏按需下载,修复点子游戏白屏(§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,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<void> {
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.jsH5 同步读),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<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,大厅恢复并带回数据)。 */
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) })