// 聊天室老虎机游戏前端组件,提供 slotPanel/slotFab Alpine 入口。 const SLOT_FAB_STORAGE_KEY = "slot_fab_pos"; const SLOT_EMOJIS = ["🍒", "🍋", "🍊", "🍇", "🔔", "💎", "💀", "7️⃣"]; /** * 读取 CSRF Token。 * * @returns {string} */ function csrf() { return document.querySelector("meta[name=csrf-token]")?.content || ""; } /** * 随机取一个老虎机图案。 * * @returns {string} */ function randomSlotEmoji() { return SLOT_EMOJIS[Math.floor(Math.random() * SLOT_EMOJIS.length)]; } /** * 读取悬浮按钮保存位置。 * * @returns {{x:number,y:number}|null} */ function readSavedFabPosition() { try { return JSON.parse(localStorage.getItem(SLOT_FAB_STORAGE_KEY) || "null"); } catch (error) { return null; } } /** * 创建老虎机悬浮入口 Alpine 组件。 * * @returns {object} */ export function slotFab() { const saved = readSavedFabPosition(); return { visible: false, posX: saved?.x ?? 18, posY: saved?.y ?? 150, dragging: false, _startX: 0, _startY: 0, _origX: 0, _origY: 0, _moved: false, /** * 初始化时检测老虎机是否开启。 * * @returns {Promise} */ async init() { try { const response = await fetch("/slot/info"); const data = await response.json(); this.visible = data.enabled === true; } catch (error) { this.visible = false; } }, /** * 开始拖动悬浮入口。 * * @param {PointerEvent} event * @returns {void} */ startDrag(event) { this.dragging = true; this._moved = false; this._startX = event.clientX; this._startY = event.clientY; this._origX = this.posX; this._origY = this.posY; event.currentTarget.setPointerCapture?.(event.pointerId); }, /** * 拖动悬浮入口。 * * @param {PointerEvent} event * @returns {void} */ onDrag(event) { if (!this.dragging) { return; } const dx = event.clientX - this._startX; const dy = event.clientY - this._startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { this._moved = true; } this.posX = Math.max(4, Math.min(window.innerWidth - 60, this._origX - dx)); this.posY = Math.max(4, Math.min(window.innerHeight - 60, this._origY + dy)); }, /** * 结束拖动;未移动时直接打开老虎机面板。 * * @returns {void} */ endDrag() { if (!this.dragging) { return; } this.dragging = false; localStorage.setItem(SLOT_FAB_STORAGE_KEY, JSON.stringify({ x: this.posX, y: this.posY, })); if (!this._moved) { this.openPanel(); } }, /** * 打开老虎机面板。 * * @returns {void} */ openPanel() { const panel = document.getElementById("slot-panel"); if (panel && window.Alpine) { window.Alpine.$data(panel)?.open?.(); } }, }; } /** * 创建老虎机主面板 Alpine 组件。 * * @returns {object} */ export function slotPanel() { return { show: false, costPerSpin: 100, dailyLimit: 0, remaining: null, balance: 0, spinning: false, reel1Stopped: false, reel2Stopped: false, reel3Stopped: false, spinEmojis: ["🎰", "🎰", "🎰"], resultEmojis: ["❓", "❓", "❓"], resultType: "", resultLabel: "", netChange: 0, history: [], _spinInterval: null, /** * Alpine 初始化,监听 show 变化自动加载配置和历史。 * * @returns {void} */ init() { this.$watch("show", async (value) => { if (value) { await this.loadInfo(); await this.loadHistory(); } }); }, /** * 打开面板并加载数据。 * * @returns {Promise} */ async open() { this.show = true; await this.loadInfo(); await this.loadHistory(); }, /** * 加载游戏配置和用户余额。 * * @returns {Promise} */ async loadInfo() { try { const response = await fetch("/slot/info"); const data = await response.json(); if (!data.enabled) { this.show = false; return; } this.costPerSpin = data.cost_per_spin; this.dailyLimit = data.daily_limit; this.remaining = data.remaining; this.balance = window.__chatUser?.jjb ?? 0; } catch (error) { // 配置加载失败时保持面板当前状态,避免影响其他游戏入口。 } }, /** * 加载最近转动记录。 * * @returns {Promise} */ async loadHistory() { try { const response = await fetch("/slot/history"); const data = await response.json(); this.history = data.history || []; } catch (error) { this.history = []; } }, /** * 执行一次老虎机转动。 * * @returns {Promise} */ async doSpin() { if (this.spinning || (this.dailyLimit > 0 && this.remaining <= 0)) { return; } this.spinning = true; this.resultType = ""; this.resultLabel = ""; this.netChange = 0; this.reel1Stopped = false; this.reel2Stopped = false; this.reel3Stopped = false; // 本地先随机滚动,服务端返回结果后再逐列停下。 this._spinInterval = window.setInterval(() => { this.spinEmojis = [randomSlotEmoji(), randomSlotEmoji(), randomSlotEmoji()]; }, 80); let data; try { const response = await fetch("/slot/spin", { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-TOKEN": csrf(), }, }); data = await response.json(); } catch (error) { window.clearInterval(this._spinInterval); this.spinning = false; window.chatDialog?.alert?.("网络异常,请稍后重试", "错误", "#ef4444"); return; } if (!data.ok) { window.clearInterval(this._spinInterval); this.spinning = false; window.chatDialog?.alert?.(data.message || "转动失败", "提示", "#ef4444"); return; } const stopReel = (reelIndex, emoji, delay) => new Promise((resolve) => { window.setTimeout(() => { window.clearInterval(this._spinInterval); this[`reel${reelIndex + 1}Stopped`] = true; this.spinEmojis = [...this.spinEmojis]; this.spinEmojis[reelIndex] = emoji; resolve(); }, delay); }); await stopReel(0, data.emojis[0], 600); this._spinInterval = window.setInterval(() => { this.spinEmojis = [data.emojis[0], randomSlotEmoji(), randomSlotEmoji()]; }, 80); await stopReel(1, data.emojis[1], 500); this._spinInterval = window.setInterval(() => { this.spinEmojis = [data.emojis[0], data.emojis[1], randomSlotEmoji()]; }, 80); await stopReel(2, data.emojis[2], 400); window.clearInterval(this._spinInterval); this.resultEmojis = data.emojis; this.resultType = data.result_type; this.resultLabel = data.result_label; this.netChange = data.payout > 0 ? data.payout - this.costPerSpin : -this.costPerSpin + (data.payout < 0 ? data.payout : 0); this.balance = data.balance; this.spinning = false; // 转动成功后同步聊天室全局余额,避免其他面板继续显示旧金币数。 if (window.__chatUser) { window.__chatUser.jjb = data.balance; } if (this.dailyLimit > 0 && this.remaining !== null) { this.remaining = Math.max(0, this.remaining - 1); } await this.loadHistory(); }, /** * 关闭面板并清理滚动动画。 * * @returns {void} */ close() { window.clearInterval(this._spinInterval); this.spinning = false; this.show = false; }, }; } /** * 绑定老虎机全局 Alpine 工厂函数。 * * @returns {void} */ export function bindSlotMachineControls() { if (typeof window === "undefined") { return; } window.slotFab = slotFab; window.slotPanel = slotPanel; }