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 0000000..e9697d2 Binary files /dev/null and b/public/cat/fire.png differ