您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Automatically displays the time taken between dungeon runs in Milky Way Idle chat.
// ==UserScript== // @name MWI Dungeon Timer // @namespace http://tampermonkey.net/ // @version 1.11 // @author qu // @description Automatically displays the time taken between dungeon runs in Milky Way Idle chat. // @match https://www.milkywayidle.com/* // @match https://test.milkywayidle.com/* // @grant GM.registerMenuCommand // @grant GM.setValue // @grant GM.getValue // @grant GM.listValues // @license MIT // @run-at document-idle // ==/UserScript== (async function () { 'use strict'; const MSG_SEL = '[class^="ChatMessage_chatMessage"]'; const TIME_PART_RE = '(?:\\d{1,2}\/\\d{1,2} )?(\\d{1,2}):(\\d{2}):(\\d{2})(?: ([AP]M))?'; const FULL_TIMESTAMP_RE = new RegExp(`^\\[${TIME_PART_RE}\\]`); const KEY_COUNTS_RE = new RegExp(`^\\[${TIME_PART_RE}\\] Key counts: `); const BATTLE_ENDED_RE = new RegExp(`\\[${TIME_PART_RE}\\] Battle ended: `); const PARTY_FAILED_RE = new RegExp(`\\[${TIME_PART_RE}\\] Party failed on wave \\d+`); const TEAM_DATA_KEY = 'dungeonTimer_teamRuns'; let teamRuns = {}; let previousTimes = []; let isVerboseLoggingEnabled = false; let previousFastestMsg = null; // UI Setup GM.registerMenuCommand('Toggle Verbose Logging', async () => { isVerboseLoggingEnabled = !isVerboseLoggingEnabled; await GM.setValue('verboseLogging', isVerboseLoggingEnabled); console.log(`[DungeonTimer] Verbose logging ${isVerboseLoggingEnabled ? 'enabled' : 'disabled'}`); }); // Initialize settings and data initDungeonTimer(); async function initDungeonTimer() { isVerboseLoggingEnabled = await GM.getValue('verboseLogging', false); try { const raw = localStorage.getItem(TEAM_DATA_KEY); teamRuns = raw ? JSON.parse(raw) : {}; } catch (e) { console.warn('[DungeonTimer] Failed to load team data:', e); } setupUIPanel(); // Wait 1.5 seconds for the chat to populate before scanning. setTimeout(() => { scanAndAnnotate(); }, 1500); const observer = new MutationObserver(mutations => { for (const m of mutations) { for (const node of m.addedNodes) { if (!(node instanceof HTMLElement)) continue; const msg = node.matches?.(MSG_SEL) ? node : node.querySelector?.(MSG_SEL); if (!msg) continue; scanAndAnnotate(); } } }); observer.observe(document.body, { childList: true, subtree: true }); } // ===================== Core Logic ====================== function extractChatEvents() { maybeLog('extractChatEvents'); const nodes = [...document.querySelectorAll(MSG_SEL)]; const events = []; for (const node of nodes) { if (node.dataset.processed === '1') continue; const text = node.textContent.trim(); const timestamp = getTimestampFromMessage(node); if (!timestamp) continue; if (KEY_COUNTS_RE.test(text)) { const team = getTeamFromMessage(node); if (!team.length) continue; events.push({ type: 'key', timestamp, team, msg: node }); } else if (PARTY_FAILED_RE.test(text)) { events.push({ type: 'fail', timestamp, msg: node }); node.dataset.processed = '1'; } else if (BATTLE_ENDED_RE.test(text)) { events.push({ type: 'cancel', timestamp, msg: node }); node.dataset.processed = '1'; } } return events; } function annotateChatEvents(events) { maybeLog('annotateChatEvents'); previousTimes.length = 0; for (let i = 0; i < events.length; i++) { const e = events[i]; if (e.type !== 'key') continue; const next = events[i + 1]; let label = null; let diff = null; if (next?.type === 'key') { diff = next.timestamp - e.timestamp; if (diff < 0) { diff += 24 * 60 * 60 * 1000; // handle midnight rollover } label = formatDuration(diff); const teamKey = e.team.join(','); const entry = { timestamp: e.timestamp.toISOString(), diff }; teamRuns[teamKey] ??= []; const isDuplicate = teamRuns[teamKey].some( r => r.timestamp === entry.timestamp && r.diff === entry.diff ); if (!isDuplicate) { teamRuns[teamKey].push(entry); } previousTimes.push({ msg: e.msg, diff }); } else if (next?.type === 'fail') { label = 'FAILED'; } else if (next?.type === 'cancel') { label = 'canceled'; } if (label){ e.msg.dataset.processed = '1'; insertDungeonTimer(label, e.msg); } } saveTeamRuns(); updateStatsPanel(); } function scanAndAnnotate() { const events = extractChatEvents(); annotateChatEvents(events); } // ===================== Utilities ====================== function maybeLog(logMessage) { if (isVerboseLoggingEnabled) { console.log("[DungeonTimer] " + logMessage); } } function getTimestampFromMessage(msg) { const match = msg.textContent.trim().match(FULL_TIMESTAMP_RE); if (!match) return null; let [_, hour, min, sec, period] = match; hour = parseInt(hour, 10); min = parseInt(min, 10); sec = parseInt(sec, 10); if (period === 'PM' && hour !== 12) hour += 12; if (period === 'AM' && hour === 12) hour = 0; const date = new Date(); date.setHours(hour, min, sec, 0); return date; } function getTeamFromMessage(msg) { const text = msg.textContent.trim(); const matches = [...text.matchAll(/\[([^\[\]-]+?)\s*-\s*\d+\]/g)]; return matches.map(m => m[1].trim()).sort(); } function insertDungeonTimer(label, msg) { if (msg.dataset.timerAppended === '1') return; const spans = msg.querySelectorAll('span'); if (spans.length < 2) return; const messageSpan = spans[1]; const timerSpan = document.createElement('span'); timerSpan.textContent = ` [${label}]`; timerSpan.classList.add('dungeon-timer'); if (label === 'FAILED') timerSpan.style.color = '#ff4c4c'; else if (label === 'canceled') timerSpan.style.color = '#ffd700'; else timerSpan.style.color = '#90ee90'; timerSpan.style.fontSize = '90%'; timerSpan.style.fontStyle = 'italic'; messageSpan.appendChild(timerSpan); msg.dataset.timerAppended = '1'; } function formatDuration(ms) { const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}m ${seconds}s`; } function saveTeamRuns() { try { localStorage.setItem(TEAM_DATA_KEY, JSON.stringify(teamRuns)); } catch (e) { console.error('[DungeonTimer] Failed to save teamRuns:', e); } } // ===================== UI Panel ====================== function setupUIPanel() { if (document.getElementById('dungeon-timer-panel')) return; const panel = document.createElement('div'); panel.id = 'dungeon-timer-panel'; panel.style.position = 'fixed'; panel.style.right = '6px'; panel.style.bottom = '6px'; panel.style.width = '260px'; panel.style.maxHeight = '50vh'; panel.style.overflowY = 'auto'; panel.style.zIndex = '9999'; panel.style.background = '#222'; panel.style.color = '#fff'; panel.style.fontSize = '12px'; panel.style.border = '1px solid #888'; panel.style.borderRadius = '6px'; // Header for dragging and minimizing const header = document.createElement('div'); header.style.cursor = 'move'; header.style.background = '#444'; header.style.padding = '4px 6px'; header.style.fontWeight = 'bold'; header.style.display = 'flex'; header.style.justifyContent = 'space-between'; header.style.alignItems = 'center'; header.innerHTML = ` <span>Dungeon Stats</span> <button id="dungeon-toggle-btn" style="background:#333; color:#fff; border:none; cursor:pointer;">−</button> `; // Content container const content = document.createElement('div'); content.id = 'dungeon-panel-content'; content.style.padding = '6px'; // Clear Button const clearBtn = document.createElement('button'); clearBtn.textContent = 'Clear'; clearBtn.style.background = '#a33'; clearBtn.style.color = '#fff'; clearBtn.style.border = 'none'; clearBtn.style.cursor = 'pointer'; clearBtn.style.padding = '4px 8px'; clearBtn.style.margin = '6px'; clearBtn.style.borderRadius = '4px'; clearBtn.style.justifyContent = 'center'; clearBtn.style.display = 'block'; clearBtn.style.margin = '0 auto'; clearBtn.style.marginBottom = '8px'; clearBtn.addEventListener('click', () => { if (confirm('Clear previous dungeon run data?')) { teamRuns = {}; saveTeamRuns(); updateStatsPanel(); } }); panel.appendChild(header); panel.appendChild(content); panel.appendChild(clearBtn); document.body.appendChild(panel); makeDraggable(panel, header); // Minimize/Expand toggle document.getElementById('dungeon-toggle-btn').addEventListener('click', () => { if (content.style.display === 'none') { content.style.display = 'block'; clearBtn.style.display = 'block'; document.getElementById('dungeon-toggle-btn').textContent = '−'; } else { content.style.display = 'none'; clearBtn.style.display = 'none'; document.getElementById('dungeon-toggle-btn').textContent = '+'; } }); } function makeDraggable(panel, handle) { let isDragging = false; let offsetX, offsetY; handle.addEventListener('mousedown', e => { isDragging = true; offsetX = e.clientX - panel.getBoundingClientRect().left; offsetY = e.clientY - panel.getBoundingClientRect().top; document.body.style.userSelect = 'none'; }); document.addEventListener('mousemove', e => { if (!isDragging) return; panel.style.left = `${e.clientX - offsetX}px`; panel.style.top = `${e.clientY - offsetY}px`; panel.style.right = 'auto'; panel.style.bottom = 'auto'; }); document.addEventListener('mouseup', () => { isDragging = false; document.body.style.userSelect = ''; }); } function updateStatsPanel() { const container = document.querySelector('#dungeon-panel-content'); if (!container) return; container.innerHTML = ''; for (const [teamKey, runs] of Object.entries(teamRuns)) { if (!runs.length) continue; const times = runs.map(r => r.diff); const avg = Math.floor(times.reduce((a, b) => a + b, 0) / times.length); const best = Math.min(...times); const worst = Math.max(...times); const bestTime = runs.find(r => r.diff === best)?.timestamp; const worstTime = runs.find(r => r.diff === worst)?.timestamp; const line = document.createElement('div'); line.innerHTML = ` <strong>${teamKey}</strong> (${runs.length} runs)<br/> Avg: ${formatDuration(avg)}<br/> Best: ${formatDuration(best)} (${formatShortDate(bestTime)})<br/> Worst: ${formatDuration(worst)} (${formatShortDate(worstTime)}) `; container.appendChild(line); } } function formatShortDate(isoStr) { const d = new Date(isoStr); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } })();