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