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.
885 lines
34 KiB
885 lines
34 KiB
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'],
|
|
[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);
|
|
});
|
|
}
|
|
}
|
|
|