diff --git a/assets/Script/ActivityPopupAnimator.ts b/assets/Script/ActivityPopupAnimator.ts index e0636660..ae44e992 100644 --- a/assets/Script/ActivityPopupAnimator.ts +++ b/assets/Script/ActivityPopupAnimator.ts @@ -28,6 +28,20 @@ interface Motion { */ export default class ActivityPopupAnimator { private static motions = new WeakMap(); + private static preparedOpacity = new WeakMap(); + + /** Hide immediately; capture layout only when the deferred entrance starts. */ + static prepareOpen(content: Content): void { + this.cancel(content); + this.nodes(content).forEach(node => { + this.preparedOpacity.set(node, node.opacity); + node.opacity = 0; + }); + } + /** A close/cancel before the scheduled entrance must not reopen the popup. */ + static openPrepared(content: Content, type = ActivityPopupAnimationType.Bounce, done?: () => void): void { + if (this.nodes(content).some(node => this.preparedOpacity.has(node))) this.open(content, type, done); + } static open(content: Content, type = ActivityPopupAnimationType.Bounce, done?: () => void): void { this.play(content, type, false, done); @@ -39,10 +53,16 @@ export default class ActivityPopupAnimator { static cancel(content: Content, restore = true): void { const nodes = this.nodes(content), key = nodes[0]; const motion = key && this.motions.get(key); - if (!motion) return; - if (motion.tween) motion.tween.stop(); - this.motions.delete(key); - if (restore) this.apply(motion, 1, 1); + if (motion) { + if (motion.tween) motion.tween.stop(); + this.motions.delete(key); + if (restore) this.apply(motion, 1, 1); + } + nodes.forEach(node => { + if (!this.preparedOpacity.has(node)) return; + if (restore) node.opacity = this.preparedOpacity.get(node); + this.preparedOpacity.delete(node); + }); } private static nodes(content: Content): cc.Node[] { return (Array.isArray(content) ? content : [content]).filter(node => node && cc.isValid(node, true)); @@ -59,6 +79,7 @@ export default class ActivityPopupAnimator { private static play(content: Content, type: ActivityPopupAnimationType, closing: boolean, done?: () => void): void { const nodes = this.nodes(content), key = nodes[0]; if (!key) return; + const prepared = nodes.some(node => this.preparedOpacity.has(node)); let motion = this.motions.get(key); if (closing && motion && motion.direction === "close") return; const interrupted = !!(motion && motion.tween); @@ -66,11 +87,13 @@ export default class ActivityPopupAnimator { if (!interrupted) { if (motion && motion.direction === "closed") this.apply(motion, 1, 1); motion = { - poses: nodes.map(node => ({ node, x: node.x, y: node.y, scaleX: node.scaleX, scaleY: node.scaleY, opacity: node.opacity })), - value: { scale: 1, alpha: 1 }, tween: null, + poses: nodes.map(node => ({ node, x: node.x, y: node.y, scaleX: node.scaleX, scaleY: node.scaleY, + opacity: this.preparedOpacity.has(node) ? this.preparedOpacity.get(node) : node.opacity })), + value: { scale: 1, alpha: prepared ? 0 : 1 }, tween: null, direction: closing ? "close" : "open", grouped: Array.isArray(content), }; } + nodes.forEach(node => this.preparedOpacity.delete(node)); motion.direction = closing ? "close" : "open"; this.motions.set(key, motion); const fade = type === ActivityPopupAnimationType.FadeScale; diff --git a/assets/Script/Avatar.ts b/assets/Script/Avatar.ts index 07c2fbf0..c9514cc8 100644 --- a/assets/Script/Avatar.ts +++ b/assets/Script/Avatar.ts @@ -19,10 +19,11 @@ export default class Avatar extends cc.Component { onEnable() { this.unschedule(this.playPopupEntrance); + PopupAnimator.prepareOpen(popupContent(this.node, ["New Sprite(Splash)","address","Loading"])); this.scheduleOnce(this.playPopupEntrance, 0); } private playPopupEntrance() { - if (cc.isValid(this.node, true) && this.node.activeInHierarchy) PopupAnimator.open(popupContent(this.node, ["New Sprite(Splash)","address","Loading"])); + if (cc.isValid(this.node, true) && this.node.activeInHierarchy) PopupAnimator.openPrepared(popupContent(this.node, ["New Sprite(Splash)","address","Loading"])); } diff --git a/assets/Script/Map.ts b/assets/Script/Map.ts index e2e91f61..d6f7d804 100644 --- a/assets/Script/Map.ts +++ b/assets/Script/Map.ts @@ -805,6 +805,7 @@ export default class MapConroler extends cc.Component { return; } + this.prepareWinSettlement(); winRoot.active = true; this.releaseBlockAssets(); this.releaseLosePanel(); @@ -4757,6 +4758,30 @@ export default class MapConroler extends cc.Component { } } + // 先清除预制体默认显示状态,避免整体入场时提前显示稍后才出场的按钮和奖励。 + private prepareWinSettlement() { + const root = this.getWinRoot(); + if (!root || !this.gameOverNode) return; + ["gongxi", "homeBtn", "homeBtn2", "nextBtn", "nextBtn2", "tiaodik", "guang", "diguan", "endlessNode", "Ruzhi"].forEach(name => { + const node = this.gameOverNode.getChildByName(name); + if (node) node.active = false; + }); + ["Career", "Rank", "Ruzhi", "WinStreak", "WinStreak2"].forEach(name => { + const node = root.getChildByName(name); + if (node) node.active = false; + }); + const title = this.gameOverNode.getChildByName("gongxi"); + const cai = title && title.getChildByName("cai"); + if (cai) { + cc.Tween.stopAllByTarget(cai); + cai.scale = 0; + } + if (this.hideNewModeSettlementDecorations()) { + [title, this.gameOverNode.getChildByName("guang"), this.gameOverNode.getChildByName("diguan")] + .forEach(node => this.setSettlementNodeVisible(node, false)); + } + } + // 普通胜利和出现新功能介绍时均播放完整结算。 playWinSettlement() { const root = this.getWinRoot(); @@ -7773,34 +7798,8 @@ export default class MapConroler extends cc.Component { } let title = this.gameOverNode.getChildByName("gongxi"); - let homeBtn = this.gameOverNode.getChildByName("homeBtn"); - let nextBtn = this.getSettlementNextButton(); - let tiaodik = this.gameOverNode.getChildByName("tiaodik"); - let guang = this.gameOverNode.getChildByName("guang"); const cai = title.getChildByName("cai"); - cai.scale = 0; - const hideNewModeDecorations = this.hideNewModeSettlementDecorations(); - - - // 初始化所有元素为隐藏状态 - title.active = false; - homeBtn.active = false; - nextBtn.active = false; - tiaodik.active = false; - guang.active = false; - if (cai) { - cc.Tween.stopAllByTarget(cai); - cai.scale = 0; - } - - if (hideNewModeDecorations) { - this.setSettlementNodeVisible(title, false); - this.setSettlementNodeVisible(guang, false); - this.setSettlementNodeVisible(this.gameOverNode.getChildByName("diguan"), false); - } - - // 第一步:播放 title - 果冻弹跳效果 cc.tween(title) .to(0, { scale: 0, position: cc.v3(0, 0, 0) }) diff --git a/assets/Script/RedeemCode.ts b/assets/Script/RedeemCode.ts index c9cc6e2b..289ae0d8 100644 --- a/assets/Script/RedeemCode.ts +++ b/assets/Script/RedeemCode.ts @@ -14,10 +14,11 @@ export default class RedeemCode extends cc.Component { onEnable() { this.unschedule(this.playPopupEntrance); + PopupAnimator.prepareOpen(popupContent(this.node, ["New Sprite(Splash)"])); this.scheduleOnce(this.playPopupEntrance, 0); } private playPopupEntrance() { - if (cc.isValid(this.node, true) && this.node.activeInHierarchy) PopupAnimator.open(popupContent(this.node, ["New Sprite(Splash)"])); + if (cc.isValid(this.node, true) && this.node.activeInHierarchy) PopupAnimator.openPrepared(popupContent(this.node, ["New Sprite(Splash)"])); } @property(cc.Button) diff --git a/assets/Script/StaminaPack.ts b/assets/Script/StaminaPack.ts index 980d50bb..29439f3d 100644 --- a/assets/Script/StaminaPack.ts +++ b/assets/Script/StaminaPack.ts @@ -19,10 +19,11 @@ export default class StaminaPack extends cc.Component { onEnable() { this.unschedule(this.playPopupEntrance); + PopupAnimator.prepareOpen(popupContent(this.node, ["New Sprite(Splash)","Loading","ConfirmBox"])); this.scheduleOnce(this.playPopupEntrance, 0); } private playPopupEntrance() { - if (cc.isValid(this.node, true) && this.node.activeInHierarchy) PopupAnimator.open(popupContent(this.node, ["New Sprite(Splash)","Loading","ConfirmBox"])); + if (cc.isValid(this.node, true) && this.node.activeInHierarchy) PopupAnimator.openPrepared(popupContent(this.node, ["New Sprite(Splash)","Loading","ConfirmBox"])); } @property(cc.Label) timeLabel: cc.Label = null; diff --git a/assets/Script/monthlyCard.ts b/assets/Script/monthlyCard.ts index 48b9d672..81636603 100644 --- a/assets/Script/monthlyCard.ts +++ b/assets/Script/monthlyCard.ts @@ -25,10 +25,11 @@ export default class NewClass extends cc.Component { onEnable() { this.unschedule(this.playPopupEntrance); + PopupAnimator.prepareOpen(popupContent(this.node, ["ditu","Loading","ConfirmBox"])); this.scheduleOnce(this.playPopupEntrance, 0); } private playPopupEntrance() { - if (cc.isValid(this.node, true) && this.node.activeInHierarchy) PopupAnimator.open(popupContent(this.node, ["ditu","Loading","ConfirmBox"])); + if (cc.isValid(this.node, true) && this.node.activeInHierarchy) PopupAnimator.openPrepared(popupContent(this.node, ["ditu","Loading","ConfirmBox"])); } diff --git a/assets/action_bundle/script/DailyQuests.ts b/assets/action_bundle/script/DailyQuests.ts index cb66ccd6..623561fd 100644 --- a/assets/action_bundle/script/DailyQuests.ts +++ b/assets/action_bundle/script/DailyQuests.ts @@ -22,10 +22,11 @@ const { ccclass, property } = cc._decorator; export default class DailyQuests extends cc.Component { onEnable() { this.unschedule(this.playPopupEntrance); + PopupAnimator.prepareOpen(popupContent(this.node, ["ditu","Loading","ConfirmBox","taskRewardEffects"])); this.scheduleOnce(this.playPopupEntrance, 0); } private playPopupEntrance() { - if (cc.isValid(this.node, true) && this.node.activeInHierarchy) PopupAnimator.open(popupContent(this.node, ["ditu","Loading","ConfirmBox","taskRewardEffects"])); + if (cc.isValid(this.node, true) && this.node.activeInHierarchy) PopupAnimator.openPrepared(popupContent(this.node, ["ditu","Loading","ConfirmBox","taskRewardEffects"])); } diff --git a/assets/action_bundle/script/WinStreak.ts b/assets/action_bundle/script/WinStreak.ts index fb8627c3..a7dcdd82 100644 --- a/assets/action_bundle/script/WinStreak.ts +++ b/assets/action_bundle/script/WinStreak.ts @@ -26,11 +26,12 @@ export default class WinStreak extends cc.Component { onEnable() { if (!this.node.getChildByName("ditu")) return; this.unschedule(this.playPopupEntrance); + PopupAnimator.prepareOpen(popupContent(this.node, ["ditu", "Loading", "ConfirmBox"])); this.scheduleOnce(this.playPopupEntrance, 0); } private playPopupEntrance() { if (!this.node.getChildByName("ditu")) return; - if (cc.isValid(this.node, true) && this.node.activeInHierarchy) PopupAnimator.open(popupContent(this.node, ["ditu", "Loading", "ConfirmBox"])); + if (cc.isValid(this.node, true) && this.node.activeInHierarchy) PopupAnimator.openPrepared(popupContent(this.node, ["ditu", "Loading", "ConfirmBox"])); } diff --git a/assets/action_bundle/script/shareFriend.ts b/assets/action_bundle/script/shareFriend.ts index 057988ad..64fc7ef1 100644 --- a/assets/action_bundle/script/shareFriend.ts +++ b/assets/action_bundle/script/shareFriend.ts @@ -13,10 +13,11 @@ export default class shareFriend extends cc.Component { onEnable() { this.unschedule(this.playPopupEntrance); + PopupAnimator.prepareOpen(popupContent(this.node, ["New Sprite(Splash)"])); this.scheduleOnce(this.playPopupEntrance, 0); } private playPopupEntrance() { - if (cc.isValid(this.node, true) && this.node.activeInHierarchy) PopupAnimator.open(popupContent(this.node, ["New Sprite(Splash)"])); + if (cc.isValid(this.node, true) && this.node.activeInHierarchy) PopupAnimator.openPrepared(popupContent(this.node, ["New Sprite(Splash)"])); } diff --git a/tools/popup-animation-test-support.cjs b/tools/popup-animation-test-support.cjs index 963eea10..64d8a6b9 100644 --- a/tools/popup-animation-test-support.cjs +++ b/tools/popup-animation-test-support.cjs @@ -1,7 +1,7 @@ // Business-flow fixtures complete motion immediately. Timing/interruption is tested // against the real animator by test-popup-animator.cjs. exports.animationModule = { - default: { open(node, type, done) { if (done) done(); }, close(node, type, done) { if (done) done(); }, cancel() {} }, + default: { prepareOpen() {}, openPrepared(node, type, done) { if (done) done(); }, open(node, type, done) { if (done) done(); }, close(node, type, done) { if (done) done(); }, cancel() {} }, ActivityPopupAnimationType: { FadeScale: 0, Bounce: 1 }, }; exports.contentModule = { popupContent(root, excluded) { return (root?.children || []).filter(n => !excluded.includes(n.name)); } }; diff --git a/tools/test-daily-reward-animation.cjs b/tools/test-daily-reward-animation.cjs index e5d8542c..536ef66b 100644 --- a/tools/test-daily-reward-animation.cjs +++ b/tools/test-daily-reward-animation.cjs @@ -220,6 +220,8 @@ function setup(rewards = [{ type: 'coin', count: 50 }, { type: 'hammer', count: cc.fx.GameTool.getDailyTaskServerTime = () => Date.now(); cc.fx.GameTool.claimDailyQuestReward = context.Utils.getDailyQuestReward; cc.fx.GameTool.getCachedDailyQuests = () => null; + const { animationModule, contentModule } = require('./popup-animation-test-support.cjs'); + Object.assign(context, contentModule, { PopupAnimator: animationModule.default }); vm.runInNewContext(code, context); const daily = new context.DailyQuests(); installCountFrames(daily); daily.node = build(1); canvas.addChild(daily.node); daily.content = daily.node.getChildByName('bg').getChildByName('content'); diff --git a/tools/test-daily-task-state-animation.cjs b/tools/test-daily-task-state-animation.cjs index 04735a84..79770dcc 100644 --- a/tools/test-daily-task-state-animation.cjs +++ b/tools/test-daily-task-state-animation.cjs @@ -1,4 +1,5 @@ const { installCountFrames, countText } = require('./daily-count-test-utils.cjs'); +const { animationModule, contentModule } = require('./popup-animation-test-support.cjs'); const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); @@ -117,6 +118,7 @@ function setup(states = [0, 1, 1, 2], options = {}) { getStorage: key => storage.has(key) ? structuredClone(storage.get(key)) : null, setStorage: (key, value) => storage.set(key, structuredClone(value)) }; + Object.assign(context, contentModule, { PopupAnimator: animationModule.default }); vm.runInNewContext(code, context); const daily = new context.DailyQuests(); installCountFrames(daily); daily.node = build(1); daily.content = daily.node.getChildByName('bg').getChildByName('content'); diff --git a/tools/test-new-mode-settlement.cjs b/tools/test-new-mode-settlement.cjs index 932f443f..9a430eae 100644 --- a/tools/test-new-mode-settlement.cjs +++ b/tools/test-new-mode-settlement.cjs @@ -29,6 +29,7 @@ function setup(newMode) { ensureWinLoaded: done => done(null, win), getWinRoot: () => win, releaseBlockAssets() {}, releaseLosePanel() {}, + prepareWinSettlement: () => events.push('prepare'), setPassProgress: () => events.push('pass'), createRank: () => events.push('rank'), createCareer: () => events.push('career'), @@ -46,10 +47,10 @@ test('new gameplay introduction accompanies the same full settlement as ordinary const normal = setup(false), introduced = setup(true); normal.map.showWinWhenReady(); introduced.map.showWinWhenReady(); - assert.deepEqual(normal.events, ['pass']); - assert.deepEqual(introduced.events, ['pass']); + assert.deepEqual(normal.events, ['prepare', 'pass']); + assert.deepEqual(introduced.events, ['prepare', 'pass']); normal.enter(); introduced.enter(); - assert.deepEqual(normal.events, ['pass', 'rank', 'career', 'animation', 'streak']); + assert.deepEqual(normal.events, ['prepare', 'pass', 'rank', 'career', 'animation', 'streak']); assert.deepEqual(introduced.events, [...normal.events, 'intro:0']); assert.equal(introduced.win.active, true); assert.equal(introduced.map.gameOverNode.active, true); @@ -70,3 +71,87 @@ test('introduction buttons only close the panel without repeating settlement or assert.equal(map.gameOverNode.active, true); } }); + +// Use the authored prefab states: nextBtn, homeBtn and the coin group start active. +function presentationFixture(newMode, otherLevel) { + const fs = require('node:fs'); + const data = JSON.parse(fs.readFileSync('assets/win/prefab/Win.prefab', 'utf8')); + function inflate(id) { + const raw = data[id], position = raw._trs.array; + const node = { name: raw._name, active: raw._active, opacity: raw._opacity, + position: { x: position[0], y: position[1], z: position[2] }, scaleX: position[7], scaleY: position[8], + children: (raw._children || []).map(ref => inflate(ref.__id__)), + getChildByName(name) { return this.children.find(child => child.name === name); }, + }; + node.childrenCount = node.children.length; + return node; + } + const root = inflate(data[0].data.__id__), body = root.getChildByName('tween'); + const deferred = ['gongxi', 'homeBtn', 'homeBtn2', 'nextBtn', 'nextBtn2', 'tiaodik', 'guang', 'diguan', 'endlessNode', 'Ruzhi']; + const panels = ['Career', 'Rank', 'Ruzhi', 'WinStreak', 'WinStreak2']; + const assertHidden = () => { + for (const name of deferred) assert.equal(body.getChildByName(name).active, false, `${name} must wait for its animation`); + for (const name of panels) assert.equal(root.getChildByName(name).active, false, `${name} must wait for initialized data`); + }; + let visible = false, entrance, careerUpdates = 0, intros = 0; + Object.defineProperty(root, 'active', { get: () => visible, set(value) { + if (value) assertHidden(); // Initialization must precede activation itself. + visible = value; + } }); + const tweens = []; + const cc = { isValid: n => !!n, error: assert.fail, v3: (x,y,z) => ({x,y,z}), Tween: { stopAllByTarget() {} }, + tween(target) { + const steps = [], tween = { target, steps, + to(time, props) { steps.push(() => Object.assign(target, props)); return tween; }, + delay() { return tween; }, call(fn) { steps.push(fn); return tween; }, + start() { tweens.push(tween); return tween; }, + }; return tween; + }, + fx: { AudioManager: { _instance: { playEffect() {} } }, GameConfig: { + GM_INFO: { level: 20, addLevel: 8, otherLevel }, + canSettleCareer: () => true, getFeatureUnlockLevel: () => 20, + usesRainbowWinStreakUI: () => false, isNewPlayerB: () => false, + } }, + }; + const Controller = loadMembers('assets/Script/Map.ts', [ + 'showWinWhenReady', 'prepareWinSettlement', 'playWinSettlement', 'startWinPresentation', 'Settlement', + 'playButtonGroup', 'getSettlementNextButton', 'resetRuzhiVariantBButton', + 'settlementAnimationTime', 'hideNewModeSettlementDecorations', 'setSettlementNodeVisible', + ], { cc, PopupType: { Bounce: 1 }, popupContent: root => [body], + PopupAnimator: { open(content, type, done) { assertHidden(); entrance = done; } } }); + const map = Object.assign(new Controller(), { + node: {}, arr: [], new_mode: newMode ? 3 : 0, autoNextAfterNewMode: false, + gameOverNode: body, getWinRoot: () => root, isMapRuntimeAlive: () => true, + ensureWinLoaded: done => done(null, root), releaseBlockAssets() {}, releaseLosePanel() {}, setPassProgress() {}, + createRank() { root.getChildByName('Rank').active = true; }, + createCareer() { careerUpdates++; }, openWinStreak() {}, openNewMode() { intros++; }, + playDecorationGroup() {}, + }); + return { map, root, body, tweens, assertHidden, enter: () => entrance(), + careerUpdates: () => careerUpdates, intros: () => intros, + finish(target) { const tween = tweens.find(t => t.target === target); assert.ok(tween); tween.steps.forEach(step => step()); } }; +} + +for (const newMode of [false, true]) for (const reused of [false, true]) { + test(`Win prefab newMode=${newMode} reused=${reused}: no controls appear before their settlement phase`, () => { + const f = presentationFixture(newMode, reused ? 1 : 0); + assert.equal(f.body.getChildByName('nextBtn').active, true, 'prefab reproduces the original flash'); + if (reused) f.body.children.forEach(node => { node.active = true; }); + f.map.showWinWhenReady(); + f.assertHidden(); assert.equal(f.careerUpdates(), 0); + f.enter(); // Generic entrance completes; only now start settlement animations. + assert.equal(f.careerUpdates(), 1); assert.equal(f.intros(), newMode ? 1 : 0); + assert.equal(f.body.getChildByName('nextBtn').active, false); + assert.equal(f.body.getChildByName('nextBtn2').active, false); + assert.equal(f.body.getChildByName('tiaodik').active, false); + f.finish(f.body.getChildByName('gongxi')); // Title finishes and schedules button group. + assert.equal(f.body.getChildByName('nextBtn2').active, false); + f.finish(f.body.getChildByName(reused ? 'homeBtn2' : 'homeBtn')); + f.finish(f.body.getChildByName('nextBtn2')); + assert.equal(f.body.getChildByName('nextBtn2').active, !reused); + assert.equal(f.body.getChildByName(reused ? 'homeBtn2' : 'homeBtn').active, true); + assert.equal(f.body.getChildByName('nextBtn').active, false, 'obsolete button stays hidden'); + f.finish(f.body.getChildByName('tiaodik')); assert.equal(f.body.getChildByName('tiaodik').active, true); + assert.equal(f.careerUpdates(), 1, 'preparation does not duplicate settlement side effects'); + }); +} diff --git a/tools/test-popup-animator.cjs b/tools/test-popup-animator.cjs index 9ed5b874..398361bc 100644 --- a/tools/test-popup-animator.cjs +++ b/tools/test-popup-animator.cjs @@ -1,5 +1,5 @@ const fs=require('fs'),ts=require('typescript'),test=require('node:test'),assert=require('node:assert/strict'),vm=require('vm'); -function fixture(){const tweens=[];const cc={isValid:n=>!!n&&!n.destroyed,tween(target){const steps=[];const t={target,steps,stopped:false,to(duration,props,options){steps.push({duration,props,options});return t},parallel(...branches){steps.push({branches});return t},call(fn){steps.push({fn});return t},start(){tweens.push(t);return t},stop(){t.stopped=true;return t}};return t}};const exports={};vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/Script/ActivityPopupAnimator.ts','utf8'),{compilerOptions:{module:1,target:99}}).outputText,{exports,cc,WeakMap});const node=(x=20,y=40)=>({x,y,scaleX:.7,scaleY:.9,opacity:210,active:true});const frame=(t,i,ratio=1)=>{const s=t.steps[i];if(s.props){for(const [k,v]of Object.entries(s.props))t.target[k]+=(v-t.target[k])*ratio;s.options.onUpdate() }else if(s.branches){s.branches.forEach(finish)}else s.fn()};const finish=t=>{t.steps.forEach((s,i)=>frame(t,i))};return{A:exports.default,T:exports.ActivityPopupAnimationType,node,tweens,frame,finish}} +function fixture(){const tweens=[];const cc={isValid:n=>!!n&&!n.destroyed,tween(target){const steps=[];const t={target,steps,stopped:false,to(duration,props,options){steps.push({duration,props,options});return t},parallel(...branches){steps.push({branches});return t},call(fn){steps.push({fn});return t},start(){tweens.push(t);return t},stop(){t.stopped=true;return t}};return t}};const exports={};vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/Script/ActivityPopupAnimator.ts','utf8'),{compilerOptions:{module:1,target:99}}).outputText,{exports,cc,WeakMap});const node=(x=20,y=40)=>({x,y,scaleX:.7,scaleY:.9,opacity:210,active:true});const frame=(t,i,ratio=1)=>{const s=t.steps[i];if(s.props){for(const [k,v]of Object.entries(s.props))t.target[k]+=(v-t.target[k])*ratio;s.options.onUpdate() }else if(s.branches){s.branches.forEach(finish)}else s.fn()};const finish=t=>{t.steps.forEach((s,i)=>frame(t,i))};return{cc,A:exports.default,T:exports.ActivityPopupAnimationType,node,tweens,frame,finish}} test('fade preserves adapted nonuniform scale, opacity and caller-owned visibility',()=>{const f=fixture(),n=f.node();f.A.open(n,f.T.FadeScale);assert.equal(n.opacity,0);assert.equal(n.scaleX,.7*.8);assert.equal(n.x,20);f.finish(f.tweens[0]);assert.equal(n.scaleY,.9);assert.equal(n.opacity,210);let done=0;f.A.close(n,f.T.FadeScale,()=>done++);assert.equal(f.tweens[1].steps[0].duration,.25);f.finish(f.tweens[1]);assert.equal(n.active,true);assert.equal(n.opacity,0);assert.equal(done,1);f.A.open(n,f.T.FadeScale);f.finish(f.tweens[2]);assert.equal(n.scaleX,.7);assert.equal(n.opacity,210)}); test('bounce preserves tuned scale parameters and shares fade opacity duration and easing',()=>{ const f=fixture(),n=f.node();f.A.open(n);const t=f.tweens[0], [scale,alpha]=t.steps[0].branches; @@ -28,3 +28,77 @@ test('interrupted entrance and exit continue from current pose and discard old c test('duplicate closes do not restart or dispatch twice; cancel invalidates queued callbacks',()=>{const f=fixture(),n=f.node();let closed=0;f.A.close(n,undefined,()=>closed++);f.A.close(n,undefined,()=>closed++);assert.equal(f.tweens.length,1);f.A.cancel(n);f.finish(f.tweens[0]);assert.equal(closed,0);assert.equal(n.scaleX,.7);assert.equal(n.active,true)}); test('explicit sibling group scales positions together and never touches excluded mask',()=>{const f=fixture(),a=f.node(-30,60),b=f.node(40,-80),mask=f.node();const before={...mask};f.A.open([a,b]);assert.equal(a.x,-30/2);assert.ok(Math.abs(b.y+80/2)<1e-12);f.finish(f.tweens[0]);assert.equal(a.x,-30);assert.equal(b.scaleY,.9);assert.deepEqual(mask,before);f.A.close([a,b]);f.finish(f.tweens[1]);f.A.open([a,b]);f.finish(f.tweens[2]);assert.equal(b.x,40)}); test('layout cancellation refreshes baseline; destroyed content cannot fire completion',()=>{const f=fixture(),n=f.node();f.A.open(n);f.A.cancel(n);n.scaleX=.45;n.scaleY=.5;f.A.open(n);f.finish(f.tweens[1]);assert.equal(n.scaleX,.45);let done=false;f.A.close(n,undefined,()=>done=true);n.destroyed=true;f.finish(f.tweens[2]);assert.equal(done,false)}); + +for (const type of [0, 1]) test(`prepared entrance type=${type} hides immediately and captures the final layout`, () => { + const f=fixture(), a=f.node(), b=f.node(); b.opacity=128; + f.A.prepareOpen([a,b]); + assert.deepEqual([a.opacity,b.opacity],[0,0]); assert.equal(f.tweens.length,0); + a.x=80; a.scaleX=.45; b.scaleY=.6; + f.A.prepareOpen([a,b]); // onEnable followed by init, before the scheduled callback + assert.deepEqual([a.opacity,b.opacity],[0,0]); assert.equal(a.x,80); + f.A.openPrepared([a,b],type); f.finish(f.tweens[0]); + assert.deepEqual([a.opacity,b.opacity],[210,128]); + assert.equal(a.x,80); assert.equal(a.scaleX,.45); assert.equal(b.scaleY,.6); + f.A.openPrepared([a,b],type); assert.equal(f.tweens.length,1,'stale entrance is consumed'); +}); + +test('cancel before entrance restores opacity without undoing layout and permits reopening', () => { + const f=fixture(), n=f.node(); f.A.prepareOpen(n); n.x=90; n.scaleY=.3; + f.A.cancel(n); assert.equal(n.opacity,210); assert.equal(n.x,90); assert.equal(n.scaleY,.3); + f.A.openPrepared(n); assert.equal(f.tweens.length,0); + f.A.prepareOpen(n); f.A.openPrepared(n); f.finish(f.tweens[0]); + assert.equal(n.opacity,210); assert.equal(n.scaleY,.3); +}); + +test('close before entrance stays hidden and invalidates the scheduled open', () => { + const f=fixture(), n=f.node(); let closed=0; + f.A.prepareOpen(n); f.A.close(n,f.T.FadeScale,()=>closed++); + assert.equal(n.opacity,0); f.A.openPrepared(n); assert.equal(f.tweens.length,1); + f.finish(f.tweens[0]); assert.equal(closed,1); assert.equal(n.opacity,0); + f.A.prepareOpen(n); assert.equal(n.opacity,0); f.A.openPrepared(n); + f.finish(f.tweens[1]); assert.equal(n.opacity,210); assert.equal(n.scaleX,.7); +}); + +test('preparing during a tween cancels its completion and preserves the original opacity', () => { + const f=fixture(), n=f.node(); let completed=0; + f.A.open(n,f.T.FadeScale,()=>completed++); f.frame(f.tweens[0],0,.5); + f.A.prepareOpen(n); assert.equal(n.opacity,0); assert.equal(f.tweens[0].stopped,true); + f.finish(f.tweens[0]); assert.equal(completed,0); assert.equal(n.opacity,0); + f.A.openPrepared(n); f.finish(f.tweens[1]); assert.equal(n.opacity,210); +}); + +test('destroyed prepared content cannot start or finish an entrance', () => { + const f=fixture(), n=f.node(); f.A.prepareOpen(n); n.destroyed=true; + f.A.openPrepared(n, f.T.FadeScale,()=>assert.fail('destroyed entrance')); + f.A.cancel(n); assert.equal(f.tweens.length,0); +}); + +const deferredPopups = [ + 'assets/Script/Avatar.ts', 'assets/Script/monthlyCard.ts', 'assets/Script/RedeemCode.ts', + 'assets/Script/StaminaPack.ts', 'assets/action_bundle/script/DailyQuests.ts', + 'assets/action_bundle/script/shareFriend.ts', 'assets/action_bundle/script/WinStreak.ts', +]; +for (const file of deferredPopups) test(`${file}: first rendered frame is hidden, disable cancels, reopen restores`, () => { + const f=fixture(), source=ts.createSourceFile(file,fs.readFileSync(file,'utf8'),99,true); + const cls=source.statements.find(ts.isClassDeclaration); + const methods=['onEnable','playPopupEntrance','onDisable'].map(name=>cls.members.find(m=>m.name?.getText(source)===name).getText(source)).join('\n'); + const Subject=vm.runInNewContext(ts.transpileModule(`class Subject { ${methods} }; Subject;`,{compilerOptions:{target:99}}).outputText, + {cc:f.cc,PopupAnimator:f.A,popupContent:(root,excluded)=>root.children.filter(n=>!excluded.includes(n.name))}); + const subject=new Subject(), mask={...f.node(),name:file.includes('monthlyCard')||file.includes('DailyQuests')||file.includes('WinStreak')?'ditu':'New Sprite(Splash)'}, card={...f.node(),name:'body'}; + subject.node={activeInHierarchy:true,children:[mask,card],getChildByName:name=>subject.node.children.find(n=>n.name===name)}; + const scheduled=new Map(); + subject.unschedule=fn=>scheduled.delete(fn); + subject.scheduleOnce=fn=>scheduled.set(fn,{initialized:false}); + // Cocos CallbackTimer.update initializes elapsed on its first tick, then fires on the next. + const tick=()=>{for(const [fn,state] of scheduled){if(!state.initialized)state.initialized=true;else{scheduled.delete(fn);fn.call(subject);}}}; + for(const name of ['clearDailyTaskProgressAnimation','clearDailyTaskStateAnimations','clearRewardAnimation','stopWeeklyChestAnimations'])subject[name]=()=>{}; + subject.onEnable(); subject.onEnable(); assert.equal(card.opacity,0); assert.equal(mask.opacity,210); + tick(); assert.equal(card.opacity,0,'first render before timer fires'); assert.equal(f.tweens.length,0); + card.scaleX=.5; tick(); f.finish(f.tweens[0]); assert.equal(card.opacity,210); assert.equal(card.scaleX,.5); + subject.node.activeInHierarchy=false; subject.onDisable(); + subject.node.activeInHierarchy=true; subject.onEnable(); assert.equal(card.opacity,0); + subject.node.activeInHierarchy=false; subject.onDisable(); tick(); tick(); + assert.equal(f.tweens.length,1); assert.equal(card.opacity,210); + subject.node.activeInHierarchy=true; subject.onEnable(); tick(); tick(); f.finish(f.tweens[1]); + assert.equal(card.opacity,210); assert.equal(mask.opacity,210); +});