Custom scroll speed for online rooms (private or public lobbies) in gpop.io
// ==UserScript==
// @name PurrfectScrollSpeed (online)
// @namespace http://tampermonkey.net/
// @version 2026-02-09
// @description Custom scroll speed for online rooms (private or public lobbies) in gpop.io
// @author Purrfect
// @icon https://www.google.com/s2/favicons?sz=64&domain=gpop.io
// @match https://gpop.io/room/*
// @match https://gpop.io/
// @run-at document-start
// @grant none
// ==/UserScript==
(function () {
'use strict';
// --- robust autostart ---
const MAX_WAIT_MS = 30000;
const INTERVAL_MS = 250;
const startedAt = Date.now();
function ready() {
return !!(window._$61?.prototype && typeof window._$61.prototype._$5k === "function");
}
function start() {
(() => {
const SPEED_KEY = "__stickyGameSpeed";
const UI_POS_KEY = "__purrfect_ui_pos";
const UI_HIDDEN_KEY = "__purrfect_ui_hidden";
const UI_OFF_KEY = "__purrfect_ui_off";
const UI_INIT_KEY = "__purrfect_ui_inited";
const UI_FLAG = "__purrfect_ui_loaded__";
const MINI_POS_KEY = "__purrfect_mini_pos";
const HUE_KEY = "__purrfect_lane_colors";
const WM = "__purrfect__";
const DEFAULT_SPEED = 2.5;
const MAX_SPEED = 10;
// ========= SAFETY / HOOKS =========
const SpeedProto = window._$61?.prototype;
if (!SpeedProto || typeof SpeedProto._$5k !== "function") {
console.log("PurrfectScrollSpeed: speed prototype not found");
return;
}
if (window[UI_FLAG]) return;
Object.defineProperty(window, UI_FLAG, { value: true, enumerable: false });
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const micro = typeof queueMicrotask === "function" ? queueMicrotask : (f) => setTimeout(f, 0);
const read = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
const write = (k, v) => { try { localStorage.setItem(k, String(v)); } catch {} };
const isFirstRun = () => read(UI_INIT_KEY) !== "1";
const markInited = () => write(UI_INIT_KEY, "1");
const readHidden = () => read(UI_HIDDEN_KEY) === "1";
const writeHidden = (v) => write(UI_HIDDEN_KEY, v ? "1" : "0");
const readOff = () => read(UI_OFF_KEY) === "1";
const writeOff = (v) => write(UI_OFF_KEY, v ? "1" : "0");
const readSpeed = () => {
const v = Number(read(SPEED_KEY));
return Number.isFinite(v) ? v : DEFAULT_SPEED;
};
const writeSpeed = (v) => write(SPEED_KEY, v);
const savePos = (x, y) => write(UI_POS_KEY, JSON.stringify({ x, y }));
const loadPos = () => { try { const s = read(UI_POS_KEY); return s ? JSON.parse(s) : null; } catch { return null; } };
const saveMiniPos = (x, y) => write(MINI_POS_KEY, JSON.stringify({ x, y }));
const loadMiniPos = () => { try { const s = read(MINI_POS_KEY); return s ? JSON.parse(s) : null; } catch { return null; } };
try {
if (!Object.prototype.hasOwnProperty.call(window, WM)) {
Object.defineProperty(window, WM, { value: "purr", enumerable: false, configurable: false });
}
} catch {}
function findGameInstance() {
for (const k of Object.keys(window)) {
try {
const v = window[k];
if (v && v instanceof window._$61 && typeof v._$5k === "function") return v;
} catch {}
}
return null;
}
// ========= SPEED PATCH =========
const original5k = SpeedProto._$5k;
let enabled = !readOff();
let levelDefault = null;
function applySpeed(v) {
const s = clamp(Number(v), 0.5, MAX_SPEED);
writeSpeed(s);
const inst = findGameInstance();
if (inst) { try { inst._$5k(s); } catch {} }
return s;
}
SpeedProto._$5k = function (a) {
if (!enabled) return original5k.call(this, a);
// Capture the level's original (vanilla) speed once
if (levelDefault == null && typeof a === "number" && Number.isFinite(a)) {
let base = a;
try { base = _$S._$2q(base); } catch {}
// keep only sane bounds, but allow up to MAX_SPEED
base = clamp(base, 0.5, MAX_SPEED);
levelDefault = base;
}
// Compute stable "time-based" hit window from ORIGINAL speed
// Vanilla: this._$6h = 4, this._$2I = speed * 15
if (typeof this.__purrfectBaseHitTime !== "number") {
const baseSpeed = (typeof levelDefault === "number" && Number.isFinite(levelDefault)) ? levelDefault : 1;
this.__purrfectBaseHitTime = 4 / (baseSpeed * 15);
}
let v = Number(read(SPEED_KEY));
if (!Number.isFinite(v)) v = DEFAULT_SPEED;
v = clamp(v, 0.5, MAX_SPEED);
try { v = _$S._$2q(v); } catch {}
v = clamp(v, 0.5, MAX_SPEED);
this.gamespeed = v;
this._$2I = v * 15;
// Keep judgement window constant in TIME (hitzone scales with scrollspeed)
this._$6h = this.__purrfectBaseHitTime * this._$2I;
this._$bU = 100 / this._$2I;
this._$aX = this._$3H / this._$2I;
this._$2C = this._$F / this._$2I;
this._$3m();
for (let i = 0; i < this._$8c.length; i++) this._$8S(this._$8c[i]);
this._$5v();
this._$1D();
this._$3N();
};
// ========= HUE / COLOR PATCH =========
function normalizeHex(hex) {
if (typeof hex !== "string") return null;
let h = hex.trim();
if (!h) return null;
// allow: rgb(255,0,0)
if (/^rgb/i.test(h)) {
const m = h.match(/(\d+)\D+(\d+)\D+(\d+)/);
if (!m) return null;
const r = clamp(parseInt(m[1], 10), 0, 255);
const g = clamp(parseInt(m[2], 10), 0, 255);
const b = clamp(parseInt(m[3], 10), 0, 255);
return ("#" + [r, g, b].map(x => x.toString(16).padStart(2, "0")).join("")).toLowerCase();
}
if (h[0] !== "#") h = "#" + h;
if (/^#[0-9a-f]{3}$/i.test(h)) {
const r = h[1], g = h[2], b = h[3];
return ("#" + r + r + g + g + b + b).toLowerCase();
}
if (/^#[0-9a-f]{6}$/i.test(h)) return h.toLowerCase();
return null;
}
function hexToRgba(hex, a) {
const h = normalizeHex(hex);
if (!h) return null;
const r = parseInt(h.slice(1, 3), 16);
const g = parseInt(h.slice(3, 5), 16);
const b = parseInt(h.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
function loadLaneColors() {
try {
const s = read(HUE_KEY);
if (!s) return { A: null, S: null, D: null, F: null };
const o = JSON.parse(s);
return {
A: normalizeHex(o?.A) || null,
S: normalizeHex(o?.S) || null,
D: normalizeHex(o?.D) || null,
F: normalizeHex(o?.F) || null
};
} catch {
return { A: null, S: null, D: null, F: null };
}
}
function saveLaneColors(obj) {
const o = {
A: normalizeHex(obj?.A) || null,
S: normalizeHex(obj?.S) || null,
D: normalizeHex(obj?.D) || null,
F: normalizeHex(obj?.F) || null
};
try { write(HUE_KEY, JSON.stringify(o)); } catch {}
}
function laneUpper(k) {
const s = String(k || "").toLowerCase();
if (s === "a") return "A";
if (s === "s") return "S";
if (s === "d") return "D";
if (s === "f") return "F";
return null;
}
// ---- Falling notes styling (center lanes) ----
let __purrOrigLaneColors = null;
function ensureHueStyleTag(mode) {
let tag = document.getElementById("__purrfect_hue_css");
if (!tag) {
tag = document.createElement("style");
tag.id = "__purrfect_hue_css";
document.head.appendChild(tag);
}
// Room originals from your real in-game probe
const ROOM_ORIGINALS = {
A: "rgb(255, 114, 114)",
S: "rgb(68, 240, 255)",
D: "rgb(90, 255, 68)",
F: "rgb(255, 247, 68)",
};
// Background tint for OFF
const ROOM_BG = {
A: "rgba(255, 114, 114, 0.2)",
S: "rgba(68, 240, 255, 0.2)",
D: "rgba(90, 255, 68, 0.2)",
F: "rgba(255, 247, 68, 0.2)",
};
const c = loadLaneColors();
const ON_VARS = {
A: c.A || "#ff4b4b",
S: c.S || "#2fd7ff",
D: c.D || "#3dff5f",
F: c.F || "#ffd84a",
};
const vars = (mode === "off") ? ROOM_ORIGINALS : ON_VARS;
tag.textContent = `
:root{
--purrA:${vars.A};
--purrS:${vars.S};
--purrD:${vars.D};
--purrF:${vars.F};
}
.pp-note.pp-note-a{ color:var(--purrA) !important; }
.pp-note.pp-note-s{ color:var(--purrS) !important; }
.pp-note.pp-note-d{ color:var(--purrD) !important; }
.pp-note.pp-note-f{ color:var(--purrF) !important; }
.pp-note.pp-note-a, .pp-noteextended.pp-note-a{ border-color:var(--purrA) !important; }
.pp-note.pp-note-s, .pp-noteextended.pp-note-s{ border-color:var(--purrS) !important; }
.pp-note.pp-note-d, .pp-noteextended.pp-note-d{ border-color:var(--purrD) !important; }
.pp-note.pp-note-f, .pp-noteextended.pp-note-f{ border-color:var(--purrF) !important; }
${mode === "off" ? `
/* OFF: force true vanilla tints */
.pp-note.pp-note-a, .pp-noteextended.pp-note-a{ background-color:${ROOM_BG.A} !important; box-shadow:none !important; }
.pp-note.pp-note-s, .pp-noteextended.pp-note-s{ background-color:${ROOM_BG.S} !important; box-shadow:none !important; }
.pp-note.pp-note-d, .pp-noteextended.pp-note-d{ background-color:${ROOM_BG.D} !important; box-shadow:none !important; }
.pp-note.pp-note-f, .pp-noteextended.pp-note-f{ background-color:${ROOM_BG.F} !important; box-shadow:none !important; }
.pp-note t, .pp-noteextended t{ color: inherit !important; }
` : ``}
`;
}
function styleFallingNote(el) {
if (!el || !el.notedata) return;
const lane = laneUpper(el.notedata.key);
if (!lane) return;
const colors = loadLaneColors();
const hex = colors[lane];
if (!hex) return;
el.style.borderColor = hex;
el.style.color = hex;
el.style.boxShadow = `0 0 0 2px ${hex}`;
el.style.backgroundColor = hexToRgba(hex, 0.10) || "";
if (el.classList.contains("pp-noteextended")) {
el.style.backgroundColor = hexToRgba(hex, 0.16) || "";
el.style.boxShadow = `0 0 0 2px ${hex}, 0 0 18px ${hexToRgba(hex, 0.20) || hex}`;
}
const t = el.querySelector("t");
if (t) t.style.color = hex;
}
function restyleAllFallingNotes() {
try { document.querySelectorAll(".pp-note, .pp-noteextended").forEach(styleFallingNote); } catch {}
}
// Hook creation + recalculation of falling notes
const noteHuePatch = (() => {
const Proto = window._$61?.prototype;
if (!Proto || typeof Proto._$3q !== "function" || typeof Proto._$8S !== "function") return { ok: false };
const orig3q = Proto._$3q;
const orig8S = Proto._$8S;
let installed = false;
function install() {
if (installed) return;
installed = true;
ensureHueStyleTag("on");
Proto._$3q = function (...args) {
const note = orig3q.apply(this, args);
try { styleFallingNote(note); } catch {}
return note;
};
Proto._$8S = function (note, ...rest) {
const r = orig8S.call(this, note, ...rest);
try { styleFallingNote(note); } catch {}
return r;
};
micro(() => restyleAllFallingNotes());
}
function uninstall() {
if (!installed) return;
installed = false;
Proto._$3q = orig3q;
Proto._$8S = orig8S;
try { ensureHueStyleTag("off"); } catch {}
micro(() => {
try {
document.querySelectorAll(".pp-note, .pp-noteextended").forEach((el) => {
el.style.borderColor = "";
el.style.color = "";
el.style.boxShadow = "";
el.style.backgroundColor = "";
const t = el.querySelector("t");
if (t) t.style.color = "";
});
} catch {}
});
}
return { ok: true, install, uninstall };
})();
// ---- Playerlist notes styling (top-left panels) ----
const PlpProto = window._$1e?.prototype;
const huePatch = (() => {
if (!PlpProto || typeof PlpProto._$aW !== "function" || typeof PlpProto._$2W !== "function") {
return { ok: false };
}
const orig = {
aW: PlpProto._$aW,
w2: PlpProto._$2W,
y7: typeof PlpProto._$7Y === "function" ? PlpProto._$7Y : null,
r2: typeof PlpProto._$2r === "function" ? PlpProto._$2r : null,
};
function laneBaseColor(a, alpha) {
const lane = laneUpper(a);
if (!lane) return null;
const colors = loadLaneColors();
const hex = colors[lane];
if (!hex) return null;
return hexToRgba(hex, alpha);
}
function applyToExisting(plp) {
try {
for (const k of ["a", "s", "d", "f"]) {
if (!plp?.notes?.[k]) continue;
const c = laneBaseColor(k, 0.15);
if (c) plp.notes[k].style["background-color"] = c;
}
} catch {}
}
function patchOn() {
PlpProto._$aW = function (a) {
const c = laneBaseColor(a, 0.15);
if (c && this?.notes?.[a]) {
this.notes[a].style["background-color"] = c;
return;
}
return orig.aW.call(this, a);
};
PlpProto._$2W = function (a) {
const c = laneBaseColor(a, 0.4);
if (c && this?.notes?.[a]) {
this.notes[a].style["background-color"] = c;
this._$ab[a] = 1;
return;
}
return orig.w2.call(this, a);
};
if (orig.y7) {
PlpProto._$7Y = function () {
var b = _$S.epoch();
if (this._$br != -1) {
if (b >= this._$br) {
this.score.style.transform = "";
this._$br = -1;
}
}
if (this._$bn != -1) {
if (b >= this._$bn) {
this._$4W();
this._$bn = -1;
}
}
for (var a in this._$95) {
if (this._$95[a] != -1) {
if (b >= this._$95[a]) {
this._$aW(a);
this.notes[a + "1"].style.opacity = 0;
this._$95[a] = -1;
this._$ab[a] = 0;
}
}
}
};
}
if (orig.r2) {
PlpProto._$2r = function (a) {
const lane = laneUpper(a);
const colors = loadLaneColors();
const hex = lane ? colors[lane] : null;
if (hex && this?.notes?.[a]) this.notes[a].style.color = hex;
return orig.r2.call(this, a);
};
}
micro(() => {
try {
const list = window.list;
if (list?.players) for (const id in list.players) applyToExisting(list.players[id]);
} catch {}
});
}
function patchOff() {
PlpProto._$aW = orig.aW;
PlpProto._$2W = orig.w2;
if (orig.y7) PlpProto._$7Y = orig.y7;
if (orig.r2) PlpProto._$2r = orig.r2;
micro(() => {
try {
const list = window.list;
if (list?.players) {
for (const id in list.players) {
const p = list.players[id];
if (!p?.notes) continue;
for (const k of ["a", "s", "d", "f"]) {
if (p.notes[k]) {
p.notes[k].style.color = "";
p.notes[k].style["background-color"] = "";
}
}
}
}
} catch {}
});
}
return { ok: true, patchOn, patchOff };
})();
// ========= MOD ON/OFF =========
function modOn() {
enabled = true;
writeOff(false);
const inst = findGameInstance();
if (inst && levelDefault == null && typeof inst.gamespeed === "number") levelDefault = inst.gamespeed;
if (huePatch.ok) huePatch.patchOn();
if (noteHuePatch.ok) noteHuePatch.install();
// apply saved speed
applySpeed(readSpeed());
micro(() => {
ensureHueStyleTag("on");
restyleAllFallingNotes();
console.log("PurrfectScrollSpeed ON");
});
}
function modOff() {
enabled = false;
writeOff(true);
const inst = findGameInstance();
if (inst && levelDefault == null && typeof inst.gamespeed === "number") levelDefault = inst.gamespeed;
if (inst) {
const back = (typeof levelDefault === "number" && Number.isFinite(levelDefault))
? levelDefault
: (typeof inst.gamespeed === "number" && Number.isFinite(inst.gamespeed) ? inst.gamespeed : DEFAULT_SPEED);
try { delete inst.__purrfectBaseHitTime; } catch {}
try { inst._$6h = 4; } catch {} // vanilla hit window base
try { original5k.call(inst, back); } catch {}
}
if (huePatch.ok) huePatch.patchOff();
if (noteHuePatch.ok) noteHuePatch.uninstall();
micro(() => console.log("PurrfectScrollSpeed OFF"));
}
// quick commands
window.setGameSpeed = (v) => (enabled ? applySpeed(v) : "Mod is OFF");
window.purrModOn = () => (modOn(), "ON");
window.purrModOff = () => (modOff(), "OFF");
// ========= FIRST RUN DEFAULTS =========
if (isFirstRun()) {
writeOff(false);
writeHidden(false);
writeSpeed(DEFAULT_SPEED);
if (!read(HUE_KEY)) saveLaneColors({ A: null, S: null, D: null, F: null });
enabled = true;
markInited();
}
if (readOff()) modOff();
else modOn();
// ========= UI =========
const root = document.createElement("div");
root.style.cssText =
"position:fixed;right:18px;bottom:18px;z-index:2147483647;" +
"font:12px/1.25 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;" +
"user-select:none;touch-action:none;";
const panel = document.createElement("div");
panel.style.cssText =
"width:260px;border:1px solid rgba(255,255,255,.14);" +
"background:rgba(20,20,24,.92);backdrop-filter:blur(8px);" +
"-webkit-backdrop-filter:blur(8px);border-radius:12px;" +
"box-shadow:0 8px 28px rgba(0,0,0,.45);color:rgba(255,255,255,.92);" +
"overflow:hidden;";
const header = document.createElement("div");
header.style.cssText =
"display:flex;align-items:center;justify-content:space-between;" +
"padding:10px 10px 8px;cursor:grab;background:rgba(255,255,255,.04);";
const title = document.createElement("div");
title.textContent = "PurrfectScrollSpeed";
title.style.cssText = "font-weight:700;letter-spacing:.2px;";
const btnWrap = document.createElement("div");
btnWrap.style.cssText = "display:flex;gap:6px;align-items:center;";
const mkBtn = (t) => {
const b = document.createElement("button");
b.type = "button";
b.textContent = t;
b.style.cssText =
"all:unset;cursor:pointer;padding:4px 8px;border-radius:8px;" +
"background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.10);" +
"color:rgba(255,255,255,.92);";
b.onmouseenter = () => (b.style.background = "rgba(255,255,255,.12)");
b.onmouseleave = () => (b.style.background = "rgba(255,255,255,.08)");
return b;
};
const hueBtn = mkBtn("Hue");
const toggleBtn = mkBtn("Off");
const hideBtn = mkBtn("Hide");
btnWrap.append(hueBtn, toggleBtn, hideBtn);
header.append(title, btnWrap);
const body = document.createElement("div");
body.style.cssText = "padding:10px;display:flex;flex-direction:column;gap:10px;";
const speedRow = document.createElement("div");
speedRow.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:10px;";
const speedLabel = document.createElement("div");
speedLabel.textContent = "Speed";
speedLabel.style.cssText = "opacity:.85;font-weight:600;";
const speedValue = document.createElement("div");
speedValue.style.cssText = "font-variant-numeric:tabular-nums;font-weight:700;";
speedRow.append(speedLabel, speedValue);
const slider = document.createElement("input");
slider.type = "range";
slider.min = "0.5";
slider.max = String(MAX_SPEED);
slider.step = "0.1";
slider.style.cssText = "width:100%;";
const presets = document.createElement("div");
presets.style.cssText = "display:grid;grid-template-columns:1fr 1fr;gap:8px;";
const addPreset = (name, val) => {
const b = mkBtn(name);
b.style.cssText += ";text-align:center;padding:8px 8px;";
b.onclick = () => {
if (!enabled) return;
slider.value = String(val);
speedValue.textContent = Number(val).toFixed(1);
applySpeed(val);
};
presets.appendChild(b);
};
addPreset("Slow 1", 1);
addPreset("Normal 2.5", 2.5);
addPreset("Fast 4", 4);
addPreset("Cracked 6", 6);
const footer = document.createElement("div");
footer.style.cssText = "display:flex;gap:8px;align-items:center;justify-content:space-between;";
const hint = document.createElement("div");
hint.style.cssText = "opacity:.75;font-size:11px;";
hint.textContent = "Toggle UI: F9 / Ctrl+Alt+H";
const purr = document.createElement("div");
purr.textContent = "Purr";
purr.style.cssText = "opacity:.12;font-size:10px;letter-spacing:.8px;transform:translateY(1px);";
footer.append(hint, purr);
body.append(speedRow, slider, presets, footer);
panel.append(header, body);
root.append(panel);
const mini = document.createElement("button");
mini.type = "button";
mini.textContent = "◀";
mini.style.cssText =
"all:unset;position:fixed;right:18px;bottom:0px;z-index:2147483647;" +
"cursor:pointer;padding:8px 10px;border-radius:999px;" +
"background:rgba(20,20,24,.92);border:1px solid rgba(255,255,255,.14);" +
"box-shadow:0 8px 28px rgba(0,0,0,.45);color:rgba(255,255,255,.92);" +
"display:none;user-select:none;touch-action:none;";
document.documentElement.appendChild(root);
document.documentElement.appendChild(mini);
function setVisibility(mode) {
if (mode === "OFF") {
panel.style.display = "none";
mini.style.display = "none";
writeHidden(true);
return;
}
if (mode === "HIDE") {
panel.style.display = "none";
mini.style.display = "block";
writeHidden(true);
return;
}
panel.style.display = "block";
mini.style.display = "none";
writeHidden(false);
}
function refreshUI() {
toggleBtn.textContent = enabled ? "Off" : "On";
slider.disabled = !enabled;
const s = clamp(readSpeed(), 0.5, MAX_SPEED);
slider.value = String(s);
speedValue.textContent = s.toFixed(1);
const offStyle = !enabled;
for (const b of presets.querySelectorAll("button")) {
b.style.opacity = offStyle ? ".45" : "1";
b.style.pointerEvents = offStyle ? "none" : "auto";
}
slider.style.opacity = offStyle ? ".45" : "1";
hueBtn.style.opacity = offStyle ? ".45" : "1";
hueBtn.style.pointerEvents = offStyle ? "none" : "auto";
}
slider.addEventListener("input", () => {
const s = clamp(Number(slider.value), 0.5, MAX_SPEED);
speedValue.textContent = s.toFixed(1);
if (enabled) applySpeed(s);
});
hideBtn.onclick = () => {
setVisibility("HIDE");
};
toggleBtn.onclick = () => {
if (enabled) {
modOff();
setVisibility("HIDE");
} else {
modOn();
setVisibility("SHOW");
}
refreshUI();
};
function toggleUI() {
if (readOff() || !enabled) {
setVisibility("SHOW");
refreshUI();
return;
}
const visible = panel.style.display !== "none";
setVisibility(visible ? "HIDE" : "SHOW");
refreshUI();
}
window.addEventListener("keydown", (e) => {
if (e.key === "F9") { e.preventDefault(); toggleUI(); return; }
if (e.ctrlKey && e.altKey && e.key.toLowerCase() === "h") { e.preventDefault(); toggleUI(); return; }
});
// drag panel
let dragging = false;
let dx = 0, dy = 0;
header.addEventListener("mousedown", (e) => {
if (!enabled) return;
dragging = true;
header.style.cursor = "grabbing";
dx = e.clientX - root.getBoundingClientRect().left;
dy = e.clientY - root.getBoundingClientRect().top;
e.preventDefault();
});
window.addEventListener("mousemove", (e) => {
if (!dragging) return;
const nx = clamp(e.clientX - dx, 0, window.innerWidth - root.offsetWidth);
const ny = clamp(e.clientY - dy, 0, window.innerHeight - root.offsetHeight);
root.style.left = nx + "px";
root.style.top = ny + "px";
root.style.right = "auto";
root.style.bottom = "auto";
savePos(nx, ny);
});
window.addEventListener("mouseup", () => {
dragging = false;
header.style.cursor = "grab";
});
let miniDragging = false;
let mdx = 0, mdy = 0;
let mx0 = 0, my0 = 0;
let miniMoved = false;
mini.addEventListener("mousedown", (e) => {
if (!readHidden()) return;
miniDragging = true;
miniMoved = false;
const r = mini.getBoundingClientRect();
mdx = e.clientX - r.left;
mdy = e.clientY - r.top;
mx0 = r.left;
my0 = r.top;
e.preventDefault();
});
window.addEventListener("mousemove", (e) => {
if (!miniDragging) return;
const nx = clamp(e.clientX - mdx, 0, window.innerWidth - mini.offsetWidth);
const ny = clamp(e.clientY - mdy, 0, window.innerHeight - mini.offsetHeight);
const dist = Math.hypot(nx - mx0, ny - my0);
if (dist > 5) miniMoved = true;
mini.style.left = nx + "px";
mini.style.top = ny + "px";
mini.style.right = "auto";
mini.style.bottom = "auto";
saveMiniPos(nx, ny);
});
window.addEventListener("mouseup", () => {
if (!miniDragging) return;
const wasMoved = miniMoved;
miniDragging = false;
miniMoved = false;
// only open on a click
if (!wasMoved) {
setVisibility("SHOW");
refreshUI();
}
});
// restore positions
const pos = loadPos();
if (pos && Number.isFinite(pos.x) && Number.isFinite(pos.y)) {
root.style.left = clamp(pos.x, 0, window.innerWidth - 260) + "px";
root.style.top = clamp(pos.y, 0, window.innerHeight - 140) + "px";
root.style.right = "auto";
root.style.bottom = "auto";
}
const mpos = loadMiniPos();
if (mpos && Number.isFinite(mpos.x) && Number.isFinite(mpos.y)) {
mini.style.left = clamp(mpos.x, 0, window.innerWidth - 36) + "px";
mini.style.top = clamp(mpos.y, 0, window.innerHeight - 36) + "px";
mini.style.right = "auto";
mini.style.bottom = "auto";
} else {
const rr = root.getBoundingClientRect();
const mx = clamp(rr.left + rr.width - 22, 0, window.innerWidth - 36);
const my = clamp(window.innerHeight - 36, 0, window.innerHeight - 36);
mini.style.left = mx + "px";
mini.style.top = my + "px";
mini.style.right = "auto";
mini.style.bottom = "auto";
saveMiniPos(mx, my);
}
// ========= Hue modal =========
function makeHueModal() {
const overlay = document.createElement("div");
overlay.style.cssText =
"position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.35);" +
"display:flex;align-items:flex-end;justify-content:flex-end;padding:18px;";
const box = document.createElement("div");
box.style.cssText =
"width:320px;background:rgba(20,20,24,.95);border:1px solid rgba(255,255,255,.14);" +
"border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.55);color:rgba(255,255,255,.92);" +
"padding:12px;display:flex;flex-direction:column;gap:10px;";
const head = document.createElement("div");
head.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:10px;";
const h = document.createElement("div");
h.textContent = "Lane Colors (A S D F)";
h.style.cssText = "font-weight:700;letter-spacing:.2px;";
const close = mkBtn("Close");
head.append(h, close);
const grid = document.createElement("div");
grid.style.cssText = "display:grid;grid-template-columns:54px 1fr 110px;gap:8px;align-items:center;";
const colors = loadLaneColors();
const inputs = {};
function addRow(lane) {
const l = document.createElement("div");
l.textContent = lane;
l.style.cssText = "opacity:.9;font-weight:700;";
const text = document.createElement("input");
text.type = "text";
text.placeholder = "#rrggbb or rgb(…)";
text.value = colors[lane] || "";
text.style.cssText =
"width:100%;box-sizing:border-box;padding:7px 8px;border-radius:8px;" +
"border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.06);" +
"color:rgba(255,255,255,.92);outline:none;";
const pick = document.createElement("input");
pick.type = "color";
pick.value = colors[lane] || "#ffffff";
pick.style.cssText = "width:100%;height:34px;border:none;background:transparent;padding:0;";
text.addEventListener("input", () => {
const hx = normalizeHex(text.value);
if (hx) pick.value = hx;
});
pick.addEventListener("input", () => {
text.value = pick.value;
});
inputs[lane] = { text, pick };
grid.append(l, text, pick);
}
addRow("A");
addRow("S");
addRow("D");
addRow("F");
const actions = document.createElement("div");
actions.style.cssText = "display:flex;gap:8px;justify-content:flex-end;margin-top:2px;";
const clear = mkBtn("Clear");
const save = mkBtn("Save");
clear.onclick = () => {
saveLaneColors({ A: null, S: null, D: null, F: null });
if (enabled) {
ensureHueStyleTag("on");
restyleAllFallingNotes();
if (huePatch.ok) huePatch.patchOn();
}
overlay.remove();
};
save.onclick = () => {
const o = {};
for (const lane of ["A", "S", "D", "F"]) o[lane] = normalizeHex(inputs[lane].text.value);
saveLaneColors(o);
if (enabled) {
ensureHueStyleTag("on");
restyleAllFallingNotes();
if (huePatch.ok) huePatch.patchOn();
}
overlay.remove();
};
close.onclick = () => overlay.remove();
actions.append(clear, save);
box.append(head, grid, actions);
overlay.append(box);
overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) overlay.remove(); });
return overlay;
}
hueBtn.onclick = () => {
if (!enabled) return;
document.documentElement.appendChild(makeHueModal());
};
refreshUI();
if (readOff() || !enabled) setVisibility("OFF");
else if (readHidden()) setVisibility("HIDE");
else setVisibility("SHOW");
micro(() => console.log("PurrfectScrollSpeed ready"));
})();
}
function tick() {
if (ready()) {
start();
return;
}
if (Date.now() - startedAt > MAX_WAIT_MS) {
console.warn("PurrfectScrollSpeed: timed out waiting for game code.");
return;
}
setTimeout(tick, INTERVAL_MS);
}
tick();
})();