162 lines
4.4 KiB
TypeScript
162 lines
4.4 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef } from 'react'
|
|
|
|
interface Spore {
|
|
x: number
|
|
y: number
|
|
vx: number
|
|
vy: number
|
|
life: number
|
|
maxLife: number
|
|
size: number
|
|
}
|
|
|
|
export function CursorEffect() {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
const sporesRef = useRef<Spore[]>([])
|
|
const mouseRef = useRef({ x: 0, y: 0 })
|
|
const frameRef = useRef<number>(0)
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return
|
|
|
|
const resize = () => {
|
|
canvas.width = window.innerWidth
|
|
canvas.height = window.innerHeight
|
|
}
|
|
resize()
|
|
window.addEventListener('resize', resize)
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
const dx = e.clientX - mouseRef.current.x
|
|
const dy = e.clientY - mouseRef.current.y
|
|
const speed = Math.sqrt(dx * dx + dy * dy)
|
|
|
|
mouseRef.current = { x: e.clientX, y: e.clientY }
|
|
|
|
// Spawn spores based on movement speed
|
|
if (speed > 3) {
|
|
const count = Math.min(Math.floor(speed / 8), 3)
|
|
for (let i = 0; i < count; i++) {
|
|
const angle = Math.atan2(dy, dx) + Math.PI + (Math.random() - 0.5) * 1.5
|
|
const sporeSpeed = 0.5 + Math.random() * 1.5
|
|
|
|
sporesRef.current.push({
|
|
x: e.clientX + (Math.random() - 0.5) * 10,
|
|
y: e.clientY + (Math.random() - 0.5) * 10,
|
|
vx: Math.cos(angle) * sporeSpeed,
|
|
vy: Math.sin(angle) * sporeSpeed,
|
|
life: 1,
|
|
maxLife: 80 + Math.random() * 60,
|
|
size: 1.5 + Math.random() * 2.5,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
window.addEventListener('mousemove', handleMouseMove)
|
|
|
|
const animate = () => {
|
|
// Clear canvas completely each frame - no lingering
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
|
|
|
const spores = sporesRef.current
|
|
|
|
// Update and draw spores
|
|
sporesRef.current = spores.filter(spore => {
|
|
// Drift toward other nearby spores (flocking)
|
|
let avgX = 0, avgY = 0, count = 0
|
|
for (const other of spores) {
|
|
if (other === spore) continue
|
|
const dx = other.x - spore.x
|
|
const dy = other.y - spore.y
|
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
if (dist < 80 && dist > 5) {
|
|
avgX += dx / dist
|
|
avgY += dy / dist
|
|
count++
|
|
|
|
// Draw faint connection lines
|
|
if (dist < 50 && spore.life > 0.3 && other.life > 0.3) {
|
|
const alpha = Math.min(spore.life, other.life) * 0.15 * (1 - dist / 50)
|
|
ctx.beginPath()
|
|
ctx.moveTo(spore.x, spore.y)
|
|
ctx.lineTo(other.x, other.y)
|
|
ctx.strokeStyle = `rgba(180, 160, 140, ${alpha})`
|
|
ctx.lineWidth = 0.5
|
|
ctx.stroke()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply gentle flocking
|
|
if (count > 0) {
|
|
spore.vx += (avgX / count) * 0.02
|
|
spore.vy += (avgY / count) * 0.02
|
|
}
|
|
|
|
// Add slight upward drift
|
|
spore.vy -= 0.01
|
|
|
|
// Damping
|
|
spore.vx *= 0.98
|
|
spore.vy *= 0.98
|
|
|
|
// Update position
|
|
spore.x += spore.vx
|
|
spore.y += spore.vy
|
|
|
|
// Decay
|
|
spore.life -= 1 / spore.maxLife
|
|
|
|
// Draw spore
|
|
if (spore.life > 0) {
|
|
const alpha = spore.life * 0.7
|
|
const size = spore.size * (0.5 + spore.life * 0.5)
|
|
|
|
ctx.beginPath()
|
|
ctx.arc(spore.x, spore.y, size, 0, Math.PI * 2)
|
|
ctx.fillStyle = `rgba(200, 175, 140, ${alpha})`
|
|
ctx.fill()
|
|
|
|
// Soft glow
|
|
ctx.beginPath()
|
|
ctx.arc(spore.x, spore.y, size * 2, 0, Math.PI * 2)
|
|
ctx.fillStyle = `rgba(200, 175, 140, ${alpha * 0.2})`
|
|
ctx.fill()
|
|
}
|
|
|
|
return spore.life > 0
|
|
})
|
|
|
|
// Limit spore count
|
|
if (sporesRef.current.length > 150) {
|
|
sporesRef.current = sporesRef.current.slice(-150)
|
|
}
|
|
|
|
frameRef.current = requestAnimationFrame(animate)
|
|
}
|
|
|
|
frameRef.current = requestAnimationFrame(animate)
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', resize)
|
|
window.removeEventListener('mousemove', handleMouseMove)
|
|
cancelAnimationFrame(frameRef.current)
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="fixed inset-0 pointer-events-none z-50"
|
|
style={{ opacity: 0.9 }}
|
|
/>
|
|
)
|
|
}
|