HTML canvas fps limiter

Fps limiter for browser games or some 2D/3D animations

目前為 2023-05-25 提交的版本,檢視 最新版本

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址