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