tr nq stats

nq stats

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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

})();