MatchMaster/assets/jungle_treasure/script/JungleView.ts
2026-08-07 10:59:22 +08:00

479 lines
19 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, "00:00:00", 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 >= 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));
let iconScales = row.map((reward) => this.isToolReward(reward.type)
? 0.45 * (safeRewards.length === 1 ? 1 : iconScale)
: iconScale);
let widths = frames.map((frame, index) => frame ? frame.getOriginalSize().width * iconScales[index] : 0);
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 = frame ? frame.getOriginalSize() : cc.size(0, 0);
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) {
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);
icon.parent = group;
let rewardIconScale = iconScales[index];
icon.setScale(rewardIconScale);
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();
}
}