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