YTyping PP Counter

タイピングページにリアルタイムPPカウンターを追加

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

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

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==UserScript==
// @name         YTyping PP Counter
// @namespace    https://github.com/ytyping
// @version      1.5.0
// @description  タイピングページにリアルタイムPPカウンターを追加
// @match        https://ytyping.net/*
// @match        http://localhost:3000/*
// @match        http://127.0.0.1:3000/*
// @grant        none
// @license MIT
// @run-at       document-idle
// @require      https://update.greatest.deepsurf.us/scripts/570420/1779088/SPA%20Navigate%20Library.js
// ==/UserScript==

(() => {
  /** @typedef {{ accuracy: number; clearRate: number; minPlaySpeed: number }} RawPPInput */

  const hudId = "ytyping-userscript-live-pp";

  let listeners = [];
  let cleanups = [];

  function addListener(y, type, handler) {
    y.addEventListener(type, handler);
    listeners.push({ y, type, handler });
  }

  function removeAllListeners() {
    for (const { y, type, handler } of listeners) {
      y.removeEventListener(type, handler);
    }
    listeners = [];
  }

  function runCleanups() {
    for (const fn of cleanups) fn();
    cleanups = [];
  }

  function removeHud() {
    document.getElementById(hudId)?.remove();
  }

  /**
   * @param {number} type - 成功打鍵数
   * @param {number} miss - ミス数
   * @returns {number} accuracy (0–1)
   */
  function calcAcc(type, miss) {
    const denom = type + miss;
    return denom > 0 ? type / denom : 1;
  }

  /** md 以上かどうか (Tailwind の md: 768px に合わせる) */
  function isMd(w) {
    return w.matchMedia("(min-width: 768px)").matches;
  }

  /**
   * 要素を現在のブレークポイントに応じて配置し直す。
   * md+  → #action-buttons の先頭にインライン配置
   * md未満 → body に fixed で右上フローティング
   * @returns {boolean} 配置できたか
   */
  function placeHud(w, el) {
    if (isMd(w)) {
      const actionButtons = w.document.getElementById("action-buttons");
      if (!actionButtons) return false;
      el.style.position = "";
      el.style.top = "";
      el.style.right = "";
      el.style.zIndex = "";
      if (el.parentElement !== actionButtons) {
        actionButtons.insertBefore(el, actionButtons.firstChild);
      }
    } else {
      el.style.position = "fixed";
      el.style.top = "80px";
      el.style.right = "16px";
      el.style.zIndex = "2147483646";
      if (el.parentElement !== w.document.body) {
        w.document.body.appendChild(el);
      }
    }
    return true;
  }

  const badgeStyle = {
    display: "inline-flex",
    padding: "5px 14px",
    borderRadius: "999px",
    background: "var(--accent)",
    color: "var(--accent-foreground)",
    fontFamily: "system-ui, sans-serif",
    fontSize: "17px",
    fontWeight: "600",
    lineHeight: "1",
    whiteSpace: "nowrap",
    pointerEvents: "none",
    letterSpacing: "0.02em",
    fontVariantNumeric: "tabular-nums",
    minWidth: "8ch",
    justifyContent: "center",
  };

  function makeBadge(w, text) {
    const badge = w.document.createElement("div");
    Object.assign(badge.style, badgeStyle);
    const span = w.document.createElement("span");
    span.textContent = text ?? "";
    badge.appendChild(span);
    return badge;
  }

  function ensureHud(w) {
    let el = w.document.getElementById(hudId);
    if (el) return el;

    // md+ のとき #action-buttons がまだ無ければ null を返してリトライを促す
    if (isMd(w) && !w.document.getElementById("action-buttons")) return null;

    el = w.document.createElement("div");
    el.id = hudId;
    Object.assign(el.style, {
      display: "flex",
      flexDirection: "column",
      justifyContent: "flex-end",
      gap: "6px",
      alignItems: "start",
    });

    const rankedBadge = makeBadge(w);
    rankedBadge.style.background = "var(--muted)";
    rankedBadge.style.color = "var(--foreground)/90";
    rankedBadge.style.display = "none";
    el.appendChild(rankedBadge);

    const mainBadge = makeBadge(w, "0pp");
    el.appendChild(mainBadge);

    el.style.visibility = "hidden";
    placeHud(w, el);
    return el;
  }

  function setHudMain(el, text) {
    el.children[1].children[0].textContent = text;
  }

  function setHudRanked(el, pp, rank, w) {
    const badge = el.children[0];
    const show = pp != null && isMd(w);
    badge.style.display = show ? "inline-flex" : "none";
    if (pp != null) badge.children[0].textContent = `${Math.floor(pp)}pp #${rank - 1}`;
  }

  /** @param {Window} w */
  function mount(w) {
    let retryTimer = null;
    let initTimer = null;

    // ブレークポイント変化時に配置を更新
    const mq = w.matchMedia("(min-width: 768px)");
    const onBreakpointChange = () => {
      const el = w.document.getElementById(hudId);
      if (!el) return;
      placeHud(w, el);
      const badge = el.children[0];
      if (badge?.children[0]?.textContent) {
        badge.style.display = isMd(w) ? "inline-flex" : "none";
      }
    };
    mq.addEventListener("change", onBreakpointChange);
    cleanups.push(() => mq.removeEventListener("change", onBreakpointChange));

    function tryInit() {
      const hud = ensureHud(w);
      if (!hud) {
        initTimer = setTimeout(tryInit, 100);
        return;
      }
      trySetup();
    }

    function trySetup() {
      const y = w.__ytyping_type;
      if (!y?.calcRawPP || y.CHAR_POINT == null) {
        retryTimer = setTimeout(trySetup, 200);
        return;
      }
      setup(w, y);
    }

    tryInit();

    cleanups.push(() => {
      if (retryTimer !== null) clearTimeout(retryTimer);
      if (initTimer !== null) clearTimeout(initTimer);
    });
  }

  /** @param {Window} w @param {object} y */
  function setup(w, y) {
    let noteEquivFromPoints = 0;
    let minPlaySpeedTracked = 1;
    let lastPP = 0;
    let rankedPP = null;
    let rankedPPRank = null;
    let rankedPPFetched = false;

    async function tryFetchRankedPP() {
      if (rankedPPFetched) return;
      const mapInfo = y.getMapInfo?.();
      if (!mapInfo?.id) return;
      if (!w.__ytyping?.getSessionUser?.()) {
        rankedPPFetched = true;
        const el = ensureHud(w);
        if (el) el.style.visibility = "";
        return;
      }
      rankedPPFetched = true;
      const topPPs = await y.getUserTopPPs?.();
      const el = ensureHud(w);
      if (!Array.isArray(topPPs)) {
        if (el) el.style.visibility = "";
        return;
      }
      const entry = topPPs.find((item) => item.mapId === mapInfo.id);
      rankedPP = entry?.pp ?? null;
      rankedPPRank = rankedPP != null ? topPPs.filter((item) => item.pp >= rankedPP).length + 1 : null;
      if (el) {
        setHudRanked(el, rankedPP, rankedPPRank, w);
        el.style.visibility = "";
      }
    }

    const fetchInitTimer = setInterval(() => {
      if (rankedPPFetched) {
        clearInterval(fetchInitTimer);
        return;
      }
      tryFetchRankedPP();
    }, 200);
    cleanups.push(() => clearInterval(fetchInitTimer));

    function resetSession() {
      noteEquivFromPoints = 0;
      minPlaySpeedTracked = 1;
      lastPP = 0;
      const el = ensureHud(w);
      if (!el) return;
      el.children[1].style.background = "var(--accent)";
      el.children[1].style.color = "var(--accent-foreground)";
      setHudMain(el, "0pp");
      setHudRanked(el, rankedPP, rankedPPRank, w);
    }

    function updateMinSpeed() {
      const lineCount = y.getLineCount?.();
      const lineResults = y.getLineResults?.();
      if (typeof lineCount !== "number" || !Array.isArray(lineResults) || lineResults.length === 0) return;

      const lineIndex = Math.min(lineCount, lineResults.length - 1);
      const speeds = lineResults[lineIndex]?.status?.speed;
      if (!Array.isArray(speeds) || speeds.length === 0) return;

      const lineMin = Math.min(...speeds);
      if (typeof lineMin === "number" && lineMin > 0 && Number.isFinite(lineMin)) {
        minPlaySpeedTracked = Math.min(minPlaySpeedTracked, lineMin);
      }
    }

    function computeLivePp() {
      tryFetchRankedPP();
      const map = y.getBuiltMap?.();
      const mapInfo = y.getMapInfo?.();
      if (!map || !mapInfo?.difficulty) {
        const el = ensureHud(w);
        if (el) setHudMain(el, "0pp");
        return;
      }

      const star = Number(mapInfo.difficulty.rating) || 0;
      const keyRate = map.keyRate;
      const missRate = map.missRate ?? 0;

      const st = y.getStatus?.();
      const accuracy = calcAcc(st?.type ?? 0, st?.miss ?? 0);

      const rawClear = (noteEquivFromPoints * keyRate) / 100;
      const missCount = st?.miss ?? 0;
      const rawMiss = (missCount * missRate) / 100;
      const clearRate = Math.min(1, Math.max(0, rawClear - rawMiss));

      /** @type {RawPPInput} */
      const input = { accuracy, clearRate, minPlaySpeed: minPlaySpeedTracked };

      const pp = Math.floor(y.calcRawPP(input, star));
      lastPP = pp;
      const el = ensureHud(w);
      if (!el) return;
      setHudMain(el, `${pp}pp`);
    }

    async function onTimerEnd() {
      if (!w.__ytyping?.getSessionUser?.()) return;
      const topPPs = await y.getUserTopPPs?.();
      if (!Array.isArray(topPPs)) return;
      const rank = topPPs.filter((item) => item.pp >= lastPP).length + 1;
      const isNewRecord = rankedPP != null && lastPP > rankedPP;
      const scene = y.getScene?.();
      if (scene !== "play" && scene !== "play_end") return;
      if (rank > 200 || !isNewRecord) return;
      const el = ensureHud(w);
      if (!el) return;
      setHudMain(el, `${lastPP}pp #${rank}`);
      setHudRanked(el, isNewRecord ? rankedPP : null, rankedPPRank, w);
      if (lastPP > 0) {
        el.children[1].style.background = "var(--perfect)";
        el.children[1].style.color = "#000";
      }
    }

    function onTypeSuccess(d) {
      const CHAR = y.CHAR_POINT;
      if (typeof d?.updatePoint === "number" && CHAR > 0) {
        noteEquivFromPoints += d.updatePoint / CHAR;
      }
      computeLivePp();
    }

    function onLineChange() {
      updateMinSpeed();
      computeLivePp();
    }

    addListener(y, "yt:start", resetSession);
    addListener(y, "type:success", onTypeSuccess);
    addListener(y, "type:miss", computeLivePp);
    addListener(y, "restart", resetSession);
    addListener(y, "replay:success", onTypeSuccess);
    addListener(y, "replay:miss", computeLivePp);
    addListener(y, "timer:lineChange", onLineChange);
    addListener(y, "timer:end", onTimerEnd);
  }

  function isTypePage(href) {
    return /\/type\//.test(new URL(href).pathname);
  }

  SPANavigate.on(() => {
    removeAllListeners();
    runCleanups();
    removeHud();
    if (isTypePage(location.href)) {
      mount(window);
    }
  });

  if (isTypePage(location.href)) {
    mount(window);
  }
})();