HTML canvas fps limiter

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

当前为 2023-05-25 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name          HTML canvas fps limiter
// @description   Fps limiter for browser games or some 2D/3D animations
// @author        Konf
// @namespace     https://greasyfork.org/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;
  }
})();