// 聊天室钓鱼小游戏模块,管理抛竿、收竿、自动钓鱼循环和浮漂交互。
import { escapeHtml } from "./html.js";
let fishingEventsBound = false;
let fishingTimer = null;
let fishingReelTimeout = null;
let fishToken = null;
let autoFishing = false;
let autoFishCooldownTimer = null;
let autoFishCooldownCountdown = null;
let fishingCastPending = false;
const FISHING_MESSAGE_META_FONT_SIZE = "0.78em";
const FISHING_MESSAGE_BODY_FONT_SIZE = "1em";
/**
* 读取 CSRF Token。
*
* @returns {string}
*/
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content || "";
}
/**
* 获取私聊消息容器,钓鱼提示沿用旧版展示位置。
*
* @returns {HTMLElement|null}
*/
function messageContainer() {
return document.getElementById("chat-messages-container2");
}
/**
* 判断当前是否允许自动滚动。
*
* @returns {boolean}
*/
function shouldAutoScroll() {
return typeof window.isChatAutoScrollEnabled === "function" ? window.isChatAutoScrollEnabled() : true;
}
/**
* 追加一条钓鱼提示消息。
*
* @param {string} html
* @returns {void}
*/
function appendFishingMessage(html) {
const container = messageContainer();
if (!container) {
return;
}
const line = document.createElement("div");
line.className = "msg-line";
line.innerHTML = html;
container.appendChild(line);
if (shouldAutoScroll()) {
container.scrollTop = container.scrollHeight;
}
}
/**
* 当前本地时间文本。
*
* @returns {string}
*/
function timeText() {
return new Date().toLocaleTimeString("zh-CN", { hour12: false });
}
/**
* 注入浮漂和自动钓鱼按钮需要的动画样式。
*
* @returns {void}
*/
function ensureFishingStyles() {
if (!document.getElementById("bobber-style")) {
const style = document.createElement("style");
style.id = "bobber-style";
style.textContent = `
@keyframes bobberFloat {
0%,100% { transform: translateY(0) rotate(-8deg); }
50% { transform: translateY(-10px) rotate(8deg); }
}
@keyframes bobberSink {
0% { transform: translateY(0) scale(1); opacity:1; }
30% { transform: translateY(12px) scale(1.3); opacity:1; }
100% { transform: translateY(40px) scale(0.5); opacity:0; }
}
#fishing-bobber.sinking {
animation: bobberSink 1.5s forwards !important;
}
`;
document.head.appendChild(style);
}
if (!document.getElementById("auto-fish-stop-style")) {
const style = document.createElement("style");
style.id = "auto-fish-stop-style";
style.textContent = `
@keyframes autoFishBtnPulse {
0%,100% { box-shadow: 0 4px 12px rgba(220,38,38,0.4); }
50% { box-shadow: 0 4px 20px rgba(220,38,38,0.7); }
}
#auto-fish-stop-btn {
position: fixed;
z-index: 10000;
background: linear-gradient(135deg, #dc2626, #b91c1c);
color: #fff;
border: none;
border-radius: 20px;
padding: 8px 18px;
font-size: 13px;
font-weight: bold;
cursor: grab;
user-select: none;
animation: autoFishBtnPulse 1.8s ease-in-out infinite;
touch-action: none;
}
#auto-fish-stop-btn:active { cursor: grabbing; }
#auto-fish-stop-btn .drag-hint {
display: block;
font-size: 9px;
font-weight: normal;
opacity: .65;
margin-top: 1px;
text-align: center;
letter-spacing: .5px;
}
`;
document.head.appendChild(style);
}
}
/**
* 创建浮漂 DOM 元素。
*
* @param {number} x 水平百分比
* @param {number} y 垂直百分比
* @returns {HTMLElement}
*/
export function createBobber(x, y) {
ensureFishingStyles();
const bobber = document.createElement("div");
bobber.id = "fishing-bobber";
bobber.style.cssText = `
position: fixed;
left: ${Number(x) || 50}vw;
top: ${Number(y) || 50}vh;
font-size: 28px;
cursor: pointer;
z-index: 9999;
animation: bobberFloat 1.2s ease-in-out infinite;
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.4));
user-select: none;
transition: transform 0.3s;
`;
bobber.textContent = "🪝";
bobber.title = "鱼上钩了!快点击!";
return bobber;
}
/**
* 移除当前浮漂。
*
* @returns {void}
*/
export function removeBobber() {
document.getElementById("fishing-bobber")?.remove();
}
/**
* 获取钓鱼按钮。
*
* @returns {HTMLButtonElement|null}
*/
function fishingButton() {
const button = document.getElementById("fishing-btn");
return button instanceof HTMLButtonElement ? button : null;
}
/**
* 设置钓鱼按钮文案和禁用状态。
*
* @param {string} text
* @param {boolean} disabled
* @returns {void}
*/
function setFishingButton(text, disabled) {
const button = fishingButton();
if (!button) {
return;
}
button.textContent = text;
button.disabled = disabled;
}
/**
* 判断当前是否已有进行中的钓鱼会话。
*
* 说明:
* - 手动点击抛竿后,在等待浮漂、等待点击、等待自动收竿期间都视为会话未结束。
* - 购买自动钓鱼卡后的自动接管,也必须避开这些中间态,避免重复抛竿。
*
* @returns {boolean}
*/
function hasActiveFishingSession() {
return Boolean(
fishingCastPending ||
fishToken ||
fishingTimer ||
fishingReelTimeout ||
document.getElementById("fishing-bobber"),
);
}
/**
* 启动自动钓鱼冷却倒计时(基于时间戳,不受浏览器后台节流影响)。
*
* @param {number} cooldown 冷却秒数
* @returns {void}
*/
function startAutoFishingCooldown(cooldown) {
clearAutoFishingTimers();
const endTime = Date.now() + cooldown * 1000;
setFishingButton(`⏳ 冷却 ${cooldown}s`, true);
showAutoFishStopButton(cooldown);
// 基于时间戳更新倒计时 UI — 后台节流后回来也能准确显示
autoFishCooldownCountdown = window.setInterval(() => {
const remaining = Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
setFishingButton(`⏳ 冷却 ${remaining}s`, true);
updateAutoFishStopButtonCountdown(remaining);
if (remaining <= 0) {
window.clearInterval(autoFishCooldownCountdown);
autoFishCooldownCountdown = null;
}
}, 200);
// 基于时间戳检测冷却结束 — 后台节流后立即触发
autoFishCooldownTimer = null;
const checkEnd = () => {
if (Date.now() >= endTime) {
autoFishCooldownTimer = null;
if (autoFishing) {
updateAutoFishStopButtonCountdown(0, "正在继续自动钓鱼");
void startFishing();
return;
}
hideAutoFishStopButton();
return;
}
autoFishCooldownTimer = window.setTimeout(checkEnd, 200);
};
autoFishCooldownTimer = window.setTimeout(checkEnd, Math.min(cooldown * 1000, 200));
}
/**
* 展示可拖动的停止自动钓鱼按钮。
*
* @param {number} cooldown
* @returns {void}
*/
function showAutoFishStopButton(cooldown) {
const currentButton = document.getElementById("auto-fish-stop-btn");
if (currentButton) {
updateAutoFishStopButtonCountdown(cooldown);
return;
}
ensureFishingStyles();
const button = document.createElement("button");
button.id = "auto-fish-stop-btn";
button.innerHTML = `🛑 停止自动钓鱼冷却 ${Number(cooldown) || 0}s · 可拖动`;
try {
const saved = JSON.parse(window.localStorage.getItem("autoFishBtnPos") || "null");
if (saved) {
button.style.left = `${Number(saved.left) || 0}px`;
button.style.top = `${Number(saved.top) || 0}px`;
} else {
button.style.bottom = "80px";
button.style.right = "20px";
}
} catch (error) {
button.style.bottom = "80px";
button.style.right = "20px";
}
bindAutoFishStopDrag(button);
document.body.appendChild(button);
}
/**
* 同步更新停止自动钓鱼浮层上的冷却秒数,避免与主按钮倒计时不一致。
*
* @param {number} cooldown
* @param {string|null} text
* @returns {void}
*/
function updateAutoFishStopButtonCountdown(cooldown, text = null) {
const hint = document.querySelector("#auto-fish-stop-btn .drag-hint");
if (!(hint instanceof HTMLElement)) {
return;
}
hint.textContent = text || `冷却 ${Number(cooldown) || 0}s · 可拖动`;
}
/**
* 给停止自动钓鱼按钮绑定拖拽和点击停止事件。
*
* @param {HTMLButtonElement} button
* @returns {void}
*/
function bindAutoFishStopDrag(button) {
let isDragging = false;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
const dragStart = (event) => {
const rect = button.getBoundingClientRect();
button.style.left = `${rect.left}px`;
button.style.top = `${rect.top}px`;
button.style.right = "auto";
button.style.bottom = "auto";
isDragging = false;
const point = event.touches ? event.touches[0] : event;
startX = point.clientX;
startY = point.clientY;
startLeft = rect.left;
startTop = rect.top;
document.addEventListener("mousemove", dragMove, { passive: false });
document.addEventListener("mouseup", dragEnd);
document.addEventListener("touchmove", dragMove, { passive: false });
document.addEventListener("touchend", dragEnd);
};
const dragMove = (event) => {
event.preventDefault();
const point = event.touches ? event.touches[0] : event;
const dx = point.clientX - startX;
const dy = point.clientY - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
isDragging = true;
}
if (!isDragging) {
return;
}
const nextLeft = Math.max(0, Math.min(window.innerWidth - button.offsetWidth, startLeft + dx));
const nextTop = Math.max(0, Math.min(window.innerHeight - button.offsetHeight, startTop + dy));
button.style.left = `${nextLeft}px`;
button.style.top = `${nextTop}px`;
};
const dragEnd = () => {
document.removeEventListener("mousemove", dragMove);
document.removeEventListener("mouseup", dragEnd);
document.removeEventListener("touchmove", dragMove);
document.removeEventListener("touchend", dragEnd);
if (isDragging) {
window.localStorage.setItem("autoFishBtnPos", JSON.stringify({
left: Number.parseInt(button.style.left, 10),
top: Number.parseInt(button.style.top, 10),
}));
}
};
button.addEventListener("mousedown", dragStart);
button.addEventListener("touchstart", dragStart, { passive: true });
button.addEventListener("click", () => {
if (!isDragging) {
stopAutoFishing();
}
});
}
/**
* 隐藏停止自动钓鱼按钮。
*
* @returns {void}
*/
function hideAutoFishStopButton() {
document.getElementById("auto-fish-stop-btn")?.remove();
}
/**
* 开始钓鱼:调用抛竿接口并显示浮漂。
*
* @returns {Promise}
*/
export async function startFishing() {
if (hasActiveFishingSession()) {
return;
}
fishingCastPending = true;
setFishingButton("🎣 抛竿中...", true);
try {
const response = await fetch(window.chatContext.fishCastUrl, {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Accept": "application/json",
},
});
const data = await response.json();
if (!response.ok || data.status !== "success") {
if (autoFishing && response.status === 429) {
// 服务端冷却 TTL 可能与前端倒计时有毫秒级误差,自动模式下按服务端剩余时间续等。
startAutoFishingCooldown(Math.max(1, Number(data.cooldown) || 1));
return;
}
if (autoFishing && response.status === 409) {
// 多标签页或重复自动抛竿时,后端会保留先到的 token,当前页等待后再接管。
appendFishingMessage(`【钓鱼】${escapeHtml(data.message || "已有钓鱼正在进行,稍后自动重试。")}(${timeText()})`);
startAutoFishingCooldown(Math.max(1, Number(data.retry_after) || 5));
return;
}
window.chatDialog?.alert?.(data.message || "钓鱼失败", "操作失败", "#cc4444");
setFishingButton("🎣 钓鱼", false);
return;
}
// 抛竿成功后进入正式钓鱼会话,由 token / timer 接管后续状态。
fishToken = data.token;
autoFishing = Boolean(data.auto_fishing);
appendFishingMessage(`🎣【钓鱼】${escapeHtml(data.message)}(${timeText()})`);
setFishingButton("🎣 等待中...", true);
const bobber = createBobber(data.bobber_x, data.bobber_y);
document.body.appendChild(bobber);
if (data.auto_fishing) {
showAutoFishStopButton(0);
updateAutoFishStopButtonCountdown(0, "等待鱼儿上钩 · 可拖动");
}
fishingTimer = window.setTimeout(() => {
// 等待计时器触发后必须立即释放句柄,否则自动钓鱼冷却结束会误判仍有会话进行中。
fishingTimer = null;
bobber.classList.add("sinking");
bobber.textContent = "🐟";
if (data.auto_fishing) {
showAutoFishStopButton(0);
updateAutoFishStopButtonCountdown(0, "自动收竿中 · 可拖动");
appendFishingMessage(`🎣 自动钓鱼卡生效!自动收竿中... (剩余${Number(data.auto_fishing_minutes_left) || 0}分钟)`);
fishingReelTimeout = window.setTimeout(() => {
removeBobber();
void reelFish();
}, 1800);
return;
}
appendFishingMessage(`🐟 鱼上钩了!快点击屏幕上的浮漂!`);
setFishingButton("🎣 点击浮漂!", true);
bobber.addEventListener("click", () => {
removeBobber();
if (fishingReelTimeout) {
window.clearTimeout(fishingReelTimeout);
fishingReelTimeout = null;
}
void reelFish();
}, { once: true });
fishingReelTimeout = window.setTimeout(() => {
removeBobber();
fishToken = null;
appendFishingMessage('💨 你反应太慢了,鱼跑掉了...');
resetFishingBtn();
}, 8000);
}, Number(data.wait_time || 0) * 1000);
} catch (error) {
window.chatDialog?.alert?.(`网络错误:${error.message}`, "网络异常", "#cc4444");
removeBobber();
setFishingButton("🎣 钓鱼", false);
} finally {
fishingCastPending = false;
}
}
/**
* 收竿并提交本次钓鱼 token。
*
* @returns {Promise}
*/
export async function reelFish() {
setFishingButton("🎣 拉竿中...", true);
if (fishingReelTimeout) {
window.clearTimeout(fishingReelTimeout);
fishingReelTimeout = null;
}
const token = fishToken;
fishToken = null;
try {
const response = await fetch(window.chatContext.fishReelUrl, {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ token }),
});
const data = await response.json();
if (response.ok && data.status === "success") {
const result = data.result || {};
const color = Number(result.exp || 0) >= 0 ? "#16a34a" : "#dc2626";
appendFishingMessage(
`${escapeHtml(result.emoji || "🎣")}【钓鱼结果】${escapeHtml(result.message || "")}` +
` (经验:${Number(data.exp_num) || 0} 金币:${Number(data.jjb) || 0})` +
`(${timeText()})`,
);
if (autoFishing) {
startAutoFishingCooldown(Number(data.cooldown_seconds) || 300);
return;
}
} else {
appendFishingMessage(`【钓鱼】${escapeHtml(data.message || "操作失败")}(${timeText()})`);
if (autoFishing) {
retryAutoFishing();
return;
}
autoFishing = false;
}
} catch (error) {
if (autoFishing) {
appendFishingMessage('⚠️ 网络异常,5秒后自动重试钓鱼...');
retryAutoFishing();
return;
}
window.chatDialog?.alert?.(`网络错误:${error.message}`, "网络异常", "#cc4444");
autoFishing = false;
}
resetFishingBtn();
}
/**
* 自动钓鱼异常时短暂等待后重试。
*
* @returns {void}
*/
function retryAutoFishing() {
setFishingButton("⏳ 重试中...", true);
autoFishCooldownTimer = window.setTimeout(() => {
autoFishCooldownTimer = null;
if (autoFishing) {
void startFishing();
}
}, 5000);
}
/**
* 手动停止自动钓鱼循环。
*
* @returns {void}
*/
export function stopAutoFishing() {
autoFishing = false;
clearAutoFishingTimers();
hideAutoFishStopButton();
appendFishingMessage('🛑 已停止自动钓鱼。');
resetFishingBtn();
}
/**
* 清理自动钓鱼冷却计时器。
*
* @returns {void}
*/
function clearAutoFishingTimers() {
if (autoFishCooldownTimer) {
window.clearTimeout(autoFishCooldownTimer);
autoFishCooldownTimer = null;
}
if (autoFishCooldownCountdown) {
window.clearInterval(autoFishCooldownCountdown);
autoFishCooldownCountdown = null;
}
}
/**
* 重置钓鱼按钮和临时状态。
*
* @returns {void}
*/
export function resetFishingBtn() {
autoFishing = false;
fishingCastPending = false;
clearAutoFishingTimers();
hideAutoFishStopButton();
if (fishingTimer) {
window.clearTimeout(fishingTimer);
fishingTimer = null;
}
if (fishingReelTimeout) {
window.clearTimeout(fishingReelTimeout);
fishingReelTimeout = null;
}
setFishingButton("🎣 钓鱼", false);
removeBobber();
}
/**
* 检查自动钓鱼卡状态并恢复自动循环。
*
* @returns {void}
*/
export function checkAndAutoStartFishing() {
const minutesLeft = Number(window.chatContext?.autoFishingMinutesLeft || 0);
const initialCooldown = Number(window.chatContext?.fishingCooldownSeconds || 0);
if (minutesLeft <= 0 || autoFishing || hasActiveFishingSession()) {
return;
}
autoFishing = true;
if (initialCooldown > 0) {
console.log(`检测到自动钓鱼卡有效,恢复钓鱼状态,剩余冷却 ${initialCooldown}s`);
startAutoFishingCooldown(initialCooldown);
return;
}
console.log("检测到自动钓鱼卡有效,自动抛竿");
void startFishing();
}
/**
* 绑定钓鱼按钮、全局兼容入口和清屏恢复事件。
*
* @returns {void}
*/
export function bindFishingControls() {
if (typeof window === "undefined") {
return;
}
window.createBobber = createBobber;
window.removeBobber = removeBobber;
window.startFishing = startFishing;
window.reelFish = reelFish;
window.stopAutoFishing = stopAutoFishing;
window.resetFishingBtn = resetFishingBtn;
window.checkAndAutoStartFishing = checkAndAutoStartFishing;
if (fishingEventsBound || typeof document === "undefined") {
return;
}
fishingEventsBound = true;
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element) || !event.target.closest("[data-chat-fishing-start]")) {
return;
}
event.preventDefault();
void startFishing();
});
document.addEventListener("DOMContentLoaded", () => {
checkAndAutoStartFishing();
window.addEventListener("chat:screen-cleared", () => {
if (!autoFishing) {
checkAndAutoStartFishing();
}
});
});
}