YTyping PP Counter

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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