232 lines
10 KiB
TypeScript
232 lines
10 KiB
TypeScript
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<object, { cancel: () => 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;
|
|
}
|
|
}
|