タイピングページにリアルタイムPPカウンターを追加
// ==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);
}
})();