// oneko.js: https://github.com/adryd325/oneko.js export function initOneko(): void { 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 persistPosition = true; let nekoPosX = 32; let nekoPosY = 32; let mousePosX = 0; let mousePosY = 0; let frameCount = 0; let idleTime = 0; let idleAnimation: string | null = null; let idleAnimationFrame = 0; const nekoSpeed = 10; const spriteSets: Record = { 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 init(): void { const assetBase = window.ASSET_BASE || ''; let nekoFile = `${assetBase}/oneko/oneko.gif`; const curScript = document.currentScript as HTMLScriptElement; if (curScript?.dataset.cat) { nekoFile = curScript.dataset.cat; } if (curScript?.dataset.persistPosition) { if (curScript.dataset.persistPosition === '') { persistPosition = true; } else { persistPosition = JSON.parse(curScript.dataset.persistPosition.toLowerCase()); } } if (persistPosition) { const storedNekoStr = window.localStorage.getItem('oneko'); const storedNeko = storedNekoStr ? JSON.parse(storedNekoStr) : null; 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'; nekoEl.style.backgroundImage = `url(${nekoFile})`; document.body.appendChild(nekoEl); document.addEventListener('mousemove', (event: MouseEvent) => { mousePosX = event.clientX; mousePosY = event.clientY; }); if (persistPosition) { window.addEventListener('beforeunload', () => { 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: number | undefined; function onAnimationFrame(timestamp: number): void { if (!nekoEl.isConnected) { return; } if (!lastFrameTimestamp) { lastFrameTimestamp = timestamp; } if (lastFrameTimestamp && timestamp - lastFrameTimestamp > 100) { lastFrameTimestamp = timestamp; frame(); } window.requestAnimationFrame(onAnimationFrame); } function setSprite(name: string, frame: number): void { const sprite = spriteSets[name][frame % spriteSets[name].length]; nekoEl.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`; } function resetIdleAnimation(): void { idleAnimation = null; idleAnimationFrame = 0; } function idle(): void { idleTime += 1; // every ~ 20 seconds if (idleTime > 10 && Math.floor(Math.random() * 200) === 0 && idleAnimation == null) { const avalibleIdleAnimations: string[] = ['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(): void { 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); // count down after being alerted before moving 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(); }