cursorBuddy: Feat - Sourced Oneko, Allow Changing Oneko's Color (#634)

* cursorBuddy: QOL - Updated Copyright Date

* cursorBuddy: QOL - Sourced `oneko.js`

* cursorBuddy: QOL - Changed Tab Spacing for `oneko.js`

* cursorBuddy: QOL - Reworked Plugin to Use Local Oneko Source

* cursorBuddy: Feat - Oneko Color Changing

* cursorBuddy: QOL - Better Descriptions

* cursorBuddy: QOL - Add Sections to Settings Menu

* cursorBuddy: Docs - Updated Authors

* cursorBuddy: Fix - Corrected Minor Spelling Mistake 🥀

* cursorBuddy: Fix - Corrected Minor Spelling Mistake 🥀

* cursorBuddy: legal - Added Licensing Info for Oneko Source
This commit is contained in:
Z ❤'s Life 2026-01-09 15:27:52 -05:00 • committed by GitHub
parent 9ac7dee47c
commit dd2ccb6225
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 401 additions and 29 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
* 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: () => (
<div>
<Heading style={{ fontSize: "1.6em", marginTop: "10px" }}>Oneko</Heading>
<Divider style={{ marginBottom: "-10px" }}></Divider>
</div>
),
},
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: () => (
<div>
<Heading style={{ fontSize: "1.6em", marginTop: "10px" }}>Fatass Horse</Heading>
<Divider style={{ marginBottom: "-10px" }}></Divider>
</div>
),
},
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,

View file

@ -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();
}