You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
AdLike2048/assets/mx/module/res/PreloadPredictor.ts

886 lines
34 KiB

3 weeks ago
import { assetManager, Prefab } from 'cc';
import { Singleton } from '../../tools/Singleton';
import {
getAllDownloadableBundles,
getBattleSilentBundles,
getBattleSilentUiIds,
getHallSilentUiIds,
getHallWarmLongTailResPakage,
getHallWarmResPakage,
getHerosResPakage,
getMainShellResPakage,
GBundle,
UIConfigs,
} from 'db://assets/script/game/ConfigRes';
import { UIID } from 'db://assets/script/game/ConfigRes';
import { ResPkgBuilder } from 'db://assets/script/game/ResPkgBuilder';
import { GEvent } from '../event/GEvent';
import { LoadScheduler } from './LoadScheduler';
import { PRELOAD_RULES, parseBattleScopedToken } from './PreloadRules';
import { ChapterLevelBuilder } from 'db://assets/script/battle/config/ChapterLevelBuilder';
import { DEV } from 'cc/env';
import { PerfReport } from 'db://assets/script/game/PerfReport';
import { PerfLoadTimer } from 'db://assets/script/game/PerfLoadTimer';
import { MemoryProfile } from 'db://assets/script/game/MemoryProfile';
import { BundleLifecycle, isTabScopedBundle } from './BundleLifecycle';
import { awaitWithTimeout, LoadRecoveryConfig } from './LoadRecovery';
import {
createMembershipSet,
membershipAdd,
membershipFromArray,
membershipToArray,
type MembershipSet,
} from '../../tools/MembershipSet';
/**
* 规则驱动的静默预加载:大厅分包下载 / 局内弹窗补载 / 战斗前 warm。
* 预制只经 ResourcesManager.loadRes;进战释放 HallWarm 预制,不卸 popCommon zip。
*/
export class PreloadPredictor extends Singleton {
private _started = false;
private _longTailTimer: ReturnType<typeof setTimeout> | null = null;
private _doneKeys = createMembershipSet<string>();
/** 战场战斗预载已完成(preLoadResPakg 回调结束) */
private _battleWarmComplete = createMembershipSet<string>();
private _battleWarmPromises = new Map<string, Promise<void>>();
/** 大厅静默下载代数:递增即作废未执行队列 */
private _silentGen = 0;
/** 已确认不存在的分包,避免重复 404 */
private _silentMissBundles = createMembershipSet<string>();
/** 规则执行中:await 与 emit 共享同一 Promise,完成前不标 done */
private _ruleInflight: Record<string, Promise<void> | undefined> = {};
private static _warmKey(levelId: number): string {
return `${levelId}`;
}
constructor() {
super();
this._doneKeys = createMembershipSet<string>();
}
private _isBattleExecutedKey(key: string): boolean {
return key.startsWith('battle:')
|| key.startsWith('token:battleScoped:')
|| key.startsWith('beforeBattle:');
}
private _isHallExecutedKey(key: string): boolean {
return key.startsWith('enterHall:')
|| key.startsWith('idleOnHome:')
|| key.startsWith('tabIntent:')
|| key.startsWith('panelOpen:')
|| key.startsWith('token:bundle:')
|| key.startsWith('token:ui:')
|| key === 'token:pkg:HallWarmRes'
|| key === 'token:pkg:HallWarmLongTailRes'
|| key === 'token:pkg:HerosRes';
}
/** MembershipSet 迭代在部分小游戏运行时上不可靠,先收集 string key 再删 */
private _removeExecutedKeys(match: (key: string) => boolean): void {
const remove: string[] = [];
for (const key of this._doneKeys.keys()) {
if (typeof key !== 'string') continue;
if (match(key)) remove.push(key);
}
for (const key of remove) {
this._doneKeys.delete(key);
}
}
start(): void {
if (this._started) return;
this._started = true;
GEvent.Ins.on(GEvent.UpdateMainUI, this._scheduleHomeIdle, this);
this._scheduleHomeIdle();
}
stop(): void {
if (!this._started) return;
this._started = false;
GEvent.Ins.off(GEvent.UpdateMainUI, this._scheduleHomeIdle, this);
if (this._longTailTimer) clearTimeout(this._longTailTimer);
}
/** 大厅战场预载是否已完成(用于 skip beforeBattle 重复预热) */
isChapterPreloadDone(levelId: number): boolean {
return gg.game?.isChapterPreloaded?.(levelId) ?? false;
}
onBeforeBattle(): void {
const levelId = gg.game?.CurentSelectLevelId ?? 1;
if (this.isChapterPreloadDone(levelId)) return;
this.emitSignal('beforeBattle');
}
/**
* 进战斗正式 loadResPakg 前调用:作废 Predictor 侧 BattleRes 预载登记并清对应节点池。
* 避免 warmFrontier / beforeBattle 的 preLoadResPakg 与 loadResPakg(clearResPkg) 并发导致贴图失效仍被渲染。
*/
abortBattleWarmForChapter(levelId: number): void {
if (levelId <= 0) return;
const levelData = gg.data.project.getLevelConfig(levelId);
if (!levelData) return;
const mapId = ChapterLevelBuilder.getLevelMapId(levelId);
const resPkg = ResPkgBuilder.buildBattlePackage(levelId, mapId, 0);
gg.res.invalidatePreloadForPackage(resPkg);
gg.res.evictPackageFromBundles(resPkg);
const bundleNames = createMembershipSet<string>();
for (const conf of resPkg.ress) {
if (typeof conf.bundle === 'string' && conf.bundle) {
membershipAdd(bundleNames, conf.bundle);
}
}
gg.res.purgeNodePoolForBundles(membershipToArray(bundleNames));
this._battleWarmComplete.delete(PreloadPredictor._warmKey(levelId));
this._battleWarmPromises.delete(PreloadPredictor._warmKey(levelId));
this._removeExecutedKeys((key) => key.startsWith('battle:') || key.startsWith('beforeBattle:'));
}
/** 指定战场战斗预载是否已完成(可被 loadResPakg 复用) */
isBattleWarmReady(levelId: number): boolean {
if (levelId <= 0) return false;
const key = PreloadPredictor._warmKey(levelId);
return this._battleWarmComplete.has(key);
}
/**
* 战斗预载是否仍在飞(超时后也可能未结束)。
* @param levelId 关卡
*/
isBattleWarmInflight(levelId: number): boolean {
if (levelId <= 0) return false;
return this._battleWarmPromises.has(PreloadPredictor._warmKey(levelId));
}
/**
* 等待战斗预载。超时返回 false,不抛错,避免匹配门闩 / 进战被卡死。
* @param levelId 关卡
*/
async awaitBattleWarm(levelId: number): Promise<boolean> {
const warmKey = PreloadPredictor._warmKey(levelId);
if (levelId <= 0) return false;
if (this._battleWarmComplete.has(warmKey)) return true;
if (!this._battleWarmPromises.has(warmKey)) {
const taskKey = `battle:${warmKey}`;
if (this._doneKeys.has(taskKey)) {
this._doneKeys.delete(taskKey);
}
this.scheduleBattleWarm(levelId, 'interactive');
}
const promise = this._battleWarmPromises.get(warmKey);
if (!promise) return false;
try {
await awaitWithTimeout(
promise,
LoadRecoveryConfig.battleWarmTimeoutMs,
`battleWarm:lv${levelId}`,
);
return this._battleWarmComplete.has(warmKey);
} catch (e) {
console.warn('[PreloadPredictor] battleWarm 超时,改走进战加载', String(e));
return false;
}
}
/** loadResPakg 复用预载后清除登记,避免下次误跳过 abort */
consumeBattleWarm(levelId: number): void {
if (levelId <= 0) return;
const warmKey = PreloadPredictor._warmKey(levelId);
this._battleWarmComplete.delete(warmKey);
this._battleWarmPromises.delete(warmKey);
this._doneKeys.delete(`battle:${warmKey}`);
}
/**
* Boot / 首屏:对进度前沿章节(ch1–2)静默预热战斗分包与资源,缩短首关 LD-BTL。
*/
warmFrontierChapterBattle(): void {
return;
MemoryProfile.resolve();
const levelId = Math.max(1, gg.data?.doc?.rankId ?? gg.game?.CurentSelectLevelId ?? 1);
if (levelId > 2) return;
for (const bundle of [
GBundle.Heros,
GBundle.PopBattle,
GBundle.BattleGame,
GBundle.BattleMap,
GBundle.BattleGameMonster,
]) {
if (!assetManager.getBundle(bundle)) {
assetManager.loadBundle(bundle);
}
}
const levels: number[] = [];
if (levelId <= 1) {
levels.push(1);
if (MemoryProfile.tier === 'tier3') {
levels.push(2);
}
} else {
levels.push(levelId);
}
for (const lv of levels) {
this.scheduleBattleWarm(lv, 'silent');
}
}
/**
* 进战斗前等待预载完成。interactive 立刻 preLoadResPakg;silent 大厅预热仍关闭。
*/
scheduleBattleWarm(
levelId: number,
priority: 'silent' | 'interactive' = 'silent',
): void {
if (priority === 'silent') return;
const warmKey = PreloadPredictor._warmKey(levelId);
if (levelId <= 0 || this._battleWarmComplete.has(warmKey)) return;
if (this._battleWarmPromises.has(warmKey)) return;
const taskKey = `battle:${warmKey}`;
if (this._doneKeys.has(taskKey)) {
if (this._battleWarmComplete.has(warmKey)) return;
if (this._battleWarmPromises.has(warmKey)) return;
this._doneKeys.delete(taskKey);
}
membershipAdd(this._doneKeys, taskKey);
const promise = this._runBattleWarm(levelId, warmKey).then(() => {
this._battleWarmPromises.delete(warmKey);
});
this._battleWarmPromises.set(warmKey, promise);
}
/**
* 预载本关 BattleRes 并登记 warm 完成
* @param levelId 关卡
* @param warmKey 预载键
*/
private _runBattleWarm(levelId: number, warmKey: string): Promise<void> {
return new Promise((resolve) => {
const levelData = gg.data.project.getLevelConfig(levelId);
if (!levelData) {
membershipAdd(this._battleWarmComplete, warmKey);
resolve();
return;
}
const mapId = ChapterLevelBuilder.getLevelMapId(levelId);
const resPkg = ResPkgBuilder.buildBattlePackage(levelId, mapId, 0);
gg.res.preLoadResPakg(resPkg, null, (result) => {
if (result?.ok) {
membershipAdd(this._battleWarmComplete, warmKey);
}
resolve();
});
});
}
emitSignal(signal: string): void {
for (const rule of PRELOAD_RULES) {
if (rule.when !== signal) continue;
void this._executeRule(rule, signal);
}
}
/** 等待信号对应规则执行完成(用于 enterHall 后再章节预载) */
async awaitSignal(signal: string, forUserWait = false): Promise<void> {
const rules = PRELOAD_RULES.filter((r) => r.when === signal);
if (rules.length === 0) return;
await Promise.all(rules.map((rule) => this._executeRuleAwait(rule, signal, forUserWait)));
}
/** 退战斗回大厅:允许下局重新 beforeBattle / 章节 warm */
resetBattlePhase(): void {
this.abortHallSilentDownload();
this._battleWarmComplete.clear();
this._battleWarmPromises.clear();
this._removeExecutedKeys((key) => this._isBattleExecutedKey(key));
}
/** 进战斗:清大厅预载登记,回大厅后可重新 enterHall / idle */
resetHallPhase(): void {
this.abortHallSilentDownload();
this._removeExecutedKeys((key) => this._isHallExecutedKey(key));
if (this._longTailTimer) {
clearTimeout(this._longTailTimer);
this._longTailTimer = null;
}
}
/**
* 冷启动最早窗口:first/comm/home 已落地后立刻下 icons 与 popCommon zip。
* 只 kickLoad,不 acquire、不 load 预制。
*/
startEarlyIconsDownload(): void {
const life = BundleLifecycle.getInstance();
life.kickLoad(GBundle.Items);
life.kickLoad(GBundle.PopCommon);
}
/**
* BootRes 完成后立刻并行下战斗分包(只 loadBundle,不 acquire)。
* 不走 silent 队列:点开始不会被 abort / beginUserLoad 停掉。
*/
startEarlyBattleBundleDownload(): void {
const names = getBattleSilentBundles();
const life = BundleLifecycle.getInstance();
for (let i = 0; i < names.length; i++) {
const name = names[i];
if (name === GBundle.PopBattle) continue;
life.kickLoad(name);
}
}
/**
* 闲时点火磁盘上全部尚未落地的业务分包 zip(rank/shenjiang/战斗包)。
* 只 kickLoad,不进 silent 队列、不 acquire、不 load 资源。
*/
kickAllRemainingBundleZips(): void {
const life = BundleLifecycle.getInstance();
const names = getAllDownloadableBundles();
for (let i = 0; i < names.length; i++) {
life.kickLoad(names[i]);
}
}
/**
* 进首页后:先 decode 大厅高频弹窗,再静默战斗 zip 与其它页。
* 用户点击 beginUserLoad 停发 silent;进行中的同路径 load 与 openPanel 合并。
*/
startHallSilentDownload(): void {
this._silentGen++;
const gen = this._silentGen;
const scheduler = LoadScheduler.getInstance();
scheduler.clearSilentQueue();
void scheduler.enqueue('interactive', () => this._decodeHallWarmThenSilent(gen));
}
/**
* popCommon zip → HallWarm decode → 再 kick 战斗 zip 与 silent UI。
* @param gen 静默代数
*/
private _decodeHallWarmThenSilent(gen: number): Promise<void> {
if (gen !== this._silentGen) return Promise.resolve();
return this._downloadBundleIfGen(gen, GBundle.PopCommon).then(() => this._loadHallWarmIfGen(gen)).then(() => {
if (gen !== this._silentGen) return;
this._enqueueHallSilentTail(gen);
});
}
/**
* 大厅壳 decode 之后再下战斗/Tab zip。用户加载中不 kickLoad,改进行列等停手。
* @param gen 静默代数
*/
private _enqueueHallSilentTail(gen: number): void {
const scheduler = LoadScheduler.getInstance();
if (!scheduler.isUserLoadActive()) {
this.kickAllRemainingBundleZips();
} else {
const names = getAllDownloadableBundles();
for (let i = 0; i < names.length; i++) {
const name = names[i];
void scheduler.enqueue('silent', () => this._downloadBundleIfGen(gen, name));
}
}
void scheduler.enqueue('silent', () => this._downloadBundleIfGen(gen, GBundle.Items));
const uiIds = getHallSilentUiIds();
for (let i = 0; i < uiIds.length; i++) {
const uiId = uiIds[i];
void scheduler.enqueue('silent', () => this._preloadHallUiIfGen(gen, uiId));
}
}
/** 进战 / 回大厅重建:丢掉未开始的 silent */
abortHallSilentDownload(): void {
this._silentGen++;
LoadScheduler.getInstance().clearSilentQueue();
}
/**
* BattleGame 打开后:preload 局内弹窗,再 load 受击/武器长尾。
* 不 addRef;点开弹窗仍走 openPanel 最高优先。
* @param levelId 本局 Level
*/
startBattleSilentDownload(levelId: number): void {
this._silentGen++;
const gen = this._silentGen;
const scheduler = LoadScheduler.getInstance();
scheduler.clearSilentQueue();
void scheduler.enqueue('silent', () => this._downloadBundleIfGen(gen, GBundle.PopBattle));
const uiIds = getBattleSilentUiIds();
for (let i = 0; i < uiIds.length; i++) {
const uiId = uiIds[i];
void scheduler.enqueue('silent', () => this._preloadHallUiIfGen(gen, uiId));
}
const items = ResPkgBuilder.getBattleAfterEnterItems(levelId);
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.name) {
void scheduler.enqueue('silent', () => this._loadPrefabIfGen(gen, item.bundle, item.path, item.name));
} else {
void scheduler.enqueue('silent', () => this._loadDirIfGen(gen, item.bundle, item.path));
}
}
}
/**
* 只下载分包,不 acquire,避免钉死战斗包卸载。
* @param gen 静默代数
* @param bundleName 分包名
*/
private _downloadBundleIfGen(gen: number, bundleName: string): Promise<void> {
if (gen !== this._silentGen) return Promise.resolve();
if (this._silentMissBundles.has(bundleName)) return Promise.resolve();
if (assetManager.getBundle(bundleName)) return Promise.resolve();
return BundleLifecycle.getInstance().ensureLoaded(bundleName).then((bundle) => {
if (bundle || assetManager.getBundle(bundleName)) return;
if (BundleLifecycle.getInstance().isLoadInflight(bundleName)) return;
membershipAdd(this._silentMissBundles, bundleName);
});
}
/**
* 下载页面分包并 load 预制(经 ResourcesManager 同路径合并)。
* @param gen 静默代数
* @param uiId 面板 id
*/
private _preloadHallUiIfGen(gen: number, uiId: number): Promise<void> {
if (gen !== this._silentGen) return Promise.resolve();
const conf = UIConfigs[uiId];
if (!conf?.bundle || !conf.path || !conf.name) return Promise.resolve();
return this._downloadBundleIfGen(gen, conf.bundle).then(() => {
if (gen !== this._silentGen) return;
return this._preloadPrefabOnly(conf.bundle, conf.path + conf.name);
});
}
/**
* 大厅高频弹窗 decode(进战会 invalidate 预制,zip 常驻)。
* @param gen 静默代数
*/
private _loadHallWarmIfGen(gen: number): Promise<void> {
if (gen !== this._silentGen) return Promise.resolve();
return new Promise<void>((resolve) => {
gg.res.preLoadResPakg(getHallWarmResPakage(), null, () => {
if (gen === this._silentGen) {
this._markTokenDone('pkg:HallWarmRes');
}
resolve();
});
});
}
/**
* 走 ResourcesManager.loadRes 同路径合并,避免与 openPanel 双飞丢回调。
* @param bundleName 分包
* @param prefabPath 预制路径
*/
private _preloadPrefabOnly(bundleName: string, prefabPath: string): Promise<void> {
const bundle = assetManager.getBundle(bundleName);
if (!bundle) return Promise.resolve();
return new Promise<void>((resolve) => {
gg.res.loadResPublic(bundle, prefabPath, () => resolve(), Prefab);
});
}
/**
* 局内长尾:bundle.load 单预制,供 getNode 命中。
* @param gen 静默代数
* @param bundleName 分包
* @param dirPath 目录
* @param name 预制名
*/
private _loadPrefabIfGen(gen: number, bundleName: string, dirPath: string, name: string): Promise<void> {
if (gen !== this._silentGen) return Promise.resolve();
const bundle = assetManager.getBundle(bundleName);
if (!bundle) return Promise.resolve();
const fullPath = dirPath + name;
return new Promise<void>((resolve) => {
gg.res.loadResPublic(bundle, fullPath, () => resolve(), Prefab);
});
}
/**
* 局内长尾:bundle.loadDir,不 addRef。
* @param gen 静默代数
* @param bundleName 分包
* @param dirPath 目录
*/
private _loadDirIfGen(gen: number, bundleName: string, dirPath: string): Promise<void> {
if (gen !== this._silentGen) return Promise.resolve();
const bundle = assetManager.getBundle(bundleName);
if (!bundle) return Promise.resolve();
return new Promise<void>((resolve) => {
bundle.loadDir(dirPath, Prefab, () => resolve());
});
}
/** 内存告警 D3:释放 HallWarm 长尾登记,弹窗打开时再按需加载 */
clearHallLongTailPreload(): void {
this._doneKeys.delete('token:pkg:HallWarmLongTailRes');
this._removeExecutedKeys((key) => key.startsWith('idleOnHome:'));
}
onTabIntent(tabId: UIID): void {
const sig = PreloadPredictor.tabSignalFor(tabId);
if (sig) this.emitSignal(sig);
}
/** 等待 Tab 预载规则完成后再 openPanel(用户可见路径:优先 interactive,资源已在包内则跳过) */
async awaitTabIntent(tabId: UIID): Promise<void> {
const sig = PreloadPredictor.tabSignalFor(tabId);
if (!sig) return;
if (this._areSignalTokensReady(sig)) return;
await awaitWithTimeout(
this.awaitSignal(sig, true),
LoadRecoveryConfig.tabIntentTimeoutMs,
`tabIntent:${tabId}`,
);
}
/**
* MainShell loadResPakg 完成后:Home bundle + PanelHome 已在缓存,登记 tabIntent:Home 避免排队。
*/
markMainShellLoaded(): void {
this._markTokenDone('bundle:home');
this._markTokenDone('ui:101');
this._markTokenDone('bundle:heros');
this._markTokenDone('pkg:HerosRes');
const rule = PRELOAD_RULES.find((r) => r.when === 'tabIntent:Home');
if (rule) {
membershipAdd(this._doneKeys, `tabIntent:Home:${rule.load.join(',')}`);
}
}
private _areSignalTokensReady(signal: string): boolean {
const rules = PRELOAD_RULES.filter((r) => r.when === signal);
if (rules.length === 0) return true;
for (const rule of rules) {
if (!this._areRuleTokensResident(rule, signal)) return false;
}
return true;
}
private _areRuleTokensResident(rule: typeof PRELOAD_RULES[0], signal: string): boolean {
let pending = rule.load.filter((t) => !this._isTokenDone(t));
if (signal === 'enterHall') {
pending = pending.filter((t) => !this._shouldSkipEnterHallToken(t));
}
return pending.every((t) => this._isTokenAssetResident(t));
}
private _markResidentRuleTokens(rule: typeof PRELOAD_RULES[0], signal: string): void {
let pending = rule.load.filter((t) => !this._isTokenDone(t));
if (signal === 'enterHall') {
pending = pending.filter((t) => !this._shouldSkipEnterHallToken(t));
}
for (const token of pending) {
if (this._isTokenAssetResident(token)) {
this._markTokenDone(token);
}
}
}
private _isTokenAssetResident(token: string): boolean {
if (token.startsWith('bundle:')) {
return !!assetManager.getBundle(token.slice('bundle:'.length));
}
if (token.startsWith('ui:')) {
const uiId = Number(token.slice('ui:'.length));
if (!Number.isFinite(uiId)) return false;
const conf = UIConfigs[uiId as UIID];
if (!conf) return false;
const bundle = assetManager.getBundle(conf.bundle);
if (!bundle) return false;
return !!bundle.get(conf.path + conf.name, Prefab);
}
return false;
}
/** 面板打开后触发 panelOpen 规则(子弹窗预测预载) */
onPanelOpened(panelId: number): void {
const map: Partial<Record<UIID, string>> = {
[UIID.Weapon]: 'panelOpen:Weapon',
[UIID.Role]: 'panelOpen:Role',
};
const sig = map[panelId as UIID];
if (sig) this.emitSignal(sig);
}
/** 回大厅后重新调度 idle 长尾预载 */
rescheduleHomeIdle(): void {
this._scheduleHomeIdle();
}
/**
* enterHall 完成后静默登记 Home 相关预载(新产品无 MainUI Tab)。
*/
warmHighPriorityHallTabs(): void {
this.onTabIntent(UIID.Home);
}
static tabSignalFor(tabId: UIID): string | null {
const map: Partial<Record<UIID, string>> = {
[UIID.Shop]: 'tabIntent:Shop',
[UIID.Weapon]: 'tabIntent:Weapon',
[UIID.Role]: 'tabIntent:Role',
[UIID.Home]: 'tabIntent:Home',
};
return map[tabId] ?? null;
}
/** 主 Tab 对应的 bundle 列表(与 PreloadRules tabIntent 一致) */
static getTabBundlesForPage(tabId: UIID): readonly string[] {
const map: Partial<Record<UIID, readonly string[]>> = {
[UIID.Shop]: ['shop', 'icons'],
[UIID.Weapon]: ['gemstone', 'icons'],
3 weeks ago
[UIID.Role]: ['role'],
};
return map[tabId] ?? [];
}
/** Tab bundle 卸载后清除预载登记,切回时可重新 tabIntent 预载 */
clearTabPreloadForBundles(bundleNames: readonly string[]): void {
if (!bundleNames.length) return;
const names = membershipFromArray(bundleNames);
for (const name of names.keys()) {
this._doneKeys.delete(`token:bundle:${name}`);
}
const remove: string[] = [];
for (const key of this._doneKeys.keys()) {
if (!key.startsWith('tabIntent:')) continue;
for (const name of names.keys()) {
if (key.includes(`bundle:${name}`)) {
remove.push(key);
break;
}
}
}
for (const key of remove) {
this._doneKeys.delete(key);
}
}
private _tokenKey(token: string): string {
return `token:${token}`;
}
private _isTokenDone(token: string): boolean {
return this._doneKeys.has(this._tokenKey(token));
}
private _markTokenDone(token: string): void {
membershipAdd(this._doneKeys, this._tokenKey(token));
}
private _loadBundleToken(token: string, bundleName: string): Promise<void> {
if (this._isTokenDone(token)) return Promise.resolve();
const owner = isTabScopedBundle(bundleName) ? 'tab' : 'res';
return new Promise((resolve) => {
gg.res.loadBundle(bundleName, () => {
this._markTokenDone(token);
resolve();
}, owner);
});
}
private _scheduleHomeIdle = (): void => {
if (!MemoryProfile.current.enableHallWarmLongTail) return;
if (this._longTailTimer) clearTimeout(this._longTailTimer);
this._longTailTimer = setTimeout(() => {
if (gg.ui?.hasPanel?.(UIID.BattleGame)) return;
this.kickAllRemainingBundleZips();
this.emitSignal('idleOnHome:5s');
}, MemoryProfile.current.idleLongTailSec * 1000);
};
/** tier0/1 跳过低优先级 enterHall 项,减轻首登内存与 IO 峰值 */
private _shouldSkipEnterHallToken(token: string): boolean {
MemoryProfile.resolve();
if (MemoryProfile.tier === 'tier3') return false;
if (token === 'bundle:chapterGift' || token === 'bundle:diamondShop') return true;
if (MemoryProfile.tier === 'tier0') {
return token === 'ui:617' || token === 'bundle:role';
}
return false;
}
private async _executeRule(rule: typeof PRELOAD_RULES[0], signal: string): Promise<void> {
await this._runRule(rule, signal, false);
}
private async _executeRuleAwait(
rule: typeof PRELOAD_RULES[0],
signal: string,
forUserWait = false,
): Promise<void> {
await this._runRule(rule, signal, forUserWait);
}
/**
* 执行一条预载规则;完成前不写入 done,同 key 复用 inflight。
* @param rule 规则
* @param signal 信号
* @param forUserWait 用户等待时升为 interactive
*/
private _runRule(rule: typeof PRELOAD_RULES[0], signal: string, forUserWait: boolean): Promise<void> {
if (!forUserWait && rule.priority === 'silent' && gg.ui?.hasPanel?.(UIID.BattleGame)) {
return Promise.resolve();
}
const dedupeKey = `${signal}:${rule.load.join(',')}`;
if (this._doneKeys.has(dedupeKey)) return Promise.resolve();
const existing = this._ruleInflight[dedupeKey];
if (existing) return existing;
const run = async (): Promise<void> => {
if (this._areRuleTokensResident(rule, signal)) {
this._markResidentRuleTokens(rule, signal);
membershipAdd(this._doneKeys, dedupeKey);
return;
}
const timer = PerfLoadTimer.start();
const queuePriority = forUserWait ? 'interactive' : rule.priority;
if (queuePriority === 'silent') {
await LoadScheduler.getInstance().enqueue('silent', () => this._loadRuleTokens(rule, signal));
} else {
await LoadScheduler.getInstance().enqueue(queuePriority, () => this._loadRuleTokens(rule, signal));
}
membershipAdd(this._doneKeys, dedupeKey);
if (signal === 'enterHall') {
PerfReport.reportPreloadHall(timer.foregroundMs(), timer.wallMs());
}
timer.dispose();
};
const p = run().finally(() => {
if (this._ruleInflight[dedupeKey] === p) {
this._ruleInflight[dedupeKey] = undefined;
}
});
this._ruleInflight[dedupeKey] = p;
return p;
}
private async _loadRuleTokens(rule: typeof PRELOAD_RULES[0], signal?: string): Promise<void> {
let pending = rule.load.filter((t) => !this._isTokenDone(t));
if (signal === 'enterHall') {
pending = pending.filter((t) => !this._shouldSkipEnterHallToken(t));
}
if (pending.length === 0) return;
const parallel = Math.max(1, MemoryProfile.current.silentPreloadParallel);
await this._mapWithConcurrency(pending, parallel, (token) => this._loadToken(token, rule));
}
private async _loadToken(token: string, rule: typeof PRELOAD_RULES[0]): Promise<void> {
if (this._isTokenDone(token)) return;
if (token.startsWith('bundle:')) {
const name = token.slice('bundle:'.length);
await this._loadBundleToken(token, name);
return;
}
if (token === 'pkg:MainShellRes') {
await new Promise<void>((resolve, reject) => {
gg.res.loadResPakg(getMainShellResPakage(), null, (result) => {
if (!result.ok) {
reject(new Error(`MainShellRes fail:${result.failed.length}`));
return;
}
this._markTokenDone(token);
resolve();
});
});
return;
}
if (token === 'pkg:HallWarmRes') {
await new Promise<void>((resolve) => {
gg.res.preLoadResPakg(getHallWarmResPakage(), null, () => {
this._markTokenDone(token);
resolve();
});
});
return;
}
if (token === 'pkg:HallWarmLongTailRes') {
await new Promise<void>((resolve) => {
gg.res.preLoadResPakg(getHallWarmLongTailResPakage(), null, () => {
this._markTokenDone(token);
resolve();
});
});
return;
}
if (token === 'pkg:HerosRes') {
await new Promise<void>((resolve) => {
gg.res.preLoadResPakg(getHerosResPakage(), null, () => {
this._markTokenDone(token);
resolve();
});
});
return;
}
if (token.startsWith('battleScoped:')) {
const scoped = token.slice('battleScoped:'.length);
const { levelId } = parseBattleScopedToken(scoped);
if (levelId) {
this.scheduleBattleWarm(levelId, rule.priority === 'interactive' ? 'interactive' : 'silent');
}
this._markTokenDone(token);
return;
}
if (token.startsWith('ui:')) {
const uiId = Number(token.slice('ui:'.length));
if (Number.isFinite(uiId)) {
await this._preloadUiPrefab(uiId);
}
}
}
private async _mapWithConcurrency<T>(
items: readonly T[],
limit: number,
fn: (item: T) => Promise<void>,
): Promise<void> {
if (items.length === 0) return;
let next = 0;
const workerCount = Math.min(Math.max(1, limit), items.length);
const workers = Array.from({ length: workerCount }, async () => {
while (next < items.length) {
const idx = next++;
await fn(items[idx]);
}
});
await Promise.all(workers);
}
private async _preloadUiPrefab(uiId: number): Promise<void> {
const token = `ui:${uiId}`;
if (this._isTokenDone(token)) return;
const conf = UIConfigs[uiId];
if (!conf?.bundle || !conf.path || !conf.name) {
console.error('[PreloadPredictor] ui 配置不完整', uiId);
return;
}
const bundleName = conf.bundle;
await this._loadBundleToken(`bundle:${bundleName}`, bundleName);
const prefabPath = conf.path + conf.name;
const bundle = assetManager.getBundle(bundleName);
if (!bundle) {
console.error('[PreloadPredictor] ui 分包未就绪', bundleName, uiId);
return;
}
await new Promise<void>((resolve) => {
gg.res.loadResPublic(bundle, prefabPath, (asset) => {
if (!asset?.isValid) {
console.error('[PreloadPredictor] load ui 失败', bundleName, prefabPath);
resolve();
return;
}
this._markTokenDone(token);
resolve();
}, Prefab);
});
}
}