您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Enhances Torn Racing with real-time telemetry, race stats history, and recent race logs.
当前为
// ==UserScript== // @name Torn Racing Telemetry // @namespace https://www.torn.com/profiles.php?XID=2782979 // @version 2.4.3 // @description Enhances Torn Racing with real-time telemetry, race stats history, and recent race logs. // @match https://www.torn.com/page.php?sid=racing* // @match https://www.torn.com/loader.php?sid=racing* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_connect // @connect api.torn.com // @license MIT // ==/UserScript== (function() { 'use strict'; if (window.racingTelemetryScriptHasRun) return; window.racingTelemetryScriptHasRun = true; const defaultConfig = { displayMode: 'speed', colorCode: true, animateChanges: true, speedUnit: 'mph', periodicCheckInterval: 1000, // Hardcoded to 1 second (1000ms) minUpdateInterval: 500, language: 'en', apiKey: '' }; // Load config and log it with JSON parse fix let config = Object.assign({}, defaultConfig, (() => { const savedConfig = GM_getValue('racingTelemetryConfig', null); if (savedConfig) { try { return JSON.parse(savedConfig); } catch (e) { console.error("Error parsing saved config, using default:", e); return {}; // Parsing failed, return empty object to avoid errors } } return {}; // No saved config found, return empty object to merge with defaultConfig })()); let currentRaceId = null; let raceStarted = false; let periodicCheckIntervalId = null; let telemetryVisible = true; let observers = []; let lastUpdateTimes = {}; const state = { previousMetrics: {}, trackInfo: { total: 0, laps: 0, length: 0 }, racingStats: null, statsHistory: [], raceLog: [] }; (function initStorage() { let savedStatsHistory = GM_getValue('statsHistory', []); if (!Array.isArray(savedStatsHistory)) savedStatsHistory = []; state.statsHistory = savedStatsHistory.filter(entry => entry && entry.timestamp && entry.skill).slice(0, 50); const savedRaces = GM_getValue('raceLog', []); state.raceLog = (Array.isArray(savedRaces) ? savedRaces.filter(r => r?.id).slice(0, 50) : []); })(); const utils = { convertSpeed(speed, unit) { return unit === 'kmh' ? speed * 1.60934 : speed; }, formatTime(seconds) { const minutes = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${minutes}:${secs < 10 ? '0' : ''}${secs}`; }, parseTime(timeString) { if (!timeString || !timeString.includes(':')) return 0; const parts = timeString.split(':'); let minutes = 0; let seconds = 0; if (parts.length === 2) { // MM:SS.SS or MM:SS.HH format minutes = parseInt(parts[0], 10) || 0; seconds = parseFloat(parts[1]) || 0; // Parse seconds with decimal part } else if (parts.length === 3) { // Optional HH:MM:SS.SS format in future? // If we ever encounter HH:MM:SS.SS, handle it here. For now assume MM:SS.SS is standard. console.warn("Time string with 3 parts encountered, assuming HH:MM:SS.SS and taking last two parts as MM:SS.SS:", timeString); minutes = parseInt(parts[1], 10) || 0; seconds = parseFloat(parts[2]) || 0; } else { console.error("Unexpected time string format:", timeString); return 0; // Or handle error as needed } return (minutes * 60) + seconds; // Total seconds }, parseProgress(text) { const match = text.match(/(\d+\.?\d*)%/); return match ? parseFloat(match[1]) : 0; }, displayError(message) { const errorDiv = document.createElement('div'); errorDiv.style.cssText = 'position: fixed; bottom: 10px; right: 10px; background: #f44336; color: #fff; padding: 10px; border-radius: 5px;'; errorDiv.textContent = message; document.body.appendChild(errorDiv); setTimeout(() => errorDiv.remove(), 5000); }, fetchWithRetry: async (url, retries = 3) => { try { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); } catch (e) { if (retries > 0) return utils.fetchWithRetry(url, retries - 1); throw e; } }, formatTimestamp: (timestamp) => { let date; if (typeof timestamp === 'number') { date = new Date(timestamp); } else if (typeof timestamp === 'string') { date = new Date(timestamp); } else { return 'N/A'; } if (isNaN(date)) { console.error("Invalid date object created from timestamp:", timestamp); return 'Invalid Date'; } return date.toLocaleDateString() + ' ' + date.toLocaleTimeString(); } }; const dataManager = { async fetchRacingStats() { if (!config.apiKey?.trim()) return; try { const data = await utils.fetchWithRetry( `https://api.torn.com/v2/user/personalstats?cat=racing&key=${config.apiKey}` ); if (data.error) throw new Error(data.error.error); this.processStats(data.personalstats.racing); } catch (e) { console.error('Stats fetch failed:', e); utils.displayError(`API Error: ${e.message}`); } }, processStats(newStats) { const oldStats = state.racingStats?.racing; const changes = { timestamp: Date.now(), skill: { old: oldStats?.skill || 0, new: newStats.skill }, points: { old: oldStats?.points || 0, new: newStats.points }, racesEntered: { old: oldStats?.races?.entered || 0, new: newStats.races?.entered }, racesWon: { old: oldStats?.races?.won || 0, new: newStats.races?.won } }; if (!oldStats || JSON.stringify(changes) !== JSON.stringify(oldStats)) { state.statsHistory.unshift(changes); state.statsHistory = state.statsHistory.slice(0, 50); GM_setValue('statsHistory', state.statsHistory); } state.racingStats = { racing: newStats }; GM_setValue('racingStats', JSON.stringify(state.racingStats)); const statsPanelContent = document.querySelector('.stats-history-popup .popup-content'); if (statsPanelContent) { statsPanelContent.innerHTML = generateStatsContent(); } }, async fetchRaces() { if (!config.apiKey?.trim()) return; try { const data = await utils.fetchWithRetry( `https://api.torn.com/v2/user/races?key=${config.apiKey}&limit=10&sort=DESC&cat=official` ); data.races?.forEach(race => this.processRace(race)); GM_setValue('raceLog', state.raceLog); const racesPanelContent = document.querySelector('.recent-races-popup .popup-content'); if (racesPanelContent) { racesPanelContent.innerHTML = generateRacesContent(); } } catch (e) { console.error('Race fetch failed:', e); utils.displayError(`Race data error: ${e.message}`); } }, processRace(race) { const exists = state.raceLog.some(r => r.id === race.id); if (!exists && race?.id) { state.raceLog.unshift({ ...race, fetchedAt: Date.now() }); state.raceLog = state.raceLog.slice(0, 50); } } }; GM_addStyle(` :root { --primary-color: #4CAF50; --background-dark: #1a1a1a; --background-light: #2a2a2a; --text-color: #e0e0e0; --border-color: #404040; --accent-color: #64B5F6; /* Example accent color */ } body.telemetry-hidden #telemetryButtonContainer { margin-bottom: 0; /* Adjust margin when telemetry is hidden */ } .telemetry-button-container { display: flex; width: 100%; margin: 0 0px 0px; /* Reduced side margin, added bottom margin */ border-radius: 6px; overflow: hidden; background: var(--background-dark); /* Set background for the container */ border: 1px solid var(--border-color); } .telemetry-button { flex-grow: 1; background: transparent; /* Button background is transparent */ color: var(--text-color); border: none; /* No individual button borders */ padding: 12px 15px; /* Slightly increased padding */ text-align: center; cursor: pointer; transition: background-color 0.2s ease, color 0.2s ease; /* Smoother transitions */ font-size: 15px; /* Slightly larger font size */ } .telemetry-button:hover { background-color: var(--background-light); /* Lighter background on hover */ color: var(--accent-color); /* Accent color on hover */ } .telemetry-button:active { background-color: var(--accent-color); /* Accent color on active */ color: var(--background-dark); /* Dark background text on active */ } .telemetry-button:not(:first-child) { /* No specific styling needed as we removed individual borders */ } .telemetry-popup-container { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); /* Slightly darker semi-transparent background */ display: flex; justify-content: center; align-items: center; z-index: 1000; backdrop-filter: blur(5px); /* Added blur for a modern look */ } .telemetry-popup { background: var(--background-dark); border-radius: 10px; /* More rounded corners for popup */ padding: 20px; /* Increased padding for more space */ color: var(--text-color); font-family: 'Arial', sans-serif; box-shadow: 0 8px 16px rgba(0,0,0,0.6); /* Increased shadow for depth */ width: 90%; /* Slightly wider on default */ max-width: 700px; /* Maximum width for larger screens */ max-height: 95%; overflow-y: auto; position: relative; border: 1px solid var(--border-color); /* Added border to popup */ } .popup-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; /* Increased margin */ padding-bottom: 8px; border-bottom: 1px solid var(--border-color); /* Separator line */ } .popup-title { font-size: 1.4em; /* Slightly larger title */ font-weight: bold; color: var(--primary-color); } .close-button { background: var(--background-light); border: none; border-radius: 6px; color: var(--text-color); padding: 8px 16px; /* Adjusted padding */ cursor: pointer; transition: background-color 0.2s ease, color 0.2s ease; font-size: 14px; } .close-button:hover { background-color: var(--accent-color); color: var(--background-dark); } .popup-content { padding: 15px 0; /* Vertical padding for content */ /* background: #202020; - Inherited from .telemetry-popup now */ border-radius: 8px; max-height: 70vh; /* Increased max height for content scroll */ overflow-y: auto; } #leaderBoard .driver-item .name { display: flex !important; align-items: center; min-width: 0; padding-right: 10px !important; } #leaderBoard .driver-item .name > span:first-child { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 8px; } .driver-status { flex: 0 0 auto; margin-left: auto; font-size: 11px; background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px; white-space: nowrap; transition: color ${config.minUpdateInterval / 1000}s ease, opacity 0.3s ease; } .telemetry-hidden .driver-status { display: none; } @media (max-width: 480px) { .telemetry-button-container { flex-direction: column; /* Stack buttons on mobile */ margin: 0 5px 10px; /* Adjust mobile margins */ border-radius: 8px; } .telemetry-button { border-radius: 0; /* No border radius for stacked buttons */ } .telemetry-button:not(:last-child) { border-bottom: 1px solid var(--border-color); /* Add bottom border to separate stacked buttons */ } .telemetry-popup { width: 95%; /* Full width on mobile */ margin: 10px; /* Add margin around mobile popup */ border-radius: 8px; padding: 15px; } .popup-content { max-height: 80vh; /* Adjust content max height for mobile */ padding: 10px 0; } #leaderBoard .driver-item .name { padding-right: 5px !important; } .driver-status { font-size: 10px; padding: 1px 4px; margin-left: 4px; } .telemetry-settings, .stats-history, .recent-races { margin: 8px; padding: 12px; /* Removed max-height and overflow-y from panel styles */ } .settings-title, .history-title, .races-title { font-size: 14px; } } .settings-header, .history-header, .races-header { display: flex; justify-content: space-between; align-items: center; background: var(--background-light); /* Slightly lighter header background */ padding: 10px 15px; border-radius: 8px 8px 0 0; /* Rounded top corners for headers */ } .settings-title, .history-title, .races-title { font-size: 1.1em; font-weight: bold; color: var(--primary-color); display: flex; align-items: center; gap: 8px; } .toggle-btn, .reset-btn, .toggle-telemetry-btn, .copy-btn { /* Keep existing styles for other buttons */ background: var(--background-light); border: none; border-radius: 6px; color: var(--text-color); padding: 8px 16px; cursor: pointer; transition: background-color 0.2s ease, color 0.2s ease; font-size: 14px; margin-top: 5px; /* Added a little top margin for spacing */ } .toggle-btn:hover, .reset-btn:hover, .toggle-telemetry-btn:hover, .copy-btn:hover { background-color: var(--accent-color); color: var(--background-dark); } .reset-system-btn { /* New style for Reset System Button */ background: #f44336; /* Red background */ border: none; /* No border */ border-radius: 6px; /* Inherit border-radius */ color: #fff; /* Inherit text color */ padding: 8px 16px; /* Inherit padding */ cursor: pointer; /* Inherit cursor */ transition: all 0.2s; /* Inherit transition */ font-size: 14px; /* Inherit font-size */ } .reset-system-btn:hover { background-color: #d32f2f; /* Darker red on hover */ } .settings-content, .history-content, .races-content { display: flex; /* Use flex to arrange setting groups vertically */ flex-direction: column; gap: 15px; /* Increased gap between setting groups */ padding: 15px; background: var(--background-dark); /* Match popup background */ border-radius: 0 0 8px 8px; /* Rounded bottom corners for content */ } .setting-group { display: flex; /* Flex to stack setting items */ flex-direction: column; gap: 10px; /* Gap between setting items */ padding: 10px 15px; background: var(--background-light); /* Lighter background for setting groups */ border-radius: 8px; border: 1px solid var(--border-color); /* Added border to setting groups */ } .setting-item { display: flex; flex-direction: column; /* Stack title and control vertically */ align-items: stretch; /* Stretch items to full width */ justify-content: flex-start; padding: 12px; background: var(--background-dark); /* Darker background for individual settings */ border-radius: 6px; } .setting-item > span { margin-bottom: 8px; /* More space below setting titles */ font-weight: normal; /* Slightly thinner font for titles */ color: var(--text-color); } .switch { position: relative; display: inline-block; /* Ensure switch is inline-block */ width: 45px; /* Slightly wider switch */ height: 24px; /* Slightly taller switch */ } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background: #4d4d4d; /* Darker default slider background */ transition: .3s; /* Slightly slower transition */ border-radius: 12px; /* More rounded slider */ } .slider:before { position: absolute; content: ""; height: 20px; /* Slightly smaller slider handle */ width: 20px; left: 3px; bottom: 2px; background: #f4f4f4; /* Lighter handle color */ transition: .3s; border-radius: 50%; } input:checked + .slider { background: var(--primary-color); /* Primary color when checked */ } input:checked + .slider:before { transform: translateX(21px); /* Adjusted handle movement */ } .radio-group { display: flex; /* Keep flex for horizontal radio groups if still used elsewhere */ gap: 10px; padding: 8px; background: #252525; border-radius: 6px; } .radio-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #333; border-radius: 4px; cursor: pointer; margin: 5px 0; /* Add vertical margin between radio items */ } /* Target the text inside radio-item labels specifically */ .radio-item label { text-align: left; /* Explicitly left align text within radio-item labels */ display: block; /* Ensure label takes full width for text alignment to work */ width: 100%; /* Ensure label takes full width */ } .radio-item:first-child { margin-top: 0; /* Remove top margin for the first item */ } .api-key-input { width: 100%; /* Full width input */ padding: 0px; /* Increased input padding */ border-radius: 6px; /* Rounded corners for input */ border: 1px solid var(--border-color); background: var(--background-light); color: var(--text-color); font-size: 14px; } .api-key-input:focus { outline: none; border-color: var(--accent-color); /* Accent color on focus */ box-shadow: 0 0 5px rgba(var(--accent-color-rgb), 0.5); /* Subtle shadow on focus - requires defining --accent-color-rgb if you use named colors */ } .history-entry, .race-entry { padding: 15px; /* Increased padding for entries */ background: var(--background-light); border-radius: 6px; margin-bottom: 8px; /* Spacing between entries */ font-size: 14px; line-height: 1.5; border: 1px solid var(--border-color); /* Added border to entries */ } .history-entry:last-child, .race-entry:last-child { margin-bottom: 0; /* No bottom margin for the last entry */ } .current-stats { padding: 15px; background: var(--background-light); border-radius: 6px; margin-bottom: 15px; border: 1px solid var(--border-color); } .current-stats h3 { color: var(--primary-color); margin-top: 0; margin-bottom: 10px; font-size: 1.2em; } .current-stats p { margin: 5px 0; } .setting-dropdown { /* NEW: Style for dropdowns */ width: 100%; padding: 10px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--background-light); color: var(--text-color); font-size: 14px; appearance: none; /* Remove default appearance */ -webkit-appearance: none; /* For Safari */ -moz-appearance: none; /* For Firefox */ background-image: url('data:image/svg+xml;utf8,<svg fill="white" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M7 10l5 5 5-5z"/><path d="M0 0h24v24H0z" fill="none"/></svg>'); background-repeat: no-repeat; background-position-x: 98%; /* Position arrow more to the right */ background-position-y: 12px; /* Vertically center arrow */ padding-right: 30px; /* Increased padding for arrow */ } .setting-dropdown::-ms-expand { display: none; /*For IE and Edge*/ } .setting-dropdown:focus { outline: none; border-color: var(--accent-color); /* Accent color on focus */ box-shadow: 0 0 5px rgba(var(--accent-color-rgb), 0.5); /* Subtle shadow on focus */ } `); // Easing function: easeInOutQuad ramps up then down. function easeInOutQuad(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; } // Helper to interpolate between two colors. function interpolateColor(color1, color2, factor) { const result = color1.map((c, i) => Math.round(c + factor * (color2[i] - c))); return `rgb(${result[0]}, ${result[1]}, ${result[2]})`; } // Returns a color based on acceleration (in g's). If color coding is disabled, always return grey. function getTelemetryColor(acceleration) { const grey = [136, 136, 136]; if (!config.colorCode) return `rgb(${grey[0]}, ${grey[1]}, ${grey[2]})`; const green = [76, 175, 80]; const red = [244, 67, 54]; const maxAcc = 1.0; let factor = Math.min(Math.abs(acceleration) / maxAcc, 1); if (acceleration > 0) { return interpolateColor(grey, green, factor); } else if (acceleration < 0) { return interpolateColor(grey, red, factor); } else { return `rgb(${grey[0]}, ${grey[1]}, ${grey[2]})`; } } // Animate telemetry text using an easeInOut function over a dynamic duration. function animateTelemetry(element, fromSpeed, toSpeed, fromAcc, toAcc, duration, displayMode, speedUnit, extraText) { let startTime = null; function step(timestamp) { if (!startTime) startTime = timestamp; let linearProgress = (timestamp - startTime) / duration; if (linearProgress > 1) linearProgress = 1; let progress = easeInOutQuad(linearProgress); let currentSpeed = fromSpeed + (toSpeed - fromSpeed) * progress; let currentAcc = fromAcc + (toAcc - fromAcc) * progress; element._currentSpeed = currentSpeed; element._currentAcc = currentAcc; let color = config.colorCode ? getTelemetryColor(currentAcc) : 'rgb(136, 136, 136)'; let text; if (displayMode === 'speed') { text = `${Math.round(currentSpeed)} ${speedUnit}`; } else if (displayMode === 'acceleration') { text = `${currentAcc.toFixed(1)} g`; } else { text = `${Math.round(currentSpeed)} ${speedUnit} | ${currentAcc.toFixed(1)} g`; } text += extraText; element.textContent = text; element.style.color = color; if (linearProgress < 1) { element._telemetryAnimationFrame = requestAnimationFrame(step); } else { element._telemetryAnimationFrame = null; } } element._telemetryAnimationFrame = requestAnimationFrame(step); } // Function to update the settings UI with the current config function updateSettingsUI(popupContent) { popupContent.querySelectorAll('input, select').forEach(el => { if (el.type === 'checkbox') el.checked = config[el.dataset.setting]; else if (el.tagName === 'SELECT') el.value = config[el.dataset.setting]; // Handle dropdowns else if (el.type === 'radio') el.checked = config[el.name] === el.value; else if (el.type === 'number') el.value = config[el.dataset.setting]; else if (el.classList.contains('api-key-input')) el.value = config[el.dataset.setting]; }); } function createPopupContainer(title, contentGenerator, popupClass) { const container = document.createElement('div'); container.className = 'telemetry-popup-container ' + popupClass; container.innerHTML = ` <div class="telemetry-popup"> <div class="popup-header"> <span class="popup-title">${title}</span> <button class="close-button">Close</button> </div> <div class="popup-content"> ${contentGenerator ? contentGenerator() : ''} </div> </div> `; container.querySelector('.close-button').addEventListener('click', () => { container.remove(); }); return container; } // Create the telemetry settings popup. function createSettingsPopup() { const popupContainer = createPopupContainer("RACING TELEMETRY SETTINGS", generateSettingsContent, 'settings-popup'); const popupContent = popupContainer.querySelector('.popup-content'); updateSettingsUI(popupContent); const settingsInputs = popupContent.querySelectorAll('input, select'); settingsInputs.forEach(el => { if (el) { el.addEventListener('change', () => { if (el.type === 'checkbox') config[el.dataset.setting] = el.checked; else if (el.tagName === 'SELECT') config[el.dataset.setting] = el.value; else if (el.type === 'radio') config[el.name] = el.value; else if (el.type === 'number') config[el.dataset.setting] = parseInt(el.value, 10); else if (el.classList.contains('api-key-input')) config[el.dataset.setting] = el.value; GM_setValue('racingTelemetryConfig', JSON.stringify(config)); if (el.dataset.setting === 'apiKey') { dataManager.fetchRacingStats().then(dataManager.fetchRaces.bind(dataManager)); } }); } }); const resetButton = popupContent.querySelector('.reset-system-btn'); if (resetButton) { resetButton.addEventListener('click', () => { const apiKey = config.apiKey; localStorage.clear(); localStorage.setItem('racingTelemetryConfig', JSON.stringify({ apiKey: apiKey })); window.location.reload(); }); } const copyButton = popupContent.querySelector('.copy-btn'); if (copyButton) { copyButton.addEventListener('click', () => { const container = document.getElementById('racingMainContainer'); if (container) { navigator.clipboard.writeText(container.outerHTML) .then(() => alert('HTML copied to clipboard!')) .catch(err => alert('Failed to copy HTML: ' + err)); } else { alert('racingMainContainer not found.'); } }); } const telemetryToggleButton = popupContent.querySelector('.toggle-telemetry-btn'); if (telemetryToggleButton) { telemetryToggleButton.addEventListener('click', () => { telemetryVisible = !telemetryVisible; document.body.classList.toggle('telemetry-hidden', !telemetryVisible); telemetryToggleButton.textContent = telemetryVisible ? 'Hide Telemetry' : 'Show Telemetry'; }); } return popupContainer; } function generateSettingsContent() { return ` <div class="settings-content"> <div class="setting-group"> <div class="setting-item" title="Select data to display"> <span>Display Mode</span> <select class="setting-dropdown" data-setting="displayMode"> <option value="speed" ${config.displayMode === 'speed' ? 'selected' : ''}>Speed</option> <option value="acceleration" ${config.displayMode === 'acceleration' ? 'selected' : ''}>Acceleration</option> <option value="both" ${config.displayMode === 'both' ? 'selected' : ''}>Both</option> </select> </div> <div class="setting-item" title="Color-code acceleration status"> <span>Color Coding</span> <label class="switch"><input type="checkbox" ${config.colorCode ? 'checked' : ''} data-setting="colorCode"><span class="slider"></span></label> </div> <div class="setting-item" title="Enable smooth animations"> <span>Animations</span> <label class="switch"><input type="checkbox" ${config.animateChanges ? 'checked' : ''} data-setting="animateChanges"><span class="slider"></span></label> </div> <div class="setting-item" title="Speed unit preference"> <span>Speed Unit</span> <select class="setting-dropdown" data-setting="speedUnit"> <option value="mph" ${config.speedUnit === 'mph' ? 'selected' : ''}>mph</option> <option value="kmh" ${config.speedUnit === 'kmh' ? 'selected' : ''}>km/h</option> </select> </div> </div> <div class="setting-group"> <div class="setting-item" title="Your Torn API key for fetching race data"> <span>API Key</span> <input type="password" class="api-key-input" value="${config.apiKey}" data-setting="apiKey" placeholder="Enter API Key"> </div> </div> <div class="setting-group"> <button class="reset-system-btn">Reset System</button> <button class="copy-btn">Copy HTML</button> <button class="toggle-telemetry-btn">${telemetryVisible ? 'Hide Telemetry' : 'Show Telemetry'}</button> </div> </div> `; } // Create stats history popup function createStatsPopup() { const popupContainer = createPopupContainer("PERSONAL STATS HISTORY", generateStatsContent, 'stats-history-popup'); const popupContent = popupContainer.querySelector('.popup-content'); popupContent.innerHTML = generateStatsContent(); // Initial content return popupContainer; } // Generate content for stats history panel (same as before) function generateStatsContent() { let content = ''; if (state.racingStats?.racing) { const currentRacingStats = state.racingStats.racing; content += ` <div class="current-stats"> <h3>Current Racing Stats</h3> <p>Skill: ${currentRacingStats.skill || 'N/A'}</p> <p>Points: ${currentRacingStats.points || 'N/A'}</p> <p>Races Entered: ${currentRacingStats.races?.entered || 'N/A'}</p> <p>Races Won: ${currentRacingStats.races?.won || 'N/A'}</p> </div> `; } content += state.statsHistory.map(entry => { return ` <div class="history-entry"> Time: ${utils.formatTimestamp(entry.timestamp)} Skill: ${entry.skill.old} → ${entry.skill.new} Points: ${entry.points.old} → ${entry.points.new} Races Entered: ${entry.racesEntered.old} → ${entry.racesEntered.new} Races Won: ${entry.racesWon.old} → ${entry.racesWon.new} </div> `; }).join(''); return content || '<div class="history-entry">No stats history available</div>'; } // Create recent races popup function createRacesPopup() { const popupContainer = createPopupContainer("RECENT RACES (LAST 10)", generateRacesContent, 'recent-races-popup'); const popupContent = popupContainer.querySelector('.popup-content'); popupContent.innerHTML = generateRacesContent(); // Initial content return popupContainer; } // Generate content for recent races panel (same as before) function generateRacesContent() { state.raceLog.sort((a, b) => { const startTimeA = a.schedule?.start ? new Date(a.schedule.start).getTime() : 0; const startTimeB = b.schedule?.start ? new Date(b.schedule.start).getTime() : 0; return startTimeB - startTimeA; }); return state.raceLog.map(race => { return ` <div class="race-entry"> ID: ${race.id || 'N/A'} Title: ${race.title || 'N/A'} Track ID: ${race.track_id || 'N/A'} Status: ${race.status || 'N/A'} Laps: ${race.laps || 'N/A'} Participants: ${race.participants?.current || 0}/${race.participants?.maximum || 0} Start Time: ${race.schedule?.start ? utils.formatTimestamp(race.schedule.start) : 'N/A'} </div> `; }).join('') || '<div class="race-entry">No recent races found</div>'; } function calculateDriverMetrics(driverId, progressPercentage, timestamp) { const prev = state.previousMetrics[driverId] || { progress: progressPercentage, time: timestamp, instantaneousSpeed: 0, reportedSpeed: 0, acceleration: 0, lastDisplayedSpeed: 0, lastDisplayedAcceleration: 0, firstUpdate: true }; let dt = (timestamp - prev.time) / 1000; const minDt = config.minUpdateInterval / 1000; const effectiveDt = dt < minDt ? minDt : dt; if (dt <= 0) { state.previousMetrics[driverId] = prev; return { speed: prev.reportedSpeed, acceleration: prev.acceleration, timeDelta: effectiveDt }; } const distanceDelta = state.trackInfo.total * (progressPercentage - prev.progress) / 100; const currentInstantaneousSpeed = (distanceDelta / effectiveDt) * 3600; let averagedSpeed; if (prev.firstUpdate) { averagedSpeed = currentInstantaneousSpeed; } else { averagedSpeed = (prev.instantaneousSpeed + currentInstantaneousSpeed) / 2; } let acceleration; if (prev.firstUpdate) { acceleration = 0; } else { acceleration = ((averagedSpeed - prev.reportedSpeed) / effectiveDt) * 0.44704 / 9.81; } state.previousMetrics[driverId] = { progress: progressPercentage, time: timestamp, instantaneousSpeed: currentInstantaneousSpeed, reportedSpeed: averagedSpeed, acceleration: acceleration, lastDisplayedSpeed: prev.lastDisplayedSpeed || averagedSpeed, lastDisplayedAcceleration: prev.lastDisplayedAcceleration || acceleration, firstUpdate: false }; return { speed: Math.abs(averagedSpeed), acceleration: acceleration, timeDelta: effectiveDt }; } function updateDriverDisplay(driverElement, percentageText, progressPercentage) { try { const driverId = driverElement?.id; if (!driverId) return; const now = Date.now(); if (now - (lastUpdateTimes[driverId] || 0) < config.minUpdateInterval) return; lastUpdateTimes[driverId] = now; const nameEl = driverElement.querySelector('.name'); const timeEl = driverElement.querySelector('.time'); const statusEl = driverElement.querySelector('.status-wrap div'); if (!nameEl || !timeEl || !statusEl) return; let statusText = nameEl.querySelector('.driver-status') || document.createElement('span'); statusText.className = 'driver-status'; if (!statusText.parentElement) nameEl.appendChild(statusText); const infoSpot = document.getElementById('infoSpot'); if (infoSpot && infoSpot.textContent.trim().toLowerCase() === 'race starting') { // More specific check statusText.textContent = '🛑 NOT STARTED'; statusText.style.color = 'rgb(136, 136, 136)'; return; // Early return if race is explicitly starting, thus NOT STARTED } // Check if ANY driver has started reporting time. This is a more reliable start indicator. let raceHasBegun = false; const allDriverTimes = document.querySelectorAll('#leaderBoard li[id^="lbr-"] .time'); for (const timeElement of allDriverTimes) { if (timeElement.textContent.trim() && timeElement.textContent.trim() !== '0%') { raceHasBegun = true; break; } } if (!raceHasBegun && !(infoSpot && infoSpot.textContent.trim().toLowerCase() === 'race finished')) { // Refine raceHasBegun check statusText.textContent = '🛑 NOT STARTED'; statusText.style.color = 'rgb(136, 136, 136)'; return; // Keep NOT STARTED if no driver has time and infoSpot isn't 'start' and NOT finished } const isFinished = ['finished', 'gold', 'silver', 'bronze'].some(cls => statusEl.classList.contains(cls)); if (isFinished) { const finishTime = utils.parseTime(timeEl.textContent); const avgSpeed = finishTime > 0 ? (state.trackInfo.total / finishTime) * 3600 : 0; const avgSpeedFormatted = Math.round(utils.convertSpeed(avgSpeed, config.speedUnit)); statusText.textContent = `🏁 ${avgSpeedFormatted} ${config.speedUnit}`; // Display average speed on finish statusText.style.color = 'rgb(136, 136, 136)'; } else { const metrics = calculateDriverMetrics(driverId, progressPercentage, now); const targetSpeed = Math.round(utils.convertSpeed(metrics.speed, config.speedUnit)); const targetSpeedFormatted = targetSpeed.toLocaleString(undefined, { maximumFractionalDigits: 0 }); let extraText = ""; if (driverElement.classList.contains('selected')) { const pdLapEl = document.querySelector('#racingdetails .pd-lap'); if (pdLapEl) { const [currentLap, totalLaps] = pdLapEl.textContent.split('/').map(Number); const lapPercentage = 100 / totalLaps; const progressInLap = (progressPercentage - (currentLap - 1) * lapPercentage) / lapPercentage * 100; const remainingDistance = state.trackInfo.length * (1 - progressInLap / 100); if (metrics.speed > 0) { const estTime = (remainingDistance / metrics.speed) * 3600; extraText = ` | Est. Lap: ${utils.formatTime(estTime)}`; } } } if (config.animateChanges) { if (statusText._telemetryAnimationFrame) { cancelAnimationFrame(statusText._telemetryAnimationFrame); statusText._telemetryAnimationFrame = null; } let fromSpeed = (statusText._currentSpeed !== undefined) ? statusText._currentSpeed : state.previousMetrics[driverId].lastDisplayedSpeed; let fromAcc = (statusText._currentAcc !== undefined) ? statusText._currentAcc : state.previousMetrics[driverId].lastDisplayedAcceleration; let toSpeed = targetSpeed; let toAcc = metrics.acceleration; let duration = metrics.timeDelta * 1000; animateTelemetry(statusText, fromSpeed, toSpeed, fromAcc, toAcc, duration, config.displayMode, config.speedUnit, extraText); state.previousMetrics[driverId].lastDisplayedSpeed = toSpeed; state.previousMetrics[driverId].lastDisplayedAcceleration = toAcc; } else { let text; if (config.displayMode === 'speed') { text = `${targetSpeedFormatted} ${config.speedUnit}`; } else if (displayMode === 'acceleration') { text = `${metrics.acceleration.toFixed(1)} g`; } else { text = `${targetSpeedFormatted} ${config.speedUnit} | ${metrics.acceleration.toFixed(1)} g`; } text += extraText; statusText.textContent = text; statusText.style.color = config.colorCode ? getTelemetryColor(metrics.acceleration) : 'rgb(136, 136, 136)'; } } } catch (e) { console.error('Driver display update failed:', e); } } function resetRaceState() { state.previousMetrics = {}; state.trackInfo = { total: 0, laps: 0, length: 0 }; raceStarted = false; clearInterval(periodicCheckIntervalId); periodicCheckIntervalId = null; observers.forEach(obs => obs.disconnect()); observers = []; lastUpdateTimes = {}; const container = document.querySelector('.cont-black'); if (container) { const telemetryButtonContainer = container.querySelector('#telemetryButtonContainer'); if (telemetryButtonContainer) { telemetryButtonContainer.remove(); } } } function setupPeriodicCheck() { if (periodicCheckIntervalId) return; periodicCheckIntervalId = setInterval(() => { try { document.querySelectorAll('#leaderBoard li[id^="lbr-"]').forEach(driverEl => { const timeEl = driverEl.querySelector('.time'); if (!timeEl) return; const text = timeEl.textContent.trim(); const progress = utils.parseProgress(text); updateDriverDisplay(driverEl, text, progress); }); } catch (e) { console.error('Periodic check error:', e); } }, config.periodicCheckInterval); // Now using the hardcoded interval } function observeDrivers() { observers.forEach(obs => obs.disconnect()); observers = []; const drivers = document.querySelectorAll('#leaderBoard li[id^="lbr-"]'); drivers.forEach(driverEl => { const timeEl = driverEl.querySelector('.time'); if (!timeEl) return; updateDriverDisplay(driverEl, timeEl.textContent || '0%', utils.parseProgress(timeEl.textContent || '0%')); const observer = new MutationObserver(() => { try { const text = timeEl.textContent || '0%'; const progress = utils.parseProgress(text); if (progress !== state.previousMetrics[driverEl.id]?.progress) { updateDriverDisplay(driverEl, text, progress); } } catch (e) { console.error('Mutation observer error:', e); } }); observer.observe(timeEl, { childList: true, subtree: true, characterData: true }); observers.push(observer); }); } function initializeLeaderboard() { const leaderboard = document.getElementById('leaderBoard'); if (!leaderboard) return; if (leaderboard.children.length) { observeDrivers(); setupPeriodicCheck(); } else { new MutationObserver((_, obs) => { if (leaderboard.children.length) { observeDrivers(); setupPeriodicCheck(); obs.disconnect(); } }).observe(leaderboard, { childList: true }); } } function updateTrackInfo() { try { const trackHeader = document.querySelector('.drivers-list .title-black'); if (!trackHeader) throw new Error('Track header missing'); const parentElement = trackHeader.parentElement; if (!parentElement) throw new Error('Track header parent missing'); const infoElement = parentElement.querySelector('.track-info'); const lapsMatch = trackHeader.textContent.match(/(\d+)\s+laps?/i); const lengthMatch = infoElement?.dataset.length?.match(/(\d+\.?\d*)/); state.trackInfo = { laps: lapsMatch ? parseInt(lapsMatch[1]) : 5, length: lengthMatch ? parseFloat(lengthMatch[1]) : 3.4, get total() { return this.laps * this.length; } }; } catch (e) { state.trackInfo = { laps: 5, length: 3.4, total: 17 }; } } function initializeUI(container) { const buttonContainer = document.createElement('div'); buttonContainer.id = 'telemetryButtonContainer'; buttonContainer.className = 'telemetry-button-container'; const settingsButton = document.createElement('div'); settingsButton.className = 'telemetry-button'; settingsButton.textContent = 'Settings'; settingsButton.addEventListener('click', () => { document.body.appendChild(createSettingsPopup()); }); const statsButton = document.createElement('div'); statsButton.className = 'telemetry-button'; statsButton.textContent = 'Stats History'; statsButton.addEventListener('click', () => { document.body.appendChild(createStatsPopup()); dataManager.fetchRacingStats(); // Refresh data when popup is opened }); const racesButton = document.createElement('div'); racesButton.className = 'telemetry-button'; racesButton.textContent = 'Recent Races'; racesButton.addEventListener('click', () => { document.body.appendChild(createRacesPopup()); dataManager.fetchRaces(); // Refresh data when popup is opened }); buttonContainer.appendChild(settingsButton); buttonContainer.appendChild(statsButton); buttonContainer.appendChild(racesButton); container.prepend(buttonContainer); } function initialize() { try { const container = document.querySelector('.cont-black'); if (!container) throw new Error('Container not found'); const raceId = window.location.href.match(/sid=racing.*?(?=&|$)/)?.[0] || 'default'; if (currentRaceId !== raceId) { resetRaceState(); currentRaceId = raceId; } if (container.querySelector('#telemetryButtonContainer')) { updateTrackInfo(); initializeLeaderboard(); return; } initializeUI(container); updateTrackInfo(); initializeLeaderboard(); dataManager.fetchRacingStats().then(dataManager.fetchRaces.bind(dataManager)); } catch (e) { console.error('Initialization failed:', e); } } const racingUpdatesObserver = new MutationObserver((mutations) => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType === 1 && node.id === 'racingupdates') { initialize(); } }); mutation.removedNodes.forEach(node => { if (node.nodeType === 1 && node.id === 'racingupdates') { resetRaceState(); } }); }); }); racingUpdatesObserver.observe(document.body, { childList: true, subtree: true }); document.readyState === 'complete' ? initialize() : window.addEventListener('load', initialize); window.addEventListener('popstate', initialize); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址