Exposes object in window to interact with gulper.io internal game states with dynamic plugins
// ==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();
})();