/** 只负责领取反馈、轨道移动和新卡出现动画。 */ export default class JungleAnimator { constructor( private host: cc.Component, private contentNode: cc.Node, private cardFrame: cc.SpriteFrame, private unlockFrame: cc.SpriteFrame, ) { } /** 档位从锁定变为当前可领取时,播放锁旋转下坠和按钮回弹。 */ public playUnlockEffect(card: cc.Node, onComplete: () => void): void { let button = card && card.getChildByName("button"); let buttonVisual = button && button.getChildByName("button_content"); let lock = button && button.getChildByName("lock"); if (!button || !lock) { onComplete(); return; } let buttonComponent = button.getComponent(cc.Button); let lockSprite = lock.getComponent(cc.Sprite); let originalLockFrame = lockSprite && lockSprite.spriteFrame; let originalLockSize = lock.getContentSize(); let buttonLocalPosition = lock.getPosition(); let originZIndex = lock.zIndex; let lockWorldPosition = lock.convertToWorldSpaceAR(cc.Vec2.ZERO); let originPosition = this.contentNode.convertToNodeSpaceAR(lockWorldPosition); let screenBottomY = cc.visibleRect && cc.visibleRect.bottom ? cc.visibleRect.bottom.y : 0; let dropWorldPosition = cc.v2( lockWorldPosition.x, screenBottomY - lock.height * 0.6, ); let dropLocalPosition = this.contentNode.convertToNodeSpaceAR(dropWorldPosition); let originX = originPosition.x; let originY = originPosition.y; let dropDistance = Math.max(0, originY - dropLocalPosition.y); let dropDuration = Math.min(0.42, Math.max(0.22, dropDistance / 2800)); button.stopAllActions(); lock.stopAllActions(); if (buttonVisual) { buttonVisual.stopAllActions(); buttonVisual.opacity = 0; buttonVisual.scale = 0.92; } if (buttonComponent) { buttonComponent.interactable = false; } button.scale = 1; lock.active = true; lock.opacity = 255; lock.scale = 1; lock.angle = 0; if (lockSprite && this.unlockFrame) { lockSprite.spriteFrame = this.unlockFrame; lock.setContentSize(this.unlockFrame.getOriginalSize()); } lock.parent = this.contentNode; lock.zIndex = 60; lock.setPosition(originX, originY); cc.tween(button) .to(0.08, { scale: 1.08 }, { easing: "quadOut" }) .to(0.1, { scale: 1 }, { easing: "backOut" }) .start(); if (buttonVisual) { cc.tween(buttonVisual) .to(0.08, { opacity: 255, scale: 1.06 }, { easing: "backOut" }) .to(0.1, { scale: 1 }, { easing: "sineOut" }) .start(); } cc.tween(lock) .to(0.04, { y: originY + 7, angle: -12 }, { easing: "quadOut" }) .to(0.04, { y: originY + 9, angle: 10 }, { easing: "sineInOut" }) .to(0.03, { y: originY + 4, angle: -6 }, { easing: "sineInOut" }) .parallel( cc.tween().to(dropDuration, { x: originX - 28, y: dropLocalPosition.y, angle: 220, }, { easing: "quadIn" }), cc.tween().to(dropDuration, { scale: 0.82 }, { easing: "quadIn" }), ) .call(() => { lock.active = false; lock.opacity = 255; lock.scale = 1; lock.angle = 0; if (lockSprite && originalLockFrame) { lockSprite.spriteFrame = originalLockFrame; lock.setContentSize(originalLockSize); } lock.parent = button; lock.zIndex = originZIndex; lock.setPosition(buttonLocalPosition); if (buttonComponent) { buttonComponent.interactable = true; } onComplete(); }) .start(); } public playClaimEffect(card: cc.Node, onComplete: () => void): void { const button = card.getChildByName("button"); const buttonVisual = button && button.getChildByName("button_content"); const lock = button && button.getChildByName("lock"); if (button) { button.getComponent(cc.Button).interactable = false; cc.tween(button) .to(0.08, { scale: 1.08 }, { easing: "quadOut" }) .to(0.1, { scale: 1 }) .start(); } if (buttonVisual) { buttonVisual.opacity = 0; } if (lock) { lock.active = false; } const flash = this.createSpriteNode("claim_flash", this.cardFrame); flash.parent = card; flash.zIndex = 8; flash.color = cc.color(255, 244, 160); flash.opacity = 0; cc.tween(flash) .to(0.1, { opacity: 150 }) .to(0.18, { opacity: 0 }) .call(() => flash.destroy()) .start(); const check = new cc.Node("claim_check"); check.parent = button; check.zIndex = 20; check.setContentSize(84, 84); check.scale = 0; const checkGraphics = check.addComponent(cc.Graphics); checkGraphics.fillColor = cc.color(93, 205, 52, 255); checkGraphics.strokeColor = cc.color(255, 255, 255, 255); checkGraphics.lineWidth = 7; checkGraphics.circle(0, 0, 35); checkGraphics.fill(); checkGraphics.stroke(); checkGraphics.lineWidth = 9; checkGraphics.moveTo(-19, 1); checkGraphics.lineTo(-5, -14); checkGraphics.lineTo(23, 18); checkGraphics.stroke(); cc.tween(check) .to(0.16, { scale: 1.15 }, { easing: "backOut" }) .to(0.08, { scale: 1 }) .delay(0.18) .to(0.1, { opacity: 0 }) .call(() => check.destroy()) .start(); const rewardsNode = card.getChildByName("rewards"); const rewardGroups = rewardsNode ? rewardsNode.children.slice() : []; rewardGroups.forEach((group, index) => { const flyNode = cc.instantiate(group); const worldPosition = group.convertToWorldSpaceAR(cc.Vec2.ZERO); flyNode.parent = this.contentNode; flyNode.setPosition(this.contentNode.convertToNodeSpaceAR(worldPosition)); flyNode.zIndex = 30; const horizontalOffset = (index - (rewardGroups.length - 1) * 0.5) * 42; cc.tween(flyNode) .delay(index * 0.035) .parallel( cc.tween().by(0.42, { x: horizontalOffset, y: 190 + index % 2 * 24 }, { easing: "quadOut" }), cc.tween().to(0.42, { opacity: 0, scale: 1.12 }), ) .call(() => flyNode.destroy()) .start(); }); this.host.scheduleOnce(onComplete, 0.5); } public playCardExit(card: cc.Node, onComplete: () => void): void { cc.tween(card) .parallel( cc.tween().to(0.2, { scale: 0.78, opacity: 0 }), cc.tween().by(0.2, { y: 55 }), ) .call(onComplete) .start(); } public moveCardsWithOverlap( cards: cc.Node[], slotPositions: cc.Vec2[], cardIndex: number, onNewCardReady: () => void, ): void { if (cardIndex >= cards.length) { onNewCardReady(); return; } const movingCard = cards[cardIndex]; if (!movingCard.active) { this.moveCardsWithOverlap(cards, slotPositions, cardIndex + 1, onNewCardReady); return; } let nextCardIndex = cardIndex + 1; while (nextCardIndex < cards.length && !cards[nextCardIndex].active) { nextCardIndex++; } const targetPosition = slotPositions[cardIndex - 1]; const startPosition = cc.v2(movingCard.x, movingCard.y); const brakePosition = startPosition.lerp(targetPosition, 0.75); cc.tween(movingCard) .to(0.13, { x: brakePosition.x, y: brakePosition.y }, { easing: "linear" }) .call(() => { if (nextCardIndex < cards.length) { this.moveCardsWithOverlap(cards, slotPositions, nextCardIndex, onNewCardReady); } else { onNewCardReady(); } }) .to(0.5, { x: targetPosition.x, y: targetPosition.y }, { easing: "cubicOut" }) .start(); } public showNewCard(card: cc.Node, targetCenter: cc.Vec2, onComplete: () => void): void { card.setAnchorPoint(0.5, 0.5); card.scale = 0.06; card.setPosition(targetCenter); card.opacity = 0; card.active = true; cc.tween(card) .parallel( cc.tween().to(0.2, { opacity: 255 }, { easing: "sineOut" }), cc.tween() .to(0.28, { scale: 1.07 }, { easing: "cubicOut" }) .to(0.1, { scale: 0.97 }, { easing: "sineOut" }) .to(0.09, { scale: 1.02 }, { easing: "sineOut" }) .to(0.08, { scale: 1 }, { easing: "sineOut" }), ) .call(onComplete) .start(); } /** 最前方礼包的短促放大回弹,用于吸引点击。 */ public playCardAttention(card: cc.Node, onComplete: () => void): void { if (!card || !cc.isValid(card) || !card.activeInHierarchy) { onComplete(); return; } card.scaleX = 1; card.scaleY = 1; cc.tween(card) .to(0.14, { scaleX: 1.06, scaleY: 0.95 }, { easing: "sineInOut" }) .to(0.17, { scaleX: 0.97, scaleY: 1.06 }, { easing: "sineInOut" }) .to(0.18, { scaleX: 1.025, scaleY: 0.985 }, { easing: "sineInOut" }) .to(0.2, { scaleX: 1, scaleY: 1 }, { easing: "sineOut" }) .call(onComplete) .start(); } public stopCardAttention(card: cc.Node): void { if (!card || !cc.isValid(card)) { return; } cc.Tween.stopAllByTarget(card); card.scaleX = 1; card.scaleY = 1; } public playLockedFeedback(card: cc.Node): void { card.stopAllActions(); const x = card.x; cc.tween(card) .to(0.05, { x: x - 9 }) .to(0.05, { x: x + 9 }) .to(0.05, { x: x - 5 }) .to(0.05, { x }) .start(); } private createSpriteNode(name: string, frame: cc.SpriteFrame): cc.Node { const node = new cc.Node(name); const sprite = node.addComponent(cc.Sprite); sprite.sizeMode = cc.Sprite.SizeMode.RAW; sprite.spriteFrame = frame; if (frame) { node.setContentSize(frame.getOriginalSize()); } return node; } }