import LoadingCatAnimation from "../LoadingCatAnimation"; import SevenDayGiftBootstrapApi from "../seven_day_gift/SevenDayGiftBootstrapApi"; import { vibrateHomeButton } from "./Tool/HomeButtonVibration"; 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 = (event: cc.Event): void => { vibrateHomeButton(event); 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 { 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 { return this.request("read"); } claim(task: string): Promise { return this.request("claim", { task }); } acknowledge(task: string, receiptId: string): Promise { 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 { 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 { this.bundleUsers++; try { this.startEntryLoading(session); session.bundle = await new Promise((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((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 { if (session.releasing) return session.releasing; session.releasing = new Promise(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 }); } }