You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
480 lines
14 KiB
480 lines
14 KiB
|
3 weeks ago
|
import { Node, sp, Tween, tween, UIOpacity, Vec3 } from 'cc';
|
||
|
|
import { popStaggerNode } from 'db://assets/mx/module/ui/popAnim';
|
||
|
|
|
||
|
|
export interface BattleSuccessNodes {
|
||
|
|
/** 背景 */
|
||
|
|
bg: Node | null;
|
||
|
|
/** 标题 */
|
||
|
|
title: Node | null;
|
||
|
|
/** 「获得奖励」标签 */
|
||
|
|
rewardLabel: Node | null;
|
||
|
|
/** 胜负提示文案 */
|
||
|
|
tipBg: Node | null;
|
||
|
|
/** 结算 Spine */
|
||
|
|
spine: Node | null;
|
||
|
|
/** 奖励滚动区 */
|
||
|
|
scroll: Node | null;
|
||
|
|
/** 按钮行 */
|
||
|
|
btnLayout: Node | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface NodePose {
|
||
|
|
x: number;
|
||
|
|
y: number;
|
||
|
|
z: number;
|
||
|
|
angle: number;
|
||
|
|
sx: number;
|
||
|
|
sy: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface BattleSuccessPlayOpts {
|
||
|
|
nodes: BattleSuccessNodes;
|
||
|
|
rewardItems: Node[];
|
||
|
|
buttons: Node[];
|
||
|
|
/** 双倍领取(广告)按钮:强入场 + 持续脉冲 */
|
||
|
|
highlightBtn?: Node | null;
|
||
|
|
onComplete?: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
const REWARD_GAP = 0.055;
|
||
|
|
const REWARD_DUR = 0.2;
|
||
|
|
const BTN_GAP = 0.07;
|
||
|
|
|
||
|
|
const _poseMap = new WeakMap<Node, NodePose>();
|
||
|
|
const _pulseMap = new WeakMap<Node, Tween<Node>>();
|
||
|
|
|
||
|
|
function trackLoop(node: Node, t: Tween<Node>): void {
|
||
|
|
const prev = _pulseMap.get(node);
|
||
|
|
if (prev) prev.stop();
|
||
|
|
_pulseMap.set(node, t);
|
||
|
|
t.start();
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureOpacity(node: Node): UIOpacity {
|
||
|
|
return node.getComponent(UIOpacity) || node.addComponent(UIOpacity);
|
||
|
|
}
|
||
|
|
|
||
|
|
function capturePose(node: Node): NodePose {
|
||
|
|
let pose = _poseMap.get(node);
|
||
|
|
if (!pose) {
|
||
|
|
const p = node.position;
|
||
|
|
const s = node.scale;
|
||
|
|
pose = { x: p.x, y: p.y, z: p.z, angle: node.angle, sx: s.x, sy: s.y };
|
||
|
|
_poseMap.set(node, pose);
|
||
|
|
}
|
||
|
|
return pose;
|
||
|
|
}
|
||
|
|
|
||
|
|
function stopNode(node: Node | null): void {
|
||
|
|
if (!node?.isValid) return;
|
||
|
|
Tween.stopAllByTarget(node);
|
||
|
|
const op = node.getComponent(UIOpacity);
|
||
|
|
if (op) Tween.stopAllByTarget(op);
|
||
|
|
const pulse = _pulseMap.get(node);
|
||
|
|
if (pulse) {
|
||
|
|
pulse.stop();
|
||
|
|
_pulseMap.delete(node);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function lerp(a: number, b: number, t: number): number {
|
||
|
|
return a + (b - a) * t;
|
||
|
|
}
|
||
|
|
|
||
|
|
function clamp01(t: number): number {
|
||
|
|
if (t <= 0) return 0;
|
||
|
|
if (t >= 1) return 1;
|
||
|
|
return t;
|
||
|
|
}
|
||
|
|
|
||
|
|
function easeOutExpo(t: number): number {
|
||
|
|
return t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);
|
||
|
|
}
|
||
|
|
|
||
|
|
function easeOutCubic(t: number): number {
|
||
|
|
return 1 - Math.pow(1 - t, 3);
|
||
|
|
}
|
||
|
|
|
||
|
|
function easeOutQuad(t: number): number {
|
||
|
|
return 1 - (1 - t) * (1 - t);
|
||
|
|
}
|
||
|
|
|
||
|
|
function easeOutBack(t: number, overshoot = 1.6): number {
|
||
|
|
const c = overshoot + 1;
|
||
|
|
return 1 + c * Math.pow(t - 1, 3) + overshoot * Math.pow(t - 1, 2);
|
||
|
|
}
|
||
|
|
|
||
|
|
function setFinalPose(node: Node, pose: NodePose): void {
|
||
|
|
node.setPosition(pose.x, pose.y, pose.z);
|
||
|
|
node.angle = pose.angle;
|
||
|
|
node.setScale(pose.sx, pose.sy, 1);
|
||
|
|
const op = node.getComponent(UIOpacity);
|
||
|
|
if (op) op.opacity = 255;
|
||
|
|
}
|
||
|
|
|
||
|
|
function hideNode(node: Node | null): void {
|
||
|
|
if (!node?.isValid) {
|
||
|
|
console.log('[BattleSuccess] hideNode 节点缺失');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const pose = capturePose(node);
|
||
|
|
stopNode(node);
|
||
|
|
node.setScale(0, 0, 1);
|
||
|
|
ensureOpacity(node).opacity = 0;
|
||
|
|
node.setPosition(pose.x, pose.y, pose.z);
|
||
|
|
node.angle = pose.angle;
|
||
|
|
}
|
||
|
|
|
||
|
|
function hideFadeUp(node: Node | null, lift = 36): void {
|
||
|
|
if (!node?.isValid) {
|
||
|
|
console.log('[BattleSuccess] hideFadeUp 节点缺失');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const pose = capturePose(node);
|
||
|
|
stopNode(node);
|
||
|
|
node.setScale(pose.sx, pose.sy, 1);
|
||
|
|
ensureOpacity(node).opacity = 0;
|
||
|
|
node.setPosition(pose.x, pose.y - lift, pose.z);
|
||
|
|
node.angle = pose.angle;
|
||
|
|
}
|
||
|
|
|
||
|
|
function hideButtons(btnLayout: Node | null): void {
|
||
|
|
if (!btnLayout?.isValid) {
|
||
|
|
console.log('[BattleSuccess] hideButtons 节点缺失');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const pose = capturePose(btnLayout);
|
||
|
|
stopNode(btnLayout);
|
||
|
|
btnLayout.setScale(pose.sx, pose.sy, 1);
|
||
|
|
ensureOpacity(btnLayout).opacity = 0;
|
||
|
|
for (const child of btnLayout.children) {
|
||
|
|
if (!child.active) continue;
|
||
|
|
capturePose(child);
|
||
|
|
child.setScale(0, 0, 1);
|
||
|
|
ensureOpacity(child).opacity = 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 上屏前:静态节点归零,等待庆祝序列 */
|
||
|
|
export function prepareBattleSuccessOpen(nodes: BattleSuccessNodes): void {
|
||
|
|
stopBattleSuccessOpen(nodes);
|
||
|
|
hideNode(nodes.bg);
|
||
|
|
hideNode(nodes.title);
|
||
|
|
hideFadeUp(nodes.tipBg, 28);
|
||
|
|
hideNode(nodes.rewardLabel);
|
||
|
|
hideNode(nodes.spine);
|
||
|
|
hideNode(nodes.scroll);
|
||
|
|
hideButtons(nodes.btnLayout);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function stopBattleSuccessOpen(nodes: BattleSuccessNodes): void {
|
||
|
|
for (const n of Object.values(nodes)) stopNode(n);
|
||
|
|
if (!nodes.btnLayout?.isValid) return;
|
||
|
|
const layoutPose = _poseMap.get(nodes.btnLayout);
|
||
|
|
if (layoutPose) setFinalPose(nodes.btnLayout, layoutPose);
|
||
|
|
for (const child of nodes.btnLayout.children) {
|
||
|
|
if (!child.active) continue;
|
||
|
|
const pose = _poseMap.get(child);
|
||
|
|
if (pose) setFinalPose(child, pose);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function playBgFlash(node: Node | null, delay: number): void {
|
||
|
|
if (!node?.isValid) {
|
||
|
|
console.log('[BattleSuccess] playBgFlash 节点缺失');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const pose = capturePose(node);
|
||
|
|
const op = ensureOpacity(node);
|
||
|
|
node.setScale(pose.sx * 1.28, pose.sy * 1.28, 1);
|
||
|
|
op.opacity = 0;
|
||
|
|
tween(node)
|
||
|
|
.delay(delay)
|
||
|
|
.to(0.24, {}, {
|
||
|
|
easing: 'linear',
|
||
|
|
onUpdate: (_t, r) => {
|
||
|
|
const t = easeOutCubic(r ?? 0);
|
||
|
|
const s = lerp(1.28, 1, t);
|
||
|
|
node.setScale(pose.sx * s, pose.sy * s, 1);
|
||
|
|
op.opacity = Math.floor(255 * easeOutQuad(r ?? 0));
|
||
|
|
},
|
||
|
|
})
|
||
|
|
.call(() => setFinalPose(node, pose))
|
||
|
|
.start();
|
||
|
|
}
|
||
|
|
|
||
|
|
function playSpineBurst(node: Node | null, delay: number): void {
|
||
|
|
if (!node?.isValid) {
|
||
|
|
console.log('[BattleSuccess] playSpineBurst 节点缺失');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const pose = capturePose(node);
|
||
|
|
const sk = node.getComponent(sp.Skeleton);
|
||
|
|
if (sk) {
|
||
|
|
sk.setAnimation(0, '胜利', true);
|
||
|
|
}
|
||
|
|
node.setScale(0.32, 0.32, 1);
|
||
|
|
ensureOpacity(node).opacity = 255;
|
||
|
|
tween(node)
|
||
|
|
.delay(delay)
|
||
|
|
.to(0.14, {}, {
|
||
|
|
easing: 'linear',
|
||
|
|
onUpdate: (_t, r) => {
|
||
|
|
const t = easeOutExpo(r ?? 0);
|
||
|
|
const s = lerp(0.32, 1.12, t);
|
||
|
|
node.setScale(pose.sx * s, pose.sy * s, 1);
|
||
|
|
},
|
||
|
|
})
|
||
|
|
.to(0.1, {}, {
|
||
|
|
easing: 'linear',
|
||
|
|
onUpdate: (_t, r) => {
|
||
|
|
const t = easeOutCubic(r ?? 0);
|
||
|
|
const s = lerp(1.12, 1, t);
|
||
|
|
node.setScale(pose.sx * s, pose.sy * s, 1);
|
||
|
|
},
|
||
|
|
})
|
||
|
|
.call(() => setFinalPose(node, pose))
|
||
|
|
.start();
|
||
|
|
}
|
||
|
|
|
||
|
|
function playTitleSlam(node: Node | null, delay: number): void {
|
||
|
|
if (!node?.isValid) {
|
||
|
|
console.log('[BattleSuccess] playTitleSlam 节点缺失');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const pose = capturePose(node);
|
||
|
|
const op = ensureOpacity(node);
|
||
|
|
node.setScale(0, 0, 1);
|
||
|
|
node.angle = pose.angle - 14;
|
||
|
|
op.opacity = 0;
|
||
|
|
node.setPosition(pose.x, pose.y + 72, pose.z);
|
||
|
|
|
||
|
|
tween(node)
|
||
|
|
.delay(delay)
|
||
|
|
.to(0.15, {}, {
|
||
|
|
easing: 'linear',
|
||
|
|
onUpdate: (_t, r) => {
|
||
|
|
const t = easeOutExpo(r ?? 0);
|
||
|
|
const s = lerp(0, 1.14, t);
|
||
|
|
node.setScale(pose.sx * s, pose.sy * s, 1);
|
||
|
|
node.angle = pose.angle + lerp(-14, 5, t);
|
||
|
|
op.opacity = Math.floor(255 * easeOutQuad(r ?? 0));
|
||
|
|
node.setPosition(pose.x, pose.y + 72 * (1 - easeOutCubic(r ?? 0)), pose.z);
|
||
|
|
},
|
||
|
|
})
|
||
|
|
.to(0.09, {}, {
|
||
|
|
easing: 'linear',
|
||
|
|
onUpdate: (_t, r) => {
|
||
|
|
const t = r ?? 0;
|
||
|
|
const s = lerp(1.14, 1, easeOutCubic(t));
|
||
|
|
node.setScale(pose.sx * s, pose.sy * s, 1);
|
||
|
|
node.angle = pose.angle + lerp(5, 0, t);
|
||
|
|
node.setPosition(pose.x, pose.y, pose.z);
|
||
|
|
},
|
||
|
|
})
|
||
|
|
.call(() => setFinalPose(node, pose))
|
||
|
|
.start();
|
||
|
|
}
|
||
|
|
|
||
|
|
function playFadeUpIn(node: Node | null, delay: number, lift = 28): void {
|
||
|
|
if (!node?.isValid) {
|
||
|
|
console.log('[BattleSuccess] playFadeUpIn 节点缺失');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const pose = capturePose(node);
|
||
|
|
const op = ensureOpacity(node);
|
||
|
|
node.setScale(pose.sx, pose.sy, 1);
|
||
|
|
op.opacity = 0;
|
||
|
|
node.setPosition(pose.x, pose.y - lift, pose.z);
|
||
|
|
|
||
|
|
tween(node)
|
||
|
|
.delay(delay)
|
||
|
|
.parallel(
|
||
|
|
tween(node).to(0.22, { position: new Vec3(pose.x, pose.y, pose.z) }, { easing: 'cubicOut' }),
|
||
|
|
tween(op).to(0.18, { opacity: 255 }),
|
||
|
|
)
|
||
|
|
.start();
|
||
|
|
}
|
||
|
|
|
||
|
|
function playCelebratePop(node: Node | null, delay: number): void {
|
||
|
|
if (!node?.isValid) {
|
||
|
|
console.log('[BattleSuccess] playCelebratePop 节点缺失');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const pose = capturePose(node);
|
||
|
|
stopNode(node);
|
||
|
|
node.setScale(0, 0, 1);
|
||
|
|
ensureOpacity(node).opacity = 255;
|
||
|
|
tween(node)
|
||
|
|
.delay(delay)
|
||
|
|
.to(0.2, {}, {
|
||
|
|
easing: 'linear',
|
||
|
|
onUpdate: (_t, r) => {
|
||
|
|
const s = Math.min(easeOutBack(r ?? 0, 2.0), 1.1);
|
||
|
|
node.setScale(pose.sx * s, pose.sy * s, 1);
|
||
|
|
},
|
||
|
|
})
|
||
|
|
.call(() => setFinalPose(node, pose))
|
||
|
|
.start();
|
||
|
|
}
|
||
|
|
|
||
|
|
function playScrollReveal(node: Node | null, delay: number): void {
|
||
|
|
if (!node?.isValid) {
|
||
|
|
console.log('[BattleSuccess] playScrollReveal 节点缺失');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const pose = capturePose(node);
|
||
|
|
stopNode(node);
|
||
|
|
node.setScale(pose.sx * 0.88, pose.sy * 0.88, 1);
|
||
|
|
ensureOpacity(node).opacity = 0;
|
||
|
|
tween(node)
|
||
|
|
.delay(delay)
|
||
|
|
.parallel(
|
||
|
|
tween(node).to(0.2, { scale: new Vec3(pose.sx, pose.sy, 1) }, { easing: 'backOut' }),
|
||
|
|
tween(ensureOpacity(node)).to(0.16, { opacity: 255 }),
|
||
|
|
)
|
||
|
|
.start();
|
||
|
|
}
|
||
|
|
|
||
|
|
function playSecondaryButtonEnter(node: Node, delay: number): void {
|
||
|
|
const pose = capturePose(node);
|
||
|
|
stopNode(node);
|
||
|
|
node.setScale(0, 0, 1);
|
||
|
|
ensureOpacity(node).opacity = 0;
|
||
|
|
tween(node)
|
||
|
|
.delay(delay)
|
||
|
|
.to(0.16, {}, {
|
||
|
|
easing: 'linear',
|
||
|
|
onUpdate: (_t, r) => {
|
||
|
|
const s = Math.min(easeOutBack(r ?? 0, 1.5), 1.05);
|
||
|
|
node.setScale(pose.sx * s, pose.sy * s, 1);
|
||
|
|
ensureOpacity(node).opacity = Math.floor(255 * clamp01((r ?? 0) * 1.4));
|
||
|
|
},
|
||
|
|
})
|
||
|
|
.call(() => {
|
||
|
|
setFinalPose(node, pose);
|
||
|
|
ensureOpacity(node).opacity = 255;
|
||
|
|
})
|
||
|
|
.start();
|
||
|
|
}
|
||
|
|
|
||
|
|
function playHighlightButtonEnter(node: Node, delay: number): void {
|
||
|
|
const pose = capturePose(node);
|
||
|
|
stopNode(node);
|
||
|
|
node.setScale(0, 0, 1);
|
||
|
|
ensureOpacity(node).opacity = 255;
|
||
|
|
|
||
|
|
tween(node)
|
||
|
|
.delay(delay)
|
||
|
|
.to(0.13, {}, {
|
||
|
|
easing: 'linear',
|
||
|
|
onUpdate: (_t, r) => {
|
||
|
|
const t = easeOutExpo(r ?? 0);
|
||
|
|
const s = lerp(0, 1.18, t);
|
||
|
|
node.setScale(pose.sx * s, pose.sy * s, 1);
|
||
|
|
},
|
||
|
|
})
|
||
|
|
.to(0.07, {}, {
|
||
|
|
easing: 'linear',
|
||
|
|
onUpdate: (_t, r) => {
|
||
|
|
const t = r ?? 0;
|
||
|
|
const s = lerp(1.18, 0.96, t);
|
||
|
|
node.setScale(pose.sx * s, pose.sy * s, 1);
|
||
|
|
},
|
||
|
|
})
|
||
|
|
.to(0.1, {}, {
|
||
|
|
easing: 'linear',
|
||
|
|
onUpdate: (_t, r) => {
|
||
|
|
const t = easeOutBack(r ?? 0, 2.0);
|
||
|
|
const s = lerp(0.96, 1, t);
|
||
|
|
node.setScale(pose.sx * s, pose.sy * s, 1);
|
||
|
|
},
|
||
|
|
})
|
||
|
|
.call(() => playHighlightButtonPulse(node))
|
||
|
|
.start();
|
||
|
|
}
|
||
|
|
|
||
|
|
function playHighlightButtonPulse(node: Node): void {
|
||
|
|
const pose = capturePose(node);
|
||
|
|
setFinalPose(node, pose);
|
||
|
|
ensureOpacity(node).opacity = 255;
|
||
|
|
trackLoop(node, tween(node)
|
||
|
|
.repeatForever(
|
||
|
|
tween(node)
|
||
|
|
.to(0.4, { scale: new Vec3(pose.sx * 1.12, pose.sy * 1.12, 1) }, { easing: 'sineInOut' })
|
||
|
|
.to(0.14, { scale: new Vec3(pose.sx * 1.15, pose.sy * 1.15, 1) }, { easing: 'sineOut' })
|
||
|
|
.to(0.4, { scale: new Vec3(pose.sx, pose.sy, 1) }, { easing: 'sineInOut' }),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
function playButtonsEnter(buttons: Node[], highlightBtn: Node | null, delay: number): void {
|
||
|
|
buttons.forEach((btn, i) => {
|
||
|
|
if (!btn?.isValid || !btn.active) return;
|
||
|
|
const childDelay = delay + i * BTN_GAP;
|
||
|
|
capturePose(btn);
|
||
|
|
btn.setScale(0, 0, 1);
|
||
|
|
if (btn === highlightBtn) {
|
||
|
|
playHighlightButtonEnter(btn, childDelay);
|
||
|
|
} else {
|
||
|
|
playSecondaryButtonEnter(btn, childDelay);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
export function stopHighlightButtonAnim(node: Node): void {
|
||
|
|
stopNode(node);
|
||
|
|
const pose = _poseMap.get(node);
|
||
|
|
if (pose) setFinalPose(node, pose);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 背景闪光 → 角色 Spine 爆发 → 标题砸落 → 提示上浮 → 奖励标签 → 道具庆祝错峰 → 按钮压轴
|
||
|
|
*/
|
||
|
|
export function playBattleSuccessOpen(opts: BattleSuccessPlayOpts): void {
|
||
|
|
const { nodes, rewardItems, buttons, highlightBtn = null, onComplete } = opts;
|
||
|
|
const n = nodes;
|
||
|
|
|
||
|
|
playBgFlash(n.bg, 0);
|
||
|
|
playSpineBurst(n.spine, 0.05);
|
||
|
|
playTitleSlam(n.title, 0.16);
|
||
|
|
|
||
|
|
const tipDelay = 0.42;
|
||
|
|
playFadeUpIn(n.tipBg, tipDelay, 24);
|
||
|
|
|
||
|
|
const labelDelay = 0.56;
|
||
|
|
playCelebratePop(n.rewardLabel, labelDelay);
|
||
|
|
|
||
|
|
const rewardStart = 0.68;
|
||
|
|
playScrollReveal(n.scroll, rewardStart - 0.04);
|
||
|
|
|
||
|
|
rewardItems.forEach((item, i) => {
|
||
|
|
if (!item?.isValid) return;
|
||
|
|
item.setScale(Vec3.ZERO);
|
||
|
|
popStaggerNode(item, 'celebrate', rewardStart + i * REWARD_GAP);
|
||
|
|
});
|
||
|
|
|
||
|
|
const btnStart = rewardStart + Math.max(rewardItems.length, 1) * REWARD_GAP + REWARD_DUR * 0.45;
|
||
|
|
if (n.btnLayout?.isValid) {
|
||
|
|
tween(ensureOpacity(n.btnLayout))
|
||
|
|
.delay(btnStart - 0.06)
|
||
|
|
.to(0.12, { opacity: 255 })
|
||
|
|
.start();
|
||
|
|
} else {
|
||
|
|
console.log('[BattleSuccess] btnLayout 节点缺失');
|
||
|
|
}
|
||
|
|
playButtonsEnter(buttons, highlightBtn, btnStart);
|
||
|
|
|
||
|
|
const highlightExtra = highlightBtn?.active ? 0.35 : 0;
|
||
|
|
const totalDur = btnStart + buttons.length * BTN_GAP + 0.24 + highlightExtra;
|
||
|
|
const delayHost = n.bg?.isValid ? n.bg : n.btnLayout;
|
||
|
|
if (delayHost?.isValid) {
|
||
|
|
tween(delayHost)
|
||
|
|
.delay(totalDur)
|
||
|
|
.call(() => onComplete?.())
|
||
|
|
.start();
|
||
|
|
} else {
|
||
|
|
console.log('[BattleSuccess] 无节点可挂接结算完成回调');
|
||
|
|
onComplete?.();
|
||
|
|
}
|
||
|
|
}
|