|
| 1 | +'use client'; |
| 2 | + |
| 3 | +import { useCallback, useRef, useState } from 'react'; |
| 4 | +import Image from 'next/image'; |
| 5 | +import { AnimatePresence, motion } from 'framer-motion'; |
| 6 | +import { GlassCard } from '@/components/ui/glass-card'; |
| 7 | +import { useReducedMotion } from '@/hooks/useReducedMotion'; |
| 8 | + |
| 9 | +interface PettableSkinProps { |
| 10 | + src: string; |
| 11 | + alt: string; |
| 12 | + /** Image intrinsic width — used by Next/Image. */ |
| 13 | + width: number; |
| 14 | + /** Image intrinsic height — used by Next/Image. */ |
| 15 | + height: number; |
| 16 | + /** Tailwind size class applied to the rendered image (e.g. 'w-32'). */ |
| 17 | + sizeClass?: string; |
| 18 | +} |
| 19 | + |
| 20 | +interface Sparkle { |
| 21 | + id: number; |
| 22 | + x: number; |
| 23 | + y: number; |
| 24 | + glyph: string; |
| 25 | + drift: number; |
| 26 | +} |
| 27 | + |
| 28 | +const GLYPHS = ['♥', '♡', '✦', '✧']; |
| 29 | + |
| 30 | +// Module-scoped so multiple instances don't collide on re-renders. |
| 31 | +let nextSparkleId = 0; |
| 32 | + |
| 33 | +/** |
| 34 | + * Skin avatar that responds to "petting" — moving the cursor over it spawns |
| 35 | + * heart/sparkle particles at the cursor location, and the card gently wiggles |
| 36 | + * while hovered. Still uses GlassCard with breakable=true so click → shatter |
| 37 | + * remains the secondary easter-egg interaction. |
| 38 | + */ |
| 39 | +export function PettableSkin({ |
| 40 | + src, |
| 41 | + alt, |
| 42 | + width, |
| 43 | + height, |
| 44 | + sizeClass = 'w-32', |
| 45 | +}: PettableSkinProps) { |
| 46 | + const reduced = useReducedMotion(); |
| 47 | + const [sparkles, setSparkles] = useState<Sparkle[]>([]); |
| 48 | + const lastSpawnRef = useRef(0); |
| 49 | + |
| 50 | + const handleMouseMove = useCallback( |
| 51 | + (e: React.MouseEvent<HTMLDivElement>) => { |
| 52 | + if (reduced) return; |
| 53 | + const now = performance.now(); |
| 54 | + if (now - lastSpawnRef.current < 90) return; // throttle ≈11/s |
| 55 | + lastSpawnRef.current = now; |
| 56 | + |
| 57 | + const rect = e.currentTarget.getBoundingClientRect(); |
| 58 | + const id = nextSparkleId++; |
| 59 | + const sparkle: Sparkle = { |
| 60 | + id, |
| 61 | + x: e.clientX - rect.left, |
| 62 | + y: e.clientY - rect.top, |
| 63 | + glyph: GLYPHS[Math.floor(Math.random() * GLYPHS.length)], |
| 64 | + drift: (Math.random() - 0.5) * 28, |
| 65 | + }; |
| 66 | + setSparkles((prev) => [...prev.slice(-9), sparkle]); |
| 67 | + window.setTimeout(() => { |
| 68 | + setSparkles((prev) => prev.filter((s) => s.id !== id)); |
| 69 | + }, 950); |
| 70 | + }, |
| 71 | + [reduced] |
| 72 | + ); |
| 73 | + |
| 74 | + return ( |
| 75 | + <motion.div |
| 76 | + className="relative cursor-grab active:cursor-grabbing" |
| 77 | + onMouseMove={handleMouseMove} |
| 78 | + > |
| 79 | + <motion.div |
| 80 | + whileHover={ |
| 81 | + reduced |
| 82 | + ? undefined |
| 83 | + : { |
| 84 | + rotate: [0, -3, 3, -2, 2, 0], |
| 85 | + transition: { |
| 86 | + duration: 0.9, |
| 87 | + repeat: Infinity, |
| 88 | + ease: 'easeInOut', |
| 89 | + }, |
| 90 | + } |
| 91 | + } |
| 92 | + > |
| 93 | + <GlassCard className="p-4" showHighlight={false} breakable={true}> |
| 94 | + <Image |
| 95 | + src={src} |
| 96 | + alt={alt} |
| 97 | + width={width} |
| 98 | + height={height} |
| 99 | + className={`mc-pixel h-auto ${sizeClass} select-none`} |
| 100 | + draggable={false} |
| 101 | + /> |
| 102 | + </GlassCard> |
| 103 | + </motion.div> |
| 104 | + |
| 105 | + {/* Heart / sparkle particles spawned at the cursor while petting */} |
| 106 | + <AnimatePresence> |
| 107 | + {sparkles.map((s) => ( |
| 108 | + <motion.span |
| 109 | + key={s.id} |
| 110 | + className="pointer-events-none absolute z-30 text-pink-500 dark:text-pink-400 text-xl font-bold select-none drop-shadow-[0_0_4px_rgba(244,114,182,0.7)]" |
| 111 | + style={{ left: s.x, top: s.y, translateX: '-50%', translateY: '-50%' }} |
| 112 | + initial={{ opacity: 0, scale: 0.3, y: 0 }} |
| 113 | + animate={{ |
| 114 | + opacity: [0, 1, 0], |
| 115 | + scale: [0.3, 1.2, 0.4], |
| 116 | + x: s.drift, |
| 117 | + y: -55, |
| 118 | + }} |
| 119 | + exit={{ opacity: 0 }} |
| 120 | + transition={{ duration: 0.9, ease: 'easeOut' }} |
| 121 | + aria-hidden="true" |
| 122 | + > |
| 123 | + {s.glyph} |
| 124 | + </motion.span> |
| 125 | + ))} |
| 126 | + </AnimatePresence> |
| 127 | + </motion.div> |
| 128 | + ); |
| 129 | +} |
0 commit comments