MatchMaster/assets/jungle_treasure/script/JungleView.ts
2026-09-22 13:41:47 +08:00

502 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
}