338 lines
10 KiB
JavaScript
338 lines
10 KiB
JavaScript
/*
|
|
* 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 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();
|
|
}
|