Files
chatroom/resources/js/chat-room/horse-race-panel.js
T

553 lines
17 KiB
JavaScript
Raw Normal View History

2026-04-25 18:33:08 +08:00
// 赛马竞猜主面板 Alpine 组件,负责当前场次、下注、跑马进度和结算展示。
const DEFAULT_HORSE_RACE_CURRENT_URL = "/horse-race/current";
const DEFAULT_HORSE_RACE_BET_URL = "/horse-race/bet";
const DEFAULT_HORSE_RACE_HISTORY_URL = "/horse-race/history";
const DEFAULT_HORSE_RACE_MIN_BET = 100;
const DEFAULT_HORSE_RACE_MAX_BET = 100000;
const DEFAULT_HORSE_RACE_TOTAL_SECONDS = 90;
const SETTLED_AUTO_CLOSE_SECONDS = 10;
/**
* 读取赛马接口地址,优先使用 Blade 写入的命名路由。
*
* @returns {{current: string, bet: string, history: string}}
*/
function horseRaceUrls() {
const panel = document.getElementById("horse-race-panel");
return {
current: panel?.dataset.horseRaceCurrentUrl || DEFAULT_HORSE_RACE_CURRENT_URL,
bet: panel?.dataset.horseRaceBetUrl || DEFAULT_HORSE_RACE_BET_URL,
history: panel?.dataset.horseRaceHistoryUrl || DEFAULT_HORSE_RACE_HISTORY_URL,
};
}
/**
* 读取 CSRF Token。
*
* @returns {string}
*/
function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
/**
* 生成快捷下注金额,保持固定 5 档按钮稳定渲染。
*
* @param {number} min 最小下注额
* @param {number} max 最大下注额
* @returns {number[]}
*/
function buildQuickBetAmounts(min, max) {
const candidates = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000];
const steps = candidates
.map((multiplier) => min * multiplier)
.filter((value) => value >= min && value < max);
let sortedSteps = [...new Set([...steps, max])].sort((a, b) => a - b);
if (sortedSteps.length >= 5) {
return [
sortedSteps[0],
sortedSteps[Math.floor((sortedSteps.length - 1) * 0.25)],
sortedSteps[Math.floor((sortedSteps.length - 1) * 0.5)],
sortedSteps[Math.floor((sortedSteps.length - 1) * 0.75)],
sortedSteps[sortedSteps.length - 1],
];
}
// 下注范围很小时补齐中间档位,避免按钮数量变化造成布局跳动。
while (sortedSteps.length < 5) {
let maxGap = 0;
let insertIndex = -1;
for (let index = 0; index < sortedSteps.length - 1; index += 1) {
if (sortedSteps[index + 1] - sortedSteps[index] > maxGap) {
maxGap = sortedSteps[index + 1] - sortedSteps[index];
insertIndex = index;
}
}
if (insertIndex === -1) {
break;
}
let newValue = Math.floor((sortedSteps[insertIndex] + sortedSteps[insertIndex + 1]) / 2);
if (newValue > 100) {
newValue = Math.floor(newValue / 10) * 10;
}
sortedSteps.splice(insertIndex + 1, 0, newValue);
}
return sortedSteps;
}
/**
* 读取赛马接口 JSON;若后端返回 HTML/警告页,则抛出可诊断错误。
*
* @param {string} url 请求地址
* @param {RequestInit} options fetch 选项
* @returns {Promise<any>}
*/
export async function requestHorseRaceJson(url, options = {}) {
const requestUrl = new URL(url, window.location.origin);
requestUrl.searchParams.set("_ts", Date.now().toString());
const response = await fetch(requestUrl.toString(), {
cache: "no-store",
headers: {
Accept: "application/json",
"Cache-Control": "no-cache",
Pragma: "no-cache",
...(options.headers || {}),
},
...options,
});
const rawText = await response.text();
try {
return JSON.parse(rawText);
} catch (error) {
const preview = rawText.slice(0, 160).replace(/\s+/g, " ").trim();
throw new Error(`赛马接口未返回 JSON${response.status}: ${preview}`);
}
}
/**
* 创建赛马竞猜主面板 Alpine 组件。
*
* @returns {Record<string, any>}
*/
export function horseRacePanel() {
return {
show: false,
phase: "idle",
raceId: null,
totalSeconds: DEFAULT_HORSE_RACE_TOTAL_SECONDS,
countdown: DEFAULT_HORSE_RACE_TOTAL_SECONDS,
countdownTimer: null,
settledCountdown: SETTLED_AUTO_CLOSE_SECONDS,
settledTimer: null,
horses: [],
positions: {},
leaderId: null,
totalPool: 0,
myBet: false,
myBetHorseId: null,
myBetHorseName: "",
myBetAmount: 0,
selectedHorse: null,
betAmount: DEFAULT_HORSE_RACE_MIN_BET,
minBet: DEFAULT_HORSE_RACE_MIN_BET,
maxBet: DEFAULT_HORSE_RACE_MAX_BET,
submitting: false,
winnerName: "",
winnerEmoji: "",
myWon: false,
myPayout: 0,
history: [],
/**
* 兼容事件模块调用的 JSON 请求入口。
*
* @param {string} url 请求地址
* @param {RequestInit} options fetch 选项
* @returns {Promise<any>}
*/
requestJson(url, options = {}) {
return requestHorseRaceJson(url, options);
},
/**
* 同步全局聊天上下文中的金币余额,供弹窗右上角与其他面板共用。
*
* @param {number|string|null|undefined} jjb 最新金币余额
* @returns {void}
*/
syncUserGold(jjb) {
if (jjb === undefined || jjb === null || !window.chatContext) {
return;
}
window.chatContext.userJjb = Number(jjb);
window.chatContext.myGold = Number(jjb);
},
/**
* 获取当前选中马匹的预览名称。
*
* @returns {string}
*/
get myBetHorsePreviewName() {
if (!this.selectedHorse) {
return "";
}
const horse = this.horses.find((item) => item.id === this.selectedHorse);
return horse ? `${horse.emoji}${horse.name}` : "";
},
/**
* 获取快捷下注金额数组。
*
* @returns {number[]}
*/
get quickBetAmounts() {
return buildQuickBetAmounts(this.minBet || DEFAULT_HORSE_RACE_MIN_BET, this.maxBet || DEFAULT_HORSE_RACE_MAX_BET);
},
/**
* 开赛:填充场次数据并开始倒计时。
*
* @param {Record<string, any>} data 开赛广播数据
* @returns {void}
*/
openRace(data) {
this.phase = "betting";
this.raceId = data.race_id;
this.countdown = data.bet_seconds || DEFAULT_HORSE_RACE_TOTAL_SECONDS;
this.totalSeconds = this.countdown;
this.horses = data.horses || [];
this.totalPool = data.total_pool || 0;
this.myBet = false;
this.myBetHorseId = null;
this.myBetHorseName = "";
this.myBetAmount = 0;
this.selectedHorse = null;
this.betAmount = this.minBet || DEFAULT_HORSE_RACE_MIN_BET;
this.positions = {};
this.leaderId = null;
this.show = true;
this.loadCurrentRace();
this.startCountdown();
this.updateFab(true);
},
/**
* 从接口获取当前场次状态,包括本人下注、注池和赔率。
*
* @returns {Promise<void>}
*/
async loadCurrentRace() {
try {
const data = await this.requestJson(horseRaceUrls().current);
this.syncUserGold(data.jjb);
if (data.race) {
this.applyRaceState(data.race);
return;
}
this.resetToIdleState();
} catch (error) {
// 当前场次刷新失败不弹窗,避免影响用户关闭或查看面板。
}
},
/**
* 启动下注倒计时。
*
* @returns {void}
*/
startCountdown() {
clearInterval(this.countdownTimer);
this.countdownTimer = setInterval(() => {
this.countdown -= 1;
if (this.countdown <= 0) {
clearInterval(this.countdownTimer);
this.countdownTimer = null;
this.phase = "running";
}
}, 1000);
},
/**
* 提交本场下注。
*
* @returns {Promise<void>}
*/
async submitBet() {
if (!this.selectedHorse || this.betAmount < DEFAULT_HORSE_RACE_MIN_BET || this.submitting) {
return;
}
this.submitting = true;
try {
const response = await fetch(horseRaceUrls().bet, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-CSRF-TOKEN": csrfToken(),
},
body: JSON.stringify({
race_id: this.raceId,
horse_id: this.selectedHorse,
amount: this.betAmount,
}),
});
const data = await response.json();
if (data.ok) {
this.myBet = true;
this.myBetHorseId = data.horse_id;
this.selectedHorse = data.horse_id;
this.myBetAmount = data.amount;
this.setMyBetHorseName(data.horse_id);
await this.loadCurrentRace();
return;
}
window.chatDialog?.alert(data.message || "下注失败", "提示", "#ef4444");
} catch (error) {
window.chatDialog?.alert("网络异常,请稍后重试。", "错误", "#ef4444");
} finally {
this.submitting = false;
}
},
/**
* 接收跑马进度更新。
*
* @param {Record<string, any>} data 进度广播数据
* @returns {void}
*/
updateProgress(data) {
this.phase = "running";
this.positions = {
...this.positions,
...(data.positions || {}),
};
this.leaderId = data.leader_id;
},
/**
* 显示结算结果。
*
* @param {Record<string, any>} data 结算广播数据
* @returns {void}
*/
showResult(data) {
clearInterval(this.countdownTimer);
clearInterval(this.settledTimer);
this.countdownTimer = null;
this.settledTimer = null;
this.phase = "settled";
this.show = true;
this.totalPool = data.total_pool || this.totalPool;
const winner = this.horses.find((horse) => horse.id === data.winner_horse_id);
this.winnerName = winner ? `${winner.emoji}${winner.name}` : data.winner_name || "未知";
this.winnerEmoji = winner ? winner.emoji : "🐎";
if (this.myBet && this.myBetHorseId === data.winner_horse_id) {
this.myWon = true;
this.myPayout = this.calculatePayout(data);
} else {
this.myWon = false;
this.myPayout = 0;
}
this.updateFab(false);
this.loadHistory();
this.startSettledCountdown();
},
/**
* 加载历史记录。
*
* @returns {Promise<void>}
*/
async loadHistory() {
try {
const data = await this.requestJson(horseRaceUrls().history);
this.history = (data.history || []).reverse();
} catch (error) {
// 历史趋势失败不影响下注主流程。
}
},
/**
* 更新赛马悬浮按钮显示状态。
*
* @param {boolean} visible 是否显示
* @returns {void}
*/
updateFab(visible) {
const fab = document.getElementById("horse-race-fab");
if (fab && typeof window.Alpine?.$data === "function") {
window.Alpine.$data(fab).visible = visible;
}
},
/**
* 关闭面板;下注中保留悬浮按钮方便再次打开。
*
* @returns {void}
*/
close() {
this.show = false;
clearInterval(this.settledTimer);
this.settledTimer = null;
if (this.phase === "betting") {
this.updateFab(true);
}
},
/**
* 从游戏大厅入口打开面板,先同步当前场次最新状态。
*
* @returns {Promise<void>}
*/
async openFromHall() {
try {
const data = await this.requestJson(horseRaceUrls().current);
this.syncUserGold(data.jjb);
if (data.race) {
this.applyRaceState(data.race);
this.syncPhaseFromRace(data.race);
} else {
this.resetToIdleState();
}
} catch (error) {
console.warn("[赛马] openFromHall 失败", error);
}
this.show = true;
},
/**
* 套用接口返回的场次基础状态。
*
* @param {Record<string, any>} race 当前场次
* @returns {void}
*/
applyRaceState(race) {
this.raceId = race.id;
this.horses = race.horses || this.horses;
this.totalPool = race.total_pool || 0;
this.minBet = race.min_bet || DEFAULT_HORSE_RACE_MIN_BET;
this.maxBet = race.max_bet || DEFAULT_HORSE_RACE_MAX_BET;
if (race.my_bet) {
this.myBet = true;
this.myBetHorseId = race.my_bet.horse_id;
this.myBetAmount = race.my_bet.amount;
this.selectedHorse = race.my_bet.horse_id;
this.setMyBetHorseName(race.my_bet.horse_id);
return;
}
this.myBet = false;
this.myBetHorseId = null;
this.myBetHorseName = "";
this.myBetAmount = 0;
},
/**
* 根据接口状态同步面板阶段和倒计时。
*
* @param {Record<string, any>} race 当前场次
* @returns {void}
*/
syncPhaseFromRace(race) {
if (race.status === "betting" && (race.seconds_left ?? 0) > 0) {
this.phase = "betting";
this.countdown = race.seconds_left;
this.totalSeconds = race.seconds_left;
this.startCountdown();
return;
}
if (race.status === "running") {
this.phase = "running";
return;
}
this.phase = "settled";
},
/**
* 重置为没有进行中场次的空状态。
*
* @returns {void}
*/
resetToIdleState() {
clearInterval(this.countdownTimer);
this.countdownTimer = null;
this.raceId = null;
this.horses = [];
this.totalPool = 0;
this.myBet = false;
this.myBetHorseId = null;
this.myBetHorseName = "";
this.myBetAmount = 0;
this.selectedHorse = null;
this.phase = "idle";
this.countdown = 0;
},
/**
* 根据马匹 ID 回填本人下注马匹名称。
*
* @param {number|string} horseId 马匹 ID
* @returns {void}
*/
setMyBetHorseName(horseId) {
const horse = this.horses.find((item) => item.id === horseId);
this.myBetHorseName = horse ? `${horse.emoji}${horse.name}` : "";
},
/**
* 按后端同公式还原个人赔付展示值。
*
* @param {Record<string, any>} data 结算广播数据
* @returns {number}
*/
calculatePayout(data) {
const winnerPool = Number(data.winner_pool || 0);
const distributablePool = Number(data.distributable_pool || 0);
return winnerPool > 0
? Math.round(distributablePool * (Number(this.myBetAmount || 0) / winnerPool))
: 0;
},
/**
* 启动结算弹窗自动关闭倒计时。
*
* @returns {void}
*/
startSettledCountdown() {
this.settledCountdown = SETTLED_AUTO_CLOSE_SECONDS;
clearInterval(this.settledTimer);
this.settledTimer = setInterval(() => {
this.settledCountdown -= 1;
if (this.settledCountdown <= 0) {
clearInterval(this.settledTimer);
this.settledTimer = null;
this.close();
}
}, 1000);
},
};
}
/**
* 挂载赛马主面板全局组件名,兼容 Blade 的 x-data。
*
* @returns {void}
*/
export function bindHorseRacePanelControls() {
if (typeof window === "undefined") {
return;
}
window.horseRacePanel = horseRacePanel;
}