MatchMaster/assets/cloud_rise_home/scripts/CloudRisePanel.ts
2026-09-16 18:19:34 +08:00

689 lines
40 KiB
TypeScript

import Service from "./CloudRiseRuntime";
import Bridge from "../../Script/module/Config/CloudRiseService";
import CloudRiseNumberToImage from "./CloudRiseNumberToImage";
import CloudRisePage from "./CloudRisePage";
const { ccclass, property } = cc._decorator;
// Shorten avatar motion and launch delays together, preserving the staggered trajectories.
const AVATAR_MOTION_TIME_SCALE = 0.7;
/** Layout and styles are authored in prefab/CloudRise. This component binds data and actions only. */
@ccclass
export default class CloudRisePanel extends cc.Component {
@property(cc.Node) failureMessage: cc.Node = null;
@property(cc.Node) expiredMessage: cc.Node = null;
@property([cc.Node]) progressStatsContents: cc.Node[] = [];
@property(cc.Node) victoryPage: cc.Node = null;
@property(cc.Node) victoryShade: cc.Node = null;
@property([cc.Node]) victoryGroups: cc.Node[] = [];
@property(CloudRiseNumberToImage) victoryReward: CloudRiseNumberToImage = null;
@property(cc.Label) victoryPending: cc.Label = null;
@property(cc.Label) victoryOthers: cc.Label = null;
@property(cc.Node) victoryShared: cc.Node = null;
@property([cc.Node]) victorySlots: cc.Node[] = [];
private victoryKey = "";
@property(cc.Node) matchingBackground: cc.Node = null;
@property(cc.Node) progressBackground: cc.Node = null;
@property(cc.Node) matchingPrompt: cc.Node = null;
@property(cc.Label) matchingTitle: cc.Label = null;
@property(cc.Label) matchingCount: cc.Label = null;
@property(CloudRiseNumberToImage) matchingReward: CloudRiseNumberToImage = null;
@property([cc.Node]) matchingSlots: cc.Node[] = [];
private matchingAvatars: cc.Node[] = [];
@property([cc.Node]) avatarTemplates: cc.Node[] = [];
private loadedStage = 0;
private stagePage: cc.Node = null;
private matchingPage: cc.Node = null;
private pageLoads: { [key: string]: Promise<void> } = {};
private boundButtons = new Set<cc.Node>();
@property(cc.Node) progressRoster: cc.Node = null;
@property(cc.Node) progressStart: cc.Node = null;
private progressSlots: Array<{ node: cc.Node; index: number }> = [];
private progressRosterKey = "";
private animationWaits: Array<(completed: boolean) => void> = [];
@property(cc.Node) design: cc.Node = null;
@property(cc.Node) background: cc.Node = null;
@property(cc.Node) modalBackground: cc.Node = null;
@property([cc.Node]) pages: cc.Node[] = [];
@property([cc.Node]) buttons: cc.Node[] = [];
@property([cc.Label]) clocks: cc.Label[] = [];
@property([cc.Node]) checks: cc.Node[] = [];
@property([cc.Node]) tracks: cc.Node[] = [];
@property([cc.SpriteFrame]) stageArt: cc.SpriteFrame[] = [];
@property(CloudRiseNumberToImage) overviewPool: CloudRiseNumberToImage = null;
@property(cc.Node) overviewDescriptionImage: cc.Node = null;
@property(cc.Label) overviewAction: cc.Label = null;
@property(cc.Sprite) progressArt: cc.Sprite = null;
@property(cc.Label) progressCount: cc.Label = null;
@property(cc.Label) survivorCount: cc.Label = null;
@property(CloudRiseNumberToImage) progressPool: CloudRiseNumberToImage = null;
@property(cc.Label) participantsTitle: cc.Label = null;
@property(cc.Node) participantsContent: cc.Node = null;
@property(cc.Node) rowTemplate: cc.Node = null;
@property(cc.Label) errorLabel: cc.Label = null;
private mode = "overview";
private busy = false;
private matchPhase: "idle" | "preparing" | "submitting" | "animating" = "idle";
private matchRequest = 0;
private matchingWarmStarted = false;
private resultTouchReady = false;
private closePage: () => void;
private unsubscribe: () => void;
private rows: cc.Node[] = [];
private rosterKey = "";
private expiredRun = "";
private timeState = "";
private nextDeadlineSyncAt = 0;
private cloudFloatTime = 0;
private cloudFloatGroups: Array<Array<{ node: cc.Node; y: number }>> = [];
private floatingTrack: cc.Node = null;
private restingAvatars = new Map<cc.Node, { cloud: cc.Node; offsetY: number }>();
update(dt: number): void {
const page = this.pages[2];
if (!["progress", "elimination"].includes(this.mode) || !page || !page.activeInHierarchy) {
this.resetCloudFloat(); return;
}
const track = this.tracks.find(node => node && node.active);
if (track !== this.floatingTrack) {
this.resetCloudFloat(); this.cloudFloatGroups = []; this.floatingTrack = track;
}
if (!this.cloudFloatGroups.length) {
// Keep decorations on each cloud together, preserving the authored prefab positions.
const groups = [
[page.getChildByName("PrizeCloud"), page.getChildByName("Chest"), page.getChildByName("ProgressReward")],
[page.getChildByName("StartingCloud")],
...(track ? track.children.map(node => [node]) : []),
];
this.cloudFloatGroups = groups.map(nodes => nodes.filter(node => !!node).map(node => ({ node, y: node.y })));
}
this.cloudFloatTime += dt;
const fade = Math.min(this.cloudFloatTime / 0.6, 1);
this.cloudFloatGroups.forEach((group, index) => {
const amplitude = 8 + index % 3 * 2;
const period = 3.6 + index % 4 * 0.35;
const offset = Math.sin(this.cloudFloatTime * Math.PI * 2 / period + index * 0.85) * amplitude * fade;
group.forEach(({ node, y }) => { node.y = y + offset; });
});
this.followRestingClouds();
}
private followRestingClouds(): void {
this.restingAvatars.forEach(({ cloud, offsetY }, node) => {
if (cc.isValid(node) && cc.isValid(cloud)) node.y = cloud.y + offsetY;
});
}
private resetCloudFloat(): void {
if (!this.cloudFloatTime) return;
this.cloudFloatGroups.forEach(group => group.forEach(({ node, y }) => {
if (cc.isValid(node)) node.y = y;
}));
this.followRestingClouds();
this.cloudFloatTime = 0;
}
onDisable(): void { this.resetCloudFloat(); }
initialize(mode: string, _inGame: boolean, close: () => void, _play: () => void): void {
this.closePage = close;
const size = cc.view.getVisibleSize(); this.node.setContentSize(size); this.background.setContentSize(size);
this.design.scale = Math.min(size.width / this.design.width, size.height / this.design.height);
this.mode = mode || (Service.unseenResult() ? "result" : Service.run() && Service.run().status === "playing" ? "progress" : "overview");
this.bindButtons();
// Capture taps over the artwork and controls as well as the empty backdrop.
this.node.on("touchstart", this.onResultTouchStart, this, true);
this.node.on("touchend", this.onResultTouchEnd, this, true);
this.node.on("touchcancel", this.onResultTouchCancel, this, true);
this.unsubscribe = Service.subscribe(() => {
if (this.mode === "progress" && Service.unseenResult()) this.mode = "result";
if (!this.busy) this.render();
});
this.schedule(this.tick, 1); this.render(); this.warmMatching();
}
acknowledge(): void { if (this.mode === "result") Service.acknowledgeResult(); }
private onResultTouchStart(event: cc.Event.EventTouch): void {
this.resultTouchReady = this.mode === "result" && !this.busy;
if (this.mode === "result") event.stopPropagation();
}
private onResultTouchCancel(event: cc.Event.EventTouch): void {
this.resultTouchReady = false;
if (this.mode === "result") event.stopPropagation();
}
private onResultTouchEnd(event: cc.Event.EventTouch): void {
const dismiss = this.resultTouchReady && this.mode === "result" && !this.busy;
this.resultTouchReady = false;
if (this.mode === "result") event.stopPropagation();
if (!dismiss) return;
const stage = Service.stage();
const won = stage && stage.status === "won" && !Bridge.challengeExpired();
Service.acknowledgeResult();
if (won && Bridge.challengeActive() && Service.run().status === "waiting") this.show("overview");
else this.closePage();
}
private bindButtons(): void {
const actions = [() => this.closePage(), () => this.overviewClick(), () => this.show("participants"),
() => {}, () => this.show("progress"), () => { void Service.sync(); }];
this.buttons.forEach((button, i) => {
if (!button || this.boundButtons.has(button)) return;
this.boundButtons.add(button);
button.on("click", () => {
if (i === 0 && this.matchPhase === "preparing") this.cancelMatching();
else if (!this.busy) actions[i]();
}, this);
});
}
/** Load and attach only the page being opened; each stage owns its settlement and avatar templates. */
async preparePage(mode: string, stageNumber?: number): Promise<void> {
if (mode === "overview") return;
const matching = mode === "matching", stage = stageNumber || (Service.stage() && Service.stage().stage) || 1;
if (matching ? !!this.matchingPage : this.loadedStage === stage) return;
const key = matching ? "matching" : "stage" + stage;
// Cocos clears component fields on destruction; keep the pending-load map alive for cleanup.
const loads = this.pageLoads;
if (!loads[key]) loads[key] = (async () => {
const prefab = await Bridge.pageFragment(matching ? "matching" : "stage", stage);
if (!cc.isValid(this.node, true)) return;
const page = cc.instantiate(prefab), bindings = page.getComponent(CloudRisePage);
if (!matching && this.stagePage) {
this.resetCloudFloat(); this.cloudFloatGroups = []; this.floatingTrack = null;
this.clearProgressRoster(); this.rows = []; this.rosterKey = ""; this.victoryKey = "";
this.tracks = []; this.stageArt = []; this.stagePage.destroy();
}
page.active = false;
this.design.addChild(page); page.setSiblingIndex(1);
bindings.bind(this);
if (matching) this.matchingPage = page;
else { this.stagePage = page; this.loadedStage = stage; }
this.bindButtons();
})().then(() => { delete loads[key]; }, error => { delete loads[key]; throw error; });
return loads[key];
}
private show(mode: string): void { this.mode = mode; this.render(); this.warmMatching(); }
private warmMatching(): void {
if (this.mode !== "overview" || this.matchingWarmStarted || this.matchingPage) return;
const run = Service.run(), data = Service.data();
if (!(run && run.status === "waiting" && Bridge.challengeActive(run)
|| data && data.available && Bridge.registrationOpen(data))) return;
this.matchingWarmStarted = true;
// Prepare hidden nodes while the player reads the overview; never enroll here.
void this.preparePage("matching").catch(error => cc.warn("匹配页预准备失败,点击开始时重试", error));
}
private overviewClick(): void {
const run = Service.run(), data = Service.data();
if (Bridge.challengeActive(run) && run.status === "playing") this.show("progress");
else if (Bridge.challengeActive(run) && run.status === "waiting" || data && data.available && Bridge.registrationOpen(data)) void this.match();
else this.closePage();
}
async match(): Promise<boolean> {
if (this.busy) return false;
const request = ++this.matchRequest;
const active = () => cc.isValid(this.node, true) && this.matchRequest === request;
this.busy = true;
this.matchPhase = "preparing";
const current = Service.run(), nextStage = current && current.status === "waiting" ? current.stage + 1 : 1;
let resourceError = false;
try {
if (this.mode === "overview" && !this.matchingPage && this.overviewAction) {
this.overviewAction.string = "正在准备…"; this.overviewAction.node.active = true;
const cover = this.buttons[1].getChildByName("CaptionCover");
if (cover) cover.active = true;
}
// Start both downloads, but only matching readiness gates the first page switch.
// Capture an early stage failure while the matching download is still pending.
const matchingReady = this.preparePage("matching");
const stageReady = this.preparePage("progress", nextStage).then(() => ({ error: null }), error => ({ error }));
await matchingReady;
if (!active()) return false;
this.show("matching");
if (this.matchingCount) {
this.matchingCount.string = "1/100";
const data = Service.data();
this.matchingReward.setValue((current && current.pools || data && data.pools || [10000, 15000, 20000])[nextStage - 1]);
this.resetMatchingAvatars();
this.setMatchingMessage("正在准备资源,可取消匹配");
}
const prepared = await stageReady;
if (!active()) return false;
if (prepared.error) throw prepared.error;
// From this point a request may have reached the server: do not offer cancellation.
this.matchPhase = "submitting";
if (this.buttons[0]) this.buttons[0].active = false;
this.setMatchingMessage("正在确认匹配…");
const matched = await Service.startStage();
if (!active()) return false;
if (matched && Service.stage()) {
this.matchPhase = "animating";
this.matchingPrompt.active = true; this.matchingTitle.node.active = false;
await this.playMatching(Service.stage());
}
return !!matched && active() && Service.run().status === "playing";
}
catch (error) {
if (active()) { cc.warn(error); resourceError = this.matchPhase === "preparing"; }
return false;
}
finally {
// An older cancelled attempt must never reset a newer attempt's UI or busy state.
if (active()) {
this.matchPhase = "idle"; this.busy = false;
this.show(Service.run() && Service.run().status === "playing" ? "progress" : "overview");
if (resourceError) {
this.errorLabel.string = "活动资源加载失败,请重试"; this.errorLabel.node.active = true;
}
}
}
}
private setMatchingMessage(message: string): void {
if (!this.matchingTitle) return;
this.matchingPrompt.active = false; this.matchingTitle.node.active = true;
this.matchingTitle.string = message;
}
private cancelMatching(): void {
if (this.matchPhase !== "preparing") return;
++this.matchRequest;
this.matchPhase = "idle"; this.busy = false;
this.matchingAvatars.forEach(slot => cc.Tween.stopAllByTarget(slot));
this.show("overview");
}
private render(): void {
const data = Service.data(), run = Service.run(), stage = Service.stage();
const expired = Bridge.challengeExpired(run);
if (this.matchingPage) this.matchingPage.active = this.mode === "matching";
if (this.stagePage) this.stagePage.active = ["progress", "participants", "elimination", "result"].includes(this.mode);
if (this.mode !== "overview" && this.mode !== "matching" && !stage) this.mode = "overview";
const needsPage = this.mode === "matching" ? !this.matchingPage
: this.mode !== "overview" && this.loadedStage !== (stage && stage.stage);
if (needsPage) {
if (this.busy) return;
this.busy = true;
void this.preparePage(this.mode).then(() => {
if (cc.isValid(this.node, true)) { this.busy = false; this.render(); }
}).catch(error => {
cc.warn(error); if (!cc.isValid(this.node, true)) return;
this.busy = false; this.mode = "overview"; this.render();
this.errorLabel.string = "活动资源加载失败,请重试"; this.errorLabel.node.active = true;
});
return;
}
if (this.victoryPage) this.victoryPage.active = false;
if (this.failureMessage) this.failureMessage.active = false;
if (this.expiredMessage) this.expiredMessage.active = false;
this.progressStatsContents.forEach(node => node.active = true);
if (this.progressRoster) this.progressRoster.active = true;
if (this.mode === "elimination") {
this.pages.forEach((page, i) => { if (page) page.active = i === 2; });
if (this.modalBackground) this.modalBackground.active = false;
this.fitProgressBackground();
this.buttons.forEach(button => { if (button) button.active = false; });
this.errorLabel.node.active = false; return;
}
if (expired && (this.expiredRun !== run.id || ["progress", "participants"].includes(this.mode))) {
this.expiredRun = run.id; this.show("result"); return;
}
if (!stage && ["progress", "result", "participants"].includes(this.mode)) this.mode = "overview";
["overview", "matching", "progress", "result", "participants"].forEach((name, i) => { if (this.pages[i]) this.pages[i].active = this.mode === name; });
if (this.modalBackground) this.modalBackground.active = !["overview", "matching", "progress"].includes(this.mode);
const fullBackground = this.mode === "matching" ? this.matchingBackground : this.mode === "progress" ? this.progressBackground : null;
if (fullBackground) {
const size = cc.view.getVisibleSize();
fullBackground.scale = Math.max(size.width / this.design.scale / fullBackground.width,
size.height / this.design.scale / fullBackground.height);
}
this.buttons[0].active = this.mode !== "result" && (!this.busy || this.matchPhase === "preparing");
this.buttons[1].active = this.mode === "overview";
if (this.buttons[2]) this.buttons[2].active = this.mode === "progress";
if (this.buttons[4]) this.buttons[4].active = this.mode === "participants";
this.buttons[0].y = this.mode === "overview" ? 600 : 920;
this.errorLabel.string = Service.error;
this.errorLabel.node.active = !!Service.error && !this.busy;
this.buttons[5].active = this.errorLabel.node.active;
if (this.mode === "overview") {
const next = run && run.status === "waiting" ? run.stage + 1 : run ? run.stage : 1;
const pool = stage && stage.stage === next ? stage.pool : (data && data.pools || [10000, 15000, 20000])[next - 1];
if (this.overviewPool) this.overviewPool.setValue(pool);
this.checks.forEach((node, i) => {
node.active = !!run && run.stages.some(s => s.stage === i + 1 && s.status === "won");
node.parent.getChildByName("Badge" + (i + 1)).opacity = node.active ? 128 : 255;
});
let copy = 0, action = "开始";
if (Bridge.challengeActive(run) && run.status === "waiting") { copy = 1; action = "开始下一阶段"; }
else if (Bridge.challengeActive(run) && run.status === "playing") action = "查看挑战";
else if (!data || !data.available || !Bridge.registrationOpen(data)) {
copy = run && run.status === "completed" ? 2 : expired ? 3 : run && run.status === "failed" ? 4 : 5;
action = "知道了";
}
const showDescription = copy === 0 || copy === 1;
if (this.overviewDescriptionImage) this.overviewDescriptionImage.active = showDescription;
this.overviewAction.string = action;
this.overviewAction.node.active = action !== "开始";
const cover = this.buttons[1].getChildByName("CaptionCover");
if (cover) cover.active = action !== "开始";
} else if (this.mode === "progress") {
this.renderProgressStage(stage);
this.renderProgressRoster(stage);
} else if (this.mode === "participants") {
this.participantsTitle.string = "参赛者 · " + stage.survivors + "人存活";
const players = [{ ...cc.fx.GameConfig.GM_INFO, username: "我", alive: run.status !== "failed" }, ...stage.opponents];
if (this.rosterKey !== run.id + ":" + stage.stage) {
this.rows.forEach(row => row.destroy()); this.rows = [];
for (let i = 0; i < Math.ceil(players.length / this.rowTemplate.childrenCount); i++) {
const row = cc.instantiate(this.rowTemplate); row.active = true; this.participantsContent.addChild(row); this.rows.push(row);
}
this.rosterKey = run.id + ":" + stage.stage;
this.participantsContent.getComponent(cc.Layout).updateLayout();
}
this.rows.forEach((row, r) => row.children.forEach((slot, col) => {
const player = players[r * row.childrenCount + col]; slot.active = !!player;
if (player) { this.avatar(slot, player); slot.opacity = player.alive ? 255 : 90; slot.getChildByName("Status").getComponent(cc.Label).string = player.alive ? "存活" : "已淘汰"; }
}));
}
this.updateClocks();
if (this.mode === "result" && stage && stage.status === "won" && !expired && this.victoryPage) {
this.renderVictory(stage);
} else if (this.mode === "result" && stage && (stage.status === "lost" || expired) && this.failureMessage) {
this.pages[2].active = true;
this.modalBackground.active = false; this.fitProgressBackground();
this.renderProgressStage(stage);
this.progressStatsContents.forEach(node => node.active = false);
this.failureMessage.active = !expired;
if (this.expiredMessage) this.expiredMessage.active = expired;
this.progressRoster.active = false;
this.buttons[2].active = false; this.buttons[0].y = 1020;
}
}
openResult(): void { this.busy = false; this.show("result"); }
private renderVictory(stage: any): void {
this.pages[2].active = true; this.victoryPage.active = true;
this.modalBackground.active = false; this.fitProgressBackground();
const size = cc.view.getVisibleSize();
this.victoryShade.setContentSize(size.width / this.design.scale, size.height / this.design.scale);
this.renderProgressStage(stage); this.renderProgressRoster(stage);
this.buttons[2].active = false;
const saved = !!stage.rewardSaved;
this.victoryReward.node.active = saved; this.victoryPending.node.active = !saved;
if (saved) this.victoryReward.setValue(stage.reward);
this.victoryPending.string = "奖励待到账";
const others = Math.max(0, stage.survivors - 1);
this.victoryShared.active = true;
this.victoryOthers.string = String(others);
const winners = [{ ...cc.fx.GameConfig.GM_INFO, username: "我" }, ...stage.opponents.filter(o => o.alive).slice(0, 7)];
this.victorySlots.forEach((slot, i) => {
slot.active = i < winners.length;
if (slot.active) {
if (i > 0) slot.x = (i - 1 - (winners.length - 2) / 2) * 106;
this.avatar(slot, winners[i]);
}
});
const key = Service.run().id + ":" + stage.stage;
if (this.victoryKey !== key) { this.victoryKey = key; void this.animateVictory(); }
}
private async animateVictory(): Promise<void> {
this.busy = true; this.buttons[0].active = false; this.buttons[5].active = false;
this.errorLabel.node.active = false;
this.victoryGroups.forEach(group => { cc.Tween.stopAllByTarget(group); group.scale = 0; group.opacity = 0; });
for (const group of this.victoryGroups) {
cc.tween(group).to(0.35, { scale: 1, opacity: 255 }, { easing: "backOut" }).start();
if (!await this.waitAnimation(0.5)) return;
}
this.busy = false; this.render();
}
private fitProgressBackground(): void {
const size = cc.view.getVisibleSize(), bg = this.progressBackground;
bg.scale = Math.max(size.width / this.design.scale / bg.width, size.height / this.design.scale / bg.height);
}
private renderProgressStage(stage: any): void {
this.progressArt.spriteFrame = this.stageArt[stage.stage - 1];
this.progressCount.string = stage.success_num + "/" + stage.target;
this.survivorCount.string = stage.survivors + "/100";
this.progressPool.setValue(stage.pool);
this.tracks.forEach((track, i) => { if (track) track.active = i === stage.stage - 1; });
}
private cloudForScore(stage: any, score: number): cc.Node {
if (score >= stage.target) return this.pages[2].getChildByName("PrizeCloud");
if (score <= 0) return this.pages[2] && this.pages[2].getChildByName("StartingCloud");
return this.tracks[stage.stage - 1].children[score - 1];
}
private cloudPosition(stage: any, score: number): { x: number; y: number } {
if (!score) return { x: this.progressStart.x, y: this.progressStart.y };
const cloud = this.cloudForScore(stage, score);
// The last clear lands in front of the chest on its reward cloud, above the reward digits.
return { x: cloud.x, y: cloud.y + (score >= stage.target ? 0 : 60) };
}
private stackPosition(base: { x: number; y: number }, order: number, spread = 1): { x: number; y: number } {
if (order === 0) return { x: base.x, y: base.y };
// Fill both sides of the player before filling the rows behind them.
// A small upward offset keeps the central player visually in front, including matching's depth sorting.
if (order <= 2) return { x: base.x + (order === 1 ? -54 : 54) * spread, y: base.y + 8 * spread };
const counts = [5, 6, 5];
let row = 0, col = order - 3;
while (row < counts.length - 1 && col >= counts[row]) col -= counts[row++];
return {
x: base.x + ((col - (counts[row] - 1) / 2) * 38 + (row % 2 ? 6 : -6)) * spread,
y: base.y + (32 + row * 28) * spread
};
}
private clearProgressRoster(): void {
this.restingAvatars.clear();
this.progressSlots.forEach(({ node }) => { cc.Tween.stopAllByTarget(node); node.destroy(); });
this.progressSlots = []; this.progressRosterKey = "";
}
private prepareProgressRoster(stage: any, indices: number[]): void {
this.resetCloudFloat();
const rosterCloud = this.cloudForScore(stage, stage.success_num);
this.clearProgressRoster();
const base = this.cloudPosition(stage, stage.success_num);
const spread = stage.success_num === 0 ? 1.3 : 1;
this.progressSlots = [-1, ...indices].map((index, order) => {
const slot = cc.instantiate(this.avatarTemplates[index < 0 ? 2 : index % 2]);
this.progressRoster.addChild(slot); slot.active = true; slot.opacity = 255;
slot.scale = (index < 0 ? 0.65 : 0.52) * spread; slot.angle = 0;
const pose = this.stackPosition(base, order, spread); slot.setPosition(pose.x, pose.y);
if (rosterCloud) this.restingAvatars.set(slot, { cloud: rosterCloud, offsetY: pose.y - rosterCloud.y });
slot.zIndex = 100 - order;
this.avatar(slot, index < 0 ? { ...cc.fx.GameConfig.GM_INFO, username: "我" } : stage.opponents[index]);
return { node: slot, index };
});
}
private renderProgressRoster(stage: any): void {
const indices = (stage.opponents || []).map((p, i) => p.alive ? i : -1).filter(i => i >= 0).slice(0, 18);
const key = [Service.run().id, stage.stage, stage.success_num, ...indices].join(":");
if (this.progressRosterKey === key) return;
this.prepareProgressRoster(stage, indices); this.progressRosterKey = key;
}
private jumpAvatar(node: cc.Node, destination: { x: number; y: number }, delay: number, scale: number, cloud: cc.Node, variation = -1): number {
const offsetY = destination.y - cloud.y;
this.restingAvatars.delete(node);
delay *= AVATAR_MOTION_TIME_SCALE;
const rise = (variation < 0 ? 0.23 : 0.22 + variation % 3 * 0.09) * AVATAR_MOTION_TIME_SCALE;
const fall = (variation < 0 ? 0.25 : 0.26 + variation % 3 * 0.08) * AVATAR_MOTION_TIME_SCALE;
const height = variation < 0 ? 95 : 90 + variation % 4 * 45;
const tilt = variation < 0 ? 0 : (variation % 2 ? 1 : -1) * (12 + variation % 3 * 6);
cc.tween(node).delay(delay)
.to(rise, { x: (node.x + destination.x) / 2 + tilt * 2, y: Math.max(node.y, destination.y) + height, angle: tilt }, { easing: "quadOut" })
.to(fall, { ...destination, scale, angle: 0 }, { easing: "quadIn" })
.call(() => { this.restingAvatars.set(node, { cloud, offsetY }); this.followRestingClouds(); }).start();
return delay + rise + fall;
}
private dropAvatar(node: cc.Node, delay: number, done: () => void, variation = 0): number {
this.restingAvatars.delete(node);
const direction = variation % 2 ? 1 : -1;
delay *= AVATAR_MOTION_TIME_SCALE;
const rise = (0.14 + variation % 3 * 0.06) * AVATAR_MOTION_TIME_SCALE;
const fall = (0.42 + variation % 4 * 0.12) * AVATAR_MOTION_TIME_SCALE;
cc.tween(node).delay(delay)
.to(rise, {
x: node.x + direction * 14, y: node.y + 30 + variation % 4 * 16,
angle: direction * (10 + variation % 3 * 6)
}, { easing: "quadOut" })
.to(fall, {
x: node.x + direction * (45 + variation % 4 * 18),
y: node.y - 290 - variation % 3 * 45, angle: direction * (30 + variation % 4 * 16), opacity: 0
}, { easing: "quadIn" })
.call(() => { node.active = false; done(); }).start();
return delay + rise + fall;
}
private waitAnimation(seconds: number): Promise<boolean> {
return new Promise(resolve => {
this.animationWaits.push(resolve);
this.scheduleOnce(() => {
this.animationWaits = this.animationWaits.filter(fn => fn !== resolve);
resolve(cc.isValid(this.node, true));
}, seconds);
});
}
private resetMatchingAvatars(): void {
this.matchingAvatars.forEach(slot => {
cc.Tween.stopAllByTarget(slot);
if (!this.matchingSlots.includes(slot)) slot.destroy();
});
this.matchingSlots.forEach(slot => { cc.Tween.stopAllByTarget(slot); slot.active = false; });
this.matchingAvatars = [this.matchingSlots[2]];
const front = this.matchingSlots[2];
this.popMatchingAvatar(front, { ...cc.fx.GameConfig.GM_INFO, username: "我" },
{ x: 0, y: -760, scale: 1 }, 0);
}
/** Representative avatars share the progress page's staggered stack, enlarged for the matching cloud. */
private matchingPositions(): Array<{ x: number; y: number; scale: number }> {
return Array.from({ length: 18 }, (_, i) => ({
...this.stackPosition({ x: 0, y: -760 }, i + 1, 1.45), scale: 0.8
}));
}
private popMatchingAvatar(slot: cc.Node, player: any, pose: { x: number; y: number; scale: number }, delay: number, done?: () => void): void {
slot.active = true; slot.scale = 0; slot.opacity = 0;
slot.setPosition(pose.x, pose.y - 12); slot.zIndex = Math.round(1000 - pose.y);
this.avatar(slot, player);
cc.tween(slot).delay(delay).to(0.3, { y: pose.y, scale: pose.scale, opacity: 255 }, { easing: "backOut" })
.call(() => { if (done) done(); }).start();
}
private async playMatching(stage: any): Promise<void> {
if (!this.matchingCount) return;
if (!this.matchingAvatars.length) this.resetMatchingAvatars();
this.matchingReward.setValue(stage.pool);
const positions = this.matchingPositions(), total = stage.opponents.length + 1;
// Show a bounded stack; every revealed avatar stays while the full roster count advances.
const count = Math.min(positions.length, stage.opponents.length);
for (let i = 0; i < count; i++) {
const player = stage.opponents[Math.floor(i * stage.opponents.length / count)];
const slot = i < 2 ? this.matchingSlots[i] : cc.instantiate(this.matchingSlots[i % 2]);
if (i >= 2) this.matchingSlots[0].parent.addChild(slot);
this.matchingAvatars.push(slot);
this.popMatchingAvatar(slot, player, positions[i], i * 0.12,
() => { this.matchingCount.string = (1 + Math.round((i + 1) * stage.opponents.length / count)) + "/" + total; });
}
if (!await this.waitAnimation(count * 0.12 + 0.35)) return;
this.matchingPrompt.active = false; this.matchingTitle.node.active = true;
this.matchingTitle.string = "匹配成功!";
await this.waitAnimation(0.7);
}
async playElimination(item: any): Promise<boolean> {
this.busy = true;
const before = item.before, after = item.after, won = after.status !== "lost";
this.renderProgressStage(before);
const alive = (before.opponents || []).map((p, i) => p.alive ? i : -1).filter(i => i >= 0);
const survivors = alive.filter(i => after.opponents[i] && after.opponents[i].alive);
const eliminated = alive.filter(i => after.opponents[i] && !after.opponents[i].alive);
// A bounded representative stack shows both outcomes; the counter always uses the full roster.
const sample = [...survivors.slice(0, 9), ...eliminated.slice(0, 9), ...survivors.slice(9), ...eliminated.slice(9)]
.slice(0, 18).sort((a, b) => a - b);
this.prepareProgressRoster(before, sample);
this.updateClocks();
if (!await this.waitAnimation(0.25)) return false;
const destinationScore = Math.min(after.target, Number(after.round) || before.success_num + 1);
const destinationCloud = this.cloudForScore(after, destinationScore);
const self = this.progressSlots[0].node;
const jumping = this.progressSlots.filter(s => s.index >= 0 && survivors.includes(s.index));
const falling = this.progressSlots.filter(s => s.index >= 0 && eliminated.includes(s.index));
let remaining = before.survivors;
if (won) {
const duration = this.jumpAvatar(self, this.cloudPosition(after, destinationScore), 0, 0.65, destinationCloud);
if (!await this.waitAnimation(duration + 0.05)) return false;
this.progressCount.string = after.success_num + "/" + after.target;
}
let jumpDuration = 0;
jumping.forEach(({ node }, i) => {
node.zIndex = 99 - i;
// Overlapping waves vary launch order, arc and tilt without changing the final roster positions.
const delay = Math.floor(i / 4) * 0.26 + [0, 0.19, 0.07, 0.29][i % 4];
jumpDuration = Math.max(jumpDuration, this.jumpAvatar(node,
this.stackPosition(this.cloudPosition(after, destinationScore), i + (won ? 1 : 0)),
delay, 0.52, destinationCloud, i));
});
if (jumping.length && !await this.waitAnimation(jumpDuration + 0.05)) return false;
if (!won) {
const duration = this.dropAvatar(self, 0, () => { this.survivorCount.string = (--remaining) + "/100"; });
if (!await this.waitAnimation(duration + 0.05)) return false;
}
const beforeDrops = remaining;
let dropDuration = 0, dropped = 0;
falling.forEach(({ node }, i) => {
const delay = Math.floor(i / 4) * 0.22 + [0, 0.16, 0.05, 0.25][i % 4];
dropDuration = Math.max(dropDuration, this.dropAvatar(node, delay, () => {
// Different flight times can finish out of order; count completions, not avatar indices.
remaining = beforeDrops - Math.round(eliminated.length * ++dropped / falling.length);
this.survivorCount.string = remaining + "/100";
}, i));
});
if (falling.length && !await this.waitAnimation(dropDuration + 0.05)) return false;
this.survivorCount.string = after.survivors + "/100";
this.progressCount.string = after.success_num + "/" + after.target;
return this.waitAnimation(0.55);
}
private tick = (): void => {
const data = Service.data(), run = Service.run();
const state = Bridge.activityOpen(data) + ":" + Bridge.registrationOpen(data) + ":" + Bridge.challengeExpired(run);
if (!this.busy && state !== this.timeState) { this.timeState = state; this.render(); }
this.updateClocks();
const deadlinePassed = Bridge.challengeExpired(run)
|| data && data.period && Service.now() >= data.period.endsAt;
if (deadlinePassed && !this.busy && Date.now() >= this.nextDeadlineSyncAt) {
this.nextDeadlineSyncAt = Date.now() + 30000;
void Service.sync();
}
};
private updateClocks(): void {
const data = Service.data(), run = Service.run();
const active = run && (this.mode === "elimination" || this.mode === "result" && run.status === "failed"
|| ["playing", "waiting"].includes(run.status));
const end = active ? run.expiresAt : Bridge.activityOpen(data) ? data.period.endsAt : 0;
const seconds = Math.max(0, Math.ceil((end - Service.now()) / 1000));
const pad = (n: number) => n < 10 ? "0" + n : String(n);
const text = pad(Math.floor(seconds / 3600)) + ":" + pad(Math.floor(seconds / 60) % 60) + ":" + pad(seconds % 60);
this.clocks.forEach(label => { if (label) label.string = text; });
}
private avatar(slot: cc.Node, player: any): void {
slot.getChildByName("Name").getComponent(cc.Label).string = player.username || "玩家";
const face = slot.getChildByName("Face"), photo = face.getChildByName("Photo"), cat = face.getChildByName("Cat");
const icon = String(player.useravatarIcon || ""), url = /^https:\/\//.test(icon) ? icon : player.useravatar;
const key = String(url || icon);
if ((face as any).cloudRiseAvatar === key) return;
(face as any).cloudRiseAvatar = key; photo.active = false; cat.active = false;
const valid = () => cc.isValid(face, true) && (face as any).cloudRiseAvatar === key;
if (typeof url === "string" && /^https:\/\//.test(url)) {
cc.assetManager.loadRemote<cc.Texture2D>(url, { ext: ".png" }, (error, texture) => {
if (error || !texture || !valid()) return;
photo.getComponent(cc.Sprite).spriteFrame = new cc.SpriteFrame(texture); photo.active = true;
});
} else if (icon) {
const action = cc.fx.GameTool.getActionName(icon);
cc.resources.load("spine/" + action + "/skeleton", sp.SkeletonData, (error, data: sp.SkeletonData) => {
if (error || !data || !valid()) return;
cat.getComponent(sp.Skeleton).skeletonData = data; cat.active = true;
const bounds = data.skeletonJson && data.skeletonJson.skeleton;
cat.scale = Math.min(face.width, face.height) / Math.max(bounds && bounds.width || 180, bounds && bounds.height || 180);
if (bounds) cat.setPosition(-((Number(bounds.x) || 0) + (Number(bounds.width) || 0) / 2) * cat.scale,
-((Number(bounds.y) || 0) + (Number(bounds.height) || 0) / 2) * cat.scale);
});
}
}
onDestroy(): void {
this.resetCloudFloat();
this.node.off("touchstart", this.onResultTouchStart, this, true);
this.node.off("touchend", this.onResultTouchEnd, this, true);
this.node.off("touchcancel", this.onResultTouchCancel, this, true);
if (this.unsubscribe) this.unsubscribe();
this.progressSlots.map(slot => slot.node).concat(this.matchingAvatars, this.matchingSlots).forEach(slot => cc.Tween.stopAllByTarget(slot));
this.victoryGroups.forEach(group => cc.Tween.stopAllByTarget(group));
this.animationWaits.splice(0).forEach(resolve => resolve(false));
}
}