oneko.tsx

1'use client'
2
3import { useEffect } from 'react'
4
5export const Oneko = () => {
6	useEffect(() => {
7		// Check for reduced motion preference
8		const isReducedMotion =
9			window.matchMedia(`(prefers-reduced-motion: reduce)`).matches === true
10
11		if (isReducedMotion) return
12
13		const nekoEl = document.createElement('div')
14		let nekoPosX = 32
15		let nekoPosY = 32
16		let mousePosX = 0
17		let mousePosY = 0
18		let frameCount = 0
19		let idleTime = 0
20		let idleAnimation: string | null = null
21		let idleAnimationFrame = 0
22		let isPetting = false
23		let petTime = 0
24		const nekoSpeed = 10
25
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			pet: [
88				[-2, -1],
89				[-2, 0],
90			],
91			happy: [
92				[-3, -3],
93				[-2, -1],
94			],
95		}
96
97		function createHeart(x: number, y: number) {
98			const heart = document.createElement('div')
99			heart.innerHTML = 'ā¤ļø'
100			heart.style.position = 'fixed'
101			heart.style.left = `${x}px`
102			heart.style.top = `${y}px`
103			heart.style.pointerEvents = 'none'
104			heart.style.userSelect = 'none'
105			heart.style.zIndex = '1000'
106			heart.style.fontSize = '16px'
107			heart.style.transition = 'all 1s ease-out'
108			document.body.appendChild(heart)
109
110			setTimeout(() => {
111				heart.style.transform = `translate(${Math.random() * 40 - 20}px, -40px)`
112				heart.style.opacity = '0'
113			}, 50)
114
115			setTimeout(() => heart.remove(), 1000)
116		}
117
118		function setSprite(name: keyof typeof spriteSets, frame: number) {
119			const sprite = spriteSets[name][frame % spriteSets[name].length]
120			nekoEl.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`
121		}
122
123		function resetIdleAnimation() {
124			idleAnimation = null
125			idleAnimationFrame = 0
126		}
127
128		function handlePetting() {
129			isPetting = true
130			petTime = 0
131			for (let i = 0; i < 3; i++) {
132				setTimeout(() => {
133					createHeart(
134						nekoPosX - 8 + Math.random() * 32,
135						nekoPosY - 16 + Math.random() * 32,
136					)
137				}, i * 100)
138			}
139		}
140
141		function idle() {
142			if (isPetting) {
143				petTime += 1
144				setSprite(petTime % 3 === 0 ? 'happy' : 'pet', frameCount)
145				if (petTime > 20) {
146					isPetting = false
147					petTime = 0
148				}
149				return
150			}
151
152			idleTime += 1
153
154			// Start sleeping animation after 3 seconds (30 frames at 100ms per frame)
155			if (idleTime > 30 && idleAnimation == null) {
156				idleAnimation = 'sleeping'
157			}
158
159			switch (idleAnimation) {
160				case 'sleeping':
161					if (idleAnimationFrame < 8) {
162						setSprite('tired', 0)
163						break
164					}
165					setSprite('sleeping', Math.floor(idleAnimationFrame / 4))
166					// Remove the time limit for sleeping animation
167					break
168				case 'scratchWallN':
169				case 'scratchWallS':
170				case 'scratchWallE':
171				case 'scratchWallW':
172				case 'scratchSelf':
173					setSprite(idleAnimation, idleAnimationFrame)
174					if (idleAnimationFrame > 9) {
175						resetIdleAnimation()
176					}
177					break
178				default:
179					setSprite('idle', 0)
180					return
181			}
182			idleAnimationFrame += 1
183		}
184
185		function frame() {
186			frameCount += 1
187			const diffX = nekoPosX - mousePosX
188			const diffY = nekoPosY - mousePosY
189			const distance = Math.sqrt(diffX ** 2 + diffY ** 2)
190
191			if (distance < nekoSpeed || distance < 48) {
192				idle()
193				return
194			}
195
196			idleAnimation = null
197			idleAnimationFrame = 0
198
199			if (idleTime > 1) {
200				setSprite('alert', 0)
201				idleTime = Math.min(idleTime, 7)
202				idleTime -= 1
203				return
204			}
205
206			let direction = ''
207			direction += diffY / distance > 0.5 ? 'N' : ''
208			direction += diffY / distance < -0.5 ? 'S' : ''
209			direction += diffX / distance > 0.5 ? 'W' : ''
210			direction += diffX / distance < -0.5 ? 'E' : ''
211
212			setSprite(direction as keyof typeof spriteSets, frameCount)
213
214			nekoPosX -= (diffX / distance) * nekoSpeed
215			nekoPosY -= (diffY / distance) * nekoSpeed
216
217			nekoPosX = Math.min(Math.max(16, nekoPosX), window.innerWidth - 16)
218			nekoPosY = Math.min(Math.max(16, nekoPosY), window.innerHeight - 16)
219
220			nekoEl.style.left = `${nekoPosX - 16}px`
221			nekoEl.style.top = `${nekoPosY - 16}px`
222		}
223
224		nekoEl.id = 'oneko'
225		nekoEl.style.width = '32px'
226		nekoEl.style.height = '32px'
227		nekoEl.style.position = 'fixed'
228		nekoEl.style.pointerEvents = 'auto'
229		nekoEl.style.cursor = 'pointer'
230		nekoEl.style.backgroundImage = 'url(/oneko.gif)'
231		nekoEl.style.imageRendering = 'pixelated'
232		nekoEl.style.zIndex = '999'
233		nekoEl.style.left = `${nekoPosX - 16}px`
234		nekoEl.style.top = `${nekoPosY - 16}px`
235
236		nekoEl.addEventListener('click', handlePetting)
237
238		document.body.appendChild(nekoEl)
239
240		document.addEventListener('mousemove', (e) => {
241			mousePosX = e.clientX
242			mousePosY = e.clientY
243		})
244
245		let lastFrameTimestamp: number | undefined
246
247		function onAnimationFrame(timestamp: number) {
248			if (!nekoEl.isConnected) {
249				return
250			}
251
252			if (!lastFrameTimestamp) {
253				lastFrameTimestamp = timestamp
254			}
255
256			if (timestamp - lastFrameTimestamp > 100) {
257				lastFrameTimestamp = timestamp
258				frame()
259			}
260
261			window.requestAnimationFrame(onAnimationFrame)
262		}
263
264		window.requestAnimationFrame(onAnimationFrame)
265
266		return () => {
267			nekoEl.removeEventListener('click', handlePetting)
268			nekoEl.remove()
269		}
270	}, [])
271
272	return null
273}
274