161 lines
7.7 KiB
TypeScript
161 lines
7.7 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: {
|
|
// 如果觉得每段 0.1s 略慢,也可以设为 [0.12, 0.08, 0.06] 或统一 0.09
|
|
duration: 0.1,
|
|
start: 0.5, // 初始/结束缩放 (1/2)
|
|
overshoot: 16 / 15, // 约 1.067 (峰值放大)
|
|
recoil: 14 / 15, // 约 0.933 (核心修复:不能为 1,必须小于 1 才有第 3 段回弹!)
|
|
// 入场 1 -> 2 -> 3:冲到峰值(Out) -> 回落谷底(InOut) -> 归位到1(InOut)
|
|
enter: ["quadOut", "sineInOut", "sineInOut"],
|
|
// 退场 3 -> 2 -> 1(严格时间反演镜像):从1下蹲到谷底(InOut) -> 蓄力膨胀到峰值(InOut) -> 极速坍缩回0.5(In)
|
|
exit: ["sineInOut", "sineInOut", "quadIn"],
|
|
},
|
|
};
|
|
|
|
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>();
|
|
private static preparedOpacity = new WeakMap<cc.Node, number>();
|
|
|
|
/** Hide immediately; capture layout only when the deferred entrance starts. */
|
|
static prepareOpen(content: Content): void {
|
|
this.cancel(content);
|
|
this.nodes(content).forEach(node => {
|
|
this.preparedOpacity.set(node, node.opacity);
|
|
node.opacity = 0;
|
|
});
|
|
}
|
|
/** A close/cancel before the scheduled entrance must not reopen the popup. */
|
|
static openPrepared(content: Content, type = ActivityPopupAnimationType.Bounce, done?: () => void): void {
|
|
if (this.nodes(content).some(node => this.preparedOpacity.has(node))) this.open(content, type, done);
|
|
}
|
|
|
|
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) {
|
|
if (motion.tween) motion.tween.stop();
|
|
this.motions.delete(key);
|
|
if (restore) this.apply(motion, 1, 1);
|
|
}
|
|
nodes.forEach(node => {
|
|
if (!this.preparedOpacity.has(node)) return;
|
|
if (restore) node.opacity = this.preparedOpacity.get(node);
|
|
this.preparedOpacity.delete(node);
|
|
});
|
|
}
|
|
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;
|
|
const prepared = nodes.some(node => this.preparedOpacity.has(node));
|
|
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: this.preparedOpacity.has(node) ? this.preparedOpacity.get(node) : node.opacity
|
|
})),
|
|
value: { scale: 1, alpha: prepared ? 0 : 1 }, tween: null,
|
|
direction: closing ? "close" : "open", grouped: Array.isArray(content),
|
|
};
|
|
}
|
|
nodes.forEach(node => this.preparedOpacity.delete(node));
|
|
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;
|
|
// 入场:start(0.5) -> [1] overshoot(1.067) -> [2] recoil(0.933) -> [3] 1
|
|
// 退场:1 -> [3] recoil(0.933) -> [2] overshoot(1.067) -> [1] start(0.5)
|
|
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 });
|
|
});
|
|
|
|
// 透明度同样做成镜像对称:
|
|
// 入场:在前 1~2 段(2 * b.duration)内淡入完毕,最后回弹阶段完全清晰
|
|
// 退场:前 1 段保持清晰,在后 2 段(膨胀到峰值并缩回 0.5 的过程)中同步淡出
|
|
const fadeDuration = b.duration * 2;
|
|
const alphaTween = closing
|
|
? cc.tween(motion.value)
|
|
.delay(b.duration)
|
|
.to(fadeDuration, opacity, opacityOptions)
|
|
: cc.tween(motion.value)
|
|
.to(fadeDuration, opacity, opacityOptions);
|
|
|
|
tween.parallel(scaleTween, alphaTween);
|
|
}
|
|
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();
|
|
}
|
|
}
|