Files
chatroom/resources/js/chat-room/slot-machine.js
T
2026-04-25 14:16:13 +08:00

348 lines
9.7 KiB
JavaScript

// 聊天室老虎机游戏前端组件,提供 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<void>}
*/
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<void>}
*/
async open() {
this.show = true;
await this.loadInfo();
await this.loadHistory();
},
/**
* 加载游戏配置和用户余额。
*
* @returns {Promise<void>}
*/
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<void>}
*/
async loadHistory() {
try {
const response = await fetch("/slot/history");
const data = await response.json();
this.history = data.history || [];
} catch (error) {
this.history = [];
}
},
/**
* 执行一次老虎机转动。
*
* @returns {Promise<void>}
*/
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;
}