gulper.io devtools + plugin loader

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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();

})();