diff --git a/assets/Script/JiaZai.ts b/assets/Script/JiaZai.ts index 6567377..0d1a863 100644 --- a/assets/Script/JiaZai.ts +++ b/assets/Script/JiaZai.ts @@ -614,7 +614,7 @@ export default class JiaZai extends cc.Component { } /** loadBundle 只注册资源包;解析 Prefab 前必须等实际声明的跨包依赖就绪。 */ - private loadHomeBundleWithDependencies(bundleName: string, priority: number, + public loadHomeBundleWithDependencies(bundleName: string, priority: number, callback: (error: Error, bundle?: cc.AssetManager.Bundle) => void, visited: string[] = []): void { let settled = false; const finish = (error: Error, bundle?: cc.AssetManager.Bundle) => { @@ -4603,9 +4603,23 @@ export default class JiaZai extends cc.Component { const current = pass && pass[2]; if (current && Number(current.time) > Date.now() && passTier(current) === 30) return; if (cc.fx.StorageMessage.getStorage(cacheKey) === dateStr) return; - this.openPassCheckIntro(() => { - cc.fx.StorageMessage.setStorage(cacheKey, dateStr); - info.popPassCheck = false; + // 排队期间提前准备资源;只加载,不创建弹窗或记录已展示。 + const preparing = !JiaZai.cachedPassIntroPrefab || !cc.isValid(JiaZai.cachedPassIntroPrefab, true) || !this.isHomeBundleReady('passCheck') + ? this.loadHomePopupPrefab('passCheck', 'prefab/passCheckWindow', 'cachedPassIntroPrefab', 1) + .then(() => null, error => error) + : null; + HomePopupQueue.get(this.node).enqueue("passCheckIntro", 60, done => { + const show = (error?: Error) => { + if (error) { cc.warn('[战令每日弹窗] 预加载失败', error); done(); return; } + if (!this.isHomeRuntimeAlive() || cc.fx.GameConfig.isGameplayEntryPreparing() + || cc.fx.StorageMessage.getStorage(cacheKey) === dateStr) { done(); return; } + this.openPassCheckIntro(() => { + cc.fx.StorageMessage.setStorage(cacheKey, dateStr); + info.popPassCheck = false; + }, done); + }; + if (preparing) preparing.then(show); + else show(); }); }); } @@ -4621,15 +4635,15 @@ export default class JiaZai extends cc.Component { this.openPassCheckContent(event, customEventData); } - private openPassCheckIntro(onOpened?: () => void) { - if (this.passContentLoading || (this.passCheckNode && cc.isValid(this.passCheckNode))) return; - if (this.passIntroNode && cc.isValid(this.passIntroNode)) return; - if (!this.isHomeRuntimeAlive() || cc.fx.GameConfig.isGameplayEntryPreparing()) return; + private openPassCheckIntro(onOpened?: () => void, onFinished?: () => void) { + if (this.passContentLoading || (this.passCheckNode && cc.isValid(this.passCheckNode))) { if (onFinished) onFinished(); return; } + if (this.passIntroNode && cc.isValid(this.passIntroNode)) { if (onFinished) onFinished(); return; } + if (!this.isHomeRuntimeAlive() || cc.fx.GameConfig.isGameplayEntryPreparing()) { if (onFinished) onFinished(); return; } // 自动介绍不共用手动弹窗的请求编号,避免冷加载时被其他首页弹窗取消。 if (!JiaZai.cachedPassIntroPrefab || !cc.isValid(JiaZai.cachedPassIntroPrefab, true) || !this.isHomeBundleReady('passCheck')) { this.loadHomePopupPrefab('passCheck', 'prefab/passCheckWindow', 'cachedPassIntroPrefab', 1).then( - () => this.openPassCheckIntro(onOpened), - error => cc.warn('[战令每日弹窗] 介绍窗口加载失败', error)); + () => this.openPassCheckIntro(onOpened, onFinished), + error => { cc.warn('[战令每日弹窗] 介绍窗口加载失败', error); if (onFinished) onFinished(); }); return; } const intro = this.passIntroNode = cc.instantiate(JiaZai.cachedPassIntroPrefab); @@ -4637,6 +4651,7 @@ export default class JiaZai extends cc.Component { intro.scale = cc.fx.GameTool.adaptation(); intro.zIndex = 1000; if (onOpened) onOpened(); + if (onFinished) onFinished(); intro.getChildByName('close').on('click', () => { intro.destroy(); this.passIntroNode = null; }, this); intro.getChildByName('beginBtn').once('click', () => { intro.destroy(); this.passIntroNode = null; diff --git a/assets/Script/seven_day_gift/SevenDayGiftHost.ts b/assets/Script/seven_day_gift/SevenDayGiftHost.ts index 9d231e6..cb07ae8 100644 --- a/assets/Script/seven_day_gift/SevenDayGiftHost.ts +++ b/assets/Script/seven_day_gift/SevenDayGiftHost.ts @@ -185,12 +185,18 @@ export default class SevenDayGiftHost extends cc.Component { }); }; - const loadedBundle = cc.assetManager.getBundle(BUNDLE_NAME); - if (loadedBundle) { - loadPrefab(loadedBundle); + // 先加载活动分包的完整依赖,再解析 Prefab;已缓存的分包也需检查依赖。 + const canvas = cc.find("Canvas"); + const home: any = canvas && canvas.getComponent("JiaZai"); + if (!home || typeof home.loadHomeBundleWithDependencies !== "function") { + this.finishGiftLoad(new Error("主页分包加载器不可用")); return; } - cc.assetManager.loadBundle(BUNDLE_NAME, (error: Error, bundle: cc.AssetManager.Bundle) => { + home.loadHomeBundleWithDependencies(BUNDLE_NAME, 5, (error: Error, bundle: cc.AssetManager.Bundle) => { + if (loadId !== this.giftLoadId || !cc.isValid(this.node, true)) { + if (bundle) this.releaseBundle(bundle); + return; + } if (error || !bundle) { this.finishGiftLoad(error || new Error("seven_day_gift bundle is missing")); return; diff --git a/assets/home_popup_queue/HomePopupQueue.ts b/assets/home_popup_queue/HomePopupQueue.ts index 40acc54..19528c6 100644 --- a/assets/home_popup_queue/HomePopupQueue.ts +++ b/assets/home_popup_queue/HomePopupQueue.ts @@ -37,7 +37,8 @@ export default class HomePopupQueue extends cc.Component { if (seven && seven.children.some(visible)) return true; const home: any = this.node.getComponent("JiaZai"); if (!home) return false; - return ["RewardNode", "getcard", "monthlyCardNode", "actionpNode", "winStreakNode", "passCheckNode", "jungleTreasureNode"] + if (home.passContentLoading) return true; + return ["RewardNode", "getcard", "monthlyCardNode", "actionpNode", "winStreakNode", "passCheckNode", "passIntroNode", "jungleTreasureNode"] .some(key => visible(home[key])); } diff --git a/assets/passCheck/script.meta b/assets/passCheck/script.meta deleted file mode 100644 index cc28d4b..0000000 --- a/assets/passCheck/script.meta +++ /dev/null @@ -1,13 +0,0 @@ -{ - "ver": "1.1.3", - "uuid": "49407d68-e1eb-4535-b644-f0f4e720c07a", - "importer": "folder", - "isBundle": false, - "bundleName": "", - "priority": 1, - "compressionType": {}, - "optimizeHotUpdate": {}, - "inlineSpriteFrames": {}, - "isRemoteBundle": {}, - "subMetas": {} -} \ No newline at end of file diff --git a/tools/test-battle-pass-ui.cjs b/tools/test-battle-pass-ui.cjs index 5a3e4de..4c179b3 100644 --- a/tools/test-battle-pass-ui.cjs +++ b/tools/test-battle-pass-ui.cjs @@ -16,6 +16,7 @@ class Node { constructor(name = '') { Object.assign(this, { name, children: [], components: [], events: {}, active: true, x: 0, y: 0, width: 1080, height: 350 }); } set parent(value) { if (this._parent) this._parent.children = this._parent.children.filter(n => n !== this); this._parent = value; if (value) value.children.push(this); } get parent() { return this._parent; } + get activeInHierarchy() { return this.active && !this.destroyed && (!this.parent || this.parent.activeInHierarchy); } get childrenCount() { return this.children.length; } addChild(node) { node.parent = this; } getChildByName(name) { return this.children.find(n => n.name === name) || null; } @@ -129,12 +130,12 @@ test('manual rapid clicks queue each permitted lane once and reject level 36 for assert.equal(manager.stage.free[34], 0); assert.equal(manager.stage.passCheck[34], 0); assert.equal(manager.stage.free[35], 1); }); -function homeFixture() { +function homeFixture(queueOverride) { const { cc, child } = runtime(); const parsed = ts.createSourceFile('JiaZai.ts', fs.readFileSync('assets/Script/JiaZai.ts', 'utf8'), 99, true); const members = parsed.statements.find(ts.isClassDeclaration).members.filter(n => /^(popUpPassCheck|openPassCheck|openPassCheckIntro|openPassCheckContent)$/.test(n.name?.getText(parsed))).map(n => n.getText(parsed)).join('\n'); const callbacks = []; - const context = { cc, passTier: model.passTier, console: quiet, Utils: { getPassCheckInfo: fn => callbacks.push(fn) }, MiniGameSdk: { API: { showToast() {} } }, setTimeout: () => 1, clearTimeout() {} }; + const context = { HomePopupQueue: queueOverride || { get: () => ({ enqueue: (key, priority, run) => run(() => {}) }) }, cc, passTier: model.passTier, console: quiet, Utils: { getPassCheckInfo: fn => callbacks.push(fn) }, MiniGameSdk: { API: { showToast() {} } }, setTimeout: () => 1, clearTimeout() {} }; vm.runInNewContext(ts.transpileModule('class JiaZai {' + members + '} globalThis.Home=JiaZai;', { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText, context); const Home = context.Home; Home.cachedPassIntroPrefab = () => { const node = new Node('intro'); child(node, 'close'); child(node, 'beginBtn'); return node; }; Home.cachedPassCheckPrefab = () => { const node = new Node('content'); node.components.push({ constructor: { name: 'passCheck' }, setInfo() {} }); return node; }; @@ -238,3 +239,68 @@ test('daily intro retries unavailable data then opens for a level 43 free player assert.equal(home.node.children.filter(n => n.name === 'intro').length, 1); assert.equal(scheduled.length, 0); }); + + +test('battle pass waits for seven-day popup and blocks later popups until closed', () => { + let queue; + const { home, cc, callbacks } = homeFixture({ get: () => queue }); + const Queue = load('assets/home_popup_queue/HomePopupQueue.ts', {}, { cc }).default; + queue = new Queue(); queue.node = home.node; home.node.components.push(home); + const seven = new Node('sevenDayGift'); home.node.addChild(seven); seven.addChild(new Node('activity')); + Object.assign(cc.fx.GameConfig.GM_INFO, { level: 43, canIos: true, uid: 'queue-test' }); + home.deferHomePopupWhileTransfer = () => false; + const local = new Map(); cc.fx.StorageMessage = { getStorage: k => local.get(k), setStorage: (k,v) => local.set(k,v) }; + home.popUpPassCheck(); callbacks.shift()({ code: 1, data: { upgradeReady: true, time: Date.now()-1000, passCheck: 'null' } }); + queue.update(); assert.equal(home.passIntroNode, undefined); assert.equal(local.size, 0); + seven.removeAllChildren(); queue.update(); assert.ok(home.passIntroNode); assert.equal(local.size, 1); + let later = false; queue.enqueue('later', 70, done => { later = true; done(); }); + queue.update(); assert.equal(later, false); + home.passIntroNode.getChildByName('close').emit('click'); queue.update(); assert.equal(later, true); +}); + +test('failed battle-pass loading releases queue completion without recording display', async () => { + const { home, cc } = homeFixture(); let finished = 0, shown = 0; + cc.warn = () => {}; + home.isHomeBundleReady = () => false; + home.loadHomePopupPrefab = () => Promise.reject(new Error('offline')); + home.openPassCheckIntro(() => shown++, () => finished++); + await Promise.resolve(); assert.equal(finished, 1); assert.equal(shown, 0); +}); + +test('battle-pass content loading blocks the next automatic popup', () => { + const { cc } = runtime(); const Queue = load('assets/home_popup_queue/HomePopupQueue.ts', {}, { cc }).default; + const home = { passContentLoading: true }; const queue = new Queue(); + queue.node = { getChildByName: () => null, getComponent: () => home }; + let ran = false; queue.enqueue('later', 70, done => { ran = true; done(); }); + queue.update(); assert.equal(ran, false); home.passContentLoading = false; queue.update(); assert.equal(ran, true); +}); + +test('daily battle pass preloads before its queue turn without opening or recording', async () => { + let run, resolveLoad, loads = 0, ready = false; + const { home, cc, callbacks } = homeFixture({ get: () => ({ enqueue: (key, priority, task) => { run = task; } }) }); + Object.assign(cc.fx.GameConfig.GM_INFO, { level: 43, canIos: true, uid: 'preload-player' }); + home.deferHomePopupWhileTransfer = () => false; + const local = new Map(); cc.fx.StorageMessage = { getStorage: k => local.get(k), setStorage: (k,v) => local.set(k,v) }; + home.isHomeBundleReady = () => ready; + home.loadHomePopupPrefab = () => { loads++; return new Promise(resolve => { resolveLoad = () => { ready = true; resolve(); }; }); }; + home.popUpPassCheck(); callbacks.shift()({ code: 1, data: { upgradeReady: true, time: Date.now()-1000, passCheck: 'null' } }); + assert.equal(loads, 1); assert.equal(home.passIntroNode, undefined); assert.equal(local.size, 0); + resolveLoad(); await Promise.resolve(); + assert.equal(home.passIntroNode, undefined); assert.equal(local.size, 0); + let finished = false; run(() => finished = true); await Promise.resolve(); + assert.ok(home.passIntroNode); assert.equal(local.size, 1); assert.equal(loads, 1); assert.equal(finished, true); +}); + +test('daily battle pass preload rejection releases its queue turn without recording', async () => { + let run; + const { home, cc, callbacks } = homeFixture({ get: () => ({ enqueue: (key, priority, task) => { run = task; } }) }); + cc.warn = () => {}; + Object.assign(cc.fx.GameConfig.GM_INFO, { level: 43, canIos: true, uid: 'preload-fail' }); + home.deferHomePopupWhileTransfer = () => false; + const local = new Map(); cc.fx.StorageMessage = { getStorage: k => local.get(k), setStorage: (k,v) => local.set(k,v) }; + home.isHomeBundleReady = () => false; + home.loadHomePopupPrefab = () => Promise.reject(new Error('offline')); + home.popUpPassCheck(); callbacks.shift()({ code: 1, data: { upgradeReady: true, time: Date.now()-1000, passCheck: 'null' } }); + await Promise.resolve(); let finished = false; run(() => finished = true); await Promise.resolve(); + assert.equal(finished, true); assert.equal(local.size, 0); assert.equal(home.passIntroNode, undefined); +}); diff --git a/tools/test-seven-day-loading.cjs b/tools/test-seven-day-loading.cjs new file mode 100644 index 0000000..665ff53 --- /dev/null +++ b/tools/test-seven-day-loading.cjs @@ -0,0 +1,42 @@ +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 fixture(cached = false) { + const bundles = new Map(), pending = new Map(); let parses = 0; + const bundle = (name, deps = []) => ({name, deps, releaseAll() {}, load(path, type, done) { + parses++; assert.ok(bundles.has('action_bundle')); assert.ok(bundles.has('UI')); done(null, {}); + }}); + const gift = bundle('seven_day_gift', ['action_bundle']); + if (cached) bundles.set(gift.name, gift); + const view = {}; + const cc = { _decorator: { ccclass: x => x }, Component: class {}, Prefab: class {}, + isValid: n => n && !n.destroyed, + assetManager: { getBundle: name => bundles.get(name), removeBundle: b => bundles.delete(b.name), + loadBundle: (name, options, cb) => { pending.set(name, typeof options === 'function' ? options : cb); } }, + instantiate: () => ({active: false, setPosition() {}, getComponent: () => view}), + find: () => ({getComponent: () => home}) }; + const parsed = ts.createSourceFile('JiaZai.ts', fs.readFileSync('assets/Script/JiaZai.ts','utf8'),99,true); + const members = parsed.statements.find(ts.isClassDeclaration).members.filter(n => ['isHomeBundleReady','loadHomeBundleWithDependencies'].includes(n.name?.getText(parsed))).map(n=>n.getText(parsed)).join('\n'); + const ctx = {cc}; vm.runInNewContext(ts.transpileModule('class Home {'+members+'} globalThis.Home=Home;', {compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText,ctx); + const home = new ctx.Home(); const out = {}; + vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/Script/seven_day_gift/SevenDayGiftHost.ts','utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2020,module:ts.ModuleKind.CommonJS,experimentalDecorators:true}}).outputText,{cc,exports:out,require:()=>({default:{}})}); + const host = new out.default(); host.node = {addChild() {}}; + const finish = (name,error) => { const b=name==='seven_day_gift'?gift:bundle(name,name==='action_bundle'?['UI']:[]);if(!error)bundles.set(name,b);const cb=pending.get(name);assert.ok(cb,'missing request '+name);pending.delete(name);cb(error,b); }; + return {host, pending, finish, parses:()=>parses}; +} +for (const cached of [false,true]) test(`seven-day Prefab waits for nested dependencies, cached=${cached}`,()=>{ + const f=fixture(cached);let completed=0;f.host.ensureGiftLoaded(error=>{assert.ifError(error);completed++;}); + if(!cached)f.finish('seven_day_gift');assert.equal(f.parses(),0); + f.finish('action_bundle');assert.equal(f.parses(),0);f.finish('UI');assert.equal(f.parses(),1);assert.equal(completed,1); +}); +test('dependency failure completes callback and allows retry',()=>{ + const f=fixture(true);let error;f.host.ensureGiftLoaded(e=>error=e);f.finish('action_bundle',new Error('offline')); + assert.match(error.message,/offline/);assert.equal(f.host.giftLoading,false);assert.equal(f.parses(),0); + f.host.ensureGiftLoaded(e=>assert.ifError(e));f.finish('action_bundle');f.finish('UI');assert.equal(f.parses(),1); +}); +test('destroyed host does not parse the Prefab after dependencies finish',()=>{ + const f=fixture(true);f.host.ensureGiftLoaded(()=>assert.fail('stale callback'));f.host.node.destroyed=true; + f.finish('action_bundle');f.finish('UI');assert.equal(f.parses(),0); +});