133 lines
3.6 KiB
TypeScript
133 lines
3.6 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useRef } from 'react'
|
||
|
||
// Matrix-style characters: katakana, numbers, symbols
|
||
const MATRIX_CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789@#$%^&*(){}[]|;:<>?/\\~`'
|
||
|
||
interface MatrixChar {
|
||
x: number
|
||
y: number
|
||
char: string
|
||
vx: number
|
||
vy: number
|
||
life: number
|
||
maxLife: number
|
||
size: number
|
||
rotation: number
|
||
rotationSpeed: number
|
||
}
|
||
|
||
export function CursorEffect() {
|
||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||
const charsRef = useRef<MatrixChar[]>([])
|
||
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 spawnChars = (x: number, y: number, count: number) => {
|
||
for (let i = 0; i < count; i++) {
|
||
const angle = (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.5
|
||
const speed = 2 + Math.random() * 4
|
||
|
||
charsRef.current.push({
|
||
x,
|
||
y,
|
||
char: MATRIX_CHARS[Math.floor(Math.random() * MATRIX_CHARS.length)],
|
||
vx: Math.cos(angle) * speed,
|
||
vy: Math.sin(angle) * speed,
|
||
life: 1,
|
||
maxLife: 60 + Math.random() * 60,
|
||
size: 12 + Math.random() * 16,
|
||
rotation: Math.random() * Math.PI * 2,
|
||
rotationSpeed: (Math.random() - 0.5) * 0.2,
|
||
})
|
||
}
|
||
}
|
||
|
||
const handleClick = (e: MouseEvent) => {
|
||
// Burst of matrix characters on click
|
||
spawnChars(e.clientX, e.clientY, 8 + Math.floor(Math.random() * 8))
|
||
}
|
||
|
||
window.addEventListener('click', handleClick)
|
||
|
||
const animate = () => {
|
||
// Clear with slight fade for trails
|
||
ctx.fillStyle = 'rgba(18, 16, 14, 0.15)'
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||
|
||
charsRef.current = charsRef.current.filter(char => {
|
||
// Update position
|
||
char.x += char.vx
|
||
char.y += char.vy
|
||
|
||
// Gravity and friction
|
||
char.vy += 0.08
|
||
char.vx *= 0.99
|
||
char.vy *= 0.99
|
||
|
||
// Update rotation
|
||
char.rotation += char.rotationSpeed
|
||
|
||
// Decay life
|
||
char.life -= 1 / char.maxLife
|
||
|
||
// Draw character
|
||
if (char.life > 0) {
|
||
ctx.save()
|
||
ctx.translate(char.x, char.y)
|
||
ctx.rotate(char.rotation)
|
||
|
||
const alpha = char.life * 0.9
|
||
// Primary color (warm gold/amber matching the theme)
|
||
ctx.fillStyle = `rgba(200, 170, 120, ${alpha})`
|
||
ctx.font = `${char.size}px "JetBrains Mono", monospace`
|
||
ctx.textAlign = 'center'
|
||
ctx.textBaseline = 'middle'
|
||
ctx.fillText(char.char, 0, 0)
|
||
|
||
// Glow effect
|
||
ctx.fillStyle = `rgba(200, 170, 120, ${alpha * 0.3})`
|
||
ctx.font = `${char.size * 1.1}px "JetBrains Mono", monospace`
|
||
ctx.fillText(char.char, 0, 0)
|
||
|
||
ctx.restore()
|
||
}
|
||
|
||
return char.life > 0
|
||
})
|
||
|
||
frameRef.current = requestAnimationFrame(animate)
|
||
}
|
||
|
||
frameRef.current = requestAnimationFrame(animate)
|
||
|
||
return () => {
|
||
window.removeEventListener('resize', resize)
|
||
window.removeEventListener('click', handleClick)
|
||
cancelAnimationFrame(frameRef.current)
|
||
}
|
||
}, [])
|
||
|
||
return (
|
||
<canvas
|
||
ref={canvasRef}
|
||
className="fixed inset-0 pointer-events-none z-0"
|
||
style={{ opacity: 0.9 }}
|
||
/>
|
||
)
|
||
}
|