393 lines
20 KiB
TypeScript
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 });
|
|
}
|
|
}
|
|
|
|
|
|
|