您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
nq stats
// ==UserScript== // @name tr nq stats // @namespace http://tampermonkey.net/ // @version 11.0 // @description nq stats // @author aaaaaa // @match https://play.typeracer.com/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @run-at document-idle // @license MIT // ==/UserScript== (function() { 'use strict'; // basic script settings things like debug mode and animation speed const showDebugMessages = true; const popupAnimationDuration = 2000; // how long those little numbers fly up const statsBoxDefaultRight = '15px'; const statsBoxRacingRight = '220px'; // how far it's shifting when in a race function logMessage(message) { if (showDebugMessages) { console.log('[TR NQ Stats v11] ' + message); } } // these vars hold all the stats we're tracking like wpm and streak let cumulativeWPM = parseFloat(GM_getValue('tr_totalWPM_v11', '0.0')); let cumulativeAccuracy = parseFloat(GM_getValue('tr_totalAccuracy_v11', '0.0')); let completedRaceCount = parseInt(GM_getValue('tr_racesCompleted_v11', '0')); let consecutiveWins = parseInt(GM_getValue('tr_currentStreak_v11', '0')); let lowestWPMSpeed = parseFloat(GM_getValue('tr_worstWPM_v11', 'Infinity')); // start high so any race is lower let highestWPMSpeed = parseFloat(GM_getValue('tr_bestWPM_v11', '-Infinity')); // start low so any race is higher let isPlayerRacing = false; // simple flag are we in a race right now let resultsLoggedForCurrentRace = false; // did we already grab stats for this one race let trackedMainMenuButton = null; // keep a reference to the main menu button so we only attach the event listener once // this is the div element for our stats display box const statsDisplayElement = document.createElement('div'); statsDisplayElement.id = 'tr_nq_stats_blackout_v11'; // function to update the html inside the stats box with current numbers function refreshStatsPanel() { const averageWPMToDisplay = completedRaceCount > 0 ? (cumulativeWPM / completedRaceCount).toFixed(1) : '---'; const averageAccuracyToDisplay = completedRaceCount > 0 ? (cumulativeAccuracy / completedRaceCount).toFixed(1) : '---'; const worstWPMToDisplay = (completedRaceCount > 0 && lowestWPMSpeed !== Infinity) ? lowestWPMSpeed.toFixed(1) : '---'; const bestWPMToDisplay = (completedRaceCount > 0 && highestWPMSpeed !== -Infinity) ? highestWPMSpeed.toFixed(1) : '---'; statsDisplayElement.innerHTML = ` <div class="nq-item-blackout"> <span>Avg WPM:</span> <span id="nq-value-avg-wpm">${averageWPMToDisplay}</span> </div> <div class="nq-item-blackout"> <span>Avg Acc:</span> <span id="nq-value-avg-acc">${averageAccuracyToDisplay}%</span> </div> <div class="nq-item-blackout"> <span>Streak:</span> <span id="nq-value-streak">${consecutiveWins}</span> </div> <div class="nq-item-blackout nq-separator-blackout"></div> <div class="nq-item-blackout"> <span>Worst:</span> <span id="nq-value-worst-wpm">${worstWPMToDisplay}</span> </div> <div class="nq-item-blackout"> <span>Best:</span> <span id="nq-value-best-wpm">${bestWPMToDisplay}</span> </div> `; } // css stuff function setupStatsPanel() { GM_addStyle(` #tr_nq_stats_blackout_v11 { position: fixed; top: 70px; right: ${statsBoxDefaultRight}; background-color: #0A0A0A; color: #AAAAAA; padding: 8px 12px; border-radius: 4px; font-family: Arial, Helvetica, sans-serif; font-size: 12px; line-height: 1.4; z-index: 10010; border: 1px solid #222222; min-width: 135px; box-shadow: 0 1px 3px rgba(0,0,0,0.3); display: block !important; transition: right 0.3s ease-in-out; /* for that smooth slide */ } /* just item styling */ .nq-item-blackout { display: flex; justify-content: space-between; margin-bottom: 3px; } .nq-item-blackout:last-child { margin-bottom: 0; } .nq-item-blackout span:first-child { color: #888888; } /* label color */ .nq-item-blackout span:last-child { color: #BBBBBB; font-weight: bold; } /* value color */ .nq-separator-blackout { height: 1px; background-color: #333333; margin-top: 5px; margin-bottom: 5px; } /* for the stat change popups */ .stat-diff-animation { position: fixed; font-size: 11px; font-weight: bold; padding: 1px 3px; border-radius: 2px; opacity: 0; animation: popAndFade ${popupAnimationDuration / 1000}s ease-out forwards; pointer-events: none; z-index: 10011; /* needs to be above the stats box */ } @keyframes popAndFade { /* define the actual animation */ 0% { opacity: 0; transform: translateY(5px) scale(0.8); } 20% { opacity: 1; transform: translateY(-8px) scale(1.1); } 80% { opacity: 1; transform: translateY(-12px) scale(1); } 100% { opacity: 0; transform: translateY(-20px) scale(0.9); } } `); document.body.appendChild(statsDisplayElement); // stick it on the page statsDisplayElement.style.display = 'block'; refreshStatsPanel(); // fill it with initial data logMessage("Stats display initialized."); } function createStatPopupAnimation(displayValueElementId, valueDifference, isValuePercentage = false) { const animatedElement = document.getElementById(displayValueElementId); // find where the stat number is if (!animatedElement || isNaN(valueDifference) || valueDifference === 0) return; // no point if no change or can't find it const targetElementRect = animatedElement.getBoundingClientRect(); // get its position const animationPopup = document.createElement('span'); animationPopup.className = 'stat-diff-animation'; const plusOrMinusSign = valueDifference > 0 ? '+' : ''; animationPopup.textContent = `${plusOrMinusSign}${valueDifference.toFixed(1)}${isValuePercentage ? '%' : ''}`; // color it green for good red for bad if (valueDifference > 0) { animationPopup.style.color = '#4CAF50'; animationPopup.style.backgroundColor = 'rgba(76, 175, 80, 0.1)'; } else { animationPopup.style.color = '#F44336'; animationPopup.style.backgroundColor = 'rgba(244, 67, 54, 0.1)'; } // position it next to the stat animationPopup.style.top = `${targetElementRect.top + (targetElementRect.height / 2) - 7}px`; animationPopup.style.left = `${targetElementRect.right + 5}px`; document.body.appendChild(animationPopup); setTimeout(() => { // clean it up after animation if (animationPopup.parentNode) animationPopup.parentNode.removeChild(animationPopup); }, popupAnimationDuration); } // writes the current stats to GM_setValue so they persist across sessions function saveCurrentStats() { GM_setValue('tr_totalWPM_v11', cumulativeWPM.toString()); GM_setValue('tr_totalAccuracy_v11', cumulativeAccuracy.toString()); GM_setValue('tr_racesCompleted_v11', completedRaceCount.toString()); GM_setValue('tr_currentStreak_v11', consecutiveWins.toString()); GM_setValue('tr_worstWPM_v11', lowestWPMSpeed.toString()); GM_setValue('tr_bestWPM_v11', highestWPMSpeed.toString()); } // handles what happens if the user bails on a race via the main menu button function processMainMenuClick(event) { logMessage('Main Menu (leave race) clicked.'); if (isPlayerRacing) { // only if they were actually in a race logMessage('Quit detected: Resetting ALL stats and position.'); consecutiveWins = 0; cumulativeWPM = 0.0; cumulativeAccuracy = 0.0; completedRaceCount = 0; lowestWPMSpeed = Infinity; highestWPMSpeed = -Infinity; // full reset isPlayerRacing = false; resultsLoggedForCurrentRace = true; // pretend race ended so it doesnt try to parse again statsDisplayElement.style.right = statsBoxDefaultRight; // slide box back saveCurrentStats(); refreshStatsPanel(); } } // finds the "leave race" link and makes sure our click handler is on it // also makes sure not to add it multiple times if the button object changes function setupMainMenuButtonListener() { const mainMenuLinkElement = document.querySelector('a.raceLeaveLink'); // the actual typeracer button if (mainMenuLinkElement) { if (trackedMainMenuButton !== mainMenuLinkElement) { // only if its a new button or first time if (trackedMainMenuButton) trackedMainMenuButton.removeEventListener('click', processMainMenuClick); // remove old one if any mainMenuLinkElement.addEventListener('click', processMainMenuClick); trackedMainMenuButton = mainMenuLinkElement; // remember this button } } else { // if button disappeared remove listener from old one if (trackedMainMenuButton) { trackedMainMenuButton.removeEventListener('click', processMainMenuClick); trackedMainMenuButton = null; } } } // tries to find the wpm and accuracy figures from the page after a race finishes function parseAndStoreRaceResults() { logMessage('Extracting race results.'); let wpm, accuracy; // these selectors are specific to how typeracer shows your stats post-race const raceWpmElement = document.querySelector('div.tblOwnStatsNumber[title*="wpm"]'); const raceAccuracyElement = Array.from(document.querySelectorAll('div.tblOwnStatsNumber')).find(el => el.textContent.includes('%')); if (raceWpmElement) wpm = parseFloat(raceWpmElement.getAttribute('title')) || parseFloat(raceWpmElement.textContent); // try title first then text if (raceAccuracyElement) accuracy = parseFloat(raceAccuracyElement.textContent); if (!isNaN(wpm) && !isNaN(accuracy)) { // got valid numbers let oldAverageWPM = NaN, oldAverageAccuracy = NaN; const previousRaceCount = completedRaceCount; if (previousRaceCount > 0) { // need this to calculate change in average oldAverageWPM = cumulativeWPM / previousRaceCount; oldAverageAccuracy = cumulativeAccuracy / previousRaceCount; } // update records if (wpm > highestWPMSpeed) highestWPMSpeed = wpm; if (wpm < lowestWPMSpeed) lowestWPMSpeed = wpm; cumulativeWPM += wpm; cumulativeAccuracy += accuracy; completedRaceCount++; consecutiveWins++; logMessage(`Race recorded: WPM=${wpm.toFixed(1)}, Acc=${accuracy.toFixed(1)}%.`); saveCurrentStats(); refreshStatsPanel(); // save and show new numbers // animate the changes if (previousRaceCount > 0) { const newAverageWPM = cumulativeWPM / completedRaceCount; const newAverageAccuracy = cumulativeAccuracy / completedRaceCount; createStatPopupAnimation('nq-value-avg-wpm', newAverageWPM - oldAverageWPM); createStatPopupAnimation('nq-value-avg-acc', newAverageAccuracy - oldAverageAccuracy, true); } else if (completedRaceCount === 1) { // special handling for the very first race since there's no old average createStatPopupAnimation('nq-value-avg-wpm', wpm); createStatPopupAnimation('nq-value-avg-acc', accuracy, true); } } else { logMessage(`Error parsing WPM/Accuracy. WPM: ${wpm}, Acc: ${accuracy}`); refreshStatsPanel(); // important to refresh the panel even if parsing fails so it doesn't look stuck } } // this is the core logic checks page elements to see if we are in a race or not function updateRaceActivityStatus() { const statusLabel = document.querySelector('.gameStatusLabel'); // like "Go!" or "The race is on" const statusText = statusLabel ? statusLabel.innerText.trim() : ''; const textInputElement = document.querySelector('input.txtInput'); // checks if the text input field is actually enabled and visible good indicator of race active const isTypingInputActive = textInputElement && !textInputElement.disabled && textInputElement.offsetParent !== null; // check if the results table numbers are visible const areResultsDisplayed = document.querySelector('div.tblOwnStatsNumber[title*="wpm"]') && Array.from(document.querySelectorAll('div.tblOwnStatsNumber')).find(el => el.textContent.includes('%')); let hasRaceJustStarted = false; let hasRaceJustEnded = false; // race start detection: not racing now but game status says go and input is active if (!isPlayerRacing && (statusText === 'Go!' || statusText.startsWith('The race is on'))) { if (isTypingInputActive) { hasRaceJustStarted = true; isPlayerRacing = true; resultsLoggedForCurrentRace = false; // reset for new race if (showDebugMessages) logMessage('Race started.'); } } else if (isPlayerRacing) { // if we think we're racing check for end conditions // race end detection (option 1): results table is visible and we havent parsed them yet if (areResultsDisplayed && !resultsLoggedForCurrentRace) { hasRaceJustEnded = true; if (showDebugMessages) logMessage('Race finished: Results visible.'); parseAndStoreRaceResults(); } // race end detection (option 2): input is inactive game status says finished and not parsed yet else if (!isTypingInputActive && (statusText.startsWith('You finished') || statusText === 'The race has ended.') && !resultsLoggedForCurrentRace) { hasRaceJustEnded = true; if (showDebugMessages) logMessage('Race finished: Status label & input inactive.'); setTimeout(() => { // sometimes the results aren't instantly in the dom so a small delay helps if (document.querySelector('div.tblOwnStatsNumber[title*="wpm"]')) parseAndStoreRaceResults(); else { if (showDebugMessages) logMessage('Results not found after delay.'); refreshStatsPanel(); saveCurrentStats(); } }, 250); } // race end detection (option 3): fallback if critical race elements disappear suggesting user left the race page else if (!statusLabel && !isTypingInputActive && !resultsLoggedForCurrentRace && !window.location.hash.includes("#!race")) { hasRaceJustEnded = true; if (showDebugMessages) logMessage('Race likely ended abruptly (navigated away or context lost).'); refreshStatsPanel(); saveCurrentStats(); // save whatever we had } if (hasRaceJustEnded) { isPlayerRacing = false; resultsLoggedForCurrentRace = true; // mark as done for this race } } // logic for shifting the stats box left during a race and back again after if (isPlayerRacing && statsDisplayElement.style.right !== statsBoxRacingRight) { if (showDebugMessages) logMessage('Shifting stats display left for race.'); statsDisplayElement.style.right = statsBoxRacingRight; } else if (!isPlayerRacing && statsDisplayElement.style.right !== statsBoxDefaultRight) { // dont want it snapping back if the user is just looking at post-race stats if (!areResultsDisplayed && (!statusLabel || !(statusText.startsWith('You finished') || statusText === 'The race has ended.'))) { if (showDebugMessages) logMessage('Resetting stats display to default position.'); statsDisplayElement.style.right = statsBoxDefaultRight; } } if(hasRaceJustStarted || hasRaceJustEnded) refreshStatsPanel(); // call refreshStatsPanel if the race state changed to show new data setupMainMenuButtonListener(); // re-check the main menu button listener just in case it got removed or changed by TR's js } logMessage('Script starting (NQ Stats Final Tweaks v11).'); setupStatsPanel(); // call to initialize the stats display when the script loads // MutationObserver is pretty handy for reacting to dynamic page updates on typeracer const pageChangeObserver = new MutationObserver(updateRaceActivityStatus); // watch pretty much everything for changes pageChangeObserver.observe(document.documentElement, { childList: true, subtree: true }); // initial check after page load sometimes stuff isnt ready immediately setTimeout(updateRaceActivityStatus, 750); // START: Added code for resetting stats on page unload during a race window.addEventListener('beforeunload', function (e) { if (isPlayerRacing) { logMessage('Page is being unloaded (refresh/close) while player is racing. Resetting all stats.'); // Reset local script variables to their initial states cumulativeWPM = 0.0; cumulativeAccuracy = 0.0; completedRaceCount = 0; consecutiveWins = 0; lowestWPMSpeed = Infinity; highestWPMSpeed = -Infinity; // Persist these reset values saveCurrentStats(); // Note: No need to call refreshStatsPanel() as the page is unloading. } else { logMessage('Page is being unloaded (refresh/close) while player is NOT racing. Stats will be preserved.'); // Stats are saved after each race, so they should be up-to-date. } }); // END: Added code })();