Files
MatchMaster/assets/pyramid/scripts/PyramidPanel.ts
T
2026-10-13 12:55:12 +08:00

629 lines
40 KiB
TypeScript

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<void> = null;
private priceError = false;
private probabilityTask: Promise<void> = 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<void> = 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<void> {
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<void>((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<void> {
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<void>((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<void> {
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<void>((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<cc.AssetManager.Bundle>((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<void>((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<void>((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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<cc.AssetManager.Bundle>((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<cc.Prefab>((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();
}
}