MatchMaster/assets/cloud_rise_home/scripts/CloudRisePanel.ts
2026-09-24 16:50:43 +08:00

1021 lines
58 KiB
TypeScript

import Service from "./CloudRiseRuntime";
import Bridge from "../../Script/module/Config/CloudRiseService";
import CloudRiseNumberToImage from "./CloudRiseNumberToImage";
import CloudRisePage from "./CloudRisePage";
import CloudRiseCounter from "./CloudRiseCounter";
import { MiniGameSdk } from "../../Script/Sdk/MiniGameSdk";
const { ccclass, property } = cc._decorator;
// Shorten avatar motion and launch delays together, preserving the staggered trajectories.
const AVATAR_MOTION_TIME_SCALE = 0.7;
const PROGRESS_AVATAR_SCALE = 0.9;
// 1.2 times the original stage starting stack's visible bounds (341.07 x 201.33).
const AVATAR_AREA_WIDTH = 409.28, AVATAR_AREA_HEIGHT = 241.60;
const STARTING_STACK_SPREAD = 1.45024;
/** 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(CloudRiseCounter) victoryOthers: CloudRiseCounter = 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(CloudRiseCounter) matchingCount: CloudRiseCounter = null;
@property(CloudRiseNumberToImage) matchingReward: CloudRiseNumberToImage = null;
@property([cc.Node]) matchingSlots: cc.Node[] = [];
private matchingAvatars: cc.Node[] = [];
private matchingLayout: Array<{ x: number; y: number; scale: number }> = [];
@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.Node) continueHint: cc.Node = null;
@property(cc.Node) rewardChest: cc.Node = null;
@property(cc.Node) rewardHint: cc.Node = null;
private rewardHintBaseY = 0;
@property(cc.Node) overviewHelp: cc.Node = null;
@property(cc.Node) stageHelp: cc.Node = null;
@property(cc.Node) activityGuide: cc.Node = null;
@property(cc.Node) guideShade: cc.Node = null;
private guideTouchReady = false;
@property(cc.Sprite) progressArt: cc.Sprite = null;
@property(CloudRiseCounter) progressCount: CloudRiseCounter = null;
@property(CloudRiseCounter) survivorCount: CloudRiseCounter = 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" | "complete" = "idle";
private matchId = "";
private matchRequest = 0;
private matchingWarmStarted = false;
private matchingElapsed = 0;
private matchingDisplayedCount = 1;
private victorySparkleTime = 0;
private victorySparkles: Array<{ node: cc.Node; opacity: number }> = [];
private victoryRays: { node: cc.Node; angle: number } = null;
private overviewSparkles: Array<{ node: cc.Node; opacity: number }> = [];
private overviewSparkleTime = 0;
private popupAnimating = false;
private popupScale = 1;
private continueResolver: ((value: boolean) => void) = null;
private keepEliminationRoster = 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 {
if (this.mode === "matching" && ["preparing", "submitting"].includes(this.matchPhase)) {
this.matchingElapsed += dt;
while (this.matchingElapsed >= 0.4 && this.matchingDisplayedCount < 99) {
this.matchingElapsed -= 0.4;
this.addSimulatedMatch();
}
}
if (this.mode === "result" && this.victoryPage && this.victoryPage.active) {
this.victorySparkleTime += dt;
if (this.victoryRays) this.victoryRays.node.angle = this.victoryRays.angle + this.victorySparkleTime * 30 % 360;
this.victorySparkles.forEach(({ node, opacity }, i) => {
const wave = (1 + Math.cos(this.victorySparkleTime * Math.PI * 2 / (1.2 + i * 0.25) + i * 1.7)) / 2;
node.opacity = Math.round(opacity * (0.2 + 0.8 * wave));
});
} else this.stopVictorySparkles();
if (this.mode === "overview") {
if (!this.overviewSparkles.length) {
const top = this.pages[0] && this.pages[0].getChildByName("PanelTop");
const lights = top && top.getChildByName("ChestSparkles");
if (lights) this.overviewSparkles = lights.children.map(node => ({ node, opacity: node.opacity }));
}
this.overviewSparkleTime += dt;
this.overviewSparkles.forEach(({ node, opacity }, i) => {
const wave = (1 + Math.cos(this.overviewSparkleTime * Math.PI * 2 / (1.2 + i * 0.25) + i * 1.7)) / 2;
node.opacity = Math.round(opacity * (0.2 + 0.8 * wave));
});
} else this.stopOverviewSparkles();
const page = this.pages[2];
if (!(["progress", "elimination"].includes(this.mode) || this.mode === "result" && this.keepEliminationRoster) || !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(); this.stopVictorySparkles(); this.stopOverviewSparkles(); this.hideRewardHint(); this.showGuide(false); }
private onExpired: () => void = null;
initialize(mode: string, _inGame: boolean, close: () => void, _play: () => void, onExpired?: () => void): void {
this.closePage = () => this.closePopup(close);
this.onExpired = onExpired || null;
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.popupScale = this.design.scale;
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() && !Bridge.challengeExpired()) this.mode = "result";
if (!this.busy) this.render();
});
this.schedule(this.tick, 1); this.render(); this.warmMatching();
if (this.mode === "overview") this.openPopup();
}
acknowledge(): void { if (this.mode === "result") Service.acknowledgeResult(); }
private canContinue(): boolean {
return !(this.activityGuide && this.activityGuide.active) && (!!this.continueResolver || this.mode === "result" && !this.busy);
}
private isActionTarget(target: cc.Node): boolean {
if (this.mode === "result") return false;
for (let node = target; node && node !== this.node; node = node.parent) {
if (node === this.rewardChest || node === this.rewardHint || node.getComponent(cc.Button)) return true;
}
return false;
}
private updateContinueHint(): void {
if (this.continueHint) this.continueHint.active = this.canContinue();
}
private waitForContinue(): Promise<boolean> {
return new Promise(resolve => { this.continueResolver = resolve; this.updateContinueHint(); });
}
waitForProgressContinue(): Promise<boolean> {
// Keep the final animated positions; this entry is closed by the caller after the tap.
this.busy = true;
return this.waitForContinue();
}
private onResultTouchStart(event: cc.Event.EventTouch): void {
if (this.activityGuide && this.activityGuide.active) {
this.guideTouchReady = true; this.resultTouchReady = false;
event.stopPropagation(); return;
}
this.resultTouchReady = this.canContinue() && !this.isActionTarget(event.target);
if (this.mode === "result") event.stopPropagation();
}
private onResultTouchCancel(event: cc.Event.EventTouch): void {
this.guideTouchReady = false;
if (this.activityGuide && this.activityGuide.active) { event.stopPropagation(); return; }
this.resultTouchReady = false;
if (this.mode === "result") event.stopPropagation();
}
private onResultTouchEnd(event: cc.Event.EventTouch): void {
if (this.activityGuide && this.activityGuide.active) {
// Consume the whole tap so closing help cannot continue or close the activity below it.
event.stopPropagation();
if (this.guideTouchReady) this.showGuide(false);
this.guideTouchReady = false; return;
}
const dismiss = this.resultTouchReady && this.canContinue() && !this.isActionTarget(event.target);
this.resultTouchReady = false;
if (this.mode === "result") event.stopPropagation();
if (!dismiss) return;
if (this.continueResolver) {
const resolve = this.continueResolver; this.continueResolver = null;
this.updateContinueHint(); resolve(true); 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 {
[this.overviewHelp, this.stageHelp].forEach(button => {
if (!button || this.boundButtons.has(button)) return;
this.boundButtons.add(button);
button.on("click", () => { if (!this.popupAnimating) this.showGuide(true); }, this);
});
const actions = [() => this.closePage(), () => this.overviewClick(), () => { },
() => { }, () => 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 (this.popupAnimating) return;
if (i === 0 && this.matchPhase === "preparing") this.cancelMatching();
else if (!this.busy) actions[i]();
}, this);
});
if (this.rewardChest && !this.boundButtons.has(this.rewardChest)) {
this.boundButtons.add(this.rewardChest);
if (this.rewardHint) this.rewardHintBaseY = this.rewardHint.y;
this.rewardChest.on("touchend", (event: cc.Event.EventTouch) => {
event.stopPropagation();
if (["progress", "elimination"].includes(this.mode)) this.playRewardHint();
}, this);
}
}
private hideRewardHint(): void {
if (!this.rewardHint || !cc.isValid(this.rewardHint)) return;
cc.Tween.stopAllByTarget(this.rewardHint);
this.rewardHint.active = false;
this.rewardHint.y = this.rewardHintBaseY;
this.rewardHint.opacity = 255;
}
private playRewardHint(): void {
const hint = this.rewardHint;
if (!hint) return;
this.hideRewardHint();
hint.active = true;
const y = this.rewardHintBaseY;
cc.tween(hint)
.to(0.45, { y: y + 60 }, { easing: "sineOut" })
.to(0.45, { y: y + 80, opacity: 0 }, { easing: "sineIn" })
.call(() => { hint.active = false; hint.y = y; hint.opacity = 255; })
.start();
}
private showGuide(visible: boolean): void {
if (!this.activityGuide) return;
this.resultTouchReady = false;
this.guideTouchReady = false;
this.activityGuide.active = visible;
const content = this.activityGuide.children.find(node => node.name === "GuideContent") || this.activityGuide;
content.children.filter(node => node.name.indexOf("GuidePart") === 0).forEach((node, index) => {
cc.Tween.stopAllByTarget(node);
node.scale = visible ? 0 : 1;
if (visible) cc.tween(node).delay((index + 1) * 0.3)
.to(0.3, { scale: 1 }, { easing: "backOut" }).start();
});
if (visible) {
const size = cc.view.getVisibleSize();
this.guideShade.setContentSize(size.width / this.design.scale, size.height / this.design.scale);
this.activityGuide.setSiblingIndex(this.design.childrenCount - 1);
this.hideRewardHint();
}
this.updateContinueHint();
}
/** 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.stopVictorySparkles();
this.hideRewardHint();
this.resetCloudFloat(); this.cloudFloatGroups = []; this.floatingTrack = null;
this.clearProgressRoster(); this.rows = []; this.rosterKey = ""; this.victoryKey = "";
this.tracks = []; this.stageArt = []; this.victorySlots = []; this.keepEliminationRoster = false; 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 {
const enteringOverview = mode === "overview" && this.mode !== mode;
this.mode = mode; this.render(); this.warmMatching();
if (enteringOverview) this.openPopup();
}
private openPopup(): void {
this.popupAnimating = true;
const scale = this.popupScale;
cc.Tween.stopAllByTarget(this.design);
this.design.scale = scale / 7;
// Same three-step bounce as JiaZai.openMonthlyCard, relative to the adapted size.
cc.tween(this.design)
.to(0.08, { scale: scale * 8 / 7 }, { easing: "backOut" })
.to(0.08, { scale: scale * 6 / 7 }, { easing: "sineOut" })
.to(0.08, { scale }, { easing: "sineOut" })
.call(() => { this.popupAnimating = false; }).start();
}
private closePopup(close: () => void): void {
if (this.popupAnimating) return;
if (this.mode !== "overview") { close(); return; }
this.popupAnimating = true;
cc.tween(this.background).to(0.24, { opacity: 0 }).start();
cc.tween(this.design)
.to(0.08, { scale: this.popupScale * 8 / 7 }, { easing: "sineOut" })
.to(0.16, { scale: this.popupScale / 7, opacity: 0 }, { easing: "sineIn" })
.call(() => {
this.popupAnimating = false;
close();
}).start();
}
private stopOverviewSparkles(): void {
this.overviewSparkles.forEach(({ node, opacity }) => { if (cc.isValid(node)) node.opacity = opacity; });
this.overviewSparkles = []; this.overviewSparkleTime = 0;
}
private highlightStage(badge: cc.Node, active: boolean): void {
let halo = badge.getChildByName("CurrentStageGlow");
if (!halo && active) {
halo = new cc.Node("CurrentStageGlow"); badge.addChild(halo);
const glow = halo.addComponent(cc.Graphics);
// Keep the bright rim visible after the 1080-wide design is scaled to a phone.
// Offset it beyond the badge's own pale border; the outer bands supply the gold glow.
for (const [width, alpha] of [[31, 70], [25, 150], [20, 255]]) {
glow.lineWidth = width;
glow.strokeColor = width === 20 ? cc.color(255, 252, 231, alpha) : cc.color(255, 218, 135, alpha);
glow.ellipse(0, 0, badge.width / 2 + 6, badge.height / 2 + 6); glow.stroke();
}
}
if (halo) halo.active = active;
}
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 matchId = this.matchId = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
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 {
// 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");
this.matchingElapsed = 0; this.matchingDisplayedCount = 1;
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.addSimulatedMatch();
this.showMatchingPrompt();
}
const prepared = await stageReady;
if (!active()) return false;
if (prepared.error) throw prepared.error;
// The server prepares a draft roster without consuming participation.
this.matchPhase = "submitting";
if (this.buttons[0]) this.buttons[0].active = false;
const matched = await Service.prepareMatch(matchId);
if (!active()) return false;
const matching = Service.data()?.matching;
if (matched && matching?.id === matchId) {
this.matchPhase = "animating";
await this.playMatching(matching.stage, request);
if (!active()) return false;
this.matchPhase = "complete";
if (!await Service.confirmMatch(matchId)) {
if (active()) MiniGameSdk.API.showToast("匹配失败请重试");
if (active() && Service.matchRetryExhausted) this.returnHomeAfterMatchFailure();
return false;
}
if (!active()) return false;
if (this.matchingCount) this.matchingCount.string = "100/100";
if (!active() || !await this.waitForContinue()) return false;
} else {
MiniGameSdk.API.showToast("匹配失败请重试");
if (Service.matchRetryExhausted) this.returnHomeAfterMatchFailure();
return false;
}
return !!matched && active() && Service.run().status === "playing";
}
catch (error) {
if (active()) {
cc.warn(error); resourceError = this.matchPhase === "preparing";
if (!resourceError) MiniGameSdk.API.showToast("匹配失败请重试");
}
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 showMatchingPrompt(): void {
this.matchingPrompt.active = true;
this.matchingTitle.node.active = false;
}
private cancelMatching(): void {
if (!["preparing", "submitting", "animating"].includes(this.matchPhase)) return;
if (this.matchPhase !== "preparing") Service.cancelMatch(this.matchId);
++this.matchRequest;
this.matchPhase = "idle"; this.busy = false;
this.matchingAvatars.forEach(slot => cc.Tween.stopAllByTarget(slot));
this.animationWaits.splice(0).forEach(resolve => resolve(false));
this.show("overview");
}
private render(): void {
if (this.stageHelp) this.stageHelp.active = this.mode === "progress";
if (this.activityGuide && this.activityGuide.active && !["overview", "progress"].includes(this.mode)) this.showGuide(false);
const data = Service.data(), run = Service.run(), stage = Service.stage();
const expired = Bridge.challengeExpired(run);
if (expired && this.onExpired && ["overview", "progress", "participants"].includes(this.mode)) {
this.onExpired(); return;
}
this.updateContinueHint();
if (!["progress", "elimination"].includes(this.mode)) this.hideRewardHint();
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; });
this.showFailureMessage(!!stage && (stage.status === "lost" || stage.status === "expired" || expired));
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 = !["result", "matching"].includes(this.mode) && (!this.busy || this.matchPhase === "preparing");
this.buttons[1].active = this.mode === "overview";
if (this.buttons[2]) this.buttons[2].active = false;
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;
// this.highlightStage(node.parent.getChildByName("Badge" + (i + 1)), i + 1 === next);
});
const showDescription = Bridge.challengeActive(run) || !!(data && data.available && Bridge.registrationOpen(data));
if (this.overviewDescriptionImage) this.overviewDescriptionImage.active = showDescription;
} 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.showFailureMessage(true);
this.keepEliminationRoster = true;
this.progressRoster.active = true;
if (!this.progressSlots.length) {
const indices = stage.opponents.map((p, i) => p.alive ? i : -1).filter(i => i >= 0).slice(0, 18);
this.prepareProgressRoster({ ...stage, success_num: Math.min(stage.target, stage.round || stage.success_num + 1) }, indices, false);
}
this.buttons[2].active = false; this.buttons[0].y = 1020;
}
}
/** Reuse the stage nodes and subscriptions when their countdown expires. */
beginSettlement(mode: string, close: () => void): void {
this.closePage = () => this.closePopup(close);
this.resultTouchReady = false;
this.showGuide(false);
this.busy = false;
this.show(mode);
}
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)];
while (this.victorySlots.length < winners.length) {
const slot = cc.instantiate(this.victorySlots[1]);
this.victorySlots[1].parent.addChild(slot); this.victorySlots.push(slot);
}
const columns = Math.max(1, Math.min(winners.length - 1, Math.max(7, Math.ceil(Math.sqrt((winners.length - 1) * 2)))));
const rows = Math.ceil((winners.length - 1) / columns);
const spacing = Math.min(106, 800 / columns, rows > 0 ? 300 / rows : 106);
this.victorySlots.forEach((slot, i) => {
slot.active = i < winners.length;
if (slot.active) {
if (i > 0) {
const row = Math.floor((i - 1) / columns), col = (i - 1) % columns;
const rowCount = Math.min(columns, winners.length - 1 - row * columns);
slot.setPosition((col - (rowCount - 1) / 2) * spacing, -250 - row * spacing);
slot.scale = Math.min(0.7, spacing / 150);
}
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.stopVictorySparkles();
const chest = this.victoryGroups[1] && this.victoryGroups[1].getChildByName("Chest");
const sparkles = chest && chest.getChildByName("ChestSparkles");
const coins = this.victoryGroups[2];
const rays = coins && coins.getChildByName("Rays");
if (rays) this.victoryRays = { node: rays, angle: rays.angle };
// The reward stars are part of the Glow sprite; pulse it without fading the coins or amount.
const coinGlow = coins && coins.getChildByName("Glow");
const lights = sparkles ? sparkles.children.slice() : [];
if (coinGlow) lights.push(coinGlow);
this.victorySparkles = lights.map(node => ({ node, opacity: node.opacity }));
this.busy = true; this.buttons[0].active = false; this.buttons[5].active = false;
this.updateContinueHint();
this.errorLabel.node.active = false;
this.victoryGroups.forEach(group => { cc.Tween.stopAllByTarget(group); group.scale = 0; group.opacity = 0; });
for (const groups of [this.victoryGroups.slice(0, 2), [this.victoryGroups[2]], [this.victoryGroups[3]]]) {
groups.forEach(group => 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 returnHomeAfterMatchFailure(): void {
// Invalidate pending animation callbacks before closing or changing scenes.
++this.matchRequest; this.matchPhase = "idle"; this.busy = false;
this.matchingAvatars.forEach(slot => cc.Tween.stopAllByTarget(slot));
if (cc.director.getScene().name === "HomeScene") this.closePage();
else cc.director.loadScene("HomeScene");
}
private stopVictorySparkles(): void {
if (this.victoryRays && cc.isValid(this.victoryRays.node)) this.victoryRays.node.angle = this.victoryRays.angle;
this.victoryRays = null;
this.victorySparkles.forEach(({ node, opacity }) => { if (cc.isValid(node)) node.opacity = opacity; });
this.victorySparkles = []; this.victorySparkleTime = 0;
}
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 = STARTING_STACK_SPREAD, count = 100): { x: number; y: number } {
if (order === 0) return { x: base.x, y: base.y };
const density = Math.min(1, spread / STARTING_STACK_SPREAD);
// All pages use fixed avatar sizes. Only row count and overlap change with population/cloud size.
if (order <= 2) return { x: base.x + (order === 1 ? -54 : 54) * density, y: base.y + 8 * density };
const remaining = Math.max(1, count - 3);
const columns = Math.min(10, Math.ceil(Math.sqrt(remaining * 1.1)));
const rows = Math.ceil(remaining / columns), row = Math.floor((order - 3) / columns);
const inRow = Math.min(columns, remaining - row * columns), col = (order - 3) % columns;
const stepX = Math.min(44 * density, (AVATAR_AREA_WIDTH * density - 140 * 0.52 * PROGRESS_AVATAR_SCALE) / Math.max(1, columns - 1));
const stepY = Math.min(36 * density, (AVATAR_AREA_HEIGHT * density - 140 * (0.65 + 0.52) * PROGRESS_AVATAR_SCALE / 2 - 24 * density) / Math.max(1, rows - 1));
return {
x: base.x + (col - (inRow - 1) / 2) * stepX,
y: base.y + 24 * density + row * stepY
};
}
private clearProgressRoster(): void {
this.restingAvatars.clear();
this.progressSlots.forEach(({ node }) => { cc.Tween.stopAllByTarget(node); node.destroy(); });
this.progressSlots = []; this.progressRosterKey = "";
}
// Cloud size controls stack spacing only; avatar sizes stay fixed across all landings.
private cloudSpread(cloud: cc.Node): number {
if (!cloud || !cloud.width) return 1;
const centerX = cloud.name === "StartingCloud" ? this.progressStart.x : cloud.x;
const sideSpace = (this.design ? this.design.width : 1080) / 2 - Math.abs(centerX) - 24;
return Math.max(0.75, Math.min(1.6, cloud.width * Math.abs(cloud.scaleX || 1) / 300, sideSpace / 150));
}
private prepareProgressRoster(stage: any, indices: number[], includeSelf = true): void {
this.resetCloudFloat();
const rosterCloud = this.cloudForScore(stage, stage.success_num);
this.clearProgressRoster();
const base = this.cloudPosition(stage, stage.success_num);
const size = this.cloudSpread(rosterCloud);
const spread = size * (0.85 + 0.15 * size);
this.progressSlots = (includeSelf ? [-1, ...indices] : 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) * PROGRESS_AVATAR_SCALE; slot.angle = 0;
const pose = this.stackPosition(base, order, spread, indices.length + (includeSelf ? 1 : 0)); 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);
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, 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 startX = node.x, startY = node.y;
const controlY = Math.max(startY, destination.y) + (variation < 0 ? 30 : 24 + variation % 4 * 6);
const tilt = variation < 0 ? 0 : (variation % 2 ? 1 : -1) * (8 + variation % 3 * 3);
cc.tween(node).delay(delay)
.to(rise + fall, { ...destination, angle: 0 }, {
onUpdate: (target: cc.Node, ratio: number) => {
// One curve keeps velocity continuous and the crest close to the destination cloud.
const t = (1 - Math.cos(Math.PI * ratio)) / 2, inverse = 1 - t;
target.x = startX + (destination.x - startX) * t;
target.y = inverse * inverse * startY + 2 * inverse * t * controlY + t * t * destination.y;
target.angle = Math.sin(Math.PI * t) * tilt;
}
})
.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) / 2, angle: direction * (30 + variation % 4 * 16), opacity: 0
}, { easing: "quadIn" })
.call(() => { this.smoke(node); 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 smoke(avatar: cc.Node): void {
const cloud = this.pages[2] && this.pages[2].getChildByName("PrizeCloud");
const source = cloud && cloud.getComponent(cc.Sprite);
if (!source || !source.spriteFrame) return;
for (let i = 0; i < 5; i++) {
const puff = new cc.Node("EliminationSmoke");
const sprite = puff.addComponent(cc.Sprite); sprite.spriteFrame = source.spriteFrame;
sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; puff.setContentSize(85, 70);
this.progressRoster.addChild(puff); puff.setPosition(avatar.x, avatar.y);
puff.color = cc.color(237, 213, 255); puff.scale = 0.35; puff.opacity = 210;
const angle = i * Math.PI * 2 / 5;
cc.tween(puff).to(0.45, { x: avatar.x + Math.cos(angle) * 48, y: avatar.y + Math.sin(angle) * 38 + 12, scale: 1.15, opacity: 0 })
.call(() => puff.destroy()).start();
}
}
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]];
// Reserve the full roster before any arrival; filling a slot must not move earlier avatars.
this.matchingLayout = this.matchingPositions();
const front = this.matchingSlots[2];
this.popMatchingAvatar(front, { ...cc.fx.GameConfig.GM_INFO, username: "我" },
{ x: 0, y: -760, scale: 0.65 * PROGRESS_AVATAR_SCALE }, 0);
front.zIndex = 100;
}
/** Same bounds, sizes and ordering as every stage's initial cloud. */
private matchingPositions(): Array<{ x: number; y: number; scale: number }> {
return Array.from({ length: 99 }, (_, i) => ({
...this.stackPosition({ x: 0, y: -760 }, i + 1, STARTING_STACK_SPREAD, 100), scale: 0.52 * PROGRESS_AVATAR_SCALE
}));
}
private addSimulatedMatch(): void {
if (this.matchingDisplayedCount >= 99) return;
const icon = String(Math.floor(Math.random() * 17));
this.appendMatchingAvatar({ username: "玩家", useravatar: icon, useravatarIcon: icon });
}
private appendMatchingAvatar(player: any, delay = 0): void {
const i = this.matchingAvatars.length - 1;
const pose = this.matchingLayout[i];
if (!pose) return;
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, pose, delay);
slot.zIndex = 99 - i;
this.matchingDisplayedCount = Math.min(99, this.matchingAvatars.length);
this.matchingCount.string = this.matchingDisplayedCount + "/100";
}
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, request = this.matchRequest): Promise<void> {
if (!this.matchingCount) return;
this.showMatchingPrompt();
if (!this.matchingAvatars.length) this.resetMatchingAvatars();
this.matchingReward.setValue(stage.pool);
const remaining = 100 - this.matchingAvatars.length;
const players = stage.opponents.slice(0, remaining);
if (players.length !== remaining) throw new Error("匹配人数不足");
for (const player of players) {
if (request !== this.matchRequest || !cc.isValid(this.node, true)) return;
this.appendMatchingAvatar(player);
if (!await this.waitAnimation(0.04)) return;
}
await this.waitAnimation(0.35);
}
private showFailureMessage(failed: boolean): void {
this.progressStatsContents.forEach(node => node.active = !failed);
if (this.failureMessage) this.failureMessage.active = failed;
}
async playElimination(item: any): Promise<boolean> {
this.busy = true;
const before = item.before, after = item.after, won = !["lost", "expired"].includes(after.status);
// Show the failure text from the first frame, rather than switching the header after the avatars finish.
this.showFailureMessage(!won);
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);
this.prepareProgressRoster(before, alive);
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 size = this.cloudSpread(destinationCloud);
const spread = size * (0.85 + 0.15 * size);
const destinationBase = this.cloudPosition(after, destinationScore);
// Plan every landing once from the final population, relative to the floating cloud.
const landingOffsets = Array.from({ length: after.survivors }, (_, order) =>
this.stackPosition({ x: destinationBase.x - destinationCloud.x, y: destinationBase.y - destinationCloud.y },
order, spread, after.survivors));
const landingPosition = (order: number) => ({
x: destinationCloud.x + landingOffsets[order].x,
y: destinationCloud.y + landingOffsets[order].y
});
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, landingPosition(0), 0, 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 / Math.max(4, Math.ceil(jumping.length / 5))) * 0.26 + [0, 0.19, 0.07, 0.29][i % 4];
jumpDuration = Math.max(jumpDuration, this.jumpAvatar(node,
landingPosition(i + (won ? 1 : 0)),
delay, 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 / Math.max(4, Math.ceil(falling.length / 5))) * 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;
this.keepEliminationRoster = !won;
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.stopOverviewSparkles();
if (this.design) cc.Tween.stopAllByTarget(this.design);
if (this.background) cc.Tween.stopAllByTarget(this.background);
this.stopVictorySparkles();
this.hideRewardHint();
if (["submitting", "animating"].includes(this.matchPhase)) Service.cancelMatch(this.matchId);
++this.matchRequest;
if (this.continueResolver) { const resolve = this.continueResolver; this.continueResolver = null; resolve(false); }
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));
}
}