Files
youle_app_ohos/entry/src/main/ets/pages/BridgeGameContainer.ets
T
lanterngamescnandClaude Opus 4.8 a104394076 修复 H5 音效完全不出声 + 子游戏音效路径错(srcIsloop)
真机诊断([SFX-DIAG])锁定两因:
1. AVPlayer.url 不接受 file:// 本地路径,报 `401 ... not fd:// or network
   address`——所有本地音效播放失败,故"完全没声"。改用 fdSrc(openSync 取 fd),
   远程语音(mediaTypeAudio)仍走 url(网络地址可直接用)。
2. 子游戏音效路径写死 local.gamestart(=gamehall),而 sangelaok 等子游戏音效在
   各自目录(.../sangelaok/assets/wav/),找不到文件 → 子游戏更是无声。对齐 Android
   webviewActivity(gamestart)/NewwebviewActivity(gamedirectory):按"本槽内容目录"
   定位,经 CapabilityContext.gameContentDir 注入(大厅=gamestart、子游戏=其目录)。

并发模型重写(复刻 Android SoundPool 并发一次性 + MediaPlayer-per-src 循环):
- 原单 AVPlayer 每次 release 再建,互相打断、还杀正在循环的 BGM。
- 改为:循环 BGM 每 src 一个播放器(loopPlayers map);一次性音效各自独立瞬时
  播放器(oneShots,播完自释),互不打断。isloop<0 停止该 src 循环。
- fd 生命周期:随播放器释放(release 后)关闭,防泄漏;mute(voicePlaying)作用于全部播放器。

链路改动:CapabilityContext 增 gameContentDir;WebSlot.setup 透传;
BridgeGameContainer setupLobby 传 gamestart、switchGame 记录子游戏目录供 setupSubgame 传入。

真机验证:大厅(dir=gamehall)与子游戏(dir=sangelaok)音效均 exists=true、无 401,
循环 BGM 与一次性音效正常播放。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 09:55:43 +08:00

409 lines
19 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
// 大厅内容目录 = gamestartgamehall):音效等按此定位 <urlpath>/gamehall/assets/wav/*
this.slotL.setup(this.hostCtx!, this.config!, this.kv!, this.resourceRoot(), this.config!.getLocal().gamestart);
}
private setupSubgame(): void {
this.ensureDeps();
// 子游戏内容目录 = 当前进入的子游戏目录(switchGame 时记录),音效定位 <urlpath>/<dir>/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<void> {
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.jsH5 同步读),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<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 === '') {
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;
})
}
}