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(); const _pulseMap = new WeakMap>(); function trackLoop(node: Node, t: Tween): 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?.(); } }