Skip to content

Commit 3d64ca4

Browse files
Y-RyuZUclaude
andcommitted
feat(about): pettable skin + move sitting char off LinkedIn card
Hovering the avatar skin now gently wiggles the glass card and spawns heart/sparkle particles trailing the cursor — a "petting" reaction, while leaving the breakable-glass click as the existing easter egg. Also moves the sitting pose character on the Contact CTA card from top-left to top-right so it perches over the (placeholder) Discord card instead of obscuring LinkedIn. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1223727 commit 3d64ca4

3 files changed

Lines changed: 140 additions & 12 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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+
}

src/components/sections/AboutSection.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { StaggerGroup, StaggerItem } from "@/components/motion/StaggerGroup";
99
import { MotionCard } from "@/components/motion/MotionCard";
1010
import { MotionNumber } from "@/components/motion/MotionNumber";
1111
import { IdleCharacter } from "@/components/decorative/IdleCharacter";
12+
import { PettableSkin } from "@/components/decorative/PettableSkin";
1213

1314
const header1 = "所属"
1415
const content1 = `
@@ -65,17 +66,15 @@ export default function AboutSection({ id = "about" }: AboutSectionProps) {
6566
</GlassCard>
6667
</StaggerItem>
6768

68-
{/* Minecraft Skin */}
69+
{/* Minecraft Skin — pettable on hover */}
6970
<StaggerItem>
70-
<GlassCard className="p-4" showHighlight={false} breakable={true}>
71-
<Image
72-
src="/images/ryuzu.png"
73-
alt="Minecraft Skin"
74-
width={128}
75-
height={262}
76-
className="mc-pixel h-auto w-32"
77-
/>
78-
</GlassCard>
71+
<PettableSkin
72+
src="/images/ryuzu.png"
73+
alt="Minecraft Skin"
74+
width={128}
75+
height={262}
76+
sizeClass="w-32"
77+
/>
7978
</StaggerItem>
8079
</StaggerGroup>
8180

src/components/sections/ContactSection.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,9 @@ export default function ContactSection({ id = "contact" }: ContactSectionProps)
105105
{/* Quick Message */}
106106
<SectionReveal delay={0.2} y={32}>
107107
<div className="relative">
108-
{/* Sitting character — perched on the top-left edge of the CTA card (lg+) */}
108+
{/* Sitting character — perched on the top-right edge of the CTA card (lg+) */}
109109
<div
110-
className="hidden lg:block absolute -top-24 left-2 z-20 pointer-events-none"
110+
className="hidden lg:block absolute -top-24 right-2 z-20 pointer-events-none"
111111
aria-hidden="true"
112112
>
113113
<IdleCharacter pose="sitting" width={110} />

0 commit comments

Comments
 (0)