import { Label, Layout, Node, sp, Tween, tween, UIOpacity, Vec3 } from 'cc'; export interface BattleReviveNodes { bg: Node | null; title: Node | null; spine: Node | null; progressBg: Node | null; progressPrefix: Node | null; progressLabel: Node | null; progressSuffix: Node | null; rewardLabel: Node | null; rewardPanel: Node | null; tipText: Node | null; btnLayout: Node | null; } interface NodePose { x: number; y: number; z: number; angle: number; sx: number; sy: number; } export interface BattleRevivePlayOpts { nodes: BattleReviveNodes; progressPercent: number; reviveBtn: Node | null; onComplete?: () => void; } /** 按钮显隐变更后刷新 Layout,避免动效阶段错位 */ export function syncBtnLayout(btnLayout: Node): void { btnLayout?.getComponent(Layout)?.updateLayout(); } 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 | null): NodePose | null { if (!node?.isValid) return null; 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 hideNodeSafe(node: Node | null): void { if (!node?.isValid) return; const pose = capturePose(node); if (!pose) return; stopNode(node); node.setScale(0, 0, 1); ensureOpacity(node).opacity = 0; node.setPosition(pose.x, pose.y, pose.z); node.angle = pose.angle; } function hideFadeSafe(node: Node | null): void { if (!node?.isValid) return; const pose = capturePose(node); if (!pose) return; stopNode(node); node.setScale(pose.sx, pose.sy, 1); ensureOpacity(node).opacity = 0; } function hideSlideXSafe(node: Node | null, offsetX: number): void { if (!node?.isValid) return; const pose = capturePose(node); if (!pose) return; stopNode(node); node.setScale(pose.sx, pose.sy, 1); ensureOpacity(node).opacity = 0; node.setPosition(pose.x + offsetX, pose.y, pose.z); } function hideFadeUpSafe(node: Node | null, lift = 20): void { if (!node?.isValid) return; const pose = capturePose(node); if (!pose) return; stopNode(node); node.setScale(pose.sx, pose.sy, 1); ensureOpacity(node).opacity = 0; node.setPosition(pose.x, pose.y - lift, pose.z); } 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 easeOutCubic(t: number): number { return 1 - Math.pow(1 - t, 3); } function easeOutExpo(t: number): number { return t >= 1 ? 1 : 1 - Math.pow(2, -10 * t); } function easeOutBack(t: number, overshoot = 1.5): 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): void { hideNodeSafe(node); } function hideFade(node: Node): void { hideFadeSafe(node); } function hideSlideX(node: Node, offsetX: number): void { hideSlideXSafe(node, offsetX); } function hideFadeUp(node: Node, lift = 20): void { hideFadeUpSafe(node, lift); } function hideButtons(btnLayout: Node | null): void { if (!btnLayout?.isValid) return; stopNode(btnLayout); const pose = capturePose(btnLayout); if (!pose) return; btnLayout.setScale(pose.sx, pose.sy, 1); btnLayout.setPosition(pose.x, pose.y, pose.z); ensureOpacity(btnLayout).opacity = 255; for (const child of btnLayout.children) { if (!child.active) continue; capturePose(child); stopNode(child); child.setScale(0, 0, 1); ensureOpacity(child).opacity = 0; } } export function prepareBattleReviveOpen(nodes: BattleReviveNodes): void { stopBattleReviveOpen(nodes); hideFadeSafe(nodes.bg); hideNodeSafe(nodes.title); hideNodeSafe(nodes.spine); hideFadeSafe(nodes.progressBg); hideSlideXSafe(nodes.progressPrefix, -48); hideNodeSafe(nodes.progressLabel); hideSlideXSafe(nodes.progressSuffix, 48); hideNodeSafe(nodes.rewardLabel); hideFadeSafe(nodes.rewardPanel); hideFadeUpSafe(nodes.tipText, 20); hideButtons(nodes.btnLayout); } export function stopBattleReviveOpen(nodes: BattleReviveNodes): void { for (const n of Object.values(nodes)) stopNode(n); if (!nodes.btnLayout?.isValid) return; stopNode(nodes.progressBg); stopNode(nodes.progressPrefix); stopNode(nodes.progressLabel); stopNode(nodes.progressSuffix); 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); } const bgPose = nodes.progressBg ? _poseMap.get(nodes.progressBg) : undefined; if (bgPose && nodes.progressBg?.isValid) setFinalPose(nodes.progressBg, bgPose); const prefixPose = nodes.progressPrefix ? _poseMap.get(nodes.progressPrefix) : undefined; if (prefixPose && nodes.progressPrefix?.isValid) setFinalPose(nodes.progressPrefix, prefixPose); const labelPose = nodes.progressLabel ? _poseMap.get(nodes.progressLabel) : undefined; if (labelPose && nodes.progressLabel?.isValid) setFinalPose(nodes.progressLabel, labelPose); const suffixPose = nodes.progressSuffix ? _poseMap.get(nodes.progressSuffix) : undefined; if (suffixPose && nodes.progressSuffix?.isValid) setFinalPose(nodes.progressSuffix, suffixPose); } function playBgFadeIn(node: Node | null, delay: number): void { if (!node?.isValid) return; const pose = capturePose(node); if (!pose) return; const op = ensureOpacity(node); node.setScale(pose.sx * 1.08, pose.sy * 1.08, 1); op.opacity = 0; tween(node) .delay(delay) .parallel( tween(node).to(0.28, { scale: new Vec3(pose.sx, pose.sy, 1) }, { easing: 'cubicOut' }), tween(op).to(0.22, { opacity: 255 }), ) .start(); } function playSpineRevive(node: Node | null, delay: number): void { if (!node?.isValid) return; node.active = true; const pose = capturePose(node); if (!pose) return; const sk = node.getComponent(sp.Skeleton); if (sk) sk.setAnimation(0, '复活继续', true); node.setScale(pose.sx * 0.55, pose.sy * 0.55, 1); node.setPosition(pose.x, pose.y - 40, pose.z); ensureOpacity(node).opacity = 0; tween(node) .delay(delay) .parallel( tween(node).to(0.26, { position: new Vec3(pose.x, pose.y, pose.z) }, { easing: 'cubicOut' }), tween(node).to(0.22, {}, { easing: 'linear', onUpdate: (_t, r) => { const t = easeOutBack(r ?? 0, 1.4); const s = lerp(0.55, 1, t); node.setScale(pose.sx * s, pose.sy * s, 1); }, }), tween(ensureOpacity(node)).to(0.18, { opacity: 255 }), ) .call(() => setFinalPose(node, pose)) .start(); } function playTitleDrop(node: Node | null, delay: number): void { if (!node?.isValid) return; const pose = capturePose(node); if (!pose) return; const op = ensureOpacity(node); node.setScale(pose.sx * 1.4, pose.sy * 1.4, 1); op.opacity = 0; node.setPosition(pose.x, pose.y + 50, pose.z); tween(node) .delay(delay) .to(0.2, {}, { easing: 'linear', onUpdate: (_t, r) => { const t = easeOutExpo(r ?? 0); const s = lerp(1.4, 1, easeOutCubic(r ?? 0)); node.setScale(pose.sx * s, pose.sy * s, 1); op.opacity = Math.floor(255 * t); node.setPosition(pose.x, pose.y + 50 * (1 - t), pose.z); }, }) .call(() => setFinalPose(node, pose)) .start(); } function playProgressGroup( bg: Node | null, prefix: Node | null, labelNode: Node | null, suffix: Node | null, label: Label | null, targetPercent: number, delay: number, ): void { if (!bg?.isValid || !prefix?.isValid || !labelNode?.isValid || !suffix?.isValid || !label?.isValid) return; const bgPose = capturePose(bg); const prefixPose = capturePose(prefix); const labelPose = capturePose(labelNode); const suffixPose = capturePose(suffix); if (!bgPose || !prefixPose || !labelPose || !suffixPose) return; tween(ensureOpacity(bg)) .delay(delay) .to(0.14, { opacity: 255 }) .start(); tween(prefix) .delay(delay + 0.04) .parallel( tween(prefix).to(0.18, { position: new Vec3(prefixPose.x, prefixPose.y, prefixPose.z) }, { easing: 'backOut' }), tween(ensureOpacity(prefix)).to(0.14, { opacity: 255 }), ) .start(); tween(suffix) .delay(delay + 0.28) .parallel( tween(suffix).to(0.18, { position: new Vec3(suffixPose.x, suffixPose.y, suffixPose.z) }, { easing: 'backOut' }), tween(ensureOpacity(suffix)).to(0.14, { opacity: 255 }), ) .start(); labelNode.setScale(0, 0, 1); ensureOpacity(labelNode).opacity = 255; const safeTarget = Math.max(0, Math.min(99, targetPercent)); tween({ val: 0 }) .delay(delay + 0.1) .to(0.5, { val: safeTarget }, { easing: 'cubicOut', onUpdate: (obj: { val: number }) => { label.string = `${Math.floor(obj.val)}%`; const wave = 1 + 0.06 * Math.sin((obj.val / Math.max(safeTarget, 1)) * Math.PI); labelNode.setScale(labelPose.sx * wave, labelPose.sy * wave, 1); }, }) .call(() => { label.string = `${safeTarget}%`; tween(labelNode) .to(0.07, { scale: new Vec3(labelPose.sx * 1.28, labelPose.sy * 1.28, 1) }) .to(0.14, { scale: new Vec3(labelPose.sx, labelPose.sy, 1) }, { easing: 'backOut' }) .call(() => playProgressIdleLoop(bg, prefix, labelNode, suffix)) .start(); }) .start(); tween(bg) .delay(delay + 0.42) .to(0.06, { scale: new Vec3(bgPose.sx * 1.04, bgPose.sy * 1.04, 1) }) .to(0.1, { scale: new Vec3(bgPose.sx, bgPose.sy, 1) }, { easing: 'sineOut' }) .start(); } /** 「只差 X% 通关」持续呼吸:数字强脉冲 + 底条微扩 + 两侧向中间挤压 */ function playProgressIdleLoop( bg: Node, prefix: Node, labelNode: Node, suffix: Node, ): void { const bgPose = capturePose(bg); const labelPose = capturePose(labelNode); const prefixPose = capturePose(prefix); const suffixPose = capturePose(suffix); trackLoop(labelNode, tween(labelNode) .repeatForever( tween(labelNode) .to(0.5, { scale: new Vec3(labelPose.sx * 1.16, labelPose.sy * 1.16, 1) }, { easing: 'sineInOut' }) .to(0.5, { scale: new Vec3(labelPose.sx, labelPose.sy, 1) }, { easing: 'sineInOut' }), )); const bgOp = ensureOpacity(bg); trackLoop(bg, tween(bg) .repeatForever( tween(bg) .parallel( tween(bg).to(0.65, { scale: new Vec3(bgPose.sx * 1.06, bgPose.sy * 1.06, 1) }, { easing: 'sineInOut' }) .to(0.65, { scale: new Vec3(bgPose.sx, bgPose.sy, 1) }, { easing: 'sineInOut' }), tween(bgOp).to(0.65, { opacity: 215 }).to(0.65, { opacity: 255 }), ), )); trackLoop(prefix, tween(prefix) .repeatForever( tween(prefix) .to(0.55, { position: new Vec3(prefixPose.x + 5, prefixPose.y, prefixPose.z) }, { easing: 'sineInOut' }) .to(0.55, { position: new Vec3(prefixPose.x, prefixPose.y, prefixPose.z) }, { easing: 'sineInOut' }), )); trackLoop(suffix, tween(suffix) .repeatForever( tween(suffix) .to(0.55, { position: new Vec3(suffixPose.x - 5, suffixPose.y, suffixPose.z) }, { easing: 'sineInOut' }) .to(0.55, { position: new Vec3(suffixPose.x, suffixPose.y, suffixPose.z) }, { easing: 'sineInOut' }), )); } function playCelebrateIn(node: Node | null, delay: number): void { if (!node?.isValid) return; const pose = capturePose(node); if (!pose) return; 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, 1.8), 1.08); node.setScale(pose.sx * s, pose.sy * s, 1); }, }) .call(() => setFinalPose(node, pose)) .start(); } function playRewardSection(rewardLabel: Node | null, rewardPanel: Node | null, tipText: Node | null, delay: number): void { if (!rewardLabel?.isValid || !rewardPanel?.isValid || !tipText?.isValid) return; playCelebrateIn(rewardLabel, delay); const panelPose = capturePose(rewardPanel); const tipPose = capturePose(tipText); if (!panelPose || !tipPose) return; ensureOpacity(rewardPanel).opacity = 0; rewardPanel.setScale(panelPose.sx * 0.92, panelPose.sy * 0.92, 1); tween(rewardPanel) .delay(delay + 0.08) .parallel( tween(rewardPanel).to(0.2, { scale: new Vec3(panelPose.sx, panelPose.sy, 1) }, { easing: 'backOut' }), tween(ensureOpacity(rewardPanel)).to(0.16, { opacity: 255 }), ) .start(); tween(tipText) .delay(delay + 0.16) .parallel( tween(tipText).to(0.18, { position: new Vec3(tipPose.x, tipPose.y, tipPose.z) }, { easing: 'cubicOut' }), tween(ensureOpacity(tipText)).to(0.14, { opacity: 255 }), ) .start(); } function playSecondaryButtonEnter(node: Node | null, delay: number): void { if (!node?.isValid) return; const pose = capturePose(node); if (!pose) return; stopNode(node); node.setScale(0, 0, 1); 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); }, }) .call(() => { setFinalPose(node, pose); ensureOpacity(node).opacity = 255; }) .start(); } /** 整行按钮随 Layout 入场:只动 btnLayout 位移 + 子节点 scale,不改子节点 position/angle */ function playButtonLayoutEnter(btnLayout: Node | null, reviveBtn: Node | null, delay: number): void { if (!btnLayout?.isValid) return; const layoutPose = capturePose(btnLayout); if (!layoutPose) return; ensureOpacity(btnLayout).opacity = 255; btnLayout.setPosition(layoutPose.x, layoutPose.y - 22, layoutPose.z); tween(btnLayout) .delay(delay - 0.06) .to(0.18, { position: new Vec3(layoutPose.x, layoutPose.y, layoutPose.z) }, { easing: 'backOut' }) .start(); const activeChildren = btnLayout.children.filter(c => c.active); activeChildren.forEach((child, i) => { const childDelay = delay + i * 0.05; capturePose(child); child.setScale(0, 0, 1); ensureOpacity(child).opacity = 0; if (child === reviveBtn) { playReviveButtonEnter(child, childDelay); } else { playSecondaryButtonEnter(child, childDelay); } }); } function playReviveButtonEnter(node: Node | null, delay: number): void { if (!node?.isValid) return; const pose = capturePose(node); if (!pose) return; 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(() => playReviveButtonPulse(node)) .start(); } function playReviveButtonPulse(node: Node | null): void { if (!node?.isValid) return; const pose = capturePose(node); if (!pose) return; 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' }), )); } /** * 复活界面激励序列: * 沉痛开场 → 角色挣扎 → 标题落定 → 「只差 X% 通关」数字滚动 → 奖励展示 → 复活按钮脉冲压轴 */ export function playBattleReviveOpen(opts: BattleRevivePlayOpts): void { const { nodes, progressPercent, reviveBtn, onComplete } = opts; const n = nodes; syncBtnLayout(n.btnLayout); playBgFadeIn(n.bg, 0); playSpineRevive(n.spine, 0.08); playTitleDrop(n.title, 0.22); const progressDelay = 0.42; playProgressGroup( n.progressBg, n.progressPrefix, n.progressLabel, n.progressSuffix, n.progressLabel?.getComponent(Label) ?? null, progressPercent, progressDelay, ); const rewardDelay = 0.88; playRewardSection(n.rewardLabel, n.rewardPanel, n.tipText, rewardDelay); const reviveDelay = 1.18; playButtonLayoutEnter(n.btnLayout, reviveBtn, reviveDelay); const totalDur = reviveDelay + 0.7; const bg = n.bg; if (bg?.isValid) { tween(bg) .delay(totalDur) .call(() => onComplete?.()) .start(); } else { onComplete?.(); } }