import { JungleActivityConfig, JungleRenderState, JungleRewardConfig, JungleRewardType, JungleTierConfig, JungleViewAssets, JungleViewRefs, } from "./JungleTypes"; /** 负责 Jungle Treasure 的节点创建、布局和静态渲染。 */ export default class JungleView { private readonly designWidth: number = 1080; private readonly slotCount: number = 6; private contentNode: cc.Node = null; private timerLabel: cc.Label = null; private cards: cc.Node[] = []; private 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); const shade = new cc.Node("shade"); shade.parent = this.root; this.stretchToParent(shade); const 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); const contentScale = Math.min(1, (this.root.width || this.designWidth) / this.designWidth); this.contentNode.setScale(contentScale); const contentHeight = (this.root.height || 1920) / contentScale; const top = contentHeight * 0.5 - 80; const row1Y = top - 1000; const 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), ]; const track = this.createSpriteNode("track", this.assets.trackFrame); track.parent = this.contentNode; track.setPosition(0, row1Y - rowGap); track.zIndex = 0; const title = this.createSpriteNode("title", this.assets.titleFrame); title.parent = this.contentNode; title.setPosition(0, top - 515); title.zIndex = 3; const timer = this.createSpriteNode("timer", this.assets.timerFrame); timer.parent = this.contentNode; timer.setPosition(-358, top - 235); timer.zIndex = 5; this.timerLabel = this.createLabel(timer, "00:00:00", 37, cc.color(128, 91, 54), 220, 62); this.timerLabel.node.setPosition(36, -200); const close = this.createCloseButton(); close.parent = this.contentNode; close.setPosition(430, top - 245); close.zIndex = 5; for (let i = 0; i < this.slotCount; i++) { const 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++) { const 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 renderCard( card: cc.Node, config: JungleActivityConfig, state: JungleRenderState, tierIndex: number, slotIndex: number, ): void { const tier = config.tiers[tierIndex]; card.active = !!tier; if (!tier) { return; } card.name = "reward_card_" + tier.id; const rewardsNode = card.getChildByName("rewards"); rewardsNode.removeAllChildren(); this.renderRewards(rewardsNode, tier.rewards); const button = card.getChildByName("button"); const buttonLabel = button.getChildByName("button_label").getComponent(cc.Label); const lock = button.getChildByName("lock"); const isCurrent = slotIndex === 0 && !state.isPurchasing; const isExpired = Date.now() >= config.endAt; buttonLabel.string = this.formatPrice(tier); buttonLabel.node.opacity = 255; button.scale = 1; lock.active = !isCurrent || isExpired; button.getComponent(cc.Button).interactable = !state.isMoving && !isExpired; button.color = isCurrent ? cc.Color.WHITE : cc.color(220, 220, 220); } /** 更新倒计时并返回剩余秒数。 */ public updateTimer(config: JungleActivityConfig): number { if (!this.timerLabel || !config) { return 0; } const remaining = Math.max(0, Math.floor((config.endAt - Date.now()) / 1000)); const days = Math.floor(remaining / 86400); const hours = Math.floor((remaining % 86400) / 3600); const minutes = Math.floor((remaining % 3600) / 60); const seconds = remaining % 60; this.timerLabel.string = (days > 0 ? days + "天 " : "") + this.pad(hours) + ":" + this.pad(minutes) + ":" + this.pad(seconds); return remaining; } private stretchToParent(node: cc.Node): void { const parentSize = node.parent ? node.parent.getContentSize() : cc.size(this.designWidth, 1920); node.setContentSize(parentSize.width || this.designWidth, parentSize.height || 1920); const 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 { const 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 { const card = new cc.Node("reward_card_" + index); card.setContentSize(444, 400); const bg = this.createSpriteNode("card_bg", this.assets.cardFrame); bg.parent = card; const rewards = new cc.Node("rewards"); rewards.parent = card; rewards.setContentSize(380, 230); rewards.setPosition(0, 48); const 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); const buttonLabel = this.createLabel(button, "免费", 48, cc.Color.WHITE, 270, 88); buttonLabel.node.name = "button_label"; const outline = buttonLabel.node.addComponent(cc.LabelOutline); outline.color = cc.color(120, 53, 22); outline.width = 4; const 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 { const safeRewards = rewards && rewards.length > 0 ? rewards.slice(0, 5) : []; if (safeRewards.length === 0) { return; } const rows: JungleRewardConfig[][] = safeRewards.length <= 3 ? [safeRewards] : [safeRewards.slice(0, 3), safeRewards.slice(3)]; rows.forEach((row, rowIndex) => { const frames = row.map((reward) => this.getRewardFrame(reward.type)); const widths = frames.map((frame) => frame ? frame.getOriginalSize().width : 0); const gap = 8; const totalWidth = widths.reduce((sum, width) => sum + width, 0) + gap * Math.max(0, row.length - 1); let cursorX = -totalWidth * 0.5; row.forEach((reward, index) => { const frame = frames[index]; const originalSize = frame ? frame.getOriginalSize() : cc.size(0, 0); const group = new cc.Node("reward_" + reward.type); group.parent = parent; const rowY = rows.length === 1 ? 15 : (rowIndex === 0 ? 65 : -55); group.setPosition(cursorX + widths[index] * 0.5, rowY); cursorX += widths[index] + gap; const icon = this.createSpriteNode("icon", frame); icon.parent = group; const labelText = reward.type === "infinite_health" ? this.formatInfiniteHealth(reward.count) : "×" + reward.count; const label = this.createLabel( group, labelText, rows.length === 1 ? 36 : 29, cc.Color.WHITE, Math.max(105, originalSize.width), 44, ); label.node.setPosition(0, rows.length === 1 ? -originalSize.height * 0.5 - 10 : -originalSize.height * 0.5 + 17); const outline = label.node.addComponent(cc.LabelOutline); outline.color = cc.color(62, 47, 38); outline.width = 4; }); }); } 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 { const node = new cc.Node(name); const sprite = node.addComponent(cc.Sprite); sprite.sizeMode = cc.Sprite.SizeMode.RAW; sprite.spriteFrame = frame; if (frame) { node.setContentSize(frame.getOriginalSize()); } return node; } private createLabel( parent: cc.Node, text: string, fontSize: number, color: cc.Color, width: number, height: number, ): cc.Label { const node = new cc.Node("label"); node.parent = parent; node.setContentSize(width, height); const label = node.addComponent(cc.Label); label.string = text; label.fontSize = fontSize; label.lineHeight = fontSize + 8; label.horizontalAlign = cc.Label.HorizontalAlign.CENTER; label.verticalAlign = cc.Label.VerticalAlign.CENTER; label.overflow = cc.Label.Overflow.SHRINK; node.color = color; return label; } private formatPrice(tier: JungleTierConfig): string { return tier.price > 0 ? (Math.round(tier.price * 100) / 100).toString() + "元" : "免费"; } 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(); } }