502 lines
20 KiB
TypeScript
502 lines
20 KiB
TypeScript
import {
|
||
JungleActivityConfig,
|
||
JungleRenderState,
|
||
JungleRewardConfig,
|
||
JungleRewardType,
|
||
JungleTierConfig,
|
||
JungleViewAssets,
|
||
JungleViewRefs,
|
||
} from "./JungleTypes";
|
||
import JungleTitleShine from "./JungleTitleShine";
|
||
|
||
/** 负责 Jungle Treasure 的节点创建、布局和静态渲染。 */
|
||
export default class JungleView {
|
||
private readonly designWidth: number = 1080;
|
||
private readonly slotCount: number = 6;
|
||
public contentNode: cc.Node = null;
|
||
public timerLabel: cc.Label = null;
|
||
public cards: cc.Node[] = [];
|
||
public slotPositions: cc.Vec2[] = [];
|
||
|
||
constructor(
|
||
private root: cc.Node,
|
||
private assets: JungleViewAssets,
|
||
private eventTarget: any,
|
||
private onClose: () => void,
|
||
private onCardClicked: (button: cc.Button) => void,
|
||
) { }
|
||
|
||
public build(): JungleViewRefs {
|
||
this.root.name = "jungle";
|
||
this.root.zIndex = 999;
|
||
if (!this.root.getComponent(cc.BlockInputEvents)) {
|
||
this.root.addComponent(cc.BlockInputEvents);
|
||
}
|
||
this.stretchToParent(this.root);
|
||
|
||
let shade = new cc.Node("shade");
|
||
shade.parent = this.root;
|
||
this.stretchToParent(shade);
|
||
let shadeGraphics = shade.addComponent(cc.Graphics);
|
||
shadeGraphics.fillColor = cc.color(35, 25, 20, 205);
|
||
shadeGraphics.rect(-1600, -2200, 3200, 4400);
|
||
shadeGraphics.fill();
|
||
|
||
this.contentNode = new cc.Node("content");
|
||
this.contentNode.parent = this.root;
|
||
this.contentNode.setContentSize(this.designWidth, this.root.height || 1920);
|
||
let contentScale = Math.min(1, (this.root.width || this.designWidth) / this.designWidth);
|
||
this.contentNode.setScale(contentScale);
|
||
|
||
let contentHeight = (this.root.height || 1920) / contentScale;
|
||
let top = contentHeight * 0.5 - 80;
|
||
let row1Y = top - 1000;
|
||
let rowGap = 445;
|
||
this.slotPositions = [
|
||
cc.v2(-252, row1Y),
|
||
cc.v2(252, row1Y),
|
||
cc.v2(252, row1Y - rowGap),
|
||
cc.v2(-252, row1Y - rowGap),
|
||
cc.v2(-252, row1Y - rowGap * 2),
|
||
cc.v2(252, row1Y - rowGap * 2),
|
||
];
|
||
|
||
let track = this.createSpriteNode("track", this.assets.trackFrame);
|
||
track.parent = this.contentNode;
|
||
track.setPosition(0, row1Y - rowGap);
|
||
track.zIndex = 0;
|
||
|
||
let title = this.createSpriteNode("title", this.assets.titleFrame);
|
||
title.parent = this.contentNode;
|
||
title.setPosition(0, top - 515);
|
||
title.zIndex = 3;
|
||
title.addComponent(JungleTitleShine);
|
||
|
||
let timer = this.createSpriteNode("timer", this.assets.timerFrame);
|
||
timer.parent = this.contentNode;
|
||
timer.setPosition(-358, top - 235);
|
||
timer.zIndex = 5;
|
||
|
||
let timeNode = new cc.Node("time");
|
||
timeNode.parent = timer;
|
||
timeNode.setContentSize(270, 62);
|
||
timeNode.setPosition(38, 0);
|
||
timeNode.zIndex = 10;
|
||
|
||
this.timerLabel = this.createLabel(timeNode, "", 46, cc.color(151, 116, 74), 270, 62);
|
||
this.timerLabel.enableBold = false;
|
||
this.timerLabel.node.name = "timerLabel";
|
||
|
||
|
||
let close = this.createCloseButton();
|
||
close.parent = this.contentNode;
|
||
close.setPosition(430, top - 245);
|
||
close.zIndex = 5;
|
||
|
||
for (let i = 0; i < this.slotCount; i++) {
|
||
let card = this.createCard(i);
|
||
card.parent = this.contentNode;
|
||
card.setPosition(this.slotPositions[i]);
|
||
card.zIndex = 2;
|
||
this.cards.push(card);
|
||
}
|
||
|
||
return {
|
||
contentNode: this.contentNode,
|
||
cards: this.cards,
|
||
slotPositions: this.slotPositions,
|
||
};
|
||
}
|
||
|
||
public renderAllCards(config: JungleActivityConfig, state: JungleRenderState): void {
|
||
if (!config || this.cards.length === 0) {
|
||
return;
|
||
}
|
||
for (let i = 0; i < this.cards.length; i++) {
|
||
let card = this.cards[i];
|
||
card.stopAllActions();
|
||
card.setAnchorPoint(0.5, 0.5);
|
||
card.setPosition(this.slotPositions[i]);
|
||
card.scale = 1;
|
||
card.opacity = 255;
|
||
this.renderCard(card, config, state, state.currentTierIndex + i, i);
|
||
}
|
||
}
|
||
|
||
/** 运行时重新绑定领取图,兼容分包预制体未带上新资源引用的情况。 */
|
||
public setClaimFrame(frame: cc.SpriteFrame): void {
|
||
if (!frame) {
|
||
return;
|
||
}
|
||
this.assets.claimFrame = frame;
|
||
this.cards.forEach((card) => {
|
||
let button = card.getChildByName("button");
|
||
let content = button && button.getChildByName("button_content");
|
||
let claimImage = content && content.getChildByName("claim_image");
|
||
let sprite = claimImage && claimImage.getComponent(cc.Sprite);
|
||
if (!sprite) {
|
||
return;
|
||
}
|
||
sprite.spriteFrame = frame;
|
||
sprite.sizeMode = cc.Sprite.SizeMode.RAW;
|
||
claimImage.setContentSize(frame.getOriginalSize());
|
||
});
|
||
}
|
||
|
||
public renderCard(
|
||
card: cc.Node,
|
||
config: JungleActivityConfig,
|
||
state: JungleRenderState,
|
||
tierIndex: number,
|
||
slotIndex: number,
|
||
): void {
|
||
let tier = config.tiers[tierIndex];
|
||
card.active = !!tier;
|
||
if (!tier) {
|
||
return;
|
||
}
|
||
|
||
card.name = "reward_card_" + tier.id;
|
||
let rewardsNode = card.getChildByName("rewards");
|
||
rewardsNode.removeAllChildren();
|
||
this.renderRewards(rewardsNode, tier.rewards);
|
||
|
||
let button = card.getChildByName("button");
|
||
let buttonContent = button.getChildByName("button_content");
|
||
let lock = button.getChildByName("lock");
|
||
let isCurrent = slotIndex === 0 && !state.isPurchasing;
|
||
let isUnlocked = tier.state !== 0;
|
||
let isExpired = !!config.endAt && Date.now() >= config.endAt;
|
||
let isPurchaseBlocked = isExpired && tier.price > 0 && tier.state === 0;
|
||
|
||
this.renderButtonContent(buttonContent, tier);
|
||
buttonContent.opacity = 255;
|
||
buttonContent.scale = 1;
|
||
button.scale = 1;
|
||
lock.active = !isCurrent || !isUnlocked || isPurchaseBlocked;
|
||
button.getComponent(cc.Button).interactable = !state.isMoving && !isPurchaseBlocked;
|
||
button.color = isCurrent ? cc.Color.WHITE : cc.color(220, 220, 220);
|
||
}
|
||
|
||
/** 更新倒计时并返回剩余秒数。 */
|
||
public updateTimer(config: JungleActivityConfig): number {
|
||
// console.log("更新倒计时", config);
|
||
// return;
|
||
if (!this.timerLabel || !config) {
|
||
return 0;
|
||
}
|
||
let endAt = Number(config.endAt);
|
||
if (!endAt || !isFinite(endAt)) {
|
||
this.timerLabel.string = "活动中";
|
||
return -1;
|
||
}
|
||
let remaining = Math.max(0, Math.ceil((endAt - Date.now()) / 1000));
|
||
if (remaining === 0) {
|
||
this.timerLabel.string = "";
|
||
return remaining;
|
||
}
|
||
if (remaining >= 24 * 60 * 60) {
|
||
let days = Math.floor(remaining / 86400);
|
||
let hours = Math.floor((remaining % 86400) / 3600);
|
||
this.timerLabel.string = days + "天" + hours + "时";
|
||
}
|
||
else {
|
||
let hours = Math.floor(remaining / 3600);
|
||
let minutes = Math.floor((remaining % 3600) / 60);
|
||
let seconds = remaining % 60;
|
||
this.timerLabel.string = this.pad(hours)
|
||
+ ":" + this.pad(minutes) + ":" + this.pad(seconds);
|
||
}
|
||
|
||
return remaining;
|
||
}
|
||
|
||
private stretchToParent(node: cc.Node): void {
|
||
let parentSize = node.parent ? node.parent.getContentSize() : cc.size(this.designWidth, 1920);
|
||
node.setContentSize(parentSize.width || this.designWidth, parentSize.height || 1920);
|
||
let widget = node.addComponent(cc.Widget);
|
||
widget.isAlignLeft = true;
|
||
widget.isAlignRight = true;
|
||
widget.isAlignTop = true;
|
||
widget.isAlignBottom = true;
|
||
widget.left = widget.right = widget.top = widget.bottom = 0;
|
||
}
|
||
|
||
private createCloseButton(): cc.Node {
|
||
let node = this.createSpriteNode("close", this.assets.closeFrame);
|
||
node.addComponent(cc.Button);
|
||
node.on("click", this.onClose, this.eventTarget);
|
||
return node;
|
||
}
|
||
|
||
private createCard(index: number): cc.Node {
|
||
let card = new cc.Node("reward_card_" + index);
|
||
card.setContentSize(444, 400);
|
||
|
||
let bg = this.createSpriteNode("card_bg", this.assets.cardFrame);
|
||
bg.parent = card;
|
||
|
||
let rewards = new cc.Node("rewards");
|
||
rewards.parent = card;
|
||
rewards.setContentSize(380, 230);
|
||
rewards.setPosition(0, 48);
|
||
|
||
let button = this.createSpriteNode("button", this.assets.buttonFrame);
|
||
button.parent = card;
|
||
button.setPosition(0, -137);
|
||
button.addComponent(cc.Button);
|
||
button.on("click", this.onCardClicked, this.eventTarget);
|
||
|
||
let buttonContent = new cc.Node("button_content");
|
||
buttonContent.parent = button;
|
||
buttonContent.setContentSize(270, 88);
|
||
|
||
let freeImage = this.createSpriteNode("free_image", this.assets.freeFrame);
|
||
freeImage.parent = buttonContent;
|
||
|
||
let priceLabel = this.createLabel(buttonContent, "0", 56, cc.Color.WHITE, 110, 64);
|
||
priceLabel.node.name = "price_label";
|
||
priceLabel.lineHeight = 56;
|
||
if (this.assets.priceFont) {
|
||
priceLabel.font = this.assets.priceFont;
|
||
}
|
||
|
||
let yuanImage = this.createSpriteNode("yuan_image", this.assets.yuanFrame);
|
||
yuanImage.parent = buttonContent;
|
||
|
||
let claimImage = this.createSpriteNode("claim_image", this.assets.claimFrame);
|
||
claimImage.parent = buttonContent;
|
||
|
||
let lock = this.createSpriteNode("lock", this.assets.lockFrame);
|
||
lock.parent = button;
|
||
lock.setPosition(120, 18);
|
||
lock.active = false;
|
||
return card;
|
||
}
|
||
|
||
private renderRewards(parent: cc.Node, rewards: JungleRewardConfig[]): void {
|
||
let safeRewards = rewards && rewards.length > 0 ? rewards.slice(0, 5) : [];
|
||
if (safeRewards.length === 0) {
|
||
return;
|
||
}
|
||
let rewardYOffset = -25;
|
||
let numberScale = 1.2;
|
||
let iconScale = safeRewards.length === 1
|
||
? 1.1
|
||
: (safeRewards.length === 2 ? 0.85 : 0.6);
|
||
let trianglePositions = [
|
||
cc.v2(0, 75),
|
||
cc.v2(-90, -35),
|
||
cc.v2(90, -35),
|
||
];
|
||
let rows: JungleRewardConfig[][] = safeRewards.length <= 3
|
||
? [safeRewards]
|
||
: [safeRewards.slice(0, 3), safeRewards.slice(3)];
|
||
|
||
rows.forEach((row, rowIndex) => {
|
||
let frames = row.map((reward) => this.getRewardFrame(reward.type));
|
||
// 无限体力保留旧图 110×110 的布局占位,数量和其他奖励位置不变。
|
||
let sizes = frames.map((frame, index) => row[index].type === "infinite_health"
|
||
? cc.size(110, 110)
|
||
: (frame ? frame.getOriginalSize() : cc.size(0, 0)));
|
||
let iconScales = row.map((reward, index) => {
|
||
if (!this.isToolReward(reward.type)) return iconScale;
|
||
let size = frames[index] ? frames[index].getOriginalSize() : cc.size(316, 316);
|
||
// 用等比例缩放保持旧版 316×316 道具图的布局尺寸。
|
||
return 316 / Math.max(size.width, size.height, 1)
|
||
* 0.45 * (safeRewards.length === 1 ? 1 : iconScale);
|
||
});
|
||
let widths = sizes.map((size, index) => size.width * iconScales[index]);
|
||
let gap = safeRewards.length === 1
|
||
? 8
|
||
: (safeRewards.length === 2 ? 35 : 12);
|
||
let totalWidth = widths.reduce((sum, width) => sum + width, 0) + gap * Math.max(0, row.length - 1);
|
||
let cursorX = -totalWidth * 0.5;
|
||
let rowItems: { group: cc.Node; label: cc.Label }[] = [];
|
||
|
||
row.forEach((reward, index) => {
|
||
let isToolReward = this.isToolReward(reward.type);
|
||
let frame = frames[index];
|
||
let originalSize = sizes[index];
|
||
let group = new cc.Node("reward_" + reward.type);
|
||
group.parent = parent;
|
||
let rowY = (rows.length === 1 ? 15 : (rowIndex === 0 ? 65 : -55)) + rewardYOffset;
|
||
if (safeRewards.length === 3) {
|
||
rowY -= 20;
|
||
}
|
||
else if (safeRewards.length === 1 && (isToolReward || reward.type === "infinite_health")) {
|
||
rowY -= 20;
|
||
}
|
||
let groupX = cursorX + widths[index] * 0.5;
|
||
if (safeRewards.length === 3) {
|
||
groupX = trianglePositions[index].x;
|
||
rowY += trianglePositions[index].y;
|
||
}
|
||
group.setPosition(groupX, rowY);
|
||
cursorX += widths[index] + gap;
|
||
|
||
let icon = this.createSpriteNode("icon", frame);
|
||
// 配合 RAW 尺寸保留透明边距,避免裁剪后的图案被拉伸。
|
||
icon.getComponent(cc.Sprite).trim = false;
|
||
icon.parent = group;
|
||
let rewardIconScale = iconScales[index];
|
||
icon.setScale(rewardIconScale);
|
||
if (reward.type === "infinite_health" && frame) {
|
||
let size = frame.getOriginalSize();
|
||
let oldScale = rewardIconScale * Math.min(82 / size.width, 72 / size.height);
|
||
// 与锤子、冻结、魔法棒使用同一图标尺寸,保持图片比例。
|
||
icon.setScale(316 / Math.max(size.width, size.height, 1)
|
||
* 0.45 * (safeRewards.length === 1 ? 1 : iconScale));
|
||
// 单奖励与其他道具居中对齐,多奖励保留原来的文字间距。
|
||
icon.y = safeRewards.length === 1 ? 0 : size.height * (icon.scaleY - oldScale) * 0.5;
|
||
}
|
||
let labelText = reward.type === "infinite_health"
|
||
? this.formatInfiniteHealth(reward.count)
|
||
: (reward.type === "coin" ? reward.count.toString() : "x" + reward.count);
|
||
let baseFontSize = rows.length === 1 ? 36 : 29;
|
||
let rewardNumberScale = safeRewards.length === 1
|
||
? numberScale * 1.1
|
||
: numberScale;
|
||
if (isToolReward) {
|
||
rewardNumberScale *= 0.95;
|
||
}
|
||
let scaledWidth = originalSize.width * rewardIconScale;
|
||
let scaledHeight = originalSize.height * rewardIconScale;
|
||
let labelFontSize = Math.round(baseFontSize * rewardNumberScale);
|
||
let labelWidth = isToolReward
|
||
? Math.max(70, labelText.length * labelFontSize)
|
||
: Math.max(126, scaledWidth * rewardNumberScale, labelText.length * labelFontSize);
|
||
let label = this.createLabel(
|
||
group,
|
||
labelText,
|
||
labelFontSize,
|
||
cc.Color.WHITE,
|
||
labelWidth,
|
||
Math.round(44 * rewardNumberScale),
|
||
);
|
||
if (this.assets.countFont) {
|
||
label.font = this.assets.countFont;
|
||
}
|
||
if (isToolReward) {
|
||
label.node.setPosition(scaledWidth * 0.34, -scaledHeight * 0.34);
|
||
}
|
||
else {
|
||
label.node.setPosition(0, rows.length === 1 ? -scaledHeight * 0.5 - 10 : -scaledHeight * 0.5 + 17);
|
||
}
|
||
let outline = label.node.addComponent(cc.LabelOutline);
|
||
outline.color = cc.color(62, 47, 38);
|
||
outline.width = isToolReward ? 3 : 4;
|
||
rowItems.push({ group, label });
|
||
});
|
||
|
||
// 通过移动整个奖励组对齐数量文字底边,保持图标和 Label 的相对位置不变。
|
||
// 三角布局只对齐下方两个奖励;上方奖励保持独立高度。
|
||
let alignedItems = safeRewards.length === 3 ? rowItems.slice(1) : rowItems;
|
||
let labelBottoms = alignedItems.map((item) => {
|
||
return item.group.y + item.label.node.y
|
||
- item.label.node.height * item.label.node.anchorY;
|
||
});
|
||
let targetBottom = Math.min.apply(null, labelBottoms);
|
||
alignedItems.forEach((item, index) => {
|
||
item.group.y += targetBottom - labelBottoms[index];
|
||
});
|
||
});
|
||
}
|
||
|
||
private isToolReward(type: JungleRewardType): boolean {
|
||
return type === "freeze" || type === "hammer" || type === "magic_wand";
|
||
}
|
||
|
||
private getRewardFrame(type: JungleRewardType): cc.SpriteFrame {
|
||
switch (type) {
|
||
case "coin": return this.assets.coinFrame;
|
||
case "freeze": return this.assets.freezeFrame;
|
||
case "hammer": return this.assets.hammerFrame;
|
||
case "magic_wand": return this.assets.magicWandFrame;
|
||
case "infinite_health": return this.assets.infiniteHealthFrame;
|
||
default: return this.assets.coinFrame;
|
||
}
|
||
}
|
||
|
||
private createSpriteNode(name: string, frame: cc.SpriteFrame): cc.Node {
|
||
let node = new cc.Node(name);
|
||
let sprite = node.addComponent(cc.Sprite);
|
||
sprite.sizeMode = cc.Sprite.SizeMode.RAW;
|
||
sprite.spriteFrame = frame;
|
||
if (frame) {
|
||
node.setContentSize(frame.getOriginalSize());
|
||
}
|
||
return node;
|
||
}
|
||
|
||
public createLabel(
|
||
parent: cc.Node,
|
||
text: string,
|
||
fontSize: number,
|
||
color: cc.Color,
|
||
width: number,
|
||
height: number,
|
||
): cc.Label {
|
||
let node = new cc.Node("label");
|
||
node.parent = parent;
|
||
let label = node.addComponent(cc.Label);
|
||
label.overflow = cc.Label.Overflow.SHRINK;
|
||
label.string = text;
|
||
label.fontSize = fontSize;
|
||
label.lineHeight = fontSize + 8;
|
||
label.horizontalAlign = cc.Label.HorizontalAlign.CENTER;
|
||
label.verticalAlign = cc.Label.VerticalAlign.CENTER;
|
||
node.setContentSize(width, height);
|
||
node.color = color;
|
||
return label;
|
||
}
|
||
|
||
private renderButtonContent(content: cc.Node, tier: JungleTierConfig): void {
|
||
let freeImage = content.getChildByName("free_image");
|
||
let priceLabelNode = content.getChildByName("price_label");
|
||
let yuanImage = content.getChildByName("yuan_image");
|
||
let claimImage = content.getChildByName("claim_image");
|
||
let isFree = tier.price <= 0;
|
||
let isClaimablePaidTier = tier.price > 0 && tier.state === 1;
|
||
|
||
let claimSprite = claimImage.getComponent(cc.Sprite);
|
||
if (claimSprite && !claimSprite.spriteFrame && this.assets.claimFrame) {
|
||
claimSprite.spriteFrame = this.assets.claimFrame;
|
||
claimImage.setContentSize(this.assets.claimFrame.getOriginalSize());
|
||
}
|
||
|
||
freeImage.active = isFree;
|
||
priceLabelNode.active = !isFree && !isClaimablePaidTier;
|
||
yuanImage.active = !isFree && !isClaimablePaidTier;
|
||
claimImage.active = isClaimablePaidTier;
|
||
if (isFree || isClaimablePaidTier) {
|
||
return;
|
||
}
|
||
|
||
let priceText = Math.max(0, Math.round(Number(tier.price) || 0)).toString();
|
||
let digitWidth = Math.max(51, priceText.length * 51);
|
||
let yuanWidth = this.assets.yuanFrame
|
||
? this.assets.yuanFrame.getOriginalSize().width
|
||
: 51;
|
||
let gap = 5;
|
||
let totalWidth = digitWidth + gap + yuanWidth;
|
||
let left = -totalWidth * 0.5;
|
||
let priceLabel = priceLabelNode.getComponent(cc.Label);
|
||
priceLabel.string = priceText;
|
||
priceLabelNode.setContentSize(digitWidth, 64);
|
||
priceLabelNode.x = left + digitWidth * 0.5;
|
||
yuanImage.x = left + digitWidth + gap + yuanWidth * 0.5;
|
||
}
|
||
|
||
private formatInfiniteHealth(seconds: number): string {
|
||
if (seconds >= 3600 && seconds % 3600 === 0) {
|
||
return `${seconds / 3600}小时`;
|
||
}
|
||
return `${Math.max(1, Math.floor(seconds / 60))}分钟`;
|
||
}
|
||
|
||
private pad(value: number): string {
|
||
return value < 10 ? "0" + value : value.toString();
|
||
}
|
||
}
|