gulper.io devtools + plugin loader

Exposes object in window to interact with gulper.io internal game states with dynamic plugins

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         gulper.io devtools + plugin loader
// @description  Exposes object in window to interact with gulper.io internal game states with dynamic plugins
// @author       activetutorial
// @license      MIT
// @version      1.0
// @match        https://gulper.io/*
// @run-at       document-start
// @namespace https://greasyfork.org/users/1410961
// ==/UserScript==

(function () {
    const _window = Function("return this;")();


    // Plugin System Architecture


    class BasePlugin {
        constructor(manager) {
            this.manager = manager;
            this.isActive = false;
            this.name = "UnnamedPlugin";
        }

        toggle() {
            this.isActive = !this.isActive;
            if (this.isActive && this.onEnable) this.onEnable();
            if (!this.isActive && this.onDisable) this.onDisable();
        }

        // Lifecycle Hooks to be overridden by subclasses:
        // onEnable() {}
        // onDisable() {}
        // onKeyDown(key) {}
        // onNativeSetAngle(baseAngle, args) { return { angle: Number, preventDefault: Boolean }; }
        // onTick(delta, time, baseAngle) { return { angle: Number }; }
    }

    class PluginManager {
        constructor(devtools) {
            this.devtools = devtools;
            this.plugins = new Map();
        }

        register(pluginClass) {
            const plugin = new pluginClass(this);
            this.plugins.set(plugin.name, plugin);
            console.log(`[Devtools] Loaded plugin: ${plugin.name}`);
        }

        get(name) {
            return this.plugins.get(name);
        }

        getActivePlugins() {
            return Array.from(this.plugins.values()).filter(p => p.isActive);
        }

        handleKeyDown(key) {
            for (const plugin of this.plugins.values()) {
                if (plugin.onKeyDown) plugin.onKeyDown(key);
            }
        }
    }


    // Core Devtools


    class GulperDevtools {
        constructor() {
            this.bus = new EventTarget();
            this.observations = [];
            this.scene = null;
            this.game = null;
            this.captureNext = false;
            this.originals = {};

            // Core state for the central loop
            this.lastInputAngle = 0;
            this.lastFrameTime = performance.now();
            this.animationFrameId = null;

            this.plugins = new PluginManager(this);
        }

        init() {
            _window.__$3_DEVTOOLS__ = this.bus;
            _window.__devtools = this;

            this.bus.addEventListener("observe", (e) => this.handleObserve(e?.detail));

            // Quietly bypass video ads
            Object.defineProperty(_window, "start_video_ad", {
                get: () => () => { _window.on_video_ad_finished(true); },
                set: () => {}, // ignore silently
                configurable: false
            });

            // Bind global input
            window.addEventListener("keydown", (e) => this.plugins.handleKeyDown(e.key));

            // Start the central game loop
            this.animationFrameId = requestAnimationFrame((time) => this.tick(time));

            this.loadDefaultPlugins();
        }

        loadDefaultPlugins() {
            this.plugins.register(RotatePlugin);
            this.plugins.register(WigglePlugin);
        }

        handleObserve(payload) {
            if (!payload) return;

            const clone = { ...payload };
            this.observations.push([payload, clone]);

            if (this.captureNext) {
                this.scene = clone;
                this.captureNext = false;
            }

            if (payload?.domElement?.id === "aux-canvas") {
                this.captureNext = true;
            }

            // Hook the game state once we have enough observations
            if (this.observations.length === 10 && !this.game) {
                this.game = this.findGame(this.scene);
                if (this.game) {
                    this.patchSetAngle(this.game);
                    console.log("[Devtools] Game hooked:", this.game);
                }
            }
        }

        findGame(scene) {
            return Object.values(
                scene?.children
                ?.find(n => n.type === "OrthographicCamera")
                ?.children?.find(n => n.type === "Group") || {}
            )?.find(v => typeof v?.anims_on_freed === "function");
        }

        findSetAngle(game) {
            return Object.entries(game.constructor.prototype)
                .find(([_, f]) =>
                      typeof f === "function" &&
                      f.length === 2 &&
                      f.toString().includes("<<")
                     )?.[0];
        }

        patchSetAngle(game) {
            const name = this.findSetAngle(game);
            if (!name) return;

            const original = game.constructor.prototype[name];
            this.originals.setAngle = original;
            const self = this;

            game.constructor.prototype[name] = function (angle, ...args) {
                self.lastInputAngle = angle;

                let finalAngle = angle;
                let preventOriginal = false;

                // 1. Let plugins intercept/modify native mouse movements
                for (const plugin of self.plugins.getActivePlugins()) {
                    if (plugin.onNativeSetAngle) {
                        const result = plugin.onNativeSetAngle(finalAngle, args);
                        if (result) {
                            if (result.angle !== undefined) finalAngle = result.angle;
                            if (result.preventDefault) preventOriginal = true;
                        }
                    }
                }

                // 2. Apply the final resolved angle
                if (!preventOriginal) {
                    return original.apply(this, [finalAngle, ...args]);
                }
            };
        }

        // Central Loop: Handles automatic continuous updates when no mouse events are sent
        tick(time) {
            requestAnimationFrame((t) => this.tick(t));

            const delta = (time - this.lastFrameTime) / 1000;
            this.lastFrameTime = time;

            if (!this.game || !this.originals.setAngle) return;

            let finalAngle = this.lastInputAngle;
            let needsForcedUpdate = false;

            for (const plugin of this.plugins.getActivePlugins()) {
                if (plugin.onTick) {
                    const result = plugin.onTick(delta, time, finalAngle);
                    if (result && result.angle !== undefined) {
                        finalAngle = result.angle;
                        needsForcedUpdate = true;
                    }
                }
            }

            // Only push the update to the game if a plugin actively modified the angle
            if (needsForcedUpdate) {
                this.originals.setAngle.call(this.game, finalAngle, true);
            }
        }
    }


    // Plugins


    class RotatePlugin extends BasePlugin {
        constructor(manager) {
            super(manager);
            this.name = "RotateMode";
            this.angle = 0;
            this.SPEED = Math.PI; // radians per second
        }

        onKeyDown(key) {
            if (key === "c") this.toggle();
        }

        onNativeSetAngle(baseAngle, args) {
            // Block native mouse inputs entirely while spinning
            return { preventDefault: true };
        }

        onTick(delta) {
            // Continuously increment angle
            this.angle += delta * this.SPEED;
            return { angle: this.angle };
        }
    }

    class WigglePlugin extends BasePlugin {
        constructor(manager) {
            super(manager);
            this.name = "WiggleMode";
            this.time = 0;
            this.AMPLITUDE = 0.5;
            this.SPEED = 15;
        }

        onKeyDown(key) {
            if (key === "x") this.toggle();
        }

        getWiggleOffset() {
            return Math.sin(this.time * this.SPEED) * this.AMPLITUDE;
        }

        onNativeSetAngle(baseAngle, args) {
            // If Rotate is active, step back to prevent conflicts
            if (this.manager.get("RotateMode")?.isActive) return;

            // Apply wiggle offset to active mouse movements
            return { angle: baseAngle + this.getWiggleOffset() };
        }

        onTick(delta, absoluteTime, baseAngle) {
            if (this.manager.get("RotateMode")?.isActive) return;

            this.time += delta;

            // Continually push the wiggle offset to the last known mouse position
            return { angle: baseAngle + this.getWiggleOffset() };
        }
    }


    // Boot


    const devtools = new GulperDevtools();
    devtools.init();

})();