···
1
+
// oneko.js: https://github.com/adryd325/oneko.js
2
+
// petable version from https://github.com/tylxr59/oneko.js
5
+
const isReducedMotion =
6
+
window.matchMedia(`(prefers-reduced-motion: reduce)`) === true ||
7
+
window.matchMedia(`(prefers-reduced-motion: reduce)`).matches === true;
9
+
if (isReducedMotion) return;
11
+
const nekoEl = document.createElement("div");
21
+
let idleAnimation = null;
22
+
let idleAnimationFrame = 0;
24
+
// up neko speed from default 10
25
+
const nekoSpeed = 15;
26
+
const spriteSets = {
90
+
nekoEl.id = "oneko";
91
+
nekoEl.ariaHidden = true;
92
+
nekoEl.style.width = "32px";
93
+
nekoEl.style.height = "32px";
94
+
nekoEl.style.position = "fixed";
95
+
nekoEl.style.pointerEvents = "auto";
96
+
nekoEl.style.imageRendering = "pixelated";
97
+
nekoEl.style.left = `${nekoPosX - 16}px`;
98
+
nekoEl.style.top = `${nekoPosY - 16}px`;
99
+
nekoEl.style.zIndex = Number.MAX_VALUE;
101
+
let nekoFile = "./oneko.gif"
102
+
const curScript = document.currentScript
103
+
if (curScript && curScript.dataset.cat) {
104
+
nekoFile = curScript.dataset.cat
106
+
nekoEl.style.backgroundImage = `url(${nekoFile})`;
108
+
document.body.appendChild(nekoEl);
110
+
document.addEventListener("mousemove", function (event) {
111
+
mousePosX = event.clientX;
112
+
mousePosY = event.clientY;
115
+
window.requestAnimationFrame(onAnimationFrame);
118
+
let lastFrameTimestamp;
120
+
function onAnimationFrame(timestamp) {
121
+
// Stops execution if the neko element is removed from DOM
122
+
if (!nekoEl.isConnected) {
125
+
if (!lastFrameTimestamp) {
126
+
lastFrameTimestamp = timestamp;
128
+
if (timestamp - lastFrameTimestamp > 100) {
129
+
lastFrameTimestamp = timestamp
132
+
window.requestAnimationFrame(onAnimationFrame);
135
+
function setSprite(name, frame) {
136
+
const sprite = spriteSets[name][frame % spriteSets[name].length];
137
+
nekoEl.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`;
140
+
function resetIdleAnimation() {
141
+
idleAnimation = null;
142
+
idleAnimationFrame = 0;
148
+
// every ~ 20 seconds
151
+
Math.floor(Math.random() * 200) == 0 &&
152
+
idleAnimation == null
154
+
let avalibleIdleAnimations = ["sleeping", "scratchSelf"];
155
+
if (nekoPosX < 32) {
156
+
avalibleIdleAnimations.push("scratchWallW");
158
+
if (nekoPosY < 32) {
159
+
avalibleIdleAnimations.push("scratchWallN");
161
+
if (nekoPosX > window.innerWidth - 32) {
162
+
avalibleIdleAnimations.push("scratchWallE");
164
+
if (nekoPosY > window.innerHeight - 32) {
165
+
avalibleIdleAnimations.push("scratchWallS");
168
+
avalibleIdleAnimations[
169
+
Math.floor(Math.random() * avalibleIdleAnimations.length)
173
+
switch (idleAnimation) {
175
+
if (idleAnimationFrame < 8) {
176
+
setSprite("tired", 0);
179
+
setSprite("sleeping", Math.floor(idleAnimationFrame / 4));
180
+
if (idleAnimationFrame > 192) {
181
+
resetIdleAnimation();
184
+
case "scratchWallN":
185
+
case "scratchWallS":
186
+
case "scratchWallE":
187
+
case "scratchWallW":
188
+
case "scratchSelf":
189
+
setSprite(idleAnimation, idleAnimationFrame);
190
+
if (idleAnimationFrame > 9) {
191
+
resetIdleAnimation();
195
+
setSprite("idle", 0);
198
+
idleAnimationFrame += 1;
201
+
function explodeHearts() {
202
+
const parent = nekoEl.parentElement;
203
+
const rect = nekoEl.getBoundingClientRect();
204
+
const scrollLeft = window.scrollX || document.documentElement.scrollLeft;
205
+
const scrollTop = window.scrollY || document.documentElement.scrollTop;
206
+
const centerX = rect.left + rect.width / 2 + scrollLeft;
207
+
const centerY = rect.top + rect.height / 2 + scrollTop;
209
+
for (let i = 0; i < 10; i++) {
210
+
const heart = document.createElement('div');
211
+
heart.className = 'heart';
212
+
heart.textContent = '❤';
213
+
const offsetX = (Math.random() - 0.5) * 50;
214
+
const offsetY = (Math.random() - 0.5) * 50;
215
+
heart.style.left = `${centerX + offsetX - 16}px`;
216
+
heart.style.top = `${centerY + offsetY - 16}px`;
217
+
heart.style.transform = `translate(-50%, -50%) rotate(${Math.random() * 360}deg)`;
218
+
parent.appendChild(heart);
221
+
parent.removeChild(heart);
226
+
const style = document.createElement('style');
227
+
style.innerHTML = `
228
+
@keyframes heartBurst {
229
+
0% { transform: scale(0); opacity: 1; }
230
+
100% { transform: scale(1); opacity: 0; }
233
+
position: absolute;
235
+
animation: heartBurst 1s ease-out;
236
+
animation-fill-mode: forwards;
241
+
document.head.appendChild(style);
242
+
nekoEl.addEventListener('click', explodeHearts);
246
+
const diffX = nekoPosX - mousePosX;
247
+
const diffY = nekoPosY - mousePosY;
248
+
const distance = Math.sqrt(diffX ** 2 + diffY ** 2);
250
+
if (distance < nekoSpeed || distance < 48) {
255
+
idleAnimation = null;
256
+
idleAnimationFrame = 0;
258
+
if (idleTime > 1) {
259
+
setSprite("alert", 0);
260
+
// count down after being alerted before moving
261
+
idleTime = Math.min(idleTime, 7);
267
+
direction = diffY / distance > 0.5 ? "N" : "";
268
+
direction += diffY / distance < -0.5 ? "S" : "";
269
+
direction += diffX / distance > 0.5 ? "W" : "";
270
+
direction += diffX / distance < -0.5 ? "E" : "";
271
+
setSprite(direction, frameCount);
273
+
nekoPosX -= (diffX / distance) * nekoSpeed;
274
+
nekoPosY -= (diffY / distance) * nekoSpeed;
276
+
nekoPosX = Math.min(Math.max(16, nekoPosX), window.innerWidth - 16);
277
+
nekoPosY = Math.min(Math.max(16, nekoPosY), window.innerHeight - 16);
279
+
nekoEl.style.left = `${nekoPosX - 16}px`;
280
+
nekoEl.style.top = `${nekoPosY - 16}px`;