// oneko.js: https://github.com/adryd325/oneko.js type SpriteOffset = readonly [number, number]; type Direction = 'N' | 'NE' | 'E' | 'SE' | 'S' | 'SW' | 'W' | 'NW'; type IdleAnimation = | 'sleeping' | 'scratchSelf' | 'scratchWallN' | 'scratchWallS' | 'scratchWallE' | 'scratchWallW'; type SavedState = { nekoPosX: number; nekoPosY: number; mousePosX: number; mousePosY: number; frameCount: number; idleTime: number; idleAnimation: IdleAnimation | null; idleAnimationFrame: number; bgPos: string; }; const NEKO_STORAGE_KEY = 'oneko'; const FRAME_INTERVAL_MS = 100; const NEKO_SIZE_PX = 32; const NEKO_HALF_SIZE_PX = NEKO_SIZE_PX / 2; const NEKO_SPEED_PX = 10; const IDLE_DISTANCE_THRESHOLD_PX = 48; 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], ], } as const satisfies Record; type SpriteName = keyof typeof spriteSets; function prefersReducedMotion(): boolean { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(min, value), max); } function safeJsonParse(value: string | null): T | null { if (!value) return null; try { return JSON.parse(value) as T; } catch { return null; } } function parseBooleanAttribute(value: string): boolean | null { const normalized = value.trim().toLowerCase(); if (normalized === 'true') return true; if (normalized === 'false') return false; return null; } function sample(items: readonly T[]): T { return items[Math.floor(Math.random() * items.length)]; } function directionFromVector(dx: number, dy: number): Direction { // Screen coordinates: +x is right, +y is down. // atan2 gives: 0 = E, π/2 = S, π = W, -π/2 = N. const angle = Math.atan2(dy, dx); const octant = (Math.round((8 * angle) / (2 * Math.PI)) + 8) % 8; switch (octant) { case 0: return 'E'; case 1: return 'SE'; case 2: return 'S'; case 3: return 'SW'; case 4: return 'W'; case 5: return 'NW'; case 6: return 'N'; case 7: return 'NE'; default: return 'E'; } } export function initOneko(): void { if (prefersReducedMotion()) return; const nekoEl = document.createElement('div'); let persistPosition = true; let nekoPosX = NEKO_SIZE_PX; let nekoPosY = NEKO_SIZE_PX; let mousePosX = 0; let mousePosY = 0; let frameCount = 0; let idleTime = 0; let idleAnimation: IdleAnimation | null = null; let idleAnimationFrame = 0; function setSprite(name: SpriteName, frame: number): void { const sprite = spriteSets[name][frame % spriteSets[name].length]; nekoEl.style.backgroundPosition = `${sprite[0] * NEKO_SIZE_PX}px ${sprite[1] * NEKO_SIZE_PX}px`; } function resetIdleAnimation(): void { idleAnimation = null; idleAnimationFrame = 0; } function loadPersistedState(): void { if (!persistPosition) return; const stored = safeJsonParse>( window.localStorage.getItem(NEKO_STORAGE_KEY) ); if (!stored) return; if (typeof stored.nekoPosX === 'number') nekoPosX = stored.nekoPosX; if (typeof stored.nekoPosY === 'number') nekoPosY = stored.nekoPosY; if (typeof stored.mousePosX === 'number') mousePosX = stored.mousePosX; if (typeof stored.mousePosY === 'number') mousePosY = stored.mousePosY; if (typeof stored.frameCount === 'number') frameCount = stored.frameCount; if (typeof stored.idleTime === 'number') idleTime = stored.idleTime; if (typeof stored.idleAnimationFrame === 'number') idleAnimationFrame = stored.idleAnimationFrame; if (stored.idleAnimation === null) { idleAnimation = null; } else if (typeof stored.idleAnimation === 'string') { const allowed: IdleAnimation[] = [ 'sleeping', 'scratchSelf', 'scratchWallN', 'scratchWallS', 'scratchWallE', 'scratchWallW', ]; if (allowed.includes(stored.idleAnimation as IdleAnimation)) { idleAnimation = stored.idleAnimation as IdleAnimation; } } if (typeof stored.bgPos === 'string') { nekoEl.style.backgroundPosition = stored.bgPos; } } function saveState(): void { if (!persistPosition) return; const state: SavedState = { nekoPosX, nekoPosY, mousePosX, mousePosY, frameCount, idleTime, idleAnimation, idleAnimationFrame, bgPos: nekoEl.style.backgroundPosition, }; window.localStorage.setItem(NEKO_STORAGE_KEY, JSON.stringify(state)); } function idle(): void { idleTime += 1; // Every ~20 seconds, pick an idle animation. if (idleTime > 10 && Math.floor(Math.random() * 200) === 0 && idleAnimation === null) { const availableIdleAnimations: IdleAnimation[] = ['sleeping', 'scratchSelf']; if (nekoPosX < NEKO_SIZE_PX) { availableIdleAnimations.push('scratchWallW'); } if (nekoPosY < NEKO_SIZE_PX) { availableIdleAnimations.push('scratchWallN'); } if (nekoPosX > window.innerWidth - NEKO_SIZE_PX) { availableIdleAnimations.push('scratchWallE'); } if (nekoPosY > window.innerHeight - NEKO_SIZE_PX) { availableIdleAnimations.push('scratchWallS'); } idleAnimation = sample(availableIdleAnimations); } 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 < IDLE_DISTANCE_THRESHOLD_PX || distance < NEKO_SPEED_PX) { idle(); return; } resetIdleAnimation(); if (idleTime > 1) { setSprite('alert', 0); // Count down after being alerted before moving. idleTime = Math.min(idleTime, 7); idleTime -= 1; return; } const targetDx = mousePosX - nekoPosX; const targetDy = mousePosY - nekoPosY; const direction = directionFromVector(targetDx, targetDy); setSprite(direction, frameCount); nekoPosX -= (diffX / distance) * NEKO_SPEED_PX; nekoPosY -= (diffY / distance) * NEKO_SPEED_PX; nekoPosX = clamp(nekoPosX, NEKO_HALF_SIZE_PX, window.innerWidth - NEKO_HALF_SIZE_PX); nekoPosY = clamp(nekoPosY, NEKO_HALF_SIZE_PX, window.innerHeight - NEKO_HALF_SIZE_PX); nekoEl.style.left = `${nekoPosX - NEKO_HALF_SIZE_PX}px`; nekoEl.style.top = `${nekoPosY - NEKO_HALF_SIZE_PX}px`; } function init(): void { const assetBase = window.ASSET_BASE ?? ''; let nekoFile = `${assetBase}/oneko/oneko.gif`; const curScript = document.currentScript as HTMLScriptElement | null; if (curScript?.dataset.cat) { nekoFile = curScript.dataset.cat; } const persistPositionAttr = curScript?.dataset.persistPosition; if (persistPositionAttr !== undefined) { if (persistPositionAttr === '') { persistPosition = true; } else { const parsed = parseBooleanAttribute(persistPositionAttr); if (parsed !== null) { persistPosition = parsed; } } } nekoEl.id = 'oneko'; nekoEl.ariaHidden = 'true'; nekoEl.style.width = `${NEKO_SIZE_PX}px`; nekoEl.style.height = `${NEKO_SIZE_PX}px`; nekoEl.style.position = 'fixed'; nekoEl.style.pointerEvents = 'none'; nekoEl.style.imageRendering = 'pixelated'; nekoEl.style.zIndex = '2147483647'; nekoEl.style.backgroundImage = `url(${nekoFile})`; loadPersistedState(); nekoEl.style.left = `${nekoPosX - NEKO_HALF_SIZE_PX}px`; nekoEl.style.top = `${nekoPosY - NEKO_HALF_SIZE_PX}px`; document.body.appendChild(nekoEl); document.addEventListener('mousemove', (event: MouseEvent) => { mousePosX = event.clientX; mousePosY = event.clientY; }); if (persistPosition) { window.addEventListener('beforeunload', saveState); } window.requestAnimationFrame(onAnimationFrame); } let lastFrameTimestamp: number | undefined; function onAnimationFrame(timestamp: number): void { if (!nekoEl.isConnected) return; if (lastFrameTimestamp === undefined) { lastFrameTimestamp = timestamp; } if (timestamp - lastFrameTimestamp >= FRAME_INTERVAL_MS) { lastFrameTimestamp = timestamp; frame(); } window.requestAnimationFrame(onAnimationFrame); } init(); }