Files
MatchMaster/assets/Script/ActivityPopupAnimator.ts
T
2026-10-23 10:47:20 +08:00

112 lines
5.2 KiB
TypeScript

export enum ActivityPopupAnimationType {
FadeScale = 0,
Bounce = 1,
}
/** Parameters are shared by every caller. Scale is relative to the laid-out content. */
export const POPUP_ANIMATIONS = {
fade: { duration: 0.25, start: 0.8, enter: "sineOut", exit: "sineIn" },
bounce: {
duration: 0.1, start: 1 / 2, overshoot: 16 / 15, recoil: 1,
enter: ["backOut", "sineOut", "sineOut"], exit: ["sineIn", "sineIn", "backIn"],
},
};
type Content = cc.Node | cc.Node[];
interface Pose { node: cc.Node; x: number; y: number; scaleX: number; scaleY: number; opacity: number; }
interface Motion {
poses: Pose[];
value: { scale: number; alpha: number };
tween: cc.Tween;
direction: "open" | "close" | "closed";
grouped: boolean;
}
/** Content animation only. Callers own visibility, masks, input and business callbacks.
* Sibling arrays behave as one content group around their parent's origin without
* reparenting nodes (which would break existing prefab paths and event bindings).
*/
export default class ActivityPopupAnimator {
private static motions = new WeakMap<cc.Node, Motion>();
static open(content: Content, type = ActivityPopupAnimationType.Bounce, done?: () => void): void {
this.play(content, type, false, done);
}
static close(content: Content, type = ActivityPopupAnimationType.Bounce, done?: () => void): void {
this.play(content, type, true, done);
}
/** Call before relayout or disposing a view; never stops the view's own effects. */
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);
}
private static nodes(content: Content): cc.Node[] {
return (Array.isArray(content) ? content : [content]).filter(node => node && cc.isValid(node, true));
}
private static apply(motion: Motion, scale: number, alpha: number): void {
motion.poses.forEach(pose => {
if (!cc.isValid(pose.node, true)) return;
const node = pose.node;
node.scaleX = pose.scaleX * scale; node.scaleY = pose.scaleY * scale;
node.opacity = pose.opacity * alpha;
if (motion.grouped) { node.x = pose.x * scale; node.y = pose.y * scale; }
});
}
private static play(content: Content, type: ActivityPopupAnimationType, closing: boolean, done?: () => void): void {
const nodes = this.nodes(content), key = nodes[0];
if (!key) return;
let motion = this.motions.get(key);
if (closing && motion && motion.direction === "close") return;
const interrupted = !!(motion && motion.tween);
if (motion && motion.tween) motion.tween.stop();
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,
direction: closing ? "close" : "open", grouped: Array.isArray(content),
};
}
motion.direction = closing ? "close" : "open";
this.motions.set(key, motion);
const fade = type === ActivityPopupAnimationType.FadeScale;
const config = POPUP_ANIMATIONS;
if (!closing && !interrupted) {
motion.value.scale = fade ? config.fade.start : config.bounce.start;
motion.value.alpha = 0;
}
this.apply(motion, motion.value.scale, motion.value.alpha);
const current = motion;
let tween = cc.tween(motion.value);
const onUpdate = () => {
if (this.motions.get(key) === current && current.tween === tween) this.apply(current, current.value.scale, current.value.alpha);
};
const opacity = { alpha: closing ? 0 : 1 };
const opacityOptions = { easing: closing ? config.fade.exit : config.fade.enter, onUpdate };
if (fade) {
tween.to(config.fade.duration, { scale: closing ? config.fade.start : 1, ...opacity }, opacityOptions);
} else {
const b = config.bounce;
const scales = closing ? [b.recoil, b.overshoot, b.start] : [b.overshoot, b.recoil, 1];
const easings = closing ? b.exit : b.enter;
const scaleTween = cc.tween(motion.value);
scales.forEach((scale, index) => scaleTween.to(b.duration, { scale }, { easing: easings[index], onUpdate }));
tween.parallel(scaleTween, cc.tween(motion.value).to(config.fade.duration, opacity, opacityOptions));
}
tween.call(() => {
if (this.motions.get(key) !== current || current.tween !== tween || !cc.isValid(key, true)) return;
this.apply(current, current.value.scale, current.value.alpha);
current.tween = null;
if (closing) current.direction = "closed";
else this.motions.delete(key);
if (done) done();
});
motion.tween = tween;
tween.start();
}
}