diff --git a/src/equicordplugins/cursorBuddy/index.tsx b/src/equicordplugins/cursorBuddy/index.tsx
index 0e76ff54b..f84f6e1ef 100644
--- a/src/equicordplugins/cursorBuddy/index.tsx
+++ b/src/equicordplugins/cursorBuddy/index.tsx
@@ -1,29 +1,19 @@
/*
- * Vencord, a modification for Discord's desktop app
- * Copyright (c) 2022 Vendicated and contributors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
-*/
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
import { isPluginEnabled } from "@api/PluginManager";
import { definePluginSettings, migratePluginSettings } from "@api/Settings";
+import { Divider } from "@components/Divider";
+import { Heading } from "@components/Heading";
import { Devs, EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import fathorse from "./fathorse";
+import oneko from "./oneko";
-const ONEKO_SCRIPT = "https://raw.githubusercontent.com/adryd325/oneko.js/5281d057c4ea9bd4f6f997ee96ba30491aed16c0/oneko.js";
const ONEKO_IMAGE = "https://raw.githubusercontent.com/adryd325/oneko.js/5281d057c4ea9bd4f6f997ee96ba30491aed16c0/oneko.gif";
const FATASS_HORSE_IMAGE = "https://raw.githubusercontent.com/nexpid/fatass-horse/08bc4042750d5f995c55327f7b6c6710158f5263/sheet.png";
@@ -58,6 +48,38 @@ const settings = definePluginSettings({
isValid: (value: number) => value > 0 || "Framerate must be bigger than 0",
onChange: load
},
+ // Oneko Specific
+ onekoSection: {
+ type: OptionType.COMPONENT,
+ component: () => (
+
+ ),
+ },
+ furColor: {
+ description: "Fur hex color for Oneko",
+ type: OptionType.STRING,
+ default: "#FFFFFF",
+ onChange: load,
+ },
+ outlineColor: {
+ description: "Outline hex color for Oneko",
+ type: OptionType.STRING,
+ default: "#000000",
+ onChange: load,
+ },
+ // Fatass Horse Specific
+ fathorseSection: {
+ type: OptionType.COMPONENT,
+ component: () => (
+
+ ),
+ },
size: {
description: "Size of the fatass horse",
type: OptionType.NUMBER,
@@ -81,9 +103,17 @@ const settings = definePluginSettings({
description: "If the horse should shake the window when it's walking",
type: OptionType.BOOLEAN,
default: false,
- onChange: load
+ onChange: load,
},
}, {
+ // Oneko Specific
+ furColor: {
+ disabled() { return this.store.buddy !== "oneko"; }
+ },
+ outlineColor: {
+ disabled() { return this.store.buddy !== "oneko"; }
+ },
+ // Fatass Horse Specific
size: {
disabled() { return this.store.buddy !== "fathorse"; },
},
@@ -109,16 +139,14 @@ function load() {
switch (settings.store.buddy) {
case "oneko": {
- fetch(ONEKO_SCRIPT)
- .then(x => x.text())
- .then(s => s
- .replace("(isReducedMotion)", "(false)")
- .replace("persistPosition = true;", "persistPosition = false;")
- .replace("./oneko.gif", ONEKO_IMAGE)
- .replace("nekoSpeed = 10;", `nekoSpeed = ${settings.store.speed};`)
- .replace(" > 100", ` > ${1000 / settings.store.fps}`)
- )
- .then(s => Function(s)());
+ oneko({
+ speed: settings.store.speed,
+ fps: settings.store.fps,
+ image: ONEKO_IMAGE,
+ persistPosition: false,
+ furColor: settings.store.furColor,
+ outlineColor: settings.store.outlineColor
+ });
break;
}
case "fathorse": {
@@ -139,7 +167,7 @@ migratePluginSettings("CursorBuddy", "Oneko", "oneko");
export default definePlugin({
name: "CursorBuddy",
description: "only a slightly annoying plugin",
- authors: [Devs.Ven, Devs.adryd, EquicordDevs.nexpid],
+ authors: [Devs.Ven, Devs.adryd, EquicordDevs.nexpid, EquicordDevs.ZcraftElite],
settings,
isModified: true,
diff --git a/src/equicordplugins/cursorBuddy/oneko.js b/src/equicordplugins/cursorBuddy/oneko.js
new file mode 100644
index 000000000..b57176bdd
--- /dev/null
+++ b/src/equicordplugins/cursorBuddy/oneko.js
@@ -0,0 +1,344 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+/*
+ * Sourced from https://raw.githubusercontent.com/adryd325/oneko.js/5281d057c4ea9bd4f6f997ee96ba30491aed16c0/oneko.js
+ * Licensed under https://github.com/adryd325/oneko.js/blob/main/LICENSE
+ */
+
+/* eslint-disable */
+
+export default function oneko(options = {}) {
+ const {
+ speed = 10,
+ fps = 24,
+ image = "./oneko.gif",
+ persistPosition = true,
+ furColor = "#FFFFFF",
+ outlineColor = "#000000",
+ } = options;
+
+ const isReducedMotion =
+ window.matchMedia(`(prefers-reduced-motion: reduce)`) === true ||
+ window.matchMedia(`(prefers-reduced-motion: reduce)`).matches === true;
+
+ if (isReducedMotion) return;
+
+ const nekoEl = document.createElement("div");
+ let nekoPosX = 32;
+ let nekoPosY = 32;
+
+ let mousePosX = 0;
+ let mousePosY = 0;
+
+ let frameCount = 0;
+ let idleTime = 0;
+ let idleAnimation = null;
+ let idleAnimationFrame = 0;
+
+ const nekoSpeed = speed;
+ const spriteSets = {
+ idle: [[-3, -3]],
+ alert: [[-7, -3]],
+ scratchSelf: [
+ [-5, 0],
+ [-6, 0],
+ [-7, 0],
+ ],
+ scratchWallN: [
+ [0, 0],
+ [0, -1],
+ ],
+ scratchWallS: [
+ [-7, -1],
+ [-6, -2],
+ ],
+ scratchWallE: [
+ [-2, -2],
+ [-2, -3],
+ ],
+ scratchWallW: [
+ [-4, 0],
+ [-4, -1],
+ ],
+ tired: [[-3, -2]],
+ sleeping: [
+ [-2, 0],
+ [-2, -1],
+ ],
+ N: [
+ [-1, -2],
+ [-1, -3],
+ ],
+ NE: [
+ [0, -2],
+ [0, -3],
+ ],
+ E: [
+ [-3, 0],
+ [-3, -1],
+ ],
+ SE: [
+ [-5, -1],
+ [-5, -2],
+ ],
+ S: [
+ [-6, -3],
+ [-7, -2],
+ ],
+ SW: [
+ [-5, -3],
+ [-6, -1],
+ ],
+ W: [
+ [-4, -2],
+ [-4, -3],
+ ],
+ NW: [
+ [-1, 0],
+ [-1, -1],
+ ],
+ };
+
+ function recolorImage(src, furColor, outlineColor) {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.crossOrigin = "anonymous"; // * Needed for CORS
+ img.onload = () => {
+ const canvas = document.createElement("canvas");
+ canvas.width = img.width;
+ canvas.height = img.height;
+ const ctx = canvas.getContext("2d");
+
+ ctx.drawImage(img, 0, 0);
+
+ const imageData = ctx.getImageData(
+ 0,
+ 0,
+ canvas.width,
+ canvas.height
+ );
+ const data = imageData.data;
+
+ const furRGB = hexToRgb(furColor);
+ const outlineRGB = hexToRgb(outlineColor);
+
+ for (let i = 0; i < data.length; i += 4) {
+ const r = data[i];
+ const g = data[i + 1];
+ const b = data[i + 2];
+ const a = data[i + 3];
+
+ if (r === 255 && g === 255 && b === 255) {
+ data[i] = furRGB.r;
+ data[i + 1] = furRGB.g;
+ data[i + 2] = furRGB.b;
+ } else if (r === 0 && g === 0 && b === 0) {
+ data[i] = outlineRGB.r;
+ data[i + 1] = outlineRGB.g;
+ data[i + 2] = outlineRGB.b;
+ }
+ }
+
+ ctx.putImageData(imageData, 0, 0);
+ resolve(canvas.toDataURL());
+ };
+ img.onerror = reject;
+ img.src = src;
+ });
+ }
+
+ function hexToRgb(hex) {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ return result
+ ? {
+ r: parseInt(result[1], 16),
+ g: parseInt(result[2], 16),
+ b: parseInt(result[3], 16),
+ }
+ : { r: 0, g: 0, b: 0 };
+ }
+
+ function init() {
+ const nekoFile = image; // Use passed-in image URL
+
+ if (persistPosition) {
+ let storedNeko = JSON.parse(window.localStorage.getItem("oneko"));
+ if (storedNeko !== null) {
+ nekoPosX = storedNeko.nekoPosX;
+ nekoPosY = storedNeko.nekoPosY;
+ mousePosX = storedNeko.mousePosX;
+ mousePosY = storedNeko.mousePosY;
+ frameCount = storedNeko.frameCount;
+ idleTime = storedNeko.idleTime;
+ idleAnimation = storedNeko.idleAnimation;
+ idleAnimationFrame = storedNeko.idleAnimationFrame;
+ nekoEl.style.backgroundPosition = storedNeko.bgPos;
+ }
+ }
+
+ nekoEl.id = "oneko";
+ nekoEl.ariaHidden = true;
+ nekoEl.style.width = "32px";
+ nekoEl.style.height = "32px";
+ nekoEl.style.position = "fixed";
+ nekoEl.style.pointerEvents = "none";
+ nekoEl.style.imageRendering = "pixelated";
+ nekoEl.style.left = `${nekoPosX - 16}px`;
+ nekoEl.style.top = `${nekoPosY - 16}px`;
+ nekoEl.style.zIndex = 2147483647;
+
+ recolorImage(nekoFile, furColor, outlineColor)
+ .then((recoloredImageUrl) => {
+ nekoEl.style.backgroundImage = `url(${recoloredImageUrl})`;
+ })
+ .catch((err) => {
+ console.error("Failed to recolor Oneko image:", err);
+ nekoEl.style.backgroundImage = `url(${nekoFile})`; // ! Error handling for tests
+ });
+
+ document.body.appendChild(nekoEl);
+
+ document.addEventListener("mousemove", function (event) {
+ mousePosX = event.clientX;
+ mousePosY = event.clientY;
+ });
+
+ if (persistPosition) {
+ window.addEventListener("beforeunload", function (event) {
+ window.localStorage.setItem(
+ "oneko",
+ JSON.stringify({
+ nekoPosX: nekoPosX,
+ nekoPosY: nekoPosY,
+ mousePosX: mousePosX,
+ mousePosY: mousePosY,
+ frameCount: frameCount,
+ idleTime: idleTime,
+ idleAnimation: idleAnimation,
+ idleAnimationFrame: idleAnimationFrame,
+ bgPos: nekoEl.style.backgroundPosition,
+ })
+ );
+ });
+ }
+
+ window.requestAnimationFrame(onAnimationFrame);
+ }
+
+ let lastFrameTimestamp;
+
+ function onAnimationFrame(timestamp) {
+ if (!nekoEl.isConnected) return;
+ if (!lastFrameTimestamp) lastFrameTimestamp = timestamp;
+ if (timestamp - lastFrameTimestamp > 1000 / fps) {
+ lastFrameTimestamp = timestamp;
+ frame();
+ }
+ window.requestAnimationFrame(onAnimationFrame);
+ }
+
+ function setSprite(name, frame) {
+ const sprite = spriteSets[name][frame % spriteSets[name].length];
+ nekoEl.style.backgroundPosition = `${sprite[0] * 32}px ${
+ sprite[1] * 32
+ }px`;
+ }
+
+ function resetIdleAnimation() {
+ idleAnimation = null;
+ idleAnimationFrame = 0;
+ }
+
+ function idle() {
+ idleTime += 1;
+
+ if (
+ idleTime > 10 &&
+ Math.floor(Math.random() * 200) == 0 &&
+ idleAnimation == null
+ ) {
+ let avalibleIdleAnimations = ["sleeping", "scratchSelf"];
+ if (nekoPosX < 32) avalibleIdleAnimations.push("scratchWallW");
+ if (nekoPosY < 32) avalibleIdleAnimations.push("scratchWallN");
+ if (nekoPosX > window.innerWidth - 32)
+ avalibleIdleAnimations.push("scratchWallE");
+ if (nekoPosY > window.innerHeight - 32)
+ avalibleIdleAnimations.push("scratchWallS");
+ idleAnimation =
+ avalibleIdleAnimations[
+ Math.floor(Math.random() * avalibleIdleAnimations.length)
+ ];
+ }
+
+ switch (idleAnimation) {
+ case "sleeping":
+ if (idleAnimationFrame < 8) {
+ setSprite("tired", 0);
+ break;
+ }
+ setSprite("sleeping", Math.floor(idleAnimationFrame / 4));
+ if (idleAnimationFrame > 192) {
+ resetIdleAnimation();
+ }
+ break;
+ case "scratchWallN":
+ case "scratchWallS":
+ case "scratchWallE":
+ case "scratchWallW":
+ case "scratchSelf":
+ setSprite(idleAnimation, idleAnimationFrame);
+ if (idleAnimationFrame > 9) {
+ resetIdleAnimation();
+ }
+ break;
+ default:
+ setSprite("idle", 0);
+ return;
+ }
+ idleAnimationFrame += 1;
+ }
+
+ function frame() {
+ frameCount += 1;
+ const diffX = nekoPosX - mousePosX;
+ const diffY = nekoPosY - mousePosY;
+ const distance = Math.sqrt(diffX ** 2 + diffY ** 2);
+
+ if (distance < nekoSpeed || distance < 48) {
+ idle();
+ return;
+ }
+
+ idleAnimation = null;
+ idleAnimationFrame = 0;
+
+ if (idleTime > 1) {
+ setSprite("alert", 0);
+ idleTime = Math.min(idleTime, 7);
+ idleTime -= 1;
+ return;
+ }
+
+ let direction;
+ direction = diffY / distance > 0.5 ? "N" : "";
+ direction += diffY / distance < -0.5 ? "S" : "";
+ direction += diffX / distance > 0.5 ? "W" : "";
+ direction += diffX / distance < -0.5 ? "E" : "";
+ setSprite(direction, frameCount);
+
+ nekoPosX -= (diffX / distance) * nekoSpeed;
+ nekoPosY -= (diffY / distance) * nekoSpeed;
+
+ nekoPosX = Math.min(Math.max(16, nekoPosX), window.innerWidth - 16);
+ nekoPosY = Math.min(Math.max(16, nekoPosY), window.innerHeight - 16);
+
+ nekoEl.style.left = `${nekoPosX - 16}px`;
+ nekoEl.style.top = `${nekoPosY - 16}px`;
+ }
+
+ init();
+}