diff --git a/resources/js/chat-room.js b/resources/js/chat-room.js index 45e958e..dc4c6ab 100644 --- a/resources/js/chat-room.js +++ b/resources/js/chat-room.js @@ -1,5 +1,46 @@ // 聊天室 Vite 入口,集中导出从 Blade 内联脚本迁移出的纯前端工具。 +/** + * 模块引用说明: + * - html.js:提供聊天内容通用 HTML 转义工具。 + * - appointment-announcement.js:处理任命/撤销公告的大卡片和系统消息。 + * - banner.js:提供 window.chatBanner 居中大卡片通知。 + * - chat-bot.js:处理 AI 小班长发送消息和清空上下文。 + * - dialog.js:提供 window.chatDialog 全局弹窗。 + * - daily-sign-in.js:处理每日签到弹窗与补签入口。 + * - font-size.js:处理聊天输入/消息字号设置。 + * - image-upload.js:处理聊天图片上传入口。 + * - composer.js:处理聊天输入框、发送按钮和快捷操作。 + * - toast.js:提供 window.chatToast 右下角通知。 + * - friend-panel.js:处理好友面板、搜索和好友快捷操作。 + * - friend-notifications.js:监听好友通知和通用 BannerNotification。 + * - lightbox.js:处理聊天图片预览灯箱。 + * - mobile-drawer.js:处理移动端抽屉、房间列表和在线名单。 + * - marriage-status.js:处理婚姻状态展示与用户名片联动。 + * - toolbar.js:处理工具栏按钮和功能快捷入口。 + * - user-target-actions.js:处理点击用户名切换私聊目标和打开名片。 + * - welcome-menu.js:处理欢迎菜单交互。 + * - admin-menu.js:处理聊天室管理菜单交互。 + * - baccarat-loss-cover-admin.js:处理百家乐买单活动管理弹层。 + * - baccarat-loss-cover.js:处理百家乐买单活动前台弹窗。 + * - game-hall.js:处理娱乐大厅弹窗和游戏入口卡片。 + * - game-bootstrap.js:提供非关键游戏延迟初始化工具。 + * - game-panels.js:处理通用游戏面板关闭事件。 + * - holiday-modal.js:处理节日福利弹窗和系统消息入口。 + * - initial-state.js:恢复首屏历史消息、欢迎消息、入场特效和挂起婚姻事件。 + * - bank-modal.js:处理银行弹窗、转账、排行和标签切换。 + * - fishing.js:处理钓鱼入口与自动钓鱼相关交互。 + * - profile-controls.js:处理用户资料和资料相关按钮。 + * - shop-controls.js:处理商店弹窗的基础按钮事件。 + * - slot-machine.js:提供老虎机 slotPanel/slotFab Alpine 组件。 + * - vip-controls.js:处理 VIP 中心相关入口。 + * - preferences-status.js:处理聊天偏好、屏蔽系统播报和静音状态。 + * - right-panel.js:处理右侧在线用户列表和用户名交互。 + * - rooms.js:处理房间在线状态渲染和跳转 URL。 + * - reward-modal.js:处理职务奖励金币弹窗入口。 + * - message-queue.js:提供聊天消息分批渲染队列。 + */ + // 统一转发各子模块导出,方便测试或后续模块继续复用同一组工具。 export { escapeHtml, escapeHtmlWithLineBreaks } from "./chat-room/html.js"; export { bindAppointmentAnnouncementControls, showAppointmentBanner } from "./chat-room/appointment-announcement.js"; @@ -63,6 +104,7 @@ export { export { bindFishingControls } from "./chat-room/fishing.js"; export { bindProfileControls } from "./chat-room/profile-controls.js"; export { bindShopControls } from "./chat-room/shop-controls.js"; +export { bindSlotMachineControls, slotFab, slotPanel } from "./chat-room/slot-machine.js"; export { bindVipControls } from "./chat-room/vip-controls.js"; export { BLOCKABLE_SYSTEM_SENDERS, @@ -153,6 +195,7 @@ import { import { bindFishingControls } from "./chat-room/fishing.js"; import { bindProfileControls } from "./chat-room/profile-controls.js"; import { bindShopControls } from "./chat-room/shop-controls.js"; +import { bindSlotMachineControls, slotFab, slotPanel } from "./chat-room/slot-machine.js"; import { bindVipControls } from "./chat-room/vip-controls.js"; import { BLOCKABLE_SYSTEM_SENDERS, @@ -258,6 +301,9 @@ if (typeof window !== "undefined") { bindMarriageStatusControls, bindProfileControls, bindShopControls, + bindSlotMachineControls, + slotFab, + slotPanel, bindVipControls, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize, @@ -311,6 +357,8 @@ if (typeof window !== "undefined") { window.switchTarget = switchTarget; window.clearChatBotContext = clearChatBotContext; window.sendToChatBot = sendToChatBot; + window.slotFab = slotFab; + window.slotPanel = slotPanel; window.runFeatureShortcut = runFeatureShortcut; window.runToolbarAction = runToolbarAction; window.openHolidayRunFromSystemMessage = openHolidayRunFromSystemMessage; @@ -362,6 +410,7 @@ if (typeof window !== "undefined") { bindMarriageStatusControls(); bindProfileControls(); bindShopControls(); + bindSlotMachineControls(); bindVipControls(); bindChatRightPanelControls(); bindRoomStatusControls(); diff --git a/resources/js/chat-room/slot-machine.js b/resources/js/chat-room/slot-machine.js new file mode 100644 index 0000000..38119ed --- /dev/null +++ b/resources/js/chat-room/slot-machine.js @@ -0,0 +1,347 @@ +// 聊天室老虎机游戏前端组件,提供 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; +} diff --git a/resources/views/chat/partials/games/slot-machine.blade.php b/resources/views/chat/partials/games/slot-machine.blade.php index 7e8ffb1..f8c09b9 100644 --- a/resources/views/chat/partials/games/slot-machine.blade.php +++ b/resources/views/chat/partials/games/slot-machine.blade.php @@ -289,260 +289,4 @@ } - +{{-- 老虎机 Alpine 组件已迁移到 resources/js/chat-room/slot-machine.js --}}