import LoadingCatAnimation from './LoadingCatAnimation'; const { ccclass } = cc._decorator; export interface PopupBackdropSource { node: cc.Node; visual: cc.Node; active: boolean; } // Match HomeScene/Canvas/Loading/New Sprite(Splash), including activity handoffs. export const POPUP_BACKDROP_OPACITY = 200; const FADE_SECONDS = 0.15; /** Scene-owned backdrop lifetime. Existing mask locations preserve nested draw order * and click handlers; the scene bridge covers the gap between asynchronous popups. */ @ccclass export default class SharedPopupBackdrop extends cc.Component { private sources: PopupBackdropSource[] = []; private holds = new Map void; waiting: boolean }>(); private bridge: cc.Node = null; private message: cc.Node = null; private cancelButton: cc.Node = null; private gapSeconds = 0; private fading = false; private bridgeOpacity = POPUP_BACKDROP_OPACITY; private wasVisible = false; private disposed = false; static get(canvas: cc.Node): SharedPopupBackdrop { return canvas.getComponent(SharedPopupBackdrop) || canvas.addComponent(SharedPopupBackdrop); } onEnable(): void { cc.director.on(cc.Director.EVENT_BEFORE_DRAW, this.paintBeforeDraw, this); } onDisable(): void { cc.director.off(cc.Director.EVENT_BEFORE_DRAW, this.paintBeforeDraw, this); } register(source: PopupBackdropSource): void { if (this.sources.indexOf(source) < 0) this.sources.push(source); this.refresh(); } unregister(source: PopupBackdropSource): void { // Child destruction can run after the scene bridge is gone. Repaint in lateUpdate. this.sources = this.sources.filter(item => item !== source); } hold(owner: object, cancel: () => void): void { this.holds.set(owner, { cancel, waiting: true }); this.refresh(); } /** Extend an existing popup's backdrop while its successor loads. */ holdIfVisible(owner: object, cancel: () => void): void { if (this.wasVisible && (!this.fading || this.bridgeOpacity === POPUP_BACKDROP_OPACITY)) { this.hold(owner, cancel); } } setWaiting(owner: object, waiting: boolean): void { const hold = this.holds.get(owner); if (hold) hold.waiting = waiting; } release(owner: object): void { this.holds.delete(owner); this.refresh(); } /** Compare actual hierarchy order, including sibling zIndex, not registration time. */ private path(node: cc.Node): cc.Node[] { const path: cc.Node[] = []; while (node && node !== this.node) { path.unshift(node); node = node.parent; } return node === this.node ? path : []; } private compare(a: cc.Node, b: cc.Node): number { const left = this.path(a), right = this.path(b); for (let i = 0; i < Math.min(left.length, right.length); i++) { if (left[i] !== right[i]) return left[i].zIndex - right[i].zIndex || left[i].getSiblingIndex() - right[i].getSiblingIndex(); } return left.length - right.length; } /** Draw in the source's local coordinates, covering the canvas even inside scaled panels. */ static paint(visual: cc.Node, canvas: cc.Node, opacity = POPUP_BACKDROP_OPACITY): void { const parent = visual.parent; const width = canvas.width, height = canvas.height; const corners = [[-canvas.anchorX * width, -canvas.anchorY * height], [(1 - canvas.anchorX) * width, -canvas.anchorY * height], [(1 - canvas.anchorX) * width, (1 - canvas.anchorY) * height], [-canvas.anchorX * width, (1 - canvas.anchorY) * height]]; const graphics = visual.getComponent(cc.Graphics); // Creator 2.4 WebGL Graphics writes fillColor directly to vertex colors; // node.opacity does not change that alpha. Keep it 255 for Canvas parity. visual.opacity = 255; graphics.clear(); graphics.fillColor = cc.color(0, 0, 0, Math.round(opacity)); corners.forEach(([x, y], index) => { const point = parent.convertToNodeSpaceAR(canvas.convertToWorldSpaceAR(cc.v2(x, y))); if (index) graphics.lineTo(point.x, point.y); else graphics.moveTo(point.x, point.y); }); graphics.close(); graphics.fill(); } private ensureBridge(): void { if (this.bridge) return; this.bridge = new cc.Node('SharedPopupBackdrop'); this.bridge.active = false; this.node.addChild(this.bridge, cc.macro.MAX_ZINDEX - 3); this.bridge.addComponent(cc.Graphics); this.bridge.addComponent(cc.BlockInputEvents); const label = (name: string, text: string, y: number): cc.Node => { const node = new cc.Node(name); this.bridge.addChild(node); node.y = y; const textLabel = node.addComponent(cc.Label); textLabel.string = text; textLabel.fontSize = 28; textLabel.lineHeight = 36; node.setContentSize(320, 70); return node; }; const template = this.node.getChildByName('Loading'); if (template) { this.message = new cc.Node('LoadingContent'); this.message.active = false; this.bridge.addChild(this.message); // Reuse the authored cat, blocks and label, excluding the full-screen mask. ['New Label', 'block1', 'block2', 'block3', 'block4', 'cat'].forEach(name => { const child = template.getChildByName(name); if (child) this.message.addChild(cc.instantiate(child)); }); } const loadingLabel = this.message && this.message.getChildByName('New Label'); this.cancelButton = label('CancelWaiting', '暂不等待', loadingLabel ? loadingLabel.y - 90 : -330); this.cancelButton.on(cc.Node.EventType.TOUCH_END, (event: cc.Event) => { event.stopPropagation(); // Never allow a stale click to cancel a popup that has already appeared. if (!this.bridge.active || this.gapSeconds < 10 || this.visibleSources().length) return; const callbacks = Array.from(this.holds.values()).filter(hold => hold.waiting && hold.cancel).map(hold => hold.cancel); callbacks.forEach(cancel => cancel()); }); } private setLoadingVisible(visible: boolean): void { if (!cc.isValid(this.message, true) || this.message.active === visible) return; if (visible) LoadingCatAnimation.play(this.message); else LoadingCatAnimation.stop(this.message); } private visibleSources(): PopupBackdropSource[] { return this.sources.filter(source => source.active && cc.isValid(source.node, true) && cc.isValid(source.visual, true) && source.node.activeInHierarchy && this.path(source.node).length > 0); } refresh(dt = 0): void { if (this.disposed || !cc.isValid(this.node, true) || !this.node.activeInHierarchy) return; if (this.bridge && !cc.isValid(this.bridge, true)) { this.bridge = null; this.message = null; this.cancelButton = null; } this.sources = this.sources.filter(source => cc.isValid(source.node, true) && cc.isValid(source.visual, true)); const visible = this.visibleSources().sort((a, b) => this.compare(a.node, b.node)); const top = visible[visible.length - 1]; this.sources.forEach(source => { source.visual.active = source === top; if (source === top) { source.node.opacity = 255; } }); if (top) { this.setLoadingVisible(false); if (this.bridge) this.bridge.active = false; this.gapSeconds = 0; this.fading = false; this.wasVisible = true; return; } const waiting = Array.from(this.holds.values()).some(hold => hold.waiting); // An unmanaged full-screen view may temporarily own presentation. if (this.holds.size && !waiting) { this.setLoadingVisible(false); if (this.bridge) this.bridge.active = false; this.gapSeconds = 0; return; } if (!waiting && !this.wasVisible) return; this.ensureBridge(); this.bridge.active = true; this.bridge.setContentSize(this.node.getContentSize()); if (waiting) { this.bridgeOpacity = POPUP_BACKDROP_OPACITY; this.gapSeconds += dt; this.fading = false; this.wasVisible = true; } else { if (!this.fading) { this.bridgeOpacity = POPUP_BACKDROP_OPACITY; this.fading = true; } this.bridgeOpacity = Math.max(0, this.bridgeOpacity - POPUP_BACKDROP_OPACITY * dt / FADE_SECONDS); this.gapSeconds = 0; if (!this.bridgeOpacity) { this.bridge.active = false; this.wasVisible = false; this.fading = false; } } this.setLoadingVisible(waiting && this.gapSeconds >= 0.8); this.cancelButton.active = waiting && this.gapSeconds >= 10 && Array.from(this.holds.values()).every(hold => !!hold.cancel); } lateUpdate(dt: number): void { this.refresh(dt); } private paintBeforeDraw(): void { if (this.disposed || !cc.isValid(this.node, true) || !this.node.activeInHierarchy) return; // Widget aligns after lateUpdate. Use the final transforms for this rendered frame. // Refresh visibility without advancing fade or loading timers a second time. this.refresh(); this.visibleSources().forEach(source => { if (source.visual.active) SharedPopupBackdrop.paint(source.visual, this.node); }); if (this.bridge && cc.isValid(this.bridge, true) && this.bridge.active) { SharedPopupBackdrop.paint(this.bridge, this.node, this.bridgeOpacity); } } onDestroy(): void { this.onDisable(); this.disposed = true; this.setLoadingVisible(false); this.holds.clear(); this.sources.forEach(source => { if (cc.isValid(source.visual, true)) source.visual.active = false; }); this.sources = []; if (this.bridge && cc.isValid(this.bridge, true)) this.bridge.destroy(); this.bridge = null; this.message = null; this.cancelButton = null; } }