this repo has no description
1// oneko.js: https://github.com/adryd325/oneko.js
2// petable version from https://github.com/tylxr59/oneko.js
3
4(function oneko() {
5 const isReducedMotion =
6 window.matchMedia(`(prefers-reduced-motion: reduce)`) === true ||
7 window.matchMedia(`(prefers-reduced-motion: reduce)`).matches === true;
8
9 if (isReducedMotion) return;
10
11 const nekoEl = document.createElement("div");
12
13 let nekoPosX = 32;
14 let nekoPosY = 32;
15
16 let mousePosX = 0;
17 let mousePosY = 0;
18
19 let frameCount = 0;
20 let idleTime = 0;
21 let idleAnimation = null;
22 let idleAnimationFrame = 0;
23
24 // up neko speed from default 10
25 const nekoSpeed = 15;
26 const spriteSets = {
27 idle: [[-3, -3]],
28 alert: [[-7, -3]],
29 scratchSelf: [
30 [-5, 0],
31 [-6, 0],
32 [-7, 0],
33 ],
34 scratchWallN: [
35 [0, 0],
36 [0, -1],
37 ],
38 scratchWallS: [
39 [-7, -1],
40 [-6, -2],
41 ],
42 scratchWallE: [
43 [-2, -2],
44 [-2, -3],
45 ],
46 scratchWallW: [
47 [-4, 0],
48 [-4, -1],
49 ],
50 tired: [[-3, -2]],
51 sleeping: [
52 [-2, 0],
53 [-2, -1],
54 ],
55 N: [
56 [-1, -2],
57 [-1, -3],
58 ],
59 NE: [
60 [0, -2],
61 [0, -3],
62 ],
63 E: [
64 [-3, 0],
65 [-3, -1],
66 ],
67 SE: [
68 [-5, -1],
69 [-5, -2],
70 ],
71 S: [
72 [-6, -3],
73 [-7, -2],
74 ],
75 SW: [
76 [-5, -3],
77 [-6, -1],
78 ],
79 W: [
80 [-4, -2],
81 [-4, -3],
82 ],
83 NW: [
84 [-1, 0],
85 [-1, -1],
86 ],
87 };
88
89 function init() {
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;
100
101 let nekoFile = "./oneko.gif"
102 const curScript = document.currentScript
103 if (curScript && curScript.dataset.cat) {
104 nekoFile = curScript.dataset.cat
105 }
106 nekoEl.style.backgroundImage = `url(${nekoFile})`;
107
108 document.body.appendChild(nekoEl);
109
110 document.addEventListener("mousemove", function (event) {
111 mousePosX = event.clientX;
112 mousePosY = event.clientY;
113 });
114
115 window.requestAnimationFrame(onAnimationFrame);
116 }
117
118 let lastFrameTimestamp;
119
120 function onAnimationFrame(timestamp) {
121 // Stops execution if the neko element is removed from DOM
122 if (!nekoEl.isConnected) {
123 return;
124 }
125 if (!lastFrameTimestamp) {
126 lastFrameTimestamp = timestamp;
127 }
128 if (timestamp - lastFrameTimestamp > 100) {
129 lastFrameTimestamp = timestamp
130 frame()
131 }
132 window.requestAnimationFrame(onAnimationFrame);
133 }
134
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`;
138 }
139
140 function resetIdleAnimation() {
141 idleAnimation = null;
142 idleAnimationFrame = 0;
143 }
144
145 function idle() {
146 idleTime += 1;
147
148 // every ~ 20 seconds
149 if (
150 idleTime > 10 &&
151 Math.floor(Math.random() * 200) == 0 &&
152 idleAnimation == null
153 ) {
154 let avalibleIdleAnimations = ["sleeping", "scratchSelf"];
155 if (nekoPosX < 32) {
156 avalibleIdleAnimations.push("scratchWallW");
157 }
158 if (nekoPosY < 32) {
159 avalibleIdleAnimations.push("scratchWallN");
160 }
161 if (nekoPosX > window.innerWidth - 32) {
162 avalibleIdleAnimations.push("scratchWallE");
163 }
164 if (nekoPosY > window.innerHeight - 32) {
165 avalibleIdleAnimations.push("scratchWallS");
166 }
167 idleAnimation =
168 avalibleIdleAnimations[
169 Math.floor(Math.random() * avalibleIdleAnimations.length)
170 ];
171 }
172
173 switch (idleAnimation) {
174 case "sleeping":
175 if (idleAnimationFrame < 8) {
176 setSprite("tired", 0);
177 break;
178 }
179 setSprite("sleeping", Math.floor(idleAnimationFrame / 4));
180 if (idleAnimationFrame > 192) {
181 resetIdleAnimation();
182 }
183 break;
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();
192 }
193 break;
194 default:
195 setSprite("idle", 0);
196 return;
197 }
198 idleAnimationFrame += 1;
199 }
200
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;
208
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);
219
220 setTimeout(() => {
221 parent.removeChild(heart);
222 }, 1000);
223 }
224 }
225
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; }
231 }
232 .heart {
233 position: absolute;
234 font-size: 2em;
235 animation: heartBurst 1s ease-out;
236 animation-fill-mode: forwards;
237 color: #ab9df2;
238 }
239 `;
240
241 document.head.appendChild(style);
242 nekoEl.addEventListener('click', explodeHearts);
243
244 function frame() {
245 frameCount += 1;
246 const diffX = nekoPosX - mousePosX;
247 const diffY = nekoPosY - mousePosY;
248 const distance = Math.sqrt(diffX ** 2 + diffY ** 2);
249
250 if (distance < nekoSpeed || distance < 48) {
251 idle();
252 return;
253 }
254
255 idleAnimation = null;
256 idleAnimationFrame = 0;
257
258 if (idleTime > 1) {
259 setSprite("alert", 0);
260 // count down after being alerted before moving
261 idleTime = Math.min(idleTime, 7);
262 idleTime -= 1;
263 return;
264 }
265
266 let direction;
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);
272
273 nekoPosX -= (diffX / distance) * nekoSpeed;
274 nekoPosY -= (diffY / distance) * nekoSpeed;
275
276 nekoPosX = Math.min(Math.max(16, nekoPosX), window.innerWidth - 16);
277 nekoPosY = Math.min(Math.max(16, nekoPosY), window.innerHeight - 16);
278
279 nekoEl.style.left = `${nekoPosX - 16}px`;
280 nekoEl.style.top = `${nekoPosY - 16}px`;
281 }
282
283 init();
284})();