Skip to content

Commit 65ba877

Browse files
feat: 머리카락 테마 구현 (#3)
* feat: 머리카락 컴포넌트 구현 * chore: ThemeContent에 머리카락 테마 연결 * chore: 탭에 머리카락 테마 활성화 * chore: PreviewCard에 머리카락 테마 추가 * feat: 머리카락 애니메이션 구현 * chore: 머리카락 라우트 추가 * feat: 자라나라 머리머리 애니메이션 구현
1 parent b935887 commit 65ba877

17 files changed

Lines changed: 1615 additions & 24 deletions

File tree

app/api/[username]/animation/route.ts

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
import { type NextRequest, NextResponse } from "next/server";
22
import { generateFlowerAPNG } from "@/lib/themes/flower/generator";
3-
import type { FlowerType } from "@/lib/themes/types";
3+
import { generateHairAPNG } from "@/lib/themes/hair/generator";
4+
import type { FlowerType, HairCurliness } from "@/lib/themes/types";
5+
6+
type ThemeGenerator = (options: {
7+
username: string;
8+
quality: "low" | "medium" | "high";
9+
[key: string]: unknown;
10+
}) => Promise<Uint8Array>;
11+
12+
type ThemeConfig = {
13+
generator: ThemeGenerator;
14+
parseOptions: (params: URLSearchParams) => Record<string, unknown>;
15+
};
416

517
const VALID_FLOWER_TYPES: FlowerType[] = [
618
"default",
@@ -9,41 +21,75 @@ const VALID_FLOWER_TYPES: FlowerType[] = [
921
"cherry",
1022
];
1123

24+
const VALID_CURLINESS: HairCurliness[] = ["straight", "wavy", "curly"];
25+
1226
function isValidFlowerType(value: unknown): value is FlowerType {
1327
return (
1428
typeof value === "string" &&
1529
VALID_FLOWER_TYPES.includes(value as FlowerType)
1630
);
1731
}
1832

33+
function isValidCurliness(value: unknown): value is HairCurliness {
34+
return (
35+
typeof value === "string" &&
36+
VALID_CURLINESS.includes(value as HairCurliness)
37+
);
38+
}
39+
1940
function isValidHexColor(value: unknown): value is string {
2041
return typeof value === "string" && /^#[0-9A-Fa-f]{6}$/.test(value);
2142
}
2243

44+
const THEME_CONFIGS: Record<string, ThemeConfig> = {
45+
flower: {
46+
generator: generateFlowerAPNG,
47+
parseOptions: (params) => ({
48+
flowerType: isValidFlowerType(params.get("flower"))
49+
? params.get("flower")
50+
: "default",
51+
flowerColor: isValidHexColor(params.get("color"))
52+
? params.get("color")
53+
: undefined,
54+
}),
55+
},
56+
hair: {
57+
generator: generateHairAPNG,
58+
parseOptions: (params) => ({
59+
hairColor: isValidHexColor(params.get("color"))
60+
? params.get("color")
61+
: undefined,
62+
curliness: isValidCurliness(params.get("curliness"))
63+
? params.get("curliness")
64+
: "straight",
65+
}),
66+
},
67+
};
68+
69+
const DEFAULT_THEME = "flower";
70+
2371
export async function GET(
2472
request: NextRequest,
2573
{ params }: { params: Promise<{ username: string }> },
2674
) {
2775
const { username } = await params;
28-
const searchParams = request.nextUrl.searchParams;
29-
const flower = searchParams.get("flower");
30-
const color = searchParams.get("color");
3176

3277
if (!username) {
3378
return new NextResponse("Invalid username", { status: 400 });
3479
}
3580

36-
const flowerType: FlowerType = isValidFlowerType(flower) ? flower : "default";
37-
const flowerColor: string | undefined = isValidHexColor(color)
38-
? color
39-
: undefined;
81+
const searchParams = request.nextUrl.searchParams;
82+
const theme = searchParams.get("theme") || DEFAULT_THEME;
83+
84+
const themeConfig = THEME_CONFIGS[theme] || THEME_CONFIGS[DEFAULT_THEME];
4085

4186
try {
42-
const apngData = await generateFlowerAPNG({
87+
const themeOptions = themeConfig.parseOptions(searchParams);
88+
89+
const apngData = await themeConfig.generator({
4390
username,
4491
quality: "low",
45-
flowerType,
46-
flowerColor,
92+
...themeOptions,
4793
});
4894

4995
return new NextResponse(Buffer.from(apngData), {

components/features/theme/ThemeContent.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type { Theme } from "@/components/features/theme/ThemeTabs";
44
import { ComingSoonContent } from "./coming-soon/ComingSoonContent";
55
import { FlowerContent } from "./flower/FlowerContent";
6+
import { HairContent } from "./hair/HairContent";
67

78
interface ThemeContentProps {
89
theme: Theme;
@@ -12,5 +13,8 @@ export function ThemeContent({ theme }: ThemeContentProps) {
1213
if (theme === "flower") {
1314
return <FlowerContent />;
1415
}
16+
if (theme === "hair") {
17+
return <HairContent />;
18+
}
1519
return <ComingSoonContent theme={theme} />;
1620
}

components/features/theme/ThemeTabs.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,18 @@ const THEMES = [
1818
colors: ["#fce7f3", "#f9a8d4", "#ec4899", "#be185d"],
1919
available: true,
2020
},
21-
{
22-
id: "cloud" as Theme,
23-
label: "Cloud",
24-
description: "Floating sky",
25-
colors: ["#e0f2fe", "#7dd3fc", "#0ea5e9", "#0369a1"],
26-
available: false,
27-
},
2821
{
2922
id: "hair" as Theme,
3023
label: "Hair",
3124
description: "Growing strands",
3225
colors: ["#fef3c7", "#fcd34d", "#f59e0b", "#b45309"],
26+
available: true,
27+
},
28+
{
29+
id: "cloud" as Theme,
30+
label: "Cloud",
31+
description: "Floating sky",
32+
colors: ["#e0f2fe", "#7dd3fc", "#0ea5e9", "#0369a1"],
3333
available: false,
3434
},
3535
];
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import type { Position } from "./types";
5+
6+
interface BaldPersonProps {
7+
position: Position;
8+
showHairParticles: boolean;
9+
onReset: () => void;
10+
}
11+
12+
const MESSAGES = [
13+
"AAAH!",
14+
"JARANARA",
15+
"MEORIMEORI!",
16+
"sob sob...",
17+
"come back...",
18+
"T_T",
19+
];
20+
21+
function playCryingSound(isInitial = false) {
22+
try {
23+
const audioCtx = new AudioContext();
24+
25+
const osc = audioCtx.createOscillator();
26+
const gainNode = audioCtx.createGain();
27+
28+
const volume = isInitial ? 0.08 : 0.04;
29+
const baseFreq = isInitial ? 300 : 250 + Math.random() * 100;
30+
const duration = isInitial ? 0.6 : 0.4;
31+
32+
osc.type = "sine";
33+
34+
// Crying-like frequency modulation
35+
osc.frequency.setValueAtTime(baseFreq, audioCtx.currentTime);
36+
osc.frequency.linearRampToValueAtTime(
37+
baseFreq * 1.5,
38+
audioCtx.currentTime + duration * 0.3,
39+
);
40+
osc.frequency.linearRampToValueAtTime(
41+
baseFreq * 0.8,
42+
audioCtx.currentTime + duration,
43+
);
44+
45+
gainNode.gain.setValueAtTime(volume, audioCtx.currentTime);
46+
gainNode.gain.linearRampToValueAtTime(
47+
volume * 0.6,
48+
audioCtx.currentTime + duration * 0.5,
49+
);
50+
gainNode.gain.linearRampToValueAtTime(0, audioCtx.currentTime + duration);
51+
52+
osc.connect(gainNode);
53+
gainNode.connect(audioCtx.destination);
54+
55+
osc.start();
56+
osc.stop(audioCtx.currentTime + duration);
57+
} catch {}
58+
}
59+
60+
export function BaldPerson({
61+
position,
62+
showHairParticles,
63+
onReset,
64+
}: BaldPersonProps) {
65+
const [shakeIntensity, setShakeIntensity] = useState(1);
66+
const [isDragging, setIsDragging] = useState(false);
67+
const [baldPos, setBaldPos] = useState(position);
68+
const [frame, setFrame] = useState(0);
69+
const [messageIndex, setMessageIndex] = useState(0);
70+
71+
useEffect(() => {
72+
if (showHairParticles) {
73+
playCryingSound(true);
74+
}
75+
}, [showHairParticles]);
76+
77+
useEffect(() => {
78+
const interval = setInterval(
79+
() => {
80+
playCryingSound(false);
81+
},
82+
2500 + Math.random() * 1500,
83+
);
84+
85+
return () => clearInterval(interval);
86+
}, []);
87+
88+
useEffect(() => {
89+
const interval = setInterval(() => {
90+
setShakeIntensity((prev) => Math.max(prev * 0.95, 0.3));
91+
}, 100);
92+
return () => clearInterval(interval);
93+
}, []);
94+
95+
useEffect(() => {
96+
const interval = setInterval(() => {
97+
setFrame((prev) => (prev + 1) % 3);
98+
}, 400);
99+
return () => clearInterval(interval);
100+
}, []);
101+
102+
useEffect(() => {
103+
const interval = setInterval(() => {
104+
setMessageIndex((prev) => (prev + 1) % MESSAGES.length);
105+
}, 1500);
106+
return () => clearInterval(interval);
107+
}, []);
108+
109+
useEffect(() => {
110+
if (!isDragging) return;
111+
112+
const handleMove = (e: PointerEvent) => {
113+
setBaldPos({ x: e.clientX, y: e.clientY });
114+
setShakeIntensity(0.8);
115+
};
116+
117+
const handleUp = () => {
118+
setIsDragging(false);
119+
};
120+
121+
window.addEventListener("pointermove", handleMove);
122+
window.addEventListener("pointerup", handleUp);
123+
124+
return () => {
125+
window.removeEventListener("pointermove", handleMove);
126+
window.removeEventListener("pointerup", handleUp);
127+
};
128+
}, [isDragging]);
129+
130+
const handlePointerDown = (e: React.PointerEvent) => {
131+
e.stopPropagation();
132+
setIsDragging(true);
133+
setShakeIntensity(1);
134+
};
135+
136+
const baldFrames = [
137+
` (ಥ﹏ಥ)
138+
||
139+
∧∧`,
140+
` (T_T)
141+
||
142+
∧∧`,
143+
` (;_;)
144+
||
145+
∧∧`,
146+
];
147+
148+
return (
149+
<div className="fixed inset-0 pointer-events-none z-[9999]">
150+
{showHairParticles && (
151+
<div className="absolute" style={{ left: position.x, top: position.y }}>
152+
{[...Array(8)].map((_, i) => {
153+
const angle = (i / 8) * Math.PI * 2;
154+
const distance = 20 + Math.random() * 30;
155+
return (
156+
<div
157+
key={`hair-particle-${i}`}
158+
className="absolute text-xs"
159+
style={
160+
{
161+
animation: "hair-fly 0.6s ease-out forwards",
162+
"--tx": `${Math.cos(angle) * distance}px`,
163+
"--ty": `${Math.sin(angle) * distance - 20}px`,
164+
} as React.CSSProperties
165+
}
166+
>
167+
{["~", "∿", "≈", "∼"][i % 4]}
168+
</div>
169+
);
170+
})}
171+
</div>
172+
)}
173+
174+
<button
175+
type="button"
176+
className="absolute pointer-events-auto cursor-grab active:cursor-grabbing bg-transparent border-none p-0"
177+
style={{
178+
left: baldPos.x - 40,
179+
top: baldPos.y - 60,
180+
animation: "bald-float 2s ease-in-out infinite",
181+
}}
182+
onPointerDown={handlePointerDown}
183+
onClick={(e) => {
184+
e.stopPropagation();
185+
onReset();
186+
}}
187+
>
188+
<div
189+
className="absolute -top-8 left-1/2 -translate-x-1/2 bg-white rounded-lg px-3 py-1 text-xs font-bold shadow-md whitespace-nowrap border border-neutral-200"
190+
style={{
191+
animation: "bounce 0.5s ease-in-out infinite",
192+
}}
193+
>
194+
{MESSAGES[messageIndex]}
195+
</div>
196+
197+
<pre
198+
className="font-mono text-base leading-tight select-none drop-shadow-sm"
199+
style={{
200+
animation: `bald-shake ${0.1 + (1 - shakeIntensity) * 0.2}s ease-in-out infinite`,
201+
}}
202+
>
203+
{baldFrames[frame]}
204+
</pre>
205+
206+
<div className="absolute -bottom-5 left-1/2 -translate-x-1/2 text-[9px] text-neutral-400 whitespace-nowrap font-mono">
207+
[click to regrow hair]
208+
</div>
209+
</button>
210+
211+
<style jsx>{`
212+
@keyframes hair-fly {
213+
0% {
214+
transform: translate(0, 0);
215+
opacity: 1;
216+
}
217+
100% {
218+
transform: translate(var(--tx), var(--ty));
219+
opacity: 0;
220+
}
221+
}
222+
@keyframes bald-float {
223+
0%,
224+
100% {
225+
transform: translateY(0px);
226+
}
227+
50% {
228+
transform: translateY(-10px);
229+
}
230+
}
231+
@keyframes bald-shake {
232+
0%,
233+
100% {
234+
transform: translateX(0);
235+
}
236+
25% {
237+
transform: translateX(-2px) rotate(-1deg);
238+
}
239+
75% {
240+
transform: translateX(2px) rotate(1deg);
241+
}
242+
}
243+
`}</style>
244+
</div>
245+
);
246+
}

0 commit comments

Comments
 (0)