MatchMaster/assets/Script/module/MiniProgramBenefitsBridge.ts
2026-09-18 11:49:52 +08:00

393 lines
20 KiB
TypeScript

import LoadingCatAnimation from "../LoadingCatAnimation";
import SevenDayGiftBootstrapApi from "../seven_day_gift/SevenDayGiftBootstrapApi";
declare const wx: any;
const BUNDLE = "mini_program_benefits";
const TASKS = ["favorite_entry", "desktop_entry"];
/** HomeScene entry bridge. UI and the reward adapter live in the lazy bundle. */
export default class MiniProgramBenefitsBridge {
static readonly instance = new MiniProgramBenefitsBridge();
private uid = "";
private pendingEntries: { [task: string]: number } = {};
private state: any = {};
private diagnostics: any[] = [];
private home: cc.Node = null;
private entryNode: cc.Node = null;
private entryRed: cc.Node = null;
private entryFrame: cc.SpriteFrame = null;
private entryVisual: cc.Node = null;
private onEntryTouch = (): void => { this.open(); };
private session: any = null;
private rewardRequests = 0;
private testMount = 0;
private testNode: cc.Node = null;
private testBundle: cc.AssetManager.Bundle = null;
private bundleUsers = 0;
private sharedBundle: cc.AssetManager.Bundle = null;
private releaseBundleUser(): void {
this.bundleUsers--;
if (this.bundleUsers === 0 && this.sharedBundle) {
this.sharedBundle.releaseAll();
cc.assetManager.removeBundle(this.sharedBundle);
this.sharedBundle = null;
}
}
private listeners: Array<() => void> = [];
isWechat(): boolean {
return cc.sys.platform === cc.sys.WECHAT_GAME && !!this.wxApi();
}
private wxApi(): any { return typeof wx !== "undefined" ? wx : null; }
protected info(): any { return cc.fx.GameConfig.GM_INFO as any; }
getUid(): string { return this.uid; }
getState(): any { return this.state; }
getTestEnvironment() { return SevenDayGiftBootstrapApi.getTestEnvironment(); }
getTestSnapshot(): any {
return { uid: this.uid, level: this.info().level, environment: this.getTestEnvironment(),
loginWelfare: this.info().miniProgramWelfare, state: this.state,
eligibility: this.readEligibility(), diagnostics: this.getDiagnostics(),
entryVisible: !!(this.entryNode && this.entryNode.active),
redDot: !!(this.entryNode && this.entryNode.active && this.entryRed && this.entryRed.active),
panelBusy: !!this.session };
}
setTestEligibility(task?: string): void {
if (!this.getTestEnvironment().allowed) throw new Error("仅独立测试服可操作");
if (!this.uid) throw new Error("请等待登录完成");
if (this.session) throw new Error("请先关闭福利弹窗并等待释放完成");
if (task && TASKS.indexOf(task) < 0) throw new Error("未知入口类型");
const eligibility = task ? this.readEligibility() : {};
if (task) eligibility[task] = Date.now();
cc.sys.localStorage.setItem(this.getEligibilityKey(), JSON.stringify(eligibility));
this.pendingEntries = {};
TASKS.forEach(id => this.state[id] = { ...this.state[id], eligibleAt: eligibility[id] || 0 });
this.changed();
}
private getEligibilityKey(): string { return "mini_program_benefits_eligibility_v1:" + this.uid; }
clearTestClaimCache(): void {
if (!this.getTestEnvironment().allowed) throw new Error("仅独立测试服可操作");
if (!this.uid) throw new Error("请等待登录完成");
if (this.session || this.rewardRequests) throw new Error("请先关闭福利弹窗并等待领取请求完成");
const prop = cc.fx.StorageMessage.getStorage("prop");
if (prop && prop.welfareCredits) {
const credits = { ...prop.welfareCredits };
["favorite", "desktop"].forEach(type => delete credits["welfare:" + this.uid + ":" + type]);
cc.fx.StorageMessage.setStorage("prop", { ...prop, welfareCredits: credits });
}
const flags = this.info().miniProgramWelfare;
TASKS.forEach((task, index) => {
const key = index === 0 ? "favorite" : "desktop";
this.state[task] = { eligibleAt: this.state[task] && this.state[task].eligibleAt || 0,
claimedAt: flags && flags[key] === true ? 1 : 0, acknowledged: true };
});
this.changed();
}
private readEligibility(): any {
try {
const saved = JSON.parse(cc.sys.localStorage.getItem(this.getEligibilityKey()) || "{}");
const result: any = {};
TASKS.forEach(task => {
if (saved && typeof saved[task] === "number" && Number.isFinite(saved[task]) && saved[task] > 0) result[task] = saved[task];
});
return result;
} catch (_) { return {}; }
}
getRewardStorageKey(): string { return "mini_program_benefits_rewards_v1:" + this.uid; }
getDiagnostics(): any[] { return this.diagnostics.map(item => ({ ...item })); }
subscribe(callback: () => void): () => void {
this.listeners.push(callback);
return () => { this.listeners = this.listeners.filter(item => item !== callback); };
}
private changed(): void {
this.refreshEntry();
this.listeners.slice().forEach(callback => callback());
}
// Device-tested 1104/1023 record local eligibility; this is not server-verified entry proof.
capture(options: any, lifecycle: "cold" | "show"): void {
if (!this.isWechat()) return;
// Match the panel: cold launch options may describe an older entry after re-entry.
const api = this.wxApi();
if (api && typeof api.getEnterOptionsSync === "function") {
try {
const current = api.getEnterOptionsSync();
if (current && Number(current.scene) > 0) options = current;
} catch (_) { /* Older clients can still use the lifecycle callback options. */ }
}
this.diagnostics.push({ scene: Number(options && options.scene) || 0, lifecycle, observedAt: Date.now() });
this.diagnostics = this.diagnostics.slice(-24);
const scene = Number(options && options.scene);
const task = scene === 1104 ? "favorite_entry" : scene === 1023 ? "desktop_entry" : "";
if (task) {
this.pendingEntries[task] = Date.now();
this.saveEntryEligibility();
}
}
private saveEntryEligibility(): void {
if (!this.uid || !Object.keys(this.pendingEntries).length) return;
const eligibility = this.readEligibility();
Object.keys(this.pendingEntries).forEach(task => {
eligibility[task] = eligibility[task] || this.pendingEntries[task];
});
try { cc.sys.localStorage.setItem(this.getEligibilityKey(), JSON.stringify(eligibility)); }
catch (_) { this.toast("入口资格保存失败,请从对应入口重新进入重试"); return; }
Object.keys(this.pendingEntries).forEach(task => {
this.state[task] = { ...this.state[task], eligibleAt: eligibility[task] };
});
this.pendingEntries = {};
this.changed();
}
onLogin(user: any): void {
if (!this.isWechat()) return;
const nextUid = String(user && user._id || this.info().uid || "");
if (this.uid !== nextUid) {
this.close();
if (this.uid) this.pendingEntries = {};
}
this.uid = nextUid;
const welfare = user && user.miniProgramWelfare;
this.info().miniProgramWelfare = welfare;
const eligibility = this.uid ? this.readEligibility() : {};
this.state = {};
TASKS.forEach((task, index) => {
const key = index === 0 ? "favorite" : "desktop";
this.state[task] = { eligibleAt: eligibility[task] || 0, claimedAt: welfare && welfare[key] === true ? 1 : 0, acknowledged: true };
});
this.saveEntryEligibility();
this.changed();
}
bindRewardApi(api: any): void {
if (this.session && !this.session.cancelled) this.session.api = api;
}
private async request(action: string, params: any = {}): Promise<any> {
if (!this.uid) throw new Error("登录尚未完成,请稍后重试");
const session = this.session;
if (!session || session.cancelled || !session.api) throw new Error("请重新打开福利面板");
const uid = this.uid;
this.rewardRequests++;
let data: any;
try { data = await session.api.request(action, params, Number(this.info().level)); }
finally { this.rewardRequests--; }
if (uid !== this.uid) throw new Error("账号已切换,请重新打开");
this.state = data.state;
this.changed();
return data;
}
refresh(): Promise<any> { return this.request("read"); }
claim(task: string): Promise<any> { return this.request("claim", { task }); }
acknowledge(task: string, receiptId: string): Promise<any> { return this.request("ack", { task, receiptId }); }
attach(home: cc.Node): void {
const sceneEntry = cc.find("Load/Top/chengxu", home);
if (!this.isWechat()) {
if (sceneEntry) sceneEntry.active = false;
return;
}
if (this.home === home && this.entryNode && cc.isValid(this.entryNode)) return;
this.detach();
this.home = home;
// 暂停加载测试面板;恢复测试时重新启用,并打开预制体 entry 节点。
// this.mountTestPanel(home);
// HomeScene owns this node and its position; never replace or reposition it.
if (!sceneEntry) return;
const entry = this.entryNode = sceneEntry;
const sceneSprite = entry.getComponent(cc.Sprite);
if (!sceneSprite || !sceneSprite.spriteFrame) {
// Text fallback for isolated test scenes or a missing scene sprite.
const visual = this.entryVisual = new cc.Node("benefitsVisual");
visual.parent = entry; visual.setContentSize(entry.getContentSize());
const graphics = visual.addComponent(cc.Graphics);
graphics.fillColor = cc.color(246, 206, 115);
graphics.roundRect(-89, -74, 178, 148, 24); graphics.fill();
const text = new cc.Node("label"); text.active = false; text.parent = visual;
const label = text.addComponent(cc.Label);
label.useSystemFont = true; label.fontFamily = "Arial";
label.fontSize = 34; label.lineHeight = 41;
label.horizontalAlign = cc.Label.HorizontalAlign.CENTER;
label.verticalAlign = cc.Label.VerticalAlign.CENTER;
label.overflow = cc.Label.Overflow.SHRINK;
label.string = "小程序\n福利";
text.setContentSize(168, 126); text.active = true;
text.color = cc.color(108, 60, 37);
// Only this small entry texture lives in resources; the UI bundle remains lazy.
if (cc.resources) cc.resources.load("texture/mini-program-benefits-entry", cc.SpriteFrame, (error, frame: cc.SpriteFrame) => {
if (error || !frame) return;
if (this.entryNode !== entry || this.entryVisual !== visual || !cc.isValid(visual, true)) {
frame.addRef(); frame.decRef(); return;
}
frame.addRef(); this.entryFrame = frame;
graphics.enabled = false; text.active = false;
const sprite = visual.addComponent(cc.Sprite); sprite.spriteFrame = frame;
sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM; sprite.trim = false;
visual.setContentSize(entry.getContentSize());
});
}
// HomeScene binds this activity's badge directly to the shared UI/red sprite.
this.entryRed = entry.getChildByName("red");
if (!this.entryRed) {
// Isolated test scenes may omit the serialized badge.
this.entryRed = new cc.Node("red"); this.entryRed.parent = entry;
this.entryRed.setPosition(73.095, 63.753);
this.entryRed.setContentSize(56, 56);
}
entry.on(cc.Node.EventType.TOUCH_END, this.onEntryTouch);
this.refreshEntry();
}
detach(home?: cc.Node): void {
if (home && this.home !== home) return;
this.close();
this.testMount++;
if (this.testNode && cc.isValid(this.testNode)) this.testNode.destroy();
this.testNode = null;
if (this.testBundle) {
this.testBundle = null;
cc.director.once(cc.Director.EVENT_AFTER_DRAW, () => this.releaseBundleUser());
}
if (this.entryNode && cc.isValid(this.entryNode)) {
this.entryNode.off(cc.Node.EventType.TOUCH_END, this.onEntryTouch);
this.entryNode.active = false;
}
if (this.entryVisual && cc.isValid(this.entryVisual)) this.entryVisual.destroy();
if (this.entryRed && cc.isValid(this.entryRed)) this.entryRed.active = false;
this.entryVisual = null;
const frame = this.entryFrame; this.entryFrame = null;
if (frame) cc.director.once(cc.Director.EVENT_AFTER_DRAW, () => frame.decRef());
this.entryNode = this.entryRed = this.home = null;
}
private mountTestPanel(home: cc.Node): void {
if (!this.getTestEnvironment().allowed) return;
const token = ++this.testMount;
this.bundleUsers++;
cc.assetManager.loadBundle(BUNDLE, (error, bundle) => {
if (error || !bundle) { this.releaseBundleUser(); this.toast("福利测试面板加载失败"); return; }
this.sharedBundle = bundle;
const valid = () => token === this.testMount && this.home === home
&& cc.isValid(home, true) && this.getTestEnvironment().allowed;
if (!valid()) { this.releaseBundleUser(); return; }
bundle.load("test/BenefitsRuntimeTest", cc.Prefab, (loadError, prefab: cc.Prefab) => {
if (loadError || !prefab || !valid()) { this.releaseBundleUser(); return; }
const ctor = cc.js.getClassByName("BenefitsRuntimeTest");
if (!ctor) { this.releaseBundleUser(); this.toast("请重新构建福利活动分包"); return; }
this.testBundle = bundle;
const node = this.testNode = cc.instantiate(prefab);
node.active = false;
const component: any = node.addComponent(ctor as any);
component.initialize(this);
node.parent = home; node.zIndex = 3002; node.active = true;
});
});
}
refreshEntry(): void {
if (!this.entryNode || !cc.isValid(this.entryNode)) return;
const allClaimed = TASKS.every(id => this.state[id] && this.state[id].claimedAt);
const pendingReceipt = TASKS.some(id => this.state[id] && this.state[id].claimedAt && !this.state[id].acknowledged);
// Keep the entry until an outstanding awarded receipt has been shown/acknowledged after response loss.
this.entryNode.active = Number(this.info().level) >= 1 && (!allClaimed || pendingReceipt);
this.entryRed.active = TASKS.some(id => this.state[id] && this.state[id].eligibleAt && !this.state[id].claimedAt) || pendingReceipt;
}
async open(): Promise<void> {
if (!this.home || !cc.isValid(this.home) || Number(this.info().level) < 1) return;
if (this.session) {
if (!this.session.cancelled) return;
await this.session.done;
return this.open(); // recheck after awaiting: multiple rapid reopen clicks still create one session
}
const session: any = { home: this.home, cancelled: false, bundle: null, node: null };
this.session = session;
session.done = this.loadPanel(session);
await session.done;
}
private startEntryLoading(session: any): void {
const home: cc.Node = session.home;
const template = home.getChildByName("Loading")
|| (home.parent && home.parent.getChildByName("Loading"));
if (!template) return;
const parent = template.parent;
const size = parent.getContentSize();
const factor = Math.min(1, size.width / 1080, size.height / 1920);
const viewport = new cc.Node("BenefitsLoading");
viewport.setContentSize(size.width / factor, size.height / factor);
viewport.scale = factor; viewport.parent = parent; viewport.zIndex = cc.macro.MAX_ZINDEX;
viewport.addComponent(cc.BlockInputEvents);
const loading = cc.instantiate(template); loading.parent = viewport;
// Clone only the existing visual; all animation remains in the shared implementation.
session.stopLoading = () => {
LoadingCatAnimation.stop(loading);
viewport.active = false; viewport.destroy();
};
LoadingCatAnimation.play(loading);
}
private stopEntryLoading(session: any): void {
const stop = session.stopLoading; session.stopLoading = null;
if (stop) stop();
}
private async loadPanel(session: any): Promise<void> {
this.bundleUsers++;
try {
this.startEntryLoading(session);
session.bundle = await new Promise<cc.AssetManager.Bundle>((resolve, reject) => {
cc.assetManager.loadBundle(BUNDLE, (error, bundle) => error ? reject(error) : resolve(bundle));
});
this.sharedBundle = session.bundle;
if (session.cancelled || !cc.isValid(session.home)) return;
const bundle: cc.AssetManager.Bundle = session.bundle;
const prefab = await new Promise<cc.Prefab>((resolve, reject) => {
bundle.load("prefab/BenefitsWindow", cc.Prefab, (error, asset) => error ? reject(error) : resolve(asset as cc.Prefab));
});
if (session.cancelled || !cc.isValid(session.home, true)) return;
session.node = cc.instantiate(prefab);
session.node.parent = session.home; session.node.zIndex = 3000;
const panel: any = session.node.getComponent("MiniProgramBenefitsPanel");
if (!panel) throw new Error("福利预制体缺少控制组件");
panel.init(this);
} catch (error) {
session.cancelled = true;
if (cc.isValid(session.home) && this.home === session.home) this.toast("福利加载失败,请重试");
} finally {
this.stopEntryLoading(session);
session.loaded = true;
if (session.cancelled || !cc.isValid(session.home)) await this.releaseSession(session);
}
}
close(): void {
const session = this.session;
if (!session || session.cancelled) return;
session.cancelled = true;
this.stopEntryLoading(session);
if (session.node && cc.isValid(session.node)) { session.node.active = false; session.node.destroy(); }
if (session.loaded) session.done = this.releaseSession(session);
}
onPanelDestroyed(node: cc.Node): void {
if (this.session && this.session.node === node) this.close();
}
private async releaseSession(session: any): Promise<void> {
if (session.releasing) return session.releasing;
session.releasing = new Promise<void>(resolve => {
const release = () => {
// Test entry and activity window share the same bundle; release after both leave.
this.releaseBundleUser();
session.api = session.bundle = session.node = session.home = null;
if (this.session === session) this.session = null;
resolve();
};
if (session.node) {
if (cc.isValid(session.node)) session.node.destroy();
// destroy() is deferred: release textures only after onDestroy has detached listeners/sprites.
cc.director.once(cc.Director.EVENT_AFTER_DRAW, release);
} else release();
});
return session.releasing;
}
toast(message: string): void {
const wx = this.wxApi();
if (wx && wx.showToast) wx.showToast({ title: message, icon: "none", duration: 2500 });
}
}