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