您需要先安装一个扩展,例如 篡改猴、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.1 // @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; return timeString.split(':').reduce((acc, val, idx) => acc + (parseFloat(val) * Math.pow(60, idx)), 0); }, 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 .history-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 .races-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; } .telemetry-settings, .stats-history, .recent-races { background: var(--background-dark); border: 1px solid var(--border-color); border-radius: 8px; margin: 15px; padding: 15px; color: var(--text-color); font-family: 'Arial', sans-serif; box-shadow: 0 4px 12px rgba(0,0,0,0.4); /* Removed max-height and overflow-y from panel styles */ } #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) { #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: #252525; padding: 8px; border-radius: 6px; } .settings-title, .history-title, .races-title { font-size: 16px; 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: #333; border: 1px solid var(--border-color); border-radius: 5px; color: #fff; padding: 6px 12px; cursor: pointer; transition: all 0.2s; font-size: 12px; } .reset-system-btn { /* New style for Reset System Button */ background: #f44336; /* Red background */ border: 1px solid var(--border-color); /* Inherit border */ border-radius: 5px; /* Inherit border-radius */ color: #fff; /* Inherit text color */ padding: 6px 12px; /* Inherit padding */ cursor: pointer; /* Inherit cursor */ transition: all 0.2s; /* Inherit transition */ font-size: 12px; /* Inherit font-size */ } .settings-content, .history-content, .races-content { display: grid; gap: 12px; padding: 10px; background: #202020; border-radius: 6px; max-height: 300px; /* Set max height for content area */ overflow-y: auto; /* Enable vertical scrolling */ } .setting-group { display: grid; gap: 8px; } .setting-item { display: flex; align-items: center; justify-content: space-between; padding: 10px; background: var(--background-light); border-radius: 4px; } .switch { position: relative; width: 40px; height: 22px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: #404040; transition: .2s; border-radius: 11px; } .slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 2px; bottom: 2px; background: #fff; transition: .2s; border-radius: 50%; } input:checked + .slider { background: var(--primary-color); } input:checked + .slider:before { transform: translateX(18px); } .radio-group { display: flex; 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; } .api-key-input { width: 150px; padding: 5px; border-radius: 4px; border: 1px solid var(--border-color); background: var(--background-light); color: var(--text-color); } .history-entry, .race-entry { padding: 10px; background: var(--background-light); border-radius: 4px; white-space: pre-wrap; } .current-stats { padding: 10px; background: var(--background-light); border-radius: 4px; margin-bottom: 10px; } `); // 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(panel) { panel.querySelectorAll('input, select').forEach(el => { if (el.type === 'checkbox') el.checked = config[el.dataset.setting]; 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]; }); } // Create the telemetry settings panel. function createSettingsPanel() { const panel = document.createElement('div'); panel.className = 'telemetry-settings'; const isOpen = localStorage.getItem('telemetrySettingsOpen') === 'true'; panel.innerHTML = ` <div class="settings-header"> <span class="settings-title"> <svg width="20" height="20" viewBox="0 0 24 24" fill="var(--primary-color)"> <path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97 0-.33-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.50.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1 0 .33.03.65.07.97l-2.11 1.66c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l-.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64L19.43 13Z"/> </svg> RACING TELEMETRY </span> <button class="toggle-btn">${isOpen ? '▲' : '▼'}</button> </div> <div class="settings-content" style="display: ${isOpen ? 'grid' : 'none'}"> <div class="setting-group"> <div class="setting-item" title="Select data to display"> <span>Display Mode</span> <div class="radio-group"> <label class="radio-item"><input type="radio" name="displayMode" value="speed" ${config.displayMode === 'speed' ? 'checked' : ''}>Speed</label> <label class="radio-item"><input type="radio" name="displayMode" value="acceleration" ${config.displayMode === 'acceleration' ? 'checked' : ''}>Acceleration</label> <label class="radio-item"><input type="radio" name="displayMode" value="both" ${config.displayMode === 'both' ? 'checked' : ''}>Both</label> </div> </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> <div class="radio-group"> <label class="radio-item"><input type="radio" name="speedUnit" value="mph" ${config.speedUnit === 'mph' ? 'checked' : ''}>mph</label> <label class="radio-item"><input type="radio" name="speedUnit" value="kmh" ${config.speedUnit === 'kmh' ? 'checked' : ''}>km/h</label> </div> </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> <button class="reset-system-btn">Reset System</button> <button class="copy-btn" style="margin-top: 10px;">Copy HTML</button> <button class="toggle-telemetry-btn" style="margin-top: 10px;">${telemetryVisible ? 'Hide Telemetry' : 'Show Telemetry'}</button> </div> `; const toggleButton = panel.querySelector('.toggle-btn'); if (toggleButton) { toggleButton.addEventListener('click', () => { const content = panel.querySelector('.settings-content'); const isVisible = content.style.display === 'grid'; content.style.display = isVisible ? 'none' : 'grid'; panel.querySelector('.toggle-btn').textContent = isVisible ? '▼' : '▲'; localStorage.setItem('telemetrySettingsOpen', !isVisible); }); } const resetButton = panel.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 settingsInputs = panel.querySelectorAll('input, select'); settingsInputs.forEach(el => { if (el) { el.addEventListener('change', () => { if (el.type === 'checkbox') config[el.dataset.setting] = el.checked; 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 copyButton = panel.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 = panel.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 panel; } // Create stats history panel function createStatsPanel() { const panel = document.createElement('div'); panel.className = 'stats-history'; const isOpen = localStorage.getItem('statsHistoryOpen') === 'true'; panel.innerHTML = ` <div class="history-header"> <span class="history-title">PERSONAL STATS HISTORY</span> <button class="toggle-btn">${isOpen ? '▲' : '▼'}</button> </div> <div class="history-content" style="display: ${isOpen ? 'grid' : 'none'}"> ${generateStatsContent()} </div> `; panel.querySelector('.toggle-btn').addEventListener('click', () => { const content = panel.querySelector('.history-content'); const isVisible = content.style.display === 'grid'; content.style.display = isVisible ? 'none' : 'grid'; panel.querySelector('.toggle-btn').textContent = isVisible ? '▼' : '▲'; localStorage.setItem('statsHistoryOpen', !isVisible); }); return panel; } // Generate content for stats history panel 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 panel function createRacesPanel() { const panel = document.createElement('div'); panel.className = 'recent-races'; const isOpen = localStorage.getItem('recentRacesOpen') === 'true'; panel.innerHTML = ` <div class="races-header"> <span class="races-title">RECENT RACES (LAST 10)</span> <button class="toggle-btn">${isOpen ? '▲' : '▼'}</button> </div> <div class="races-content" style="display: ${isOpen ? 'grid' : 'none'}"> ${generateRacesContent()} </div> `; panel.querySelector('.toggle-btn').addEventListener('click', () => { const content = panel.querySelector('.races-content'); const isVisible = content.style.display === 'grid'; content.style.display = isVisible ? 'none' : 'grid'; panel.querySelector('.toggle-btn').textContent = isVisible ? '▼' : '▲'; localStorage.setItem('recentRacesOpen', !isVisible); }); return panel; } // Generate content for recent races panel 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 (config.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 telemetryContainer = container.querySelector('#tornRacingTelemetryContainer'); if (telemetryContainer) { telemetryContainer.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 telemetryContainer = document.createElement('div'); telemetryContainer.id = 'tornRacingTelemetryContainer'; container.prepend(telemetryContainer); const settingsPanel = createSettingsPanel(); telemetryContainer.append( settingsPanel, createStatsPanel(), createRacesPanel() ); updateSettingsUI(settingsPanel); } 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('#tornRacingTelemetryContainer')) { 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或关注我们的公众号极客氢云获取最新地址