136 lines
7.2 KiB
TypeScript
136 lines
7.2 KiB
TypeScript
// 只传展示数据;来源使用世界坐标,兼容卡片缩放和位移。
|
||
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;
|
||
}
|
||
}
|