From 0a43eeb4a210540e3b3379db1df730b00050a866 Mon Sep 17 00:00:00 2001 From: Poyraz Avsever Date: Wed, 11 Mar 2026 11:59:58 +0300 Subject: [PATCH] feat(ui): add cursor-following fire neko --- app/layout.tsx | 2 + components/neko-follower.tsx | 371 +++++++++++++++++++++++++++++++++++ public/cat/fire.png | Bin 0 -> 6577 bytes 3 files changed, 373 insertions(+) create mode 100644 components/neko-follower.tsx create mode 100644 public/cat/fire.png diff --git a/app/layout.tsx b/app/layout.tsx index 0cc9229..46a0764 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import "./globals.css"; import { SiteNavbar } from "@/components/site-navbar"; +import { NekoFollower } from "@/components/neko-follower"; import { ANNOUNCEMENT_ITEMS } from "@/data/site-settings"; import { Icon } from "@iconify/react"; import Link from "next/link"; @@ -52,6 +53,7 @@ export default function RootLayout({ return ( +
{announcement ? ( diff --git a/components/neko-follower.tsx b/components/neko-follower.tsx new file mode 100644 index 0000000..c2970b5 --- /dev/null +++ b/components/neko-follower.tsx @@ -0,0 +1,371 @@ +"use client"; + +import { useEffect } from "react"; + +const NEKO_WIDTH = 32; +const NEKO_HEIGHT = 32; +const NEKO_HALF_WIDTH = NEKO_WIDTH / 2; +const NEKO_HALF_HEIGHT = NEKO_HEIGHT / 2; +const NEKO_SPEED = 20; +const FRAME_RATE = 300; +const Z_INDEX = Number.MAX_SAFE_INTEGER; +const ALERT_TIME = 3; +const IDLE_THRESHOLD = 3; +const IDLE_ANIMATION_CHANCE = 1 / 20; +const MIN_DISTANCE = 10; +const SPRITE_GAP = 1; +const BACKGROUND_TARGET_COLOR: [number, number, number] = [0, 174, 240]; + +type SpriteSet = Record; + +class Neko { + private posX: number; + private posY: number; + private mouseX: number; + private mouseY: number; + private frameCount: number; + private idleTime: number; + private idleAnimation: string | null; + private idleAnimationFrame: number; + private alertTimeRemaining: number; + private nekoElement: HTMLDivElement | null; + private lastFrameTimestamp: number | null; + private animationFrameId: number | null; + private readonly isReducedMotion: boolean; + private readonly nekoImageUrl: string; + private readonly nekoName: string; + private readonly spriteSets: SpriteSet; + + constructor({ nekoName, nekoImageUrl }: { nekoName: string; nekoImageUrl: string }) { + const margin = 16; + + this.nekoName = nekoName; + this.nekoImageUrl = nekoImageUrl; + this.posX = Math.max(NEKO_HALF_WIDTH, window.innerWidth - NEKO_HALF_WIDTH - margin); + this.posY = Math.max(NEKO_HALF_HEIGHT, window.innerHeight - NEKO_HALF_HEIGHT - margin); + this.mouseX = this.posX; + this.mouseY = this.posY; + this.frameCount = 0; + this.idleTime = 0; + this.idleAnimation = null; + this.idleAnimationFrame = 0; + this.alertTimeRemaining = 0; + this.nekoElement = null; + this.lastFrameTimestamp = null; + this.animationFrameId = null; + this.isReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + this.spriteSets = { + idle: [[0, 0]], + alert: [[7, 0]], + lickPaw: [[1, 0]], + scratchSelf: [ + [2, 0], + [3, 0], + ], + tired: [[4, 0]], + sleeping: [ + [5, 0], + [6, 0], + ], + S: [ + [0, 1], + [1, 1], + ], + SE: [ + [2, 1], + [3, 1], + ], + E: [ + [4, 1], + [5, 1], + ], + NE: [ + [6, 1], + [7, 1], + ], + N: [ + [0, 2], + [1, 2], + ], + NW: [ + [2, 2], + [3, 2], + ], + W: [ + [4, 2], + [5, 2], + ], + SW: [ + [6, 2], + [7, 2], + ], + }; + } + + init() { + if (this.isReducedMotion) return; + if (document.getElementById(this.nekoName)) return; + + void this.createNekoElement(); + this.addEventListeners(); + this.animationLoop(); + } + + static async makeTransparent( + imageUrl: string, + targetColor: [number, number, number], + ): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.src = imageUrl; + img.crossOrigin = "Anonymous"; + + img.onload = () => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + reject(new Error("Canvas not supported")); + return; + } + + canvas.width = img.width; + canvas.height = img.height; + ctx.drawImage(img, 0, 0); + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + + for (let i = 0; i < data.length; i += 4) { + if ( + data[i] === targetColor[0] && + data[i + 1] === targetColor[1] && + data[i + 2] === targetColor[2] + ) { + data[i + 3] = 0; + } + } + + ctx.putImageData(imageData, 0, 0); + resolve(canvas.toDataURL("image/png")); + }; + + img.onerror = () => reject(new Error("Image load failed")); + }); + } + + private async createNekoElement() { + const element = document.createElement("div"); + + element.id = this.nekoName; + element.ariaHidden = "true"; + element.style.width = `${NEKO_WIDTH}px`; + element.style.height = `${NEKO_HEIGHT}px`; + element.style.position = "fixed"; + element.style.pointerEvents = "none"; + element.style.imageRendering = "pixelated"; + element.style.left = `${this.posX - NEKO_HALF_WIDTH}px`; + element.style.top = `${this.posY - NEKO_HALF_HEIGHT}px`; + element.style.zIndex = Z_INDEX.toString(); + element.style.backgroundImage = `url("${this.nekoImageUrl}")`; + + try { + const transparentImageUrl = await Neko.makeTransparent( + this.nekoImageUrl, + BACKGROUND_TARGET_COLOR, + ); + element.style.backgroundImage = `url("${transparentImageUrl}")`; + } catch (error) { + console.error("Neko image process failed", error); + } + + this.nekoElement = element; + document.body.appendChild(element); + this.setSprite("idle", 0); + this.render(); + } + + private handleMouseMove = (event: MouseEvent) => { + this.mouseX = event.clientX; + this.mouseY = event.clientY; + }; + + private handleResize = () => { + this.clampToViewport(); + this.render(); + }; + + private addEventListeners() { + document.addEventListener("mousemove", this.handleMouseMove); + window.addEventListener("resize", this.handleResize); + } + + private animationLoop() { + const loop = (timestamp: number) => { + if (this.lastFrameTimestamp === null) { + this.lastFrameTimestamp = timestamp; + } + + const delta = timestamp - this.lastFrameTimestamp; + if (delta > FRAME_RATE) { + this.lastFrameTimestamp = timestamp; + this.updateState(); + this.render(); + } + + this.animationFrameId = window.requestAnimationFrame(loop); + }; + + this.animationFrameId = window.requestAnimationFrame(loop); + } + + private updateState() { + this.frameCount += 1; + this.followMouse(); + } + + private followMouse() { + const diffX = this.posX - this.mouseX; + const diffY = this.posY - this.mouseY; + const distance = Math.hypot(diffX, diffY); + + if (distance < MIN_DISTANCE) { + this.idleBehavior(); + return; + } + + if (this.idleTime > IDLE_THRESHOLD && this.alertTimeRemaining === 0) { + this.alertTimeRemaining = ALERT_TIME; + } + + if (this.alertTimeRemaining > 0) { + this.setSprite("alert", 0); + this.alertTimeRemaining -= 1; + this.idleTime = 0; + return; + } + + this.idleTime = 0; + this.idleAnimation = null; + this.idleAnimationFrame = 0; + + 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" : ""; + this.setSprite(direction || "idle", this.frameCount); + + const step = Math.min(NEKO_SPEED, distance); + this.posX -= (diffX / distance) * step; + this.posY -= (diffY / distance) * step; + this.clampToViewport(); + } + + private idleBehavior() { + this.idleTime += 1; + + if ( + this.idleTime > IDLE_THRESHOLD && + Math.random() < IDLE_ANIMATION_CHANCE && + this.idleAnimation === null + ) { + const options = ["sleeping", "scratchSelf", "lickPaw"] as const; + this.idleAnimation = options[Math.floor(Math.random() * options.length)] ?? null; + this.idleAnimationFrame = 0; + } + + switch (this.idleAnimation) { + case "sleeping": + if (this.idleAnimationFrame < 8) { + this.setSprite("tired", 0); + } else if (this.idleAnimationFrame < 16) { + this.setSprite("idle", 0); + } else { + this.setSprite("sleeping", Math.floor(this.idleAnimationFrame / 4)); + } + if (this.idleAnimationFrame > 64) { + this.resetIdleAnimation(); + } + break; + case "lickPaw": + case "scratchSelf": + this.setSprite(this.idleAnimation, this.idleAnimationFrame); + if (this.idleAnimationFrame > 8) { + this.resetIdleAnimation(); + } + break; + default: + this.setSprite("idle", 0); + break; + } + + this.idleAnimationFrame += 1; + } + + private resetIdleAnimation() { + this.idleAnimation = null; + this.idleAnimationFrame = 0; + } + + private clampToViewport() { + this.posX = Math.min( + Math.max(NEKO_HALF_WIDTH, this.posX), + window.innerWidth - NEKO_HALF_WIDTH, + ); + this.posY = Math.min( + Math.max(NEKO_HALF_HEIGHT, this.posY), + window.innerHeight - NEKO_HALF_HEIGHT, + ); + } + + private render() { + if (!this.nekoElement) return; + this.nekoElement.style.left = `${this.posX - NEKO_HALF_WIDTH}px`; + this.nekoElement.style.top = `${this.posY - NEKO_HALF_HEIGHT}px`; + } + + private setSprite(name: string, frame: number) { + if (!this.nekoElement) return; + const spriteSet = this.spriteSets[name]; + if (!spriteSet || spriteSet.length === 0) return; + + const sprite = spriteSet[frame % spriteSet.length]; + if (!sprite) return; + + const posX = sprite[0] * (NEKO_WIDTH + SPRITE_GAP); + const posY = sprite[1] * (NEKO_HEIGHT + SPRITE_GAP); + this.nekoElement.style.backgroundPosition = `-${posX}px -${posY}px`; + } + + destroy() { + if (this.animationFrameId !== null) { + window.cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + + document.removeEventListener("mousemove", this.handleMouseMove); + window.removeEventListener("resize", this.handleResize); + + if (this.nekoElement) { + this.nekoElement.remove(); + this.nekoElement = null; + } + } +} + +export function NekoFollower() { + useEffect(() => { + const neko = new Neko({ + nekoName: "fire-neko", + nekoImageUrl: "/cat/fire.png", + }); + + neko.init(); + return () => neko.destroy(); + }, []); + + return null; +} + diff --git a/public/cat/fire.png b/public/cat/fire.png new file mode 100644 index 0000000000000000000000000000000000000000..e9697d2064bb69c0392f580275eb7dcc109915c0 GIT binary patch literal 6577 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