// ==UserScript==
// @name HTML canvas fps limiter
// @description Fps limiter for browser games or some 2D/3D animations
// @author Konf
// @namespace https://gf.qytechs.cn/users/424058
// @icon https://img.icons8.com/external-neu-royyan-wijaya/32/external-animation-neu-solid-neu-royyan-wijaya.png
// @icon64 https://img.icons8.com/external-neu-royyan-wijaya/64/external-animation-neu-solid-neu-royyan-wijaya.png
// @version 1.0.0
// @match *://*/*
// @compatible Chrome
// @compatible Opera
// @compatible Firefox
// @run-at document-start
// @grant unsafeWindow
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// ==/UserScript==
/*
* Implementation is kinda rough, but it seems working, so I don't care anymore
*
* A huge part is inspired (stolen) from:
* https://chriscourses.com/blog/standardize-your-javascript-games-framerate-for-different-monitors
*
* msPrevMap is needed to provide individual rate limiting in cases
* where requestAnimationFrame is used by more than one function loop.
* Using a variable instead of a map in such cases makes so the only one
* random loop will be working, and the others will not be working at all.
* But if some loop is using anonymous functions, the map mode can't limit it,
* so I've decided to make a switcher: the map mode or the single variable mode.
* Default is the map mode (mode 1)
*/
/* jshint esversion: 8 */
(function() {
function DataStore(uuid, defaultStorage = {}) {
if (typeof uuid !== 'string' && typeof uuid !== 'number') {
throw new Error('Expected uuid when creating DataStore');
}
let cachedStorage = defaultStorage;
try {
cachedStorage = JSON.parse(GM_getValue(uuid));
} catch (err) {
GM_setValue(uuid, JSON.stringify(defaultStorage));
}
const getter = (obj, prop) => cachedStorage[prop];
const setter = (obj, prop, val) => {
cachedStorage[prop] = val;
GM_setValue(uuid, JSON.stringify(cachedStorage));
return val;
}
return new Proxy({}, { get: getter, set: setter });
}
const MODE = {
map: 1,
variable: 2,
};
const DEFAULT_FPS_CAP = 5;
const DEFAULT_MODE = MODE.map;
const s = DataStore('storage', {
fpsCap: DEFAULT_FPS_CAP,
isFirstRun: true,
mode: DEFAULT_MODE,
});
const oldRequestAnimationFrame = window.requestAnimationFrame;
const msPrevMap = new Map();
const menuCommandsIds = [];
let msPerFrame = 1000 / s.fpsCap;
let msPrev = 0;
unsafeWindow.requestAnimationFrame = function newRequestAnimationFrame(cb, el) {
return oldRequestAnimationFrame((now) => {
const msPassed = now - ((s.mode === MODE.map ? msPrevMap.get(cb) : msPrev) || 0);
if (msPassed < msPerFrame) return newRequestAnimationFrame(cb, el);
if (s.mode === MODE.variable) {
msPrev = now - (msPassed % msPerFrame); // subtract excess time
} else {
msPrevMap.set(cb, now - (msPassed % msPerFrame));
}
return cb(now);
}, el);
}
// mode 1 garbage collector. 50 is random number
setInterval(() => (msPrevMap.size > 50) && msPrevMap.clear(), 1000);
function changeFpsCapWithUser() {
const userInput = prompt(
`Current fps cap: ${s.fpsCap}. ` +
'What should be the new one? Leave empty or cancel to not to change'
);
if (userInput !== null && userInput !== '') {
let userInputNum = Number(userInput);
if (isNaN(userInputNum)) {
messageUser('bad input', 'Seems like the input is not number');
} else if (userInputNum > 9999) {
s.fpsCap = 9999;
messageUser(
'bad input',
'Seems like the input is way too big number. Decreasing it to 9999',
);
} else if (userInputNum < 0) {
messageUser(
'bad input',
"The input number can't be negative",
);
} else {
s.fpsCap = userInputNum;
}
msPerFrame = 1000 / s.fpsCap;
// can't be applied in iframes
messageUser(
`the fps cap was set to ${s.fpsCap}`,
"For some places the fps cap change can't be applied without a reload, " +
"and if you can't tell worked it out or not, better to refresh the page",
);
unregisterMenuCommands();
registerMenuCommands();
}
}
function messageUser(title, text) {
alert(`Fps limiter: ${title}.\n\n${text}`);
}
function registerMenuCommands() {
menuCommandsIds.push(GM_registerMenuCommand(
`Cap fps (${s.fpsCap} now)`, () => changeFpsCapWithUser(), 'c'
));
menuCommandsIds.push(GM_registerMenuCommand(
`Switch mode to ${s.mode === MODE.map ? MODE.variable : MODE.map}`, () => {
s.mode = s.mode === MODE.map ? MODE.variable : MODE.map;
// can't be applied in iframes
messageUser(
`the mode was set to ${s.mode}`,
"For some places the mode change can't be applied without a reload, " +
"and if you can't tell worked it out or not, better to refresh the page. " +
"You can find description of the modes at the script download page",
);
unregisterMenuCommands();
registerMenuCommands();
}, 'm'
));
}
function unregisterMenuCommands() {
for (const id of menuCommandsIds) {
GM_unregisterMenuCommand(id);
}
menuCommandsIds.length = 0;
}
registerMenuCommands();
if (s.isFirstRun) {
messageUser(
'it seems like your first run of this script',
'You need to refresh the page on which this script should work. ' +
`What fps cap do you prefer? Default is ${DEFAULT_FPS_CAP} as a demonstration. ` +
'You can always quickly change it from your script manager icon ↗'
);
changeFpsCapWithUser();
s.isFirstRun = false;
}
})();