diff --git a/assets/Script/CloudRiseView.ts b/assets/Script/CloudRiseView.ts index a2425383..d885a1f1 100644 --- a/assets/Script/CloudRiseView.ts +++ b/assets/Script/CloudRiseView.ts @@ -247,6 +247,8 @@ export default class CloudRiseView extends cc.Component { if (!item && !CloudRiseService.unseenResult()) return; } this.overlay = cc.instantiate(prefab); + // Keep both drawing and input disabled while page fragments are loading. + this.overlay.active = false; this.host.addChild(this.overlay); this.overlay.zIndex = 1001; this.panel = this.overlay.getComponent("CloudRisePanel"); } @@ -323,6 +325,7 @@ export default class CloudRiseView extends cc.Component { if (!cc.isValid(this.node, true) || !canOpen()) return; if (locked()) return; this.overlay = cc.instantiate(prefab); + this.overlay.active = false; this.host.addChild(this.overlay); this.overlay.zIndex = 1001; this.panel = this.overlay.getComponent("CloudRisePanel"); await this.panel.preparePage(pageMode); diff --git a/assets/cloud_rise_home/scripts/CloudRisePanel.ts b/assets/cloud_rise_home/scripts/CloudRisePanel.ts index 0de961c6..75af25e1 100644 --- a/assets/cloud_rise_home/scripts/CloudRisePanel.ts +++ b/assets/cloud_rise_home/scripts/CloudRisePanel.ts @@ -15,10 +15,18 @@ const PROGRESS_AVATAR_SCALE = 0.9; // 1.2 times the original stage starting stack's visible bounds (341.07 x 201.33). const AVATAR_AREA_WIDTH = 409.28, AVATAR_AREA_HEIGHT = 241.60; const STARTING_STACK_SPREAD = 1.45024; +declare const wx: any; -/** Layout and styles are authored in prefab/CloudRise. This component binds data and actions only. */ +/** Prefabs own the visual layout; runtime positioning only adjusts close for the native capsule. */ @ccclass export default class CloudRisePanel extends cc.Component { + private static capsuleCache: { width: number; height: number; bottom: number } = null; + private closeOverviewY = 600; + private closePageY = 920; + private safeLayoutListening = false; + private safeLayoutPending = false; + private safeLayoutRetries = 0; + private openAfterLayout = false; @property(cc.Node) failureMessage: cc.Node = null; @property(cc.Node) expiredMessage: cc.Node = null; @property([cc.Node]) progressStatsContents: cc.Node[] = []; @@ -181,7 +189,10 @@ export default class CloudRisePanel extends cc.Component { this.cloudFloatTime = 0; } - onDisable(): void { this.resetCloudFloat(); this.stopVictorySparkles(); this.stopOverviewSparkles(); this.hideRewardHint(); this.showGuide(false); } + onDisable(): void { + if (this.safeLayoutPending) return; // A hidden geometry pass must preserve the current page state. + this.resetCloudFloat(); this.stopVictorySparkles(); this.stopOverviewSparkles(); this.hideRewardHint(); this.showGuide(false); + } private onExpired: () => void = null; initialize(mode: string, _inGame: boolean, close: () => void, _play: () => void, onExpired?: () => void): void { @@ -201,7 +212,82 @@ export default class CloudRisePanel extends cc.Component { if (!this.busy) this.render(); }); this.schedule(this.tick, 1); this.render(); this.warmMatching(); - if (this.mode === "overview") this.openPopup(); + if (typeof wx !== "undefined") { + this.openAfterLayout = this.mode === "overview"; + this.safeLayoutListening = true; + cc.view.on("canvas-resize", this.queueSafeLayout, this); + cc.game.on(cc.game.EVENT_SHOW, this.queueSafeLayout, this); + this.queueSafeLayout(); + } else { + this.node.active = true; + if (this.mode === "overview") this.openPopup(); + } + } + + /** Inactive roots neither draw nor receive input, including during the bounded retry. */ + private queueSafeLayout(): void { + if (this.safeLayoutPending) return; + this.safeLayoutPending = true; this.safeLayoutRetries = 0; + this.node.active = false; + cc.director.once(cc.Director.EVENT_BEFORE_DRAW, this.finishSafeLayout, this); + } + private finishSafeLayout(): void { + if (!cc.isValid(this.node, true)) return; + let info: any = {}, rect: any = null; + try { info = (wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()) || {}; } catch (_) { } + try { rect = wx.getMenuButtonBoundingClientRect && wx.getMenuButtonBoundingClientRect(); } catch (_) { } + const frame = cc.view.getFrameSize(); + const width = Number(info.windowWidth) || frame.width, height = Number(info.windowHeight) || frame.height; + const valid = rect && [rect.left, rect.right, rect.top, rect.bottom].every(Number.isFinite) + && rect.left >= 0 && rect.right > rect.left && rect.right <= width + && rect.top >= 0 && rect.bottom > rect.top && rect.bottom < height; + if (valid) CloudRisePanel.capsuleCache = { width, height, bottom: rect.bottom }; + const cache = CloudRisePanel.capsuleCache; + const cached = cache && cache.width === width && cache.height === height; + if (!valid && !cached && this.safeLayoutRetries++ < 1) { + cc.director.once(cc.Director.EVENT_BEFORE_DRAW, this.finishSafeLayout, this); + return; + } + // Only a hidden layout pass reads platform geometry. Data refreshes reuse these positions. + const bottom = valid ? rect.bottom : cached ? cache.bottom + : Math.max(Number(info.safeArea && info.safeArea.top) || 0, Number(info.statusBarHeight) || 0, 44) + 48; + cc.Tween.stopAllByTarget(this.design); this.popupAnimating = false; + const size = cc.view.getVisibleSize(); + this.node.setContentSize(size); this.background.setContentSize(size); + this.popupScale = Math.min(size.width / this.design.width, size.height / this.design.height); + this.design.scale = this.popupScale; + // Flush authored alignment before measuring, including ancestors outside the activity. + const ancestors: cc.Node[] = []; + for (let node = this.node; node; node = node.parent) ancestors.unshift(node); + ancestors.forEach(node => { const widget = node.getComponent(cc.Widget); if (widget && widget.enabled) widget.updateAlignment(); }); + this.node.getComponentsInChildren(cc.Widget).forEach(widget => { if (widget.enabled) widget.updateAlignment(); }); + const viewport = cc.view.getViewportRect(); + // Convert logical window pixels to engine screen coordinates, accounting for retina and viewport offsets. + const safeScreenY = (cc.game.canvas.height * (1 - (bottom + 10) / height) - viewport.y) / cc.view.getScaleY(); + this.closePageY = this.safeCloseY(920, safeScreenY, 1); + // backOut overshoots the 8/7 target: the actual maximum is < 1.25 of the final scale. + this.closeOverviewY = this.safeCloseY(600, safeScreenY, 1.25); + this.design.scale = this.popupScale; + this.applyClosePosition(); + this.safeLayoutPending = false; + this.node.active = true; + if (this.openAfterLayout && this.mode === "overview") this.openPopup(); + this.openAfterLayout = false; + } + private safeCloseY(authoredY: number, screenY: number, animationScale: number): number { + const button = this.buttons[0]; + this.design.scale = this.popupScale * animationScale; + const camera = cc.Camera.findCamera(button); + const world = button.parent.convertToWorldSpaceAR(cc.v2(button.x, authoredY)); + const screen = camera ? camera.getWorldToScreenPoint(world) : world; + screen.y = screenY; + const limit = button.parent.convertToNodeSpaceAR(camera ? camera.getScreenToWorldPoint(screen) : screen); + const control = button.getComponent(cc.Button); + const top = button.height * (1 - button.anchorY) * Math.abs(button.scaleY) * Math.max(1, control ? control.zoomScale : 1); + return Math.min(authoredY, limit.y - top); + } + private applyClosePosition(): void { + if (this.buttons[0]) this.buttons[0].y = this.mode === "overview" ? this.closeOverviewY : this.closePageY; } acknowledge(): void { if (this.mode === "result") Service.acknowledgeResult(); } private canContinue(): boolean { @@ -598,7 +684,7 @@ export default class CloudRisePanel extends cc.Component { this.buttons[1].active = this.mode === "overview"; if (this.buttons[2]) this.buttons[2].active = false; if (this.buttons[4]) this.buttons[4].active = this.mode === "participants"; - this.buttons[0].y = this.mode === "overview" ? 600 : 920; + this.applyClosePosition(); this.errorLabel.string = Service.error; this.errorLabel.node.active = !!Service.error && !this.busy; this.buttons[5].active = this.errorLabel.node.active; @@ -934,7 +1020,7 @@ export default class CloudRisePanel extends cc.Component { private updateEliminationClose(): void { if (!this.buttons[0]) return; this.buttons[0].active = this.eliminationSkippable; - this.buttons[0].y = 920; + this.buttons[0].y = this.closePageY; } async playElimination(item: any): Promise { this.busy = true; @@ -1051,6 +1137,11 @@ export default class CloudRisePanel extends cc.Component { } } onDestroy(): void { + if (this.safeLayoutListening) { + cc.view.off("canvas-resize", this.queueSafeLayout, this); + cc.game.off(cc.game.EVENT_SHOW, this.queueSafeLayout, this); + cc.director.off(cc.Director.EVENT_BEFORE_DRAW, this.finishSafeLayout, this); + } this.stopOverviewSparkles(); if (this.design) cc.Tween.stopAllByTarget(this.design); if (this.background) cc.Tween.stopAllByTarget(this.background); diff --git a/tools/test-cloud-rise-safe-layout.cjs b/tools/test-cloud-rise-safe-layout.cjs new file mode 100644 index 00000000..77f5ae16 --- /dev/null +++ b/tools/test-cloud-rise-safe-layout.cjs @@ -0,0 +1,161 @@ +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'); +const source = ts.transpileModule(fs.readFileSync('assets/cloud_rise_home/scripts/CloudRisePanel.ts', 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017, experimentalDecorators: true } +}).outputText; + +function fixture(options = {}) { + const state = { width: 393, height: 852, bottom: 92, dpr: 3, viewportY: 0, cameraY: 0, ...options }; + function emitter() { + const listeners = []; + return { + on(event, fn, target) { listeners.push({ event, fn, target }); }, + once(event, fn, target) { listeners.push({ event, fn, target, once: true }); }, + off(event, fn, target) { for (let i = listeners.length - 1; i >= 0; i--) if (listeners[i].event === event && listeners[i].fn === fn && listeners[i].target === target) listeners.splice(i, 1); }, + emit(event) { for (const l of listeners.slice()) if (l.event === event) { if (l.once) this.off(event, l.fn, l.target); l.fn.call(l.target); } }, + count: () => listeners.length + }; + } + class Node { + constructor(parent = null) { this.parent = parent; this.children = []; if (parent) parent.children.push(this); this.x = this.y = 0; this.scale = 1; this.active = true; this.width = 1080; this.height = 2200; this.anchorY = 0.5; } + get scaleY() { return this.scale; } + setContentSize(size) { this.width = size.width; this.height = size.height; } + convertToWorldSpaceAR(p) { const q = { x: this.x + p.x * this.scale, y: this.y + p.y * this.scale }; return this.parent ? this.parent.convertToWorldSpaceAR(q) : q; } + convertToNodeSpaceAR(p) { const q = this.parent ? this.parent.convertToNodeSpaceAR(p) : p; return { x: (q.x - this.x) / this.scale, y: (q.y - this.y) / this.scale }; } + getComponent(type) { return type === cc.Button ? { zoomScale: 1.03 } : null; } + getComponentsInChildren() { return []; } + on() {} off() {} + } + const director = emitter(), game = Object.assign(emitter(), { EVENT_SHOW: 'show', canvas: {} }); + const engineScale = () => state.width * state.dpr / 1080; + const view = Object.assign(emitter(), { + getVisibleSize: () => ({ width: 1080, height: state.height * 1080 / state.width }), + getFrameSize: () => ({ width: state.width, height: state.height }), + getViewportRect: () => ({ x: 0, y: state.viewportY }), getScaleY: engineScale + }); + Object.defineProperty(game.canvas, 'height', { get: () => state.height * state.dpr }); + const camera = { getWorldToScreenPoint: p => ({ x: p.x, y: p.y - state.cameraY }), getScreenToWorldPoint: p => ({ x: p.x, y: p.y + state.cameraY }) }; + const cc = { _decorator: { ccclass: c => c, property: () => () => {} }, Component: class { schedule() {} }, Node, + Label: class {}, Sprite: class {}, SpriteFrame: class {}, String, Widget: class {}, Button: class {}, + Camera: { findCamera: () => camera }, v2: (x, y) => ({ x, y }), view, game, director, + Director: { EVENT_BEFORE_DRAW: 'draw' }, Tween: { stopAllByTarget() {} }, isValid: n => !!n && !n.destroyed }; + let reads = 0; + const wx = { + getWindowInfo: () => ({ windowWidth: state.width, windowHeight: state.height, safeArea: { top: 59 } }), + getMenuButtonBoundingClientRect() { reads++; if (state.error) throw Error('not ready'); return state.bottom ? { left: state.width - 100, right: state.width - 8, top: state.bottom - 32, bottom: state.bottom } : {}; } + }; + const exports = {}; + vm.runInNewContext(source, { exports, cc, wx, console, require: () => ({ default: { subscribe: () => () => {} } }) }); + const parent = new Node(); parent.x = 540; parent.y = view.getVisibleSize().height / 2; + const panel = new exports.default(); panel.node = new Node(parent); panel.node.active = false; + panel.background = new Node(panel.node); panel.design = new Node(panel.node); + const button = new Node(panel.design); button.x = 395; button.y = 920; button.width = 103; button.height = 112; + panel.buttons = [button]; + panel.bindButtons = () => {}; panel.warmMatching = () => {}; + panel.render = () => panel.applyClosePosition(); + let openings = 0; + panel.openPopup = () => { openings++; assert(panel.node.active); }; + function top(zoom = 1.03) { + const world = button.convertToWorldSpaceAR({ x: 0, y: button.height * (1 - button.anchorY) * zoom }); + return state.height - (camera.getWorldToScreenPoint(world).y * engineScale() + state.viewportY) / state.dpr; + } + return { panel, button, state, cc, wx, parent, reads: () => reads, openings: () => openings, top, + draw: () => director.emit('draw'), init: (mode = 'progress') => panel.initialize(mode, false, () => {}, () => {}) }; +} + +for (const device of [{ width: 393, height: 852, bottom: 92, dpr: 3 }, { width: 400, height: 890, bottom: 88, dpr: 2.75 }, { width: 375, height: 667, bottom: 64, dpr: 2 }]) { + test(`first visible frame clears capsule and pressed bounds on ${device.width}x${device.height}`, () => { + const f = fixture(device); f.init(); assert.equal(f.panel.node.active, false); + f.draw(); assert.equal(f.panel.node.active, true); + assert(f.top() >= device.bottom + 10 - 1e-8, `top=${f.top()}`); + const y = f.button.y, reads = f.reads(); f.state.bottom += 20; + for (let i = 0; i < 20; i++) { f.panel.render(); f.draw(); } + assert.equal(f.button.y, y); assert.equal(f.reads(), reads, 'ordinary refresh never repositions from late API data'); + }); +} + +test('viewport offset, camera translation, nested scale and noncentral anchor are converted before reveal', () => { + const f = fixture({ viewportY: -85, cameraY: 43 }); + f.parent.scale = 0.85; f.button.anchorY = 0.2; + f.init(); f.draw(); assert(f.top() >= 102 - 1e-8); +}); + +test('overview position remains safe for the complete backOut and pressed-button envelope', () => { + const f = fixture({ bottom: 220 }); f.init('overview'); + assert.equal(f.openings(), 0); f.draw(); assert.equal(f.openings(), 1); + const finalScale = f.panel.popupScale; + for (let i = 0; i <= 100; i++) { + const t = i / 100 - 1, backOut = t * t * (2.70158 * t + 1.70158) + 1; + f.panel.design.scale = finalScale * (1 / 7 + backOut); + assert(f.top() >= 230 - 1e-8); + } + f.panel.design.scale = finalScale; +}); + +test('invalid capsule retries while invisible, then succeeds without a visible correction', () => { + const f = fixture({ bottom: 0 }); f.init(); f.draw(); + assert.equal(f.panel.node.active, false); + f.state.bottom = 92; f.draw(); assert.equal(f.panel.node.active, true); assert(f.top() >= 102 - 1e-8); +}); + +test('persistent API errors reveal a stable conservative fallback after one retry', () => { + const f = fixture({ error: true }); f.init(); f.draw(); f.draw(); + assert.equal(f.panel.node.active, true); assert(f.top() >= 59 + 48 + 10 - 1e-8); + const y = f.button.y; f.state.error = false; f.panel.render(); f.draw(); assert.equal(f.button.y, y); +}); + +test('resume uses matching cache; resize invalidates it and relayouts hidden without accumulating offsets', () => { + const f = fixture(); f.init(); f.draw(); const y = f.button.y; + f.state.error = true; f.cc.game.emit('show'); assert.equal(f.panel.node.active, false); f.draw(); + assert.equal(f.panel.node.active, true); assert.equal(f.button.y, y); + f.state.height = 800; f.cc.view.emit('canvas-resize'); f.draw(); assert.equal(f.panel.node.active, false); + f.draw(); assert.equal(f.panel.node.active, true); assert(f.top() >= 117 - 1e-8); + f.state.error = false; f.state.height = 852; f.cc.view.emit('canvas-resize'); f.draw(); assert.equal(f.button.y, y); +}); + +test('settlement close and later page refreshes share the same safe position', () => { + const f = fixture(); f.init(); f.draw(); const y = f.button.y; + f.panel.mode = 'elimination'; f.panel.eliminationSkippable = true; f.panel.updateEliminationClose(); + assert.equal(f.button.y, y); assert.equal(f.button.active, true); + f.panel.mode = 'overview'; f.panel.render(); f.panel.mode = 'progress'; f.panel.render(); assert.equal(f.button.y, y); +}); + +test('destroying a hidden pending page removes draw, resize and resume callbacks', () => { + const f = fixture(); f.init(); f.panel.onDestroy(); + assert.equal(f.cc.director.count(), 0); assert.equal(f.cc.view.count(), 0); assert.equal(f.cc.game.count(), 0); + f.draw(); assert.equal(f.panel.node.active, false); +}); + +test('temporary hiding for relayout preserves guide and cloud animation state', () => { + const f = fixture(); f.init(); f.draw(); + f.panel.cloudFloatTime = 2; + let hidden = 0; f.panel.showGuide = () => hidden++; + f.cc.game.emit('show'); f.panel.onDisable(); + assert.equal(f.panel.cloudFloatTime, 2); assert.equal(hidden, 0); +}); + +test('opening the activity attaches an inactive root throughout asynchronous fragment loading', async () => { + let release, attached; + const panel = { + preparePage: () => new Promise(resolve => { release = resolve; }), + initialize() { assert.equal(attached.active, false); attached.initialized = true; } + }; + const service = { sync: async () => true, dismissExpiryPresentation() {}, visible: () => true, + lockedUntilLevel: () => 0, retainResources: () => () => {}, pagePrefab: async () => ({}), + unseenResult: () => false, run: () => ({ status: 'playing' }), stage: () => ({ stage: 1 }), pageFragment: async () => ({}) }; + const cc = { _decorator: { ccclass: c => c }, Component: class {}, isValid: () => true, + instantiate: () => ({ active: true, getComponent: () => panel }), warn() {} }; + const exports = {}; + vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/Script/CloudRiseView.ts', 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017, experimentalDecorators: true } + }).outputText, { exports, cc, console, require: name => name.endsWith('CloudRiseService') ? { default: service } + : { default: {}, MiniGameSdk: { API: { showToast(message) { assert.fail(message); } } } } }); + const view = new exports.default(); view.node = {}; view.host = { addChild(node) { assert.equal(node.active, false); attached = node; } }; + const task = view.open('progress'); + for (let i = 0; i < 10; i++) await Promise.resolve(); + assert(attached); assert.equal(attached.active, false); assert.equal(attached.initialized, undefined); + release(); await task; assert.equal(attached.initialized, true); +});