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 } = {}; private boundButtons = new Set(); @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> = []; private floatingTrack: cc.Node = null; private restingAvatars = new Map(); 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 { return new Promise(resolve => { this.continueResolver = resolve; this.updateContinueHint(); }); } waitForProgressContinue(): Promise { // 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 { 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 { 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 { 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 { 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 { 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 { 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(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)); } }