import Service from "../../Script/module/Config/PyramidService"; import PyramidDigits from "./PyramidDigits"; import PyramidAnimationDebug from "./PyramidAnimationDebug"; import { remainingProbabilities, probabilityText } from "./PyramidProbability"; const { ccclass, property } = cc._decorator; const GOODS = { 1001: "coin", 2001: "freeze", 2002: "hammer", 2003: "magic", 2005: "infinite" }; const GOODS_IDS = [1001, 2001, 2002, 2003, 2005]; // Temporary test entry: remove this flag, the debug branch/session and PyramidAnimationDebug.ts before release. const PYRAMID_ANIMATION_DEBUG = false; const DRAW_ANIMATION_SPEED = 1.2; @ccclass export default class PyramidPanel extends cc.Component { @property([cc.SpriteFrame]) goodsFrames: cc.SpriteFrame[] = []; @property([cc.SpriteFrame]) movingGoodsFrames: cc.SpriteFrame[] = []; @property(cc.SpriteFrame) hourFrame: cc.SpriteFrame = null; @property(cc.SpriteFrame) minuteFrame: cc.SpriteFrame = null; @property(cc.SpriteFrame) countPrefixFrame: cc.SpriteFrame = null; @property(cc.SpriteFrame) probabilityCloseFrame: cc.SpriteFrame = null; @property([cc.SpriteFrame]) countdownFrames: cc.SpriteFrame[] = []; @property(cc.SpriteFrame) countdownBackgroundFrame: cc.SpriteFrame = null; private frames: { [name: string]: cc.SpriteFrame } = {}; private digits: { [key: string]: cc.SpriteFrame } = {}; private price: { [key: string]: cc.SpriteFrame } = {}; private probabilityDigits: { [key: string]: cc.SpriteFrame } = {}; private continueFrame: cc.SpriteFrame = null; private continueMaterial: cc.Material = null; private bundle: cc.AssetManager.Bundle = null; private priceTask: Promise = null; private priceError = false; private probabilityTask: Promise = null; private probabilityModal: cc.Node = null; private design: cc.Node; private rewardRoot: cc.Node; private tip: cc.Node; private ready = false; private signature = ""; private unsubscribe: () => void; private closing: () => void; private clock = 0; private clockText: cc.Node = null; private clockSignature = ""; private shimmer: cc.Node = null; private animation: Promise = null; private drawing = false; private finishAnimation: () => void = null; private renderedDraw = ""; private rewardAnimation: cc.Node = null; private summitGlow: cc.Node = null; private claimPulseNode: cc.Node = null; private claimPulseTime = 0; private currentArrows: cc.Node[] = []; private arrowTime = 0; private debugSession: PyramidAnimationDebug = null; private animationDebug(): boolean { return PYRAMID_ANIMATION_DEBUG && ((typeof CC_PREVIEW !== "undefined" && CC_PREVIEW) || ["开发版", "体验版", "网页版"].includes(cc.fx.GameTool.getWechatGameVersion())); } private state(): any { const source = Service.data(); if (!this.animationDebug()) return source; if (!source) { this.debugSession = null; return null; } const identity = String(cc.fx.GameConfig.GM_INFO.uid) + ":" + source.periodId; if (!this.debugSession || this.debugSession.identity !== identity) this.debugSession = new PyramidAnimationDebug(source, identity); return this.debugSession.state; } private isAlive(): boolean { return cc.isValid(this, true) && cc.isValid(this.node, true); } async initialize(close: () => void): Promise { if (!this.isAlive()) return; this.closing = close; this.design = this.node.getChildByName("Design"); this.summitGlow = this.design.getChildByName("SummitGlow"); this.rewardRoot = this.design.getChildByName("Rewards"); this.tip = this.design.getChildByName("Tooltip"); const size = cc.view.getVisibleSize(); this.design.setScale(Math.min(size.width / 1080, size.height / 2000)); this.node.setContentSize(size); const shield = new cc.Node("Backdrop"); this.node.insertChild(shield, 0); shield.setContentSize(size); const graphics = shield.addComponent(cc.Graphics); graphics.fillColor = new cc.Color(0, 0, 0, 165); graphics.rect(-size.width / 2, -size.height / 2, size.width, size.height); graphics.fill(); shield.addComponent(cc.BlockInputEvents); this.node.addComponent(cc.BlockInputEvents); const bundle = await Service.bundle().catch(error => { if (this.isAlive()) throw error; return null; }); if (!this.isAlive()) return; this.bundle = bundle; if (this.goodsFrames.length !== GOODS_IDS.length || this.movingGoodsFrames.length !== GOODS_IDS.length || !this.hourFrame || !this.minuteFrame) throw new Error("金字塔新版奖励素材配置不完整"); if (this.countdownFrames.length !== 10 || !this.countdownBackgroundFrame) throw new Error("金字塔缺少黄金矿工倒计时素材"); GOODS_IDS.forEach((id, index) => { this.frames[GOODS[id]] = this.goodsFrames[index]; }); this.frames.hours = this.hourFrame; this.frames.minutes = this.minuteFrame; const paths = ["check", "selection", "arrow", "arrow-dim", "draw", "yuan", "free", "claim", "tooltip", "question", "pointer", "chest-1", "chest-2", "chest-3", "chest-4"]; for (let i = 1; i <= 5; i++) paths.push("row-" + i); for (let i = 0; i < 10; i++) paths.push("digits/" + i); await Promise.all(paths.map(path => new Promise((resolve, reject) => this.bundle.load("art/" + path, cc.SpriteFrame, (err, frame: cc.SpriteFrame) => { if (!this.isAlive()) { resolve(); return; } if (err) { reject(err); return; } this.frames[path] = frame; resolve(); })))).catch(error => { if (this.isAlive()) throw error; }); if (!this.isAlive()) return; for (let i = 0; i < 10; i++) this.digits[i] = this.frames["digits/" + i]; if (!this.countPrefixFrame) throw new Error("金字塔缺少数量前缀 x 图片"); this.digits.x = this.countPrefixFrame; if (this.state() && this.state().canPay) await this.ensurePriceAssets(); if (!this.isAlive()) return; this.design.getChildByName("Free").getComponent(cc.Sprite).spriteFrame = this.frames.free; this.button("Close", () => { if (!Service.deliveryBusy && !this.animation && !this.drawing) this.closing(); }); this.button("Help", () => { void this.help(); }); this.button("Free", () => { if (this.state() && this.state().canClaimFinal) void this.action(); }); this.button("Action", () => { void this.action(); }); // Capture before the backdrop's BlockInputEvents stops bubbling. this.node.on(cc.Node.EventType.TOUCH_START, () => { this.tip.active = false; }, this, true); this.unsubscribe = Service.subscribe(() => this.render()); this.ready = true; this.tip.active = false; this.render(); if (!this.animationDebug()) { void Service.recordPriceView(); if (this.state() && this.state().pendingGrants.length) await Service.deliver(); } } private button(name: string, callback: () => void): void { const node = this.design.getChildByName(name); const button = node.addComponent(cc.Button); button.transition = cc.Button.Transition.SCALE; button.zoomScale = 1.04; node.on("click", callback); } private sprite(parent: cc.Node, name: string, frame: string, x: number, y: number, w: number, h: number): cc.Node { const n = new cc.Node(name); parent.addChild(n); n.setPosition(x, y); const s = n.addComponent(cc.Sprite); const isGood = GOODS_IDS.some(id => GOODS[id] === frame); s.sizeMode = isGood ? cc.Sprite.SizeMode.TRIMMED : cc.Sprite.SizeMode.CUSTOM; s.spriteFrame = this.frames[frame]; if (isGood) { const rect = s.spriteFrame.getRect(); n.setScale(Math.min(w / rect.width, h / rect.height)); } else { n.setContentSize(w, h); } return n; } private words(parent: cc.Node, text: string, x: number, y: number, size: number, width = 800): cc.Node { // Counts and prices use number(); the countdown has its own Label. if (/\d/.test(text)) throw new Error("金字塔数值禁止使用 Label"); const n = new cc.Node("Text"); parent.addChild(n); n.setPosition(x, y); n.setContentSize(width, size * 2.5); const label = n.addComponent(cc.Label); label.string = text; label.fontSize = size; label.lineHeight = size + 7; label.horizontalAlign = cc.Label.HorizontalAlign.CENTER; label.verticalAlign = cc.Label.VerticalAlign.CENTER; label.overflow = cc.Label.Overflow.SHRINK; n.setContentSize(width, size * 2.5); n.color = new cc.Color(105, 53, 31); return n; } private number(parent: cc.Node, value: number | string, x: number, y: number, height: number, width: number, price = false): cc.Node { const n = new cc.Node("SpriteNumber"); parent.addChild(n); n.setPosition(x, y); n.addComponent(PyramidDigits).set(value, price ? this.price : this.digits, height, width); return n; } private quantity(parent: cc.Node, good: { id: number; count: number }, x: number, y: number, height: number, width: number): void { const n = new cc.Node("Quantity"); parent.addChild(n); n.setPosition(x, y); const value = good.count; n.addComponent(PyramidDigits).set(good.id === 1001 ? value : "x" + value, this.digits, height, width, true); } private duration(parent: cc.Node, seconds: number, x: number, y: number, width: number, numberHeight: number, unitWidth: number, unitHeight: number, scale = 1): void { const useHours = seconds >= 3600; const value = Math.ceil(seconds / (useHours ? 3600 : 60)); const unitFrame = useHours ? "hours" : "minutes", gap = 2; const container = new cc.Node("Duration"); parent.addChild(container); container.setPosition(x, y); container.setScale(scale); // Allow two-digit durations at the same height as single-digit hours. const numberNode = this.number(container, value, 0, 0, numberHeight, width); const numberWidth = numberNode.width * numberNode.scaleX; const unit = this.sprite(container, useHours ? "Hours" : "Minutes", unitFrame, 0, 0, unitWidth, unitHeight); const contentWidth = numberWidth + gap + unitWidth; numberNode.x = -contentWidth / 2 + numberWidth / 2; unit.x = -contentWidth / 2 + numberWidth + gap + unitWidth / 2; } private continueHint(parent: cc.Node, x: number, y: number): cc.Node { const node = new cc.Node("ClickToContinue"); parent.addChild(node); node.setPosition(x, y); node.setContentSize(228, 47); const sprite = node.addComponent(cc.Sprite); sprite.sizeMode = cc.Sprite.SizeMode.TRIMMED; sprite.spriteFrame = this.continueFrame; sprite.setMaterial(0, this.continueMaterial); return node; } private ensurePriceAssets(): Promise { if (!this.isAlive()) return Promise.resolve(); if (this.price["0"]) return Promise.resolve(); if (!this.priceTask) { this.priceError = false; this.priceTask = Promise.all(Array.from({ length: 10 }, (_, i) => new Promise((resolve, reject) => { this.bundle.load("art/price/" + i, cc.SpriteFrame, (error, frame: cc.SpriteFrame) => { if (!this.isAlive()) { resolve(); return; } if (error) { reject(error); return; } this.price[i] = frame; resolve(); }); }))).then(() => { }).catch(error => { if (!this.isAlive()) return; this.priceTask = null; this.priceError = true; throw error; }); } return this.priceTask; } private ensureProbabilityAssets(): Promise { if (!this.isAlive()) return Promise.resolve(); if (this.probabilityDigits["0"] && this.continueFrame && this.continueMaterial) return Promise.resolve(); if (!this.probabilityTask) { const paths = ["probability/background", "probability/title", "probability/point", "probability/percent"]; for (let i = 0; i < 10; i++) paths.push("probability/" + i); const pyramidAssets = Promise.all(paths.map(path => new Promise((resolve, reject) => { this.bundle.load("art/" + path, cc.SpriteFrame, (error, frame: cc.SpriteFrame) => { if (!this.isAlive()) { resolve(); return; } if (error) { reject(error); return; } this.frames[path] = frame; resolve(); }); }))); const sharedAssets = new Promise((resolve, reject) => { cc.assetManager.loadBundle("cloud_rise_home", (error, bundle) => { if (!this.isAlive()) { resolve(null); return; } error ? reject(error) : resolve(bundle); }); }).then(bundle => { if (!this.isAlive()) return; return Promise.all([ new Promise((resolve, reject) => bundle.load("images/continue", cc.SpriteFrame, (error, frame: cc.SpriteFrame) => { if (!this.isAlive()) { resolve(); return; } if (error) { reject(error); return; } this.continueFrame = frame; resolve(); })), new Promise((resolve, reject) => bundle.load("images/continue_outline", cc.Material, (error, material: cc.Material) => { if (!this.isAlive()) { resolve(); return; } if (error) { reject(error); return; } this.continueMaterial = material; resolve(); })) ]); }); this.probabilityTask = Promise.all([pyramidAssets, sharedAssets]).then(() => { if (!this.isAlive()) return; for (let i = 0; i < 10; i++) this.probabilityDigits[i] = this.frames["probability/" + i]; this.probabilityDigits["."] = this.frames["probability/point"]; this.probabilityDigits["%"] = this.frames["probability/percent"]; }).catch(error => { if (!this.isAlive()) return; this.probabilityTask = null; throw error; }); } return this.probabilityTask; } private rewardIcon(reward: any, level: any): string { const base = reward.icon && this.frames[reward.icon] ? reward.icon : reward.goods.length > 1 ? "chest-1" : GOODS[reward.goods[0].id]; if (level.level !== 5 || !/^chest-[1-4]$/.test(base)) return base; // Same coin-equivalent values as the activity policy; infinite-health counts are seconds. const values = { 1001: 1, 2001: 200, 2002: 600, 2003: 300, 2005: 40 / 60 }; const value = (r: any) => r.goods.reduce((sum, g) => sum + g.count * (values[g.id] || 0), 0); const chests = level.rewards.filter(r => /^chest-[1-4]$/.test(r.icon) || r.goods.length > 1); if (chests.length < 2) return base; const highest = chests.reduce((best, r) => value(r) > value(best) ? r : best); return reward.rewardId === highest.rewardId ? "chest-4" : "chest-2"; } private rewardRow(level: number): cc.Node { const row = this.rewardRoot.getChildByName("Level_" + level); if (!row) throw new Error(`金字塔 prefab 缺少 Level_${level} 奖励层节点`); return row; } private rewardSlots(row: cc.Node): cc.Node[] { return row.children.filter(node => /^Slot_\d+$/.test(node.name)).sort((left, right) => { return Number(left.name.slice(5)) - Number(right.name.slice(5)); }); } private fillRewardRows(state: any): void { this.currentArrows = []; this.arrowTime = 0; state.levels.filter(level => level.level < 6).forEach(level => { const row = this.rewardRow(level.level); const background = row.getChildByName("CurrentLevelBackground"); if (!background) throw new Error(`金字塔 prefab 缺少 Level_${level.level}/CurrentLevelBackground 节点`); background.active = level.level === state.currentLevel; if (background.active) { // Supplied strips run from the narrow top (row-1) to the wide bottom (row-5). const frame = this.frames["row-" + (6 - level.level)]; const sprite = background.getComponent(cc.Sprite) || background.addComponent(cc.Sprite); // Keep the prefab's layer bounds; loading a frame must not resize into adjacent layers. sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; sprite.spriteFrame = frame; } const slots = this.rewardSlots(row); let arrows = row.getChildByName("Arrows"); if (!arrows) { arrows = new cc.Node("Arrows"); row.addChild(arrows); } this.clear(arrows); if (level.rewards.length > slots.length) throw new Error(`金字塔 Level_${level.level} 奖励数 ${level.rewards.length} 超出 prefab 槽位数 ${slots.length}`); slots.forEach((slot, index) => { slot.targetOff(this); this.clear(slot); const reward = level.rewards[index]; slot.active = !!reward; (slot as any).pyramidRewardId = reward ? reward.rewardId : null; if (!reward) return; const base = this.rewardIcon(reward, level); const chest = /^chest-[1-4]$/.test(base); this.sprite(slot, "Prize", base || "chest-1", 0, 6, 105, 100); if (!chest && reward.goods.length === 1) { const good = reward.goods[0]; if (good.id === 2005) { this.duration(slot, good.count, 0, -43, 100, 34, 62, 33); } else { this.quantity(slot, good, 0, -43, 34, 125); } } else if (chest) this.sprite(slot, "Details", "question", 45, 45, 38, 40); if (reward.up === true) { // Keep the slot transform while drawing arrows above the full-row mask. const anchor = new cc.Node(slot.name); arrows.addChild(anchor); anchor.setPosition(slot.x, slot.y); anchor.scaleX = slot.scaleX; anchor.scaleY = slot.scaleY; const arrow = this.sprite(anchor, "Up", level.level === state.currentLevel ? "arrow-dim" : "arrow", 0, 90, 54, 52); if (level.level === state.currentLevel) this.currentArrows.push(arrow); } if (reward.deliveryStatus === "delivered") this.sprite(slot, "Check", "check", 0, 4, 78, 78); else if (reward.deliveryStatus === "pending") { const glow = this.sprite(slot, "Selected", "selection", 0, 4, 134, 127); glow.setSiblingIndex(0); } if (chest) slot.on(cc.Node.EventType.TOUCH_END, (event: cc.Event.EventTouch) => { event.stopPropagation(); this.details(reward, slot); }, this); }); let cover = row.getChildByName("PassedLevelMask"); if (!cover) { cover = new cc.Node("PassedLevelMask"); row.addChild(cover); cover.addComponent(cc.Graphics); } cover.setPosition(background.x, background.y); cover.setContentSize(background.width, background.height); cover.scaleX = background.scaleX; cover.scaleY = background.scaleY; const gray = cover.getComponent(cc.Graphics); gray.clear(); gray.fillColor = new cc.Color(100, 100, 100, 100); gray.rect(-cover.width / 2, -cover.height / 2, cover.width, cover.height); gray.fill(); cover.active = level.level < state.currentLevel; cover.setSiblingIndex(row.childrenCount - 1); arrows.setSiblingIndex(row.childrenCount - 1); }); } private render(): void { if (!this.ready || !this.isAlive()) return; const s = this.state(); if (!s) return; const signature = JSON.stringify([s.revision, s.periodId, s.currentLevel]); if (signature !== this.signature && !this.drawing) { this.signature = signature; this.tip.active = false; if (this.shimmer) { cc.Tween.stopAllByTarget(this.shimmer); this.shimmer.destroy(); this.shimmer = null; } this.fillRewardRows(s); if (this.probabilityModal) void this.help(); } const action = this.design.getChildByName("Action"); const actionButton = action.getComponent(cc.Button); const enabled = !Service.busy && !this.animation && !this.drawing && (this.animationDebug() || Service.now() < s.endAt); const claim = this.design.getChildByName("Free"); claim.active = true; claim.getComponent(cc.Sprite).spriteFrame = s.currentLevel === 6 ? this.frames.claim : this.frames.free; claim.getComponent(cc.Button).interactable = enabled && s.currentLevel === 6 && s.canClaimFinal && !s.completed; claim.opacity = 255; this.syncClaimPulse(claim, s.currentLevel === 6 && s.canClaimFinal && !s.completed && Service.now() < s.endAt); const showsPrice = !s.canDraw && s.currentLevel < 6, pricePending = showsPrice && !this.price["0"]; if (pricePending && !this.priceTask && !this.priceError) void this.ensurePriceAssets().then(() => this.render(), error => { cc.warn("[Pyramid] 价格资源加载失败", error); if (cc.isValid(this.node, true)) this.render(); }); if (this.drawing || pricePending) { // Keep the previous draw artwork while throttling taps or preparing the configured price. actionButton.interactable = !this.drawing && this.priceError && enabled; this.renderClock(); return; } action.active = s.currentLevel < 6; action.children.slice().forEach(n => n.destroy()); actionButton.interactable = enabled && (!s.completed || (!this.animationDebug() && !!Service.error)); if (!action.active) { this.renderClock(); return; } if (s.canDraw) this.sprite(action, "Draw", "draw", 0, 0, 160, 79); else { const fen = s.levels[s.currentLevel - 1].itemPrice; if (fen % 100 === 0) { const n = this.number(action, fen / 100, -43, 0, 65, 325, true); this.sprite(action, "Yuan", "yuan", (n.width * n.scaleX + 18) / 2, 0, 67, 65); } else { this.number(action, fen, -35, 0, 65, 325, true); this.words(action, "分", 110, 0, 50, 70); } } this.renderClock(); } private syncClaimPulse(node: cc.Node, active: boolean): void { if (active && !this.claimPulseNode) { this.claimPulseNode = node; this.claimPulseTime = 0; node.setScale(1); node.getComponent(cc.Button).transition = cc.Button.Transition.NONE; } else if (!active && this.claimPulseNode) { this.claimPulseNode.setScale(1); this.claimPulseNode = null; node.getComponent(cc.Button).transition = cc.Button.Transition.SCALE; } } private renderClock(): void { const n = this.design.getChildByName("Countdown"), s = this.state(); if (!s) return; if (!this.clockText) { const pill = new cc.Node("ClockBackground"); n.addChild(pill); pill.setContentSize(283, 56); const background = pill.addComponent(cc.Sprite); background.sizeMode = cc.Sprite.SizeMode.CUSTOM; background.spriteFrame = this.countdownBackgroundFrame; this.clockText = new cc.Node("ClockText"); n.addChild(this.clockText); this.clockText.setPosition(25, 0); } const remaining = Math.max(0, s.endAt - Service.now()), secs = Math.ceil(remaining / 1000); const pad = (value: number) => value < 10 ? "0" + value : String(value); const text = secs >= 86400 ? `${Math.floor(secs / 86400)}天${Math.floor(secs % 86400 / 3600)}时` : `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs % 3600 / 60))}:${pad(secs % 60)}`; if (this.clockSignature === text) return; this.clockSignature = text; this.clear(this.clockText); const height = 31, gap = 1.5; const widths = text.split("").map(char => { if (!/\d/.test(char)) return height * (char === ":" ? .36 : 1); const rect = this.countdownFrames[Number(char)].getRect(); return height * rect.width / rect.height; }); const total = widths.reduce((sum, width) => sum + width, 0) + gap * (text.length - 1); let left = -total / 2; text.split("").forEach((char, index) => { const glyph = new cc.Node("Clock_" + index); this.clockText.addChild(glyph); glyph.setPosition(left + widths[index] / 2, 0); glyph.setContentSize(widths[index], height); if (/\d/.test(char)) { const sprite = glyph.addComponent(cc.Sprite); sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; sprite.spriteFrame = this.countdownFrames[Number(char)]; } else { // Match the miner countdown: separators/units use text; all digits use artwork. const label = glyph.addComponent(cc.Label); label.string = char; label.fontSize = char === ":" ? height * 1.4 : height; label.lineHeight = height * 1.6; label.horizontalAlign = cc.Label.HorizontalAlign.CENTER; label.verticalAlign = cc.Label.VerticalAlign.CENTER; label.overflow = cc.Label.Overflow.NONE; } left += widths[index] + gap; }); this.clockText.setContentSize(total, height); this.clockText.setScale(Math.min(1, 218 / total)); } private details(reward: any, slot: cc.Node): void { this.clear(this.tip); this.tip.active = true; const columns = Math.min(4, reward.goods.length), rows = Math.ceil(reward.goods.length / columns); const width = columns * 120 + 34, height = rows * 128 + 18; const anchor = this.design.convertToNodeSpaceAR(slot.convertToWorldSpaceAR(cc.v2(0, 56))); const x = Math.max(-470 + width / 2, Math.min(470 - width / 2, anchor.x)); this.tip.setPosition(x, anchor.y + height / 2 + 20); const bg = this.sprite(this.tip, "Box", "tooltip", 0, 0, width, height); const background = bg.getComponent(cc.Sprite), frame = background.spriteFrame; frame.insetLeft = frame.insetRight = frame.insetTop = frame.insetBottom = 28; background.type = cc.Sprite.Type.SLICED; this.sprite(this.tip, "Pointer", "pointer", anchor.x - x, -height / 2 - 7, 28, 20); bg.on(cc.Node.EventType.TOUCH_END, () => { this.tip.active = false; }); reward.goods.forEach((g, i) => { const x = ((i % columns) - (columns - 1) / 2) * 120; const y = (rows - 1) * 64 - Math.floor(i / columns) * 128; this.sprite(this.tip, "Good", GOODS[g.id] || "chest-1", x, y + 15, 95, 95); if (g.id === 2005) this.duration(this.tip, g.count, x, y - 36, 100, 34, 62, 33, Math.abs(slot.scaleX)); else this.quantity(this.tip, g, x, y - 36, 34, 105); }); this.tip.setSiblingIndex(this.design.childrenCount - 1); } private async help(): Promise { if (!this.isAlive()) return; const help = this.design.getChildByName("Help"), button = help && help.getComponent(cc.Button); if (button) button.interactable = false; try { await this.ensureProbabilityAssets(); } catch (error) { cc.warn("[Pyramid] 概率资源加载失败", error); return; } finally { if (this.isAlive() && cc.isValid(button, true) && cc.isValid(button.node, true)) button.interactable = true; } if (!this.isAlive()) return; const state = this.state(), level = state && state.levels.find(l => l.level === state.currentLevel); if (!level) return; this.tip.active = false; this.closeHelp(); const modal = new cc.Node("ProbabilityModal"); this.design.addChild(modal); this.probabilityModal = modal; const size = cc.view.getVisibleSize(), shade = new cc.Node("Shade"); modal.addChild(shade); shade.setContentSize(size.width / this.design.scaleX, size.height / this.design.scaleY); const dim = shade.addComponent(cc.Graphics); dim.fillColor = new cc.Color(0, 0, 0, 190); dim.rect(-shade.width / 2, -shade.height / 2, shade.width, shade.height); dim.fill(); shade.addComponent(cc.BlockInputEvents); shade.on(cc.Node.EventType.TOUCH_END, () => this.closeHelp()); const box = this.sprite(modal, "ProbabilityBox", "probability/background", 0, 0, 902, 1378); const sprite = box.getComponent(cc.Sprite), frame = sprite.spriteFrame; frame.insetTop = 200; frame.insetBottom = 125; frame.insetLeft = frame.insetRight = 60; sprite.type = cc.Sprite.Type.SLICED; box.addComponent(cc.BlockInputEvents); this.sprite(box, "Title", "probability/title", 0, 557, 314, 86); const close = new cc.Node("CloseProbability"); box.addChild(close); close.setPosition(366, 558); close.setContentSize(100, 100); const closeIcon = new cc.Node("Icon"); close.addChild(closeIcon); const closeSprite = closeIcon.addComponent(cc.Sprite); closeSprite.sizeMode = cc.Sprite.SizeMode.TRIMMED; closeSprite.spriteFrame = this.probabilityCloseFrame; close.addComponent(cc.Button).transition = cc.Button.Transition.SCALE; close.on("click", () => this.closeHelp()); const probabilities = remainingProbabilities(level.level, level.rewards); level.rewards.forEach((reward, i) => { const row = new cc.Node("Probability_" + reward.rewardId); box.addChild(row); row.setPosition(0, 375 - i * 164); const g = row.addComponent(cc.Graphics); g.fillColor = new cc.Color(255, 242, 192); g.roundRect(-394, -72, 788, 144, 34); g.fill(); g.fillColor = new cc.Color(255, 250, 227); g.roundRect(-394, -72, 334, 144, 72); g.fill(); g.rect(-260, -72, 200, 144); g.fill(); g.strokeColor = new cc.Color(234, 202, 141); g.lineWidth = 6; g.roundRect(-394, -72, 788, 144, 68); g.stroke(); g.strokeColor = cc.Color.WHITE; g.lineWidth = 2; g.roundRect(-389, -67, 778, 134, 65); g.stroke(); const base = this.rewardIcon(reward, level); const chest = /^chest-[1-4]$/.test(base); const lowerTool = ["freeze", "hammer", "magic"].includes(base); this.sprite(row, "Prize", base || "chest-1", -216, chest || lowerTool ? 0 : 15, chest ? 122 : 104, 104); if (!chest && reward.goods.length === 1) { const good = reward.goods[0], infinite = good.id === 2005; if (infinite) this.duration(row, good.count, -216, -38, 180, 34, 62, 33); else this.quantity(row, good, -216, -38, 34, 180); } if (reward.gain && level.level < 6 || reward.deliveryStatus === "delivered") this.sprite(row, "Check", "check", 243, 0, 100, 82); else if (level.level === 6) this.sprite(row, "Free", "free", 180, 0, 178, 64); else { const value = new cc.Node("ProbabilityValue"); row.addChild(value); value.setPosition(180, 0); value.addComponent(PyramidDigits).set(probabilityText(probabilities[i], level.level), this.probabilityDigits, 44, 310, true); } }); this.continueHint(modal, 0, -835); } private closeHelp(): void { if (this.probabilityModal) { this.probabilityModal.removeFromParent(); this.probabilityModal.destroy(); this.probabilityModal = null; } } private async action(): Promise { if (Service.busy || this.animation || this.drawing) return; const state = this.state(); if (state && !state.canDraw && state.currentLevel < 6 && !this.price["0"]) { try { if (!this.priceTask) await this.ensurePriceAssets(); } catch (error) { cc.warn("[Pyramid] 价格资源加载失败", error); } if (cc.isValid(this.node, true)) this.render(); return; } this.tip.active = false; if (this.animationDebug()) await this.actionDebug(); else await this.actionLive(); } /** Temporary test flow; remove together with PyramidAnimationDebug before release. */ private async actionDebug(): Promise { const s = this.state(); if (!s || s.completed) return; this.drawing = true; try { if (s.canDraw) { const result = this.debugSession.draw(); if (!result) return; this.animation = this.animateResult(result, result.available); this.render(); await this.animation; if (cc.isValid(this.node, true)) await this.animateRewards(result.goods); } else if (s.canClaimFinal) { const goods = this.debugSession.claim(); this.render(); await this.animateRewards(goods); } } finally { this.animation = null; this.drawing = false; if (cc.isValid(this.node, true)) this.render(); } } private async actionLive(): Promise { const s = Service.data(); if (!s) { await Service.sync(); return; } if (s.pendingGrants.length) { await Service.deliver(); return; } if (s.canDraw) { const available = s.levels[s.currentLevel - 1].rewards.filter(r => !r.gain).map(r => r.rewardId); this.drawing = true; try { const ok = await Service.act("draw"); if (ok && cc.isValid(this.node, true) && Service.lastDraw && Service.lastDraw.requestId !== this.renderedDraw) { const goods = Service.lastDraw.goods; this.renderedDraw = Service.lastDraw.requestId; this.animation = this.animateResult(Service.lastDraw, available); this.render(); await this.animation; if (cc.isValid(this.node, true) && await Service.deliver() && cc.isValid(this.node, true)) await this.animateRewards(goods); } } finally { this.animation = null; this.drawing = false; if (cc.isValid(this.node, true)) this.render(); } } else if (s.canClaimFinal) { const goods = s.levels.reduce((all, level) => all.concat(...level.rewards.filter(r => r.deliveryStatus !== "delivered").map(r => r.goods)), []); this.drawing = true; try { if (await Service.act("claim") && await Service.deliver() && cc.isValid(this.node, true)) await this.animateRewards(goods); } finally { this.drawing = false; if (cc.isValid(this.node, true)) this.render(); } } else if (s.canPay && !Service.error) await Service.buy(); else { if (await Service.sync(true) && Service.data() && Service.data().pendingGrants.length) await Service.deliver(); } } async recoverDelivery(): Promise { if (this.animationDebug()) return; if (this.ready && !this.animation && !this.drawing && !Service.busy && Service.data() && Service.data().pendingGrants.length) await Service.deliver(); } private animateResult(result: any, available: string[]): Promise { const row = this.rewardRow(result.previousLevel); // Row children follow the visual left-to-right slot order. The list is captured BEFORE the draw response. const slots = this.rewardSlots(row).filter(slot => available.includes((slot as any).pyramidRewardId)); const target = slots.findIndex(slot => (slot as any).pyramidRewardId === result.rewardId); if (target < 0) return Promise.resolve(); if (this.shimmer) { cc.Tween.stopAllByTarget(this.shimmer); this.shimmer.destroy(); } this.shimmer = this.sprite(row, "DrawSelection", "selection", 0, 0, 134, 127); this.shimmer.setSiblingIndex(row.getChildByName("CurrentLevelBackground") ? 1 : 0); const moveTo = (index: number) => { const slot = slots[index]; this.shimmer.setPosition(slot.x, slot.y + 4 * slot.scaleY); this.shimmer.setScale(slot.scaleX, slot.scaleY); }; moveTo(0); return new Promise(resolve => { this.finishAnimation = resolve; let tween = cc.tween(this.shimmer); const steps = slots.length > 1 ? slots.length * 3 + target : 0; for (let step = 1; step <= steps; step++) { const delay = (0.055 + 0.245 * Math.pow(step / steps, 2)) / DRAW_ANIMATION_SPEED; tween = tween.delay(delay).call(() => moveTo(step % slots.length)); } tween.delay(0.45 / DRAW_ANIMATION_SPEED).call(() => { this.finishAnimation = null; resolve(); }).start(); }); } /** Presentation only: tuning the reward reveal must never change the player's inventory. */ private async animateRewards(goods: Array<{ id: number; count: number }>): Promise { if (!this.isAlive() || !goods.length) return; const types = { 1001: "coin", 2001: "freeze", 2002: "hammer", 2003: "magic", 2005: "infinite_health" }; const rewards: Array<{ type: string; count: number; spriteFrame: cc.SpriteFrame }> = []; goods.forEach(good => { const type = types[good.id], existing = rewards.find(r => r.type === type); if (existing) existing.count += good.count; else rewards.push({ type, count: good.count, spriteFrame: this.movingGoodsFrames[GOODS_IDS.indexOf(good.id)] }); }); const bundle = cc.assetManager.getBundle("publicUI") || await new Promise((resolve, reject) => { cc.assetManager.loadBundle("publicUI", (error, loaded) => { if (!this.isAlive()) { resolve(null); return; } error ? reject(error) : resolve(loaded); }); }).catch(error => { if (this.isAlive()) throw error; return null; }); if (!this.isAlive()) return; const prefab = await new Promise((resolve, reject) => { bundle.load("prefab/RewardWindow", cc.Prefab, (error, loaded: cc.Prefab) => { if (!this.isAlive()) { resolve(null); return; } error ? reject(error) : resolve(loaded); }); }).catch(error => { if (this.isAlive()) throw error; return null; }); if (!this.isAlive()) return; // Use the battle-pass reward prefab and its own reveal/click-to-close animation. const overlay = cc.instantiate(prefab); this.node.addChild(overlay); overlay.setPosition(0, 0); overlay.zIndex = 1001; this.rewardAnimation = overlay; const widget = overlay.getComponent(cc.Widget); if (widget) widget.updateAlignment(); return new Promise(resolve => { this.finishAnimation = resolve; overlay.getComponent("Reward").init(rewards, () => { this.finishAnimation = null; this.rewardAnimation = null; resolve(); }, true); }); } private clear(node: cc.Node): void { node.children.slice().forEach(child => child.destroy()); node.removeAllChildren(); } update(dt: number): void { if (!this.ready) return; this.arrowTime += dt; const offset = Math.sin(this.arrowTime * Math.PI * 2 / 1.2) * 5; this.currentArrows.forEach(arrow => { if (cc.isValid(arrow, true)) arrow.y = 90 + offset; }); if (this.summitGlow) this.summitGlow.angle = (this.summitGlow.angle - 60 * dt) % 360; if (this.claimPulseNode) { this.claimPulseTime = (this.claimPulseTime + dt) % 2; const t = this.claimPulseTime; const scale = t < .2 ? 1 - .1 * t / .2 : t < .4 ? .9 + .2 * (t - .2) / .2 : t < .6 ? 1.1 - .1 * (t - .4) / .2 : 1; this.claimPulseNode.setScale(scale); } this.clock += dt; if (this.clock >= 1) { this.clock = 0; this.renderClock(); } } onDestroy(): void { if (this.unsubscribe) this.unsubscribe(); if (this.shimmer) cc.Tween.stopAllByTarget(this.shimmer); if (this.rewardAnimation) { cc.Tween.stopAllByTarget(this.rewardAnimation); this.rewardAnimation.children.forEach(child => cc.Tween.stopAllByTarget(child)); this.rewardAnimation.destroy(); this.rewardAnimation = null; } if (this.finishAnimation) this.finishAnimation(); } }