将游戏内 玩法介绍弹窗的释放做了优化,并且下载做了提前下载bundle,优先级放在了 GameScene 最低优先级。

This commit is contained in:
COMPUTER\EDY 2026-09-17 16:37:30 +08:00
parent ebe5a4ec35
commit 9ae6b0e6af
13 changed files with 1488 additions and 1115 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1213,6 +1213,7 @@ export default class MapConroler extends cc.Component {
if (!this.isMapRuntimeAlive()) return;
this.ensureWinLoaded(error => onComplete(error));
} : null,
() => this.SceneManager.scheduleGameplayIntroPredownload(),
);
}, 0);
};

View File

@ -45,10 +45,17 @@ export default class SceneManager extends cc.Component {
if (bundleName === "action_bundle") return;
if (!bundle) return;
setTimeout(() => {
if (cc.fx.GameTool && cc.fx.GameTool.shouldRetainGameplayPopupBundlesForGameSceneReload
if (bundleName !== "gameplay_intro" && cc.fx.GameTool && cc.fx.GameTool.shouldRetainGameplayPopupBundlesForGameSceneReload
&& cc.fx.GameTool.shouldRetainGameplayPopupBundlesForGameSceneReload()) return;
const active = SceneManager.activeInstance;
if (active && cc.isValid(active, true)) return;
if (active && cc.isValid(active, true)) {
if (bundleName !== "gameplay_intro") return;
// 旧场景晚到的介绍请求,仅在新场景确实接管此包时保留。
if (Object.keys(active.dynamicPopupStates || {}).some(name => {
const state = active.dynamicPopupStates[name];
return state.bundle === bundle || (state.loading && state.bundleName === bundleName);
})) return;
}
if (cc.assetManager.getBundle(bundleName) !== bundle) return;
bundle.releaseAll();
if (cc.assetManager.getBundle(bundleName) === bundle) {
@ -210,6 +217,36 @@ export default class SceneManager extends cc.Component {
private dynamicPopupRetries: { [popupName: string]: { displayName: string; retry: () => void } } = {};
private dynamicPopupStates: { [popupName: string]: DynamicPopupState } = {};
private popupBundleWarmupStarted: boolean = false;
private gameplayIntroPredownloadScheduled: boolean = false;
/** 地图就绪且弹窗预热结束后调用;持续空闲一秒才派发,只下载,不 loadBundle。 */
scheduleGameplayIntroPredownload() {
//@ts-ignore
if (cc.sys.platform !== cc.sys.WECHAT_GAME || typeof wx === "undefined" || !wx.preDownloadSubpackage) return;
if (this.gameplayIntroPredownloadScheduled || !this.isPopupRuntimeAlive()) return;
this.gameplayIntroPredownloadScheduled = true;
let idle = false;
const check = () => {
if (!this.isPopupRuntimeAlive()) return;
const tool = cc.fx.GameTool;
if (tool.isGameplayIntroDownloaded()) return;
const busy = tool.hasPendingGameplayResourceLoads() || cc.fx.GameConfig.isGameplayEntryPreparing()
|| Object.keys(this.dynamicPopupLoads).length > 0
|| Object.keys(this.dynamicPopupStates).some(name => this.dynamicPopupStates[name].loading);
if (busy || !idle) {
idle = !busy;
this.scheduleOnce(check, 1);
return;
}
tool.predownloadGameplayIntro().then(ready => {
if (!ready && this.isPopupRuntimeAlive()) {
idle = false;
this.scheduleOnce(check, 10);
}
});
};
this.scheduleOnce(check, 1);
}
warmupGameplayPopupBundles(
preloadInstances: boolean = false,
@ -297,11 +334,15 @@ export default class SceneManager extends cc.Component {
&& !!cc.fx.GameTool
&& !!cc.fx.GameTool.isIOSDevice
&& cc.fx.GameTool.isIOSDevice();
if (!shouldWarmupPauseBundle) return;
if (!shouldWarmupPauseBundle) {
if (onComplete) onComplete();
return;
}
const loadedPauseBundle = cc.assetManager.getBundle("pause");
if (loadedPauseBundle) {
console.log("[PopupBundle] already loaded: pause (iOS low-memory warmup)");
if (onComplete) onComplete();
return;
}
cc.assetManager.loadBundle("pause", (error: Error, bundle: cc.AssetManager.Bundle) => {
@ -311,9 +352,11 @@ export default class SceneManager extends cc.Component {
}
if (error) {
cc.warn("[PopupBundle] loadBundle failed: pause (iOS low-memory warmup)", error.message || error);
if (onComplete) onComplete(error);
return;
}
console.log("[PopupBundle] loadBundle complete: pause (iOS low-memory warmup)");
if (onComplete) onComplete();
});
}
@ -550,7 +593,10 @@ export default class SceneManager extends cc.Component {
state.root = null;
const bundle = state.bundle || cc.assetManager.getBundle(state.bundleName);
state.bundle = null;
if (bundle && !retainBundle) this.releaseDynamicPopupBundle(state.bundleName, bundle);
// 介绍只缓存到当前关,不能混入五个常用弹窗的切关保留策略。
if (bundle && (!retainBundle || state.bundleName === "gameplay_intro")) {
this.releaseDynamicPopupBundle(state.bundleName, bundle);
}
}
this.finishDynamicPopupLoad(popupName);
}
@ -879,6 +925,8 @@ export default class SceneManager extends cc.Component {
&& cc.fx.GameTool.shouldRetainGameplayPopupBundlesForGameSceneReload();
Object.keys(this.dynamicPopupStates || {}).forEach((popupName) =>
this.releaseDynamicPopup(popupName, true, retainPopupBundles));
const introBundle = cc.assetManager.getBundle("gameplay_intro");
if (introBundle) this.releaseDynamicPopupBundleIfUnused("gameplay_intro", introBundle);
if (!retainPopupBundles) {
["pause", "NewMode", "propWindow", "lose"].forEach((bundleName) => {
const bundle = cc.assetManager.getBundle(bundleName);

View File

@ -718,7 +718,7 @@ export class GameConfig {
vibrateOpen: true, //震动
coinnum: 0, //每局的金币数
paid_user: false, //是否是付费用户
version: "1.0.58", //版本号
version: "1.0.59", //版本号
shushu_DistinctId: "", //数数访客ID
shushu_AccountId: "", //数数账号ID
uid: "", //用户和后端唯一id

View File

@ -27,6 +27,7 @@ window.initMgr = function () {
cc.fx.GameConfig = GameConfig;
cc.fx.HttpUtil = HttpUtil;
cc.fx.GameTool = GameTool;
GameTool.trackResourceLoads();
cc.fx.Notifications = Notifications;

View File

@ -65,6 +65,12 @@ export default class BlockAssetManager {
private foregroundLoads = 0;
private entryDownloadHolds = 0;
/** 包括直存下载、排队任务和整批后台缓存,供最低优先级分包让路。 */
hasPendingLoads(): boolean {
return this.foregroundLoads > 0 || this.entryDownloadHolds > 0 || this.backgroundRunning
|| this.loading.size > 0 || this.fileDownloads.size > 0 || this.downloadQueue.length > 0;
}
/** 只停止后台派发;已在途下载继续完成,进关所需文件仍可提优先级。 */
holdBackgroundDownloads(): () => void {
this.entryDownloadHolds++;

View File

@ -5,6 +5,7 @@ import Freeze from "../../prop/Freeze";
import SceneManager from "../../SceneManager";
import { MiniGameSdk } from "../../Sdk/MiniGameSdk";
import Utils from "../Pay/Utils";
import BlockAssetManager from "./BlockAssetManager";
import {
starterPackReward, applyStarterPackStatus, starterPackRemaining, starterPackNow,
starterPackDay, starterPackPopupKey, starterPackFailureKey, starterPackRecordResult
@ -12,7 +13,7 @@ import {
//@ts-ignore
//最大工具类 各种公共方法,以及处理上传,获取后端接口数据
var GameTool = {
MAX_NORMAL_LEVEL: 1980,
MAX_NORMAL_LEVEL: 1960,
starterPackReactivationUid: "",
starterPackQueuedReason: "",
MEMORY_WARNING_EVENT: "game-memory-warning",
@ -34,6 +35,73 @@ var GameTool = {
_lowMemoryMode: false,
_memoryWarningCount: 0,
_gameplayPopupSubpackageDownloaded: {},
_resourceLoadTrackingInstalled: false,
_pendingResourceLoads: 0,
_gameplayIntroDownload: null as Promise<boolean>,
/** 在启动入口安装;公开管线覆盖 load/preload/Bundle 及其排队、解析阶段。 */
trackResourceLoads() {
if (this._resourceLoadTrackingInstalled) return;
this._resourceLoadTrackingInstalled = true;
[cc.assetManager.pipeline, cc.assetManager.fetchPipeline].forEach(pipeline => {
pipeline.insert((task, done) => {
this._pendingResourceLoads++;
const complete = task.onComplete;
let finished = false;
task.onComplete = (...args) => {
if (!finished) {
finished = true;
this._pendingResourceLoads--;
}
if (complete) complete.apply(task, args);
};
task.output = task.input;
done(null);
}, 0);
});
},
hasPendingGameplayResourceLoads(): boolean {
return !this._resourceLoadTrackingInstalled || this._pendingResourceLoads > 0
|| !!this._gameplayPopupSubpackagePreloadPromise || !!this._gameplayPopupMemoryWarmupPromise
|| BlockAssetManager.instance.hasPendingLoads();
},
isGameplayIntroDownloaded(): boolean {
return !!this._gameplayPopupSubpackageDownloaded.gameplay_intro
|| !!cc.assetManager.getBundle("gameplay_intro");
},
/** 仅预下载;跨启动的磁盘缓存由微信检查,不持久化可能失效的“已下载”标记。 */
predownloadGameplayIntro(): Promise<boolean> {
if (this.isGameplayIntroDownloaded()) return Promise.resolve(true);
if (this._gameplayIntroDownload) return this._gameplayIntroDownload;
// 老基础库不回退到 loadSubpackage,避免后台预下载顺带执行分包脚本。
//@ts-ignore
const wechat = typeof wx !== "undefined" ? wx : null;
if (!wechat || !wechat.preDownloadSubpackage) return Promise.resolve(false);
this._gameplayIntroDownload = new Promise<boolean>(resolve => {
console.log("[GameplayIntro] idle subpackage predownload start");
try {
wechat.preDownloadSubpackage({
name: "gameplay_intro",
success: () => {
this._gameplayPopupSubpackageDownloaded.gameplay_intro = true;
console.log("[GameplayIntro] subpackage cached; Bundle remains unloaded");
resolve(true);
},
fail: error => { cc.warn("[GameplayIntro] predownload failed", error); resolve(false); },
});
} catch (error) {
cc.warn("[GameplayIntro] predownload failed", error);
resolve(false);
}
}).then(ready => {
this._gameplayIntroDownload = null;
return ready;
});
return this._gameplayIntroDownload;
},
_gameplayPopupSubpackageRetryTimer: null,
_gameplayPopupSubpackagePreloadPromise: null,
_gameplayPopupMemoryWarmupPromise: null,

View File

@ -0,0 +1,13 @@
{
"ver": "1.1.3",
"uuid": "c9754f56-ad49-4bcc-a712-8f8e9e59dbb0",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

View File

@ -0,0 +1,13 @@
{
"ver": "1.1.3",
"uuid": "543de93e-fba1-410a-a3cf-8b39b912464a",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

View File

@ -0,0 +1,13 @@
{
"ver": "1.1.3",
"uuid": "4a7bec4e-6a81-4d81-86d8-edf1656cf2cf",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

13
assets/pause/texture.meta Normal file
View File

@ -0,0 +1,13 @@
{
"ver": "1.1.3",
"uuid": "ccd7701f-585a-411b-a8ef-a9f03f22189f",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

View File

@ -202,6 +202,7 @@ function mapFixture() {
const manager = Object.assign(new Manager(), {
node: { getChildByName: () => ({}) }, isPopupRuntimeAlive: () => !state.destroyed,
scheduleOnce: done => state.frames.push(done),
scheduleGameplayIntroPredownload: () => state.events.push('intro-idle'),
loadDynamicPopup(name, bundle, prefab, parent, done, showLoading) {
assert.equal(showLoading, false); state.popupCallbacks.push({ name, done });
},
@ -299,7 +300,7 @@ test('memory downgrade before map readiness still reveals the map but skips Pref
ready.wall(); assert.equal(state.events.includes('down'), false);
ready.block(); state.frames.shift()();
assert.equal(Config.firstGameplayEntryComplete, true);
assert.equal(state.holds, 0); assert.equal(state.events.at(-1), 'down');
assert.equal(state.holds, 0); assert.deepEqual(state.events.slice(-2), ['down', 'intro-idle']);
assert.equal(state.popupCallbacks.length, 0);
});

View File

@ -0,0 +1,196 @@
// Run: node --test tools/test-gameplay-intro-loading.cjs
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require('typescript');
function loadClass(file, names, globals) {
const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true);
const declaration = source.statements.find(ts.isClassDeclaration);
const members = names.map(name => declaration.members.find(m => m.name && m.name.getText(source) === name).getText(source));
return vm.runInNewContext(ts.transpileModule(`class ${declaration.name.text} { ${members.join('\n')} }\n${declaration.name.text}`, {
compilerOptions: { target: ts.ScriptTarget.ES2017 },
}).outputText, globals);
}
const flush = async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); };
function fixture() {
const state = { blocksBusy: false, entryBusy: false, destroyed: false, requests: [], schedules: [], timers: [], bundles: new Map(), released: [] };
const pipeline = () => ({ pipes: [], insert(fn, index) { this.pipes.splice(index, 0, fn); } });
const cc = {
sys: { platform: 'wechat', WECHAT_GAME: 'wechat' }, fx: {}, warn() {},
Node: { EventType: { TOUCH_END: 'touchend' } },
isValid: node => !!node && !node.destroyed,
assetManager: {
pipeline: pipeline(), fetchPipeline: pipeline(),
getBundle: name => state.bundles.get(name),
removeBundle: bundle => state.bundles.delete(bundle.name),
loadBundle: () => assert.fail('background predownload must not load a Bundle'),
},
};
const wx = { preDownloadSubpackage: options => state.requests.push(options) };
const BlockAssetManager = { instance: { hasPendingLoads: () => state.blocksBusy } };
const mod = { exports: {} };
vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/Script/module/Tool/GameTool.ts', 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS },
}).outputText, {
module: mod, exports: mod.exports, cc, wx, console: { log() {} },
require: name => name.endsWith('/BlockAssetManager') ? { default: BlockAssetManager } : { default: {}, MiniGameSdk: {} },
});
const tool = cc.fx.GameTool = mod.exports.GameTool;
tool.trackResourceLoads();
cc.fx.GameConfig = { isGameplayEntryPreparing: () => state.entryBusy };
const Manager = loadClass('assets/Script/SceneManager.ts', [
'activeInstance', 'scheduleGameplayIntroPredownload', 'warmupGameplayPopupBundles',
'releaseDynamicPopup', 'releaseDynamicPopupBundle', 'releaseDynamicPopupBundleIfUnused',
'releaseOrphanedPopupBundle', 'onDestroy',
], { cc, wx, console: { log() {} }, setTimeout: fn => state.timers.push(fn) });
const manager = Object.assign(new Manager(), {
node: { getChildByName: () => null, off() {} },
dynamicPopupStates: {}, dynamicPopupLoads: {},
isPopupRuntimeAlive: () => !state.destroyed,
scheduleOnce: (fn, delay) => state.schedules.push({ fn, delay }),
finishDynamicPopupLoad() {}, unbindLosePanel() {}, unbindPropWindowPanel() {},
clearDynamicPopupRetry() {}, clearFlyCoins() {}, disposeJungleTreasure() {},
});
tool.shouldRetainGameplayPopupBundlesForGameSceneReload = () => true;
Manager.activeInstance = manager;
const tick = () => { assert.ok(state.schedules.length); state.schedules.shift().fn(); };
const bundle = name => {
const result = { name, releaseAll: () => state.released.push(name) };
state.bundles.set(name, result); return result;
};
return { state, cc, wx, tool, manager, Manager, tick, bundle };
}
test('resource tracking preserves pipeline input/results and drains success and failure exactly once', () => {
const { cc, tool } = fixture();
tool.trackResourceLoads();
for (const pipeline of [cc.assetManager.pipeline, cc.assetManager.fetchPipeline]) {
assert.equal(pipeline.pipes.length, 1, 'tracking is installed once');
let callbackArgs;
const task = { input: ['asset'], onComplete(...args) { callbackArgs = args; } };
pipeline.pipes[0](task, () => assert.equal(task.output, task.input));
assert.equal(tool.hasPendingGameplayResourceLoads(), true);
const error = new Error('download failed');
task.onComplete(error, 'result');
assert.deepEqual(callbackArgs, [error, 'result']);
assert.equal(tool.hasPendingGameplayResourceLoads(), false);
}
});
test('Block idle check includes queued, in-flight, decoding and whole background batches', () => {
const Block = loadClass('assets/Script/module/Tool/BlockAssetManager.ts', ['hasPendingLoads'], {});
const block = Object.assign(new Block(), {
foregroundLoads: 0, entryDownloadHolds: 0, backgroundRunning: false,
loading: new Map(), fileDownloads: new Map(), downloadQueue: [],
});
assert.equal(block.hasPendingLoads(), false);
for (const key of ['foregroundLoads', 'entryDownloadHolds']) {
block[key] = 1; assert.equal(block.hasPendingLoads(), true); block[key] = 0;
}
for (const key of ['loading', 'fileDownloads']) {
block[key].set('pending', {}); assert.equal(block.hasPendingLoads(), true); block[key].clear();
}
block.backgroundRunning = true; assert.equal(block.hasPendingLoads(), true); block.backgroundRunning = false;
block.downloadQueue.push({}); assert.equal(block.hasPendingLoads(), true);
});
test('intro waits for block queue, engine loading, popup downloads, user popup and stable idle', async () => {
const { state, cc, tool, manager, tick } = fixture();
manager.scheduleGameplayIntroPredownload();
manager.scheduleGameplayIntroPredownload();
assert.equal(state.schedules.length, 1);
state.blocksBusy = true; tick(); tick();
state.blocksBusy = false;
const task = { input: 'queued asset' };
cc.assetManager.pipeline.pipes[0](task, () => {}); tick();
task.onComplete(null, {});
tool._gameplayPopupSubpackagePreloadPromise = {}; tick();
tool._gameplayPopupSubpackagePreloadPromise = null;
tool._gameplayPopupMemoryWarmupPromise = {}; tick();
tool._gameplayPopupMemoryWarmupPromise = null;
manager.dynamicPopupLoads.pause = true; tick();
delete manager.dynamicPopupLoads.pause;
state.entryBusy = true; tick(); state.entryBusy = false;
tick(); assert.equal(state.requests.length, 0);
tick(); assert.equal(state.requests.length, 1);
assert.equal(state.requests[0].name, 'gameplay_intro');
state.requests[0].success(); await flush();
assert.equal(tool.isGameplayIntroDownloaded(), true);
assert.equal(state.bundles.size, 0, 'disk caching does not register a Bundle');
await tool.predownloadGameplayIntro();
assert.equal(state.requests.length, 1, 'successful session cache avoids duplicate download checks');
});
test('failure retries only after a delay and another idle check; leaving scene cancels pending work', async () => {
const { state, manager, tick } = fixture();
manager.scheduleGameplayIntroPredownload(); tick(); tick();
state.requests[0].fail(new Error('offline')); await flush();
assert.equal(state.schedules[0].delay, 10);
state.blocksBusy = true; tick(); tick();
assert.equal(state.requests.length, 1);
state.blocksBusy = false; tick(); tick();
assert.equal(state.requests.length, 2);
state.destroyed = true; state.requests[1].fail(new Error('offline')); await flush();
assert.equal(state.schedules.length, 0);
});
test('departing before idle never starts a download; old SDK does not execute a subpackage', () => {
const f = fixture(); f.manager.scheduleGameplayIntroPredownload();
f.state.destroyed = true; f.tick(); assert.equal(f.state.requests.length, 0);
const old = fixture(); delete old.wx.preDownloadSubpackage;
old.wx.loadSubpackage = () => assert.fail('must not execute scripts as background predownload');
old.manager.scheduleGameplayIntroPredownload(); assert.equal(old.state.schedules.length, 0);
});
test('concurrent callers share one download, and cached Bundle needs no predownload', async () => {
const { tool, state, bundle } = fixture();
const first = tool.predownloadGameplayIntro();
assert.equal(tool.predownloadGameplayIntro(), first);
state.requests[0].success(); assert.equal(await first, true);
const second = fixture(); second.bundle('gameplay_intro');
assert.equal(await second.tool.predownloadGameplayIntro(), true);
assert.equal(second.state.requests.length, 0);
});
test('scene reload releases intro resources while retaining ordinary popup bundles', () => {
const { state, manager, bundle } = fixture();
for (const [name, bundleName] of [['gameplayIntro', 'gameplay_intro'], ['pause', 'pause']]) {
const root = { active: true, removeFromParent() {}, destroy() { this.destroyed = true; } };
manager.dynamicPopupStates[name] = { bundleName, bundle: bundle(bundleName), root, callbacks: [], requestId: 1 };
}
manager.onDestroy();
assert.deepEqual(state.released, ['gameplay_intro']);
assert.equal(state.bundles.has('pause'), true);
assert.equal(state.bundles.has('gameplay_intro'), false);
});
test('unowned intro bundle is cleaned even with retention enabled and no popup state', () => {
const { state, manager, bundle } = fixture(); bundle('gameplay_intro'); manager.onDestroy();
assert.deepEqual(state.released, ['gameplay_intro']);
});
for (const owned of [false, true]) {
test(`late old-scene intro callback respects actual new-scene ownership: ${owned}`, () => {
const { state, manager, Manager, bundle } = fixture(); const intro = bundle('gameplay_intro');
if (owned) manager.dynamicPopupStates.gameplayIntro = { bundleName: 'gameplay_intro', loading: true };
Manager.releaseOrphanedPopupBundle('gameplay_intro', intro); state.timers.shift()();
assert.equal(state.bundles.has('gameplay_intro'), owned);
});
}
for (const mode of ['skip', 'cached', 'load', 'failure']) {
test(`low-memory warmup signals completion so idle download can start: ${mode}`, () => {
const { cc, tool, manager, bundle } = fixture(); let calls = 0, done;
tool.isIOSDevice = () => mode !== 'skip';
if (mode === 'cached') bundle('pause');
cc.assetManager.loadBundle = (name, callback) => { assert.equal(name, 'pause'); done = callback; };
manager.warmupGameplayPopupBundles(false, null, error => {
assert.equal(!!error, mode === 'failure'); calls++;
});
if (done) { assert.equal(calls, 0); done(mode === 'failure' ? new Error('offline') : null, {}); }
assert.equal(calls, 1);
});
}