QCStats – Ability Kills

This script adds ability information to the statistics on stats.quake.com

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         QCStats – Ability Kills
// @namespace    https://github.com/aleab/
// @version      1.0.11
// @author       aleab
// @description  This script adds ability information to the statistics on stats.quake.com
// @icon         https://stats.quake.com/fav/favicon-32x32.png
// @icon64       https://stats.quake.com/fav/favicon-96x96.png
// @match        https://stats.quake.com
// @match        https://stats.quake.com/*
// @grant        none
// @require      https://code.jquery.com/jquery-3.3.1.min.js
// @require      https://greasyfork.org/scripts/371849-qcstats/code/QCStats.js?version=636315
// ==/UserScript==

/* jshint esversion: 6 */
/* global $:false, MutationObserver:true, aleab:false */


// VARIABLES & CONSTANTS

MutationObserver = window.MutationObserver || window.WebKitMutationObserver;

const REGEX_WEAPONS_PAGE = /https:\/\/stats\.quake\.com\/profile\/.+\/weapon\/?/;
const REGEX_MATCHES_PAGE = /https:\/\/stats\.quake\.com\/profile\/.+\/matches\/.+/;

const GAMEMODE_ALL = "ALL";
const SCORING_EVENT_ABILITYKILL = "SCORING_EVENT_ABILITYKILL";
const SCORING_EVENT_RING_OUT = "SCORING_EVENT_RING_OUT";
const SCORING_EVENT_TELEFRAG = "SCORING_EVENT_TELEFRAG";
const prop_battleReportPersonalStatistics = "battleReportPersonalStatistics";
const prop_scoringEvents = "scoringEvents";

let selectedChampion = "ALL";
let selectingChampion = false;
let noUpdObjectFoundErrorLogged = false;

let config = {};


//—————————————————————————————————————

$(document).ready(function() {
    loadConfig();

    aleab.qcstats.addPageChangedListener(/.*/, () => {
        qcMatchScoreboardObserver.disconnect();
    });
    aleab.qcstats.addPageChangedListener(REGEX_MATCHES_PAGE, addAbilityStatsToMatchDetails);
    aleab.qcstats.addPageChangedListener(REGEX_WEAPONS_PAGE, addAbilityStatsToWeaponsPage);

    if (REGEX_MATCHES_PAGE.test(location.href)) {
        addAbilityStatsToMatchDetails();
    }

    if (REGEX_WEAPONS_PAGE.test(location.href)) {
        addAbilityStatsToWeaponsPage();
    }
});

function loadConfig() {
    config = aleab.qcstats.loadConfig();

    // Set defaults
    if (config.showTooltips === undefined) { config.showTooltips = true; }
    aleab.qcstats.saveConfig(config);
}

//—————————————————————————————————————


/*=============*
 *  FUNCTIONS  *
 *=============*/

// UTILITY FUNCTIONS

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

function formatDamageNumber(n) {
    if (!n) { return; }
    if (typeof n === typeof String()) { n = Number(n); }
    else if (typeof n === typeof Number()) {}
    else { return; }

    if (Number.isNaN(n)) { return "N/A"; }
    else if (n === Number.POSITIVE_INFINITY) { return "∞"; }
    else if (n === Number.NEGATIVE_INFINITY) { return "-∞"; }

    if (n < 1e3) {
        n = `${n.toFixed(0)}`;
    } else if (n < 1e6) {
        n = n / 1e3;
        n = `${n.toFixed(n >= 10 ? 1 : 2)}k`;
    } else if (n < 1e9) {
        n = n / 1e6;
        n = `${n.toFixed(n >= 10 ? 1 : 2)}M`;
    } else if (n < 1e12) {
        n = n / 1e9;
        n = `${n.toFixed(n >= 10 ? 1 : 2)}b`;
    } else {
        n = n.toExponential(3);
    }
    return n;
}

function modifySvgCircle(svg, newValue) {
    if (!svg || newValue === undefined || newValue === null) {
        return;
    }

    if (typeof newValue === typeof String()) { newValue = Number(newValue); }
    else if (typeof newValue === typeof Number()) {}
    else { return; }

    svg.setAttribute("value", newValue);
    svg.value = newValue;
    $(svg).find("text")[0].innerHTML = Number.isNaN(newValue) ? "N/A" : `${(newValue * 100).toFixed(0)}%`;
    changeSvgCirclePercentage($(svg).find("circle")[1], newValue);
}

function changeSvgCirclePercentage(svgCircle, newValue) {
    if (!svgCircle || newValue === undefined || newValue === null) {
        return;
    }

    if (typeof newValue === typeof String()) { newValue = Number(newValue); }
    else if (typeof newValue === typeof Number()) {}
    else { return; }

    let dashArray = Number(svgCircle.getAttribute("stroke-dasharray"));
    let dashOffset = dashArray - (Number.isNaN(newValue) || !Number.isFinite(newValue) ? 0 : dashArray * newValue);
    svgCircle.setAttribute("stroke-dashoffset", dashOffset);
    svgCircle["stroke-dashoffset"] = dashOffset;
}

function addAccuratePercentagesTooltips() {
    // svg circles
    // Remove the current titles, if they had already been added
    $("svg > title.pct-tooltip").remove();

    let svgElements = $.grep($("svg"), svg => { return $(svg).find("circle").length == 2 && $(svg).find("text").length == 1; });
    if (svgElements && svgElements.length > 0) {
        $.each(svgElements, (i, svg) => {
            let svgValue = Number(svg.value || svg.getAttribute("value"));
            var title = document.createElementNS("http://www.w3.org/2000/svg", "title")
            $(title).addClass("pct-tooltip");
            title.innerHTML = Number.isNaN(svgValue) ? "Not available" : `${(svgValue * 100).toFixed(2)}%`;
            svg.prepend(title);
        });
    }
}


/*———————————*
 |  MATCHES  |
 *———————————*/

function addAbilityStatsToMatchDetails() {
    setTimeout(async function() {
        let waitingLogged = false;
        while ($(".profile-page .matchdetails-page").length == 0) {
            if (!waitingLogged) {
                console.log("[QCStats – Ability kills] Waiting for the match details...");
                waitingLogged = true;
            }
            await sleep(100);
        }

        console.log("[QCStats – Ability kills]");
        let scoreboard = $(".profile-page .matchdetails-page > .scoreboard");
        qcMatchScoreboardObserver.observe(scoreboard[0], { childList: true });
        qcMatchScoreboardObserver.observe(scoreboard[1], { childList: true });
    }, 200);
}

// This MutationObserver will observe the scoreboard element in search of changes to its children to see when one of them is expanded
var qcMatchScoreboardObserver = new MutationObserver(async function(mutations, observer) {
    if (!mutations || !mutations[0] || !mutations[0].addedNodes || mutations[0].addedNodes.length <= 0) {
        return;
    }

    let extendedPlayerInfo = $.grep(mutations[0].addedNodes, node => { return $(node).hasClass("extended"); })[0];
    if (!extendedPlayerInfo) {
        return;
    }

    let blocksJQ = $(extendedPlayerInfo).find(".item-block");
    if (!blocksJQ || blocksJQ.length <= 0) {
        return;
    }

    let weaponsBlock = $.grep(blocksJQ, block => {
        let h2 = $(block).find("h2")[0];
        return h2 !== undefined && h2.innerHTML == "Weapons";
    })[0];
    if (!weaponsBlock) {
        return;
    }

    let rowsJQ = $(weaponsBlock).find(".item-row");
    if (!rowsJQ || rowsJQ.length <= 0) {
        return;
    }

    // Get rows
    let rows = $.grep(rowsJQ, row => { return !$(row).hasClass("thead"); });
    let totalRow = $.grep(rows, row => { return $(row).find("div:first-child")[0].innerText == "Total"; })[0];
    let weaponRows = $.grep(rows, row => { return $(row).find("div:first-child")[0].innerText != "Total"; });

    // Get total stats
    let totalKills = Number($(totalRow).find("div:nth-child(2)")[0].innerText);
    let totalDamage = Number($(totalRow).find("div:nth-child(4)")[0].innerText);

    // Get weapons stats
    let weaponKills = 0;
    let weaponDamage = 0;
    $.each(weaponRows, (i, row) => {
        weaponKills += Number($(row).find("div:nth-child(2)")[0].innerText);
        weaponDamage += Number($(row).find("div:nth-child(4)")[0].innerText);
    });

    // Get the number of ring outs and telefrags
    let ringOuts = 0;
    let telefrags = 0;
    let matchId = location.href.match(/.*\/matches\/(.*)/)[1];
    let playerName = $(extendedPlayerInfo.previousSibling).find("div:first-child > a")[0].innerHTML;
    if (matchId && playerName) {
        await fetch(`https://stats.quake.com/api/v2/Player/Games?id=${matchId}&playerName=${encodeURIComponent(playerName)}`)
            .then(async function(response) {
                if (response.status === 200) {
                    await response.json().then(function(data) {
                        let playerMatchStats = $.grep(data[prop_battleReportPersonalStatistics], (v) => v.nickname === playerName)[0];
                        ringOuts = playerMatchStats[prop_scoringEvents][SCORING_EVENT_RING_OUT] || 0;
                        telefrags = playerMatchStats[prop_scoringEvents][SCORING_EVENT_TELEFRAG] || 0;
                    });
                }
            });
    }

    // Calculate ability stats
    let abilitiesKills = totalKills - weaponKills - ringOuts - telefrags;
    let abilitiesDamage = totalDamage - weaponDamage;

    let ringOutsRow = createNewMatchItemRow("Ring Out", "color: hsl(200, 5%, 40%)", ringOuts, undefined, undefined);
    let telefragsRow = createNewMatchItemRow("Telefrag", "color: hsl(295, 15%, 45%)", telefrags, undefined, undefined);
    let abilitiesRow = createNewMatchItemRow("Abilities", "color: hsl(165, 50%, 35%)", abilitiesKills, undefined, abilitiesDamage);

    totalRow.parentNode.insertBefore(ringOutsRow, totalRow.nextSibling);
    ringOutsRow.parentNode.insertBefore(telefragsRow, ringOutsRow.nextSibling);
    telefragsRow.parentNode.insertBefore(abilitiesRow, telefragsRow.nextSibling);
});

function createNewMatchItemRow(label, labelStyle, kills, accuracy, damage) {
    let row = document.createElement("div");
    row.className = "item-row";

    let d = document.createElement("div"); // Label
    d.innerText = label;
    d.style = labelStyle;
    row.appendChild(d);

    d = document.createElement("div"); // Kills
    d.innerText = kills !== undefined ? kills.toString() : "N/A";
    row.appendChild(d);

    d = document.createElement("div"); // Accuracy
    d.innerText = accuracy !== undefined ? accuracy.toString() : "N/A";
    row.appendChild(d);

    d = document.createElement("div"); // Damage
    d.innerText = damage !== undefined ? damage.toString() : "N/A";
    row.appendChild(d);

    return row;
}


/*———————————*
 |  WEAPONS  |
 *———————————*/

function addAbilityStatsToWeaponsPage() {
    setTimeout(async function() {
        let waitingLogged = false;
        while ($(".profile-page .champion-selector").length == 0) {
            if (!waitingLogged) {
                console.log("[QCStats – Ability kills] Waiting for the weapons stats...");
                waitingLogged = true;
            }
            await sleep(100);
        }

        console.log("[QCStats – Ability kills]");
        let champions = $(".profile-page .champion-selector > .champion");
        $.each(champions, (i, node) => {
            let nodeJQ = $(node);
            nodeJQ.mousedown(() => weaponsPageChampion_onMouseDown(nodeJQ));
            nodeJQ.mouseup(() => weaponsPageChampion_onMouseUp(nodeJQ));
        });
        await addAbilityStatsItemToWeaponsPage();
        if (config.showTooltips) {
            addAccuratePercentagesTooltips();
        }
    }, 200);
}

async function addAbilityStatsItemToWeaponsPage() {
    // Check if window.upd exists; if not, wait for it until timeout (5s)
    let timeWaitedForUpdObject = 0;
    while (!window.upd) {
        if (timeWaitedForUpdObject > 5000) {
            break;
        }
        await sleep(100);
        timeWaitedForUpdObject += 100;
    }
    if (!window.upd) {
        if (!noUpdObjectFoundErrorLogged) {
            console.error("[QCStats] No upd object found!");
            noUpdObjectFoundErrorLogged = true;
        }
        return;
    }

    let infoBoxJQ = $(".profile-page .info-box.bare");
    if (!infoBoxJQ || infoBoxJQ.length <= 0) {
        return;
    }

    // Remove the ability item if it's already there
    infoBoxJQ.find(".ability-item").remove();

    let weaponItemsJQ = infoBoxJQ.find(".weapon-item");
    if (!weaponItemsJQ || weaponItemsJQ.length <= 0) {
        return;
    }

    // Get the ability image url
    let imageUrl = aleab.qcstats.abilityImages[selectedChampion];
    if (imageUrl) {
        imageUrl = `https://stats.quake.com/${imageUrl}`;
    } else if (imageUrl === undefined) {
        // Don't even add a new item to the box if the selected champion doesn't have a damage ability
        return;
    }

    // Get the champion's ability damage types
    let damageTypes = aleab.qcstats.championAbilityDamageTypes[selectedChampion];
    if (damageTypes === null) {
        damageTypes = [];
        $.each(aleab.qcstats.championAbilityDamageTypes, (k, v) => {
            if (!v) { return true; }
            $.each(v, (i, s) => {
                if (!s) { return true; }
                damageTypes.push(s);
            });
        });
    }

    // Calculate ability stats
    let abilityStats = {
        accuracy: { acc: 0, n: 0 },
        killHitPercentage: { pct: 0, n: 0 },
        killPercentage: { pct: 0, n: 0 },
        kills: Number(window.upd.stats[selectedChampion].gameModes[GAMEMODE_ALL][SCORING_EVENT_ABILITYKILL]),
        damage: 0
    };

    $.each(damageTypes, (i, damageType) => {
        let d = window.upd.stats[selectedChampion].damageStatusList[damageType];
        if (d.accuracy > 0.0) {
            abilityStats.accuracy.n++;
            abilityStats.accuracy.acc += Number(d.accuracy);
        }
        if (d.killhitpct > 0.0) {
            abilityStats.killHitPercentage.n++;
            abilityStats.killHitPercentage.pct += Number(d.killhitpct);
        }
        if (d.killpct > 0.0) {
            abilityStats.killPercentage.n++;
            abilityStats.killPercentage.pct += Number(d.killpct);
        }
        abilityStats.damage += Number(d.damage);
    });

    abilityStats.accuracy = abilityStats.accuracy.acc > 0.0 ? abilityStats.accuracy.acc / abilityStats.accuracy.n : Number.NaN;
    abilityStats.killHitPercentage = abilityStats.killHitPercentage.pct > 0.0 ? abilityStats.killHitPercentage.pct / abilityStats.killHitPercentage.n : Number.NaN;
    abilityStats.killPercentage = abilityStats.killPercentage.pct > 0.0 ? abilityStats.killPercentage.pct / abilityStats.killPercentage.n : Number.NaN;

    console.log(`[QCStats – Ability kills] ${selectedChampion}:`, abilityStats);

    // Clone the last HTML item in the box
    let abilityItemJQ = weaponItemsJQ.last().clone();
    abilityItemJQ.addClass("ability-item");
    abilityItemJQ.appendTo(infoBoxJQ);

    // Modify the ability item
    let abilityItemCells = abilityItemJQ.find("div");

    // - Weapon
    let cellJQ = $(abilityItemCells[0]);
    cellJQ.find(".weapon")[0].innerText = "Ability";
    if (imageUrl) {
        let img = cellJQ.find("img")[0];
        img.src = imageUrl;
        img.alt = "Ability";
    } else {
        cellJQ.css("display", "flex").css("flex-flow", "column nowrap").css("justify-content", "center");
        cellJQ.find(".weapon").css("margin-bottom", "0");
        cellJQ.find("img").remove();
    }

    // - Accuracy
    cellJQ = $(abilityItemCells[1]);
    modifySvgCircle(cellJQ.find("svg")[0], abilityStats.accuracy);

    // - Kills / Hits
    cellJQ = $(abilityItemCells[2]);
    modifySvgCircle(cellJQ.find("svg")[0], abilityStats.killHitPrecentage);

    // - Kill %
    cellJQ = $(abilityItemCells[3]);
    modifySvgCircle(cellJQ.find("svg")[0], abilityStats.killPercentage);

    // - Kills
    cellJQ = $(abilityItemCells[4]);
    cellJQ.find(".value")[0].innerText = abilityStats.kills.toString();

    // - Damage
    cellJQ = $(abilityItemCells[5]);
    cellJQ.find(".value")[0].innerText = formatDamageNumber(abilityStats.damage);
}

function weaponsPageChampion_onMouseDown(nodeJQ) {
    selectingChampion = false;
    if (!nodeJQ || !(nodeJQ instanceof $) || nodeJQ.length <= 0) {
        return;
    }

    if (!$(nodeJQ[0]).hasClass("selected")) {
        selectingChampion = true;
    }
}

function weaponsPageChampion_onMouseUp(nodeJQ) {
    if (!nodeJQ || !(nodeJQ instanceof $) || nodeJQ.length <= 0) {
        selectingChampion = false;
        return;
    }

    if (selectingChampion) {
        selectingChampion = false;
        selectedChampion = nodeJQ[0].getAttribute("data-champion");
        setTimeout(async function() {
            await addAbilityStatsItemToWeaponsPage();
            if (config.showTooltips) {
                addAccuratePercentagesTooltips();
            }
        }, 100);
    }
}