QCStats – Ability Kills

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

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

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

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

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

您需要先安装一款用户脚本管理器扩展,例如 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);
    }
}