Greasy Fork is available in English.

YTyping PP Counter

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==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);
  }
})();