// 只传展示数据;来源使用世界坐标,兼容卡片缩放和位移。 export interface RewardClaimVisual { icon: cc.SpriteFrame; count: number; prefix: "" | "×"; isTime?: boolean; // 时间原值为秒,展示时换算分钟。 worldPosition: cc.Vec2; worldPopPosition: cc.Vec2; // 道具中心上方一个道具高度,已包含节点缩放。 } /** 独立领取效果包,只展示,不发奖、不创建奖励弹窗。 */ export default class RewardClaimEffect { // 统一创建奖励展示节点,全部动画结束后清理并通知调用方。 public static play(parent: cc.Node, rewards: RewardClaimVisual[], done: () => void, numberFrames: cc.SpriteFrame[]) { const visible = rewards.filter(item => item.count > 0); if (!visible.length) { done(); return; } const layer = new cc.Node("rewardClaimEffects"); parent.addChild(layer); layer.zIndex = 2000; const growDuration = 0.12; const returnDuration = 0.025; const stagger = growDuration + returnDuration; // 第一次放大并回正后,下一个开始生成。 const entries = visible.map(item => ({ item, source: layer.convertToNodeSpaceAR(item.worldPosition), position: layer.convertToNodeSpaceAR(item.worldPopPosition), })); entries.sort((a, b) => a.source.x - b.source.x); const rowCenterX = (entries[0].position.x + entries[entries.length - 1].position.x) / 2; const rowY = Math.max(...entries.map(entry => entry.position.y)) + 24; // 正常尺寸下图标边缘间隔 10px,整排以原道具区域居中。 const rowSpacing = 88 + 10; let remaining = visible.length; // 多奖励全部播完后,只回调一次。 entries.forEach(({ item }, index) => { const node = new cc.Node("rewardClaim" + index); layer.addChild(node); const icon = new cc.Node("icon"); node.addChild(icon); icon.setContentSize(88, 88); const sprite = icon.addComponent(cc.Sprite); sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; sprite.spriteFrame = item.icon; // 领取图标保持原图比例,最长边不超过 88px。 sprite.trim = false; if (item.icon) { const original = item.icon.getOriginalSize(); const scale = 88 / Math.max(original.width, original.height, 1); icon.setContentSize(original.width * scale, original.height * scale); } this.createAmount(node, item, numberFrames, 88, 30, icon); node.setPosition(cc.v2(rowCenterX + (index - (entries.length - 1) / 2) * rowSpacing, rowY)); node.scale = 0.2; node.opacity = 0; // 两次弹到 1.25 倍并快速回正,中间轻微收缩;停留后再上浮渐隐。 const animation = cc.sequence( cc.spawn(cc.fadeIn(0.06), cc.scaleTo(growDuration, 1.25).easing(cc.easeSineOut())), cc.scaleTo(returnDuration, 1), cc.scaleTo(0.05, 0.9).easing(cc.easeSineOut()), cc.scaleTo(0.1, 1.25).easing(cc.easeSineOut()), cc.scaleTo(0.025, 1), // 首个等最后一个弹完再停 0.25 秒;各自保留同样等待,按生成顺序起飞。 cc.delayTime((entries.length - 1) * stagger + 0.25), cc.spawn(cc.moveBy(0.6, cc.v2(0, 150)).easing(cc.easeSineOut()), cc.fadeOut(0.6)), ); node.runAction(cc.sequence( cc.delayTime(index * stagger), animation, cc.callFunc(() => { node.destroy(); if (--remaining === 0) { layer.destroy(); done(); } }), )); }); } // 卡片和领取效果共用图片数量;仅显示层换算分钟,不改变发奖数值。 public static createAmount(parent: cc.Node, item: { count: number; prefix: "" | "×"; isTime?: boolean }, frames: cc.SpriteFrame[], maxWidth: number = 88, height: number = 30, icon: cc.Node = parent) { const amount = new cc.Node("amount"); parent.addChild(amount); // 按贴图实际可见边缘定位,透明留白不参与数量位置计算。 const frame = icon.getComponent(cc.Sprite).spriteFrame; const original = frame && frame.getOriginalSize(); const rect = frame && frame.getRect(); const offset = frame && frame.getOffset(); const bottom = frame ? (offset.y - rect.height / 2) * icon.height / original.height : -icon.height / 2; const right = frame ? (offset.x + rect.width / 2) * icon.width / original.width : icon.width / 2; amount.setPosition(0, bottom + 4); const value = item.isTime ? Number((item.count / 60).toFixed(2)) : item.count; const symbols = (item.prefix + value).split(""); const glyphs = symbols.map(char => frames && frames[char === "+" ? 10 : char === "×" ? 11 : Number(char)]); if (item.isTime) glyphs.push(frames && frames[12]); // 缺图或小数使用文字兜底,保留完整的数量和单位。 if (!glyphs.every(Boolean) || !/^×?\d+$/.test(symbols.join(""))) { const fallback = this.addAmountLabel(amount, item.prefix + value + (item.isTime ? "分钟" : ""), height, 0); const label = fallback.getComponent(cc.Label); fallback.setContentSize(maxWidth, height + 6); label.overflow = cc.Label.Overflow.SHRINK; if (item.prefix === "×") amount.x = right + 4 - maxWidth / 2; return; } const scale = height / Math.max(...glyphs.map(frame => frame.getOriginalSize().height)); const widths = glyphs.map(frame => frame.getOriginalSize().width * scale); const totalWidth = widths.reduce((sum, width) => sum + width, 0) + glyphs.length - 1; const fit = Math.min(1, maxWidth / totalWidth); // 金币/时间底边居中,道具乘号数量沿右下角对齐。 if (item.prefix === "×") amount.x = right + 4 - totalWidth * fit / 2; let x = -totalWidth / 2; glyphs.forEach((frame, index) => { const glyph = new cc.Node("digit" + index); amount.addChild(glyph); const sprite = glyph.addComponent(cc.Sprite); sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; sprite.trim = false; sprite.spriteFrame = frame; glyph.setContentSize(widths[index] * fit, frame.getOriginalSize().height * scale * fit); glyph.setPosition((x + widths[index] / 2) * fit, 0); x += widths[index] + 1; }); } private static addAmountLabel(parent: cc.Node, value: string, size: number, y: number) { const node = new cc.Node("amountLabel"); parent.addChild(node); node.setPosition(0, y); const label = node.addComponent(cc.Label); label.string = value; label.fontSize = size; label.lineHeight = size + 4; const outline = node.addComponent(cc.LabelOutline); outline.color = cc.color(151, 79, 38); outline.width = 2; return node; } }