Files
MatchMaster/assets/Script/SharedPopupBackdrop.ts
T

197 lines
8.8 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);
}
register(source: PopupBackdropSource): void {
if (this.sources.indexOf(source) < 0) this.sources.push(source);
this.refresh();
}
unregister(source: PopupBackdropSource): void {
this.sources = this.sources.filter(item => item !== source);
this.refresh();
}
hold(owner: object, cancel: () => void): void {
this.holds.set(owner, { cancel, waiting: true });
this.refresh();
}
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 (!this.message || 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)) return;
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;
SharedPopupBackdrop.paint(source.visual, this.node);
}
});
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; }
}
SharedPopupBackdrop.paint(this.bridge, this.node, this.bridgeOpacity);
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); }
onDestroy(): void {
this.setLoadingVisible(false);
this.disposed = true; 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;
}
}