实现子游戏按需下载,修复点子游戏白屏(§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:
co-authored by
Claude Opus 4.8
parent
e75f567948
commit
de5b950d9b
@@ -28,6 +28,8 @@ export interface SwitchGamePayload {
|
|||||||
webtype: string;
|
webtype: string;
|
||||||
/** 目标游戏目录名 */
|
/** 目标游戏目录名 */
|
||||||
dir: string;
|
dir: string;
|
||||||
|
/** 游戏 id(gamedownloadurl 字段):按需下载时经分层配置解析 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_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 回退值由调用方提供)。 */
|
/** 便捷:拉取 + 解析一步到位(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.js(H5 同步读),launchtype='1'
|
// 子游戏目录自带 app_data.js(H5 同步读),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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user