Skip to content

Commit be1886c

Browse files
sumi-0011claude
andauthored
feat(shop): 1펫 뽑기 UX 개편 — 카드 뽑기 → 풀스크린 알 부화 연출 (#396)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9c818ff commit be1886c

7 files changed

Lines changed: 357 additions & 428 deletions

File tree

apps/web/messages/en-US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"not-enough-points": "You don't have enough points to draw a pet.",
6666
"click-to-close": "Touch the screen to close.",
6767
"pet-gotcha-title": "Pet Gotcha",
68+
"pull-lever": "Tap the egg to hatch your pet!",
6869
"open-pack": "Tap the pack to open it!",
6970
"tap-to-skip": "Tap the screen to skip"
7071
},

apps/web/messages/ko-KR.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"not-enough-points": "포인트가 부족합니다.",
6666
"click-to-close": "결과를 확인하셨다면 화면을 터치해주세요",
6767
"pet-gotcha-title": "펫 뽑기",
68+
"pull-lever": "알을 눌러 펫을 부화시켜보세요!",
6869
"open-pack": "카드팩을 눌러 개봉하세요!",
6970
"tap-to-skip": "화면을 탭하면 건너뛰기"
7071
},

apps/web/public/shop/egg-hatch.png

1.75 KB
Loading
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
/* eslint-disable jsx-a11y/click-events-have-key-events */
2+
/* eslint-disable jsx-a11y/no-static-element-interactions */
3+
'use client';
4+
5+
import { useEffect, useRef, useState } from 'react';
6+
import Image from 'next/image';
7+
import { useTranslations } from 'next-intl';
8+
import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from 'framer-motion';
9+
10+
import { AnimalCard } from '@/components/AnimalCard';
11+
import type { AnimalTierType } from '@/constants/animalTier';
12+
import { getAnimalTierInfo } from '@/utils/animals';
13+
14+
// 알 부화 스프라이트 (12프레임, 32x384 세로 스트립) — VIERGACHT/iamcrog 픽셀 에셋.
15+
const EGG_SPRITE = '/shop/egg-hatch.png';
16+
const EGG_FRAMES = 12;
17+
const SHATTER_FRAME = 9; // 껍질이 깨지기 시작하는 프레임 → 이때 컨페티 + 화면 흔들림
18+
const LAST_FRAME = EGG_FRAMES - 1; // 부화 후 남는 껍질 프레임
19+
20+
// ponytail: 티어(EX/S+/A+/B-)가 연출의 유일한 변수. 새 API 없이 dropRate에서 파생.
21+
// 알 스프라이트는 회색 단일색 → 티어는 오라/배지/컨페티로 표현.
22+
const TIER_THEME: Record<
23+
AnimalTierType,
24+
{ glow: string; confetti: string[]; rockLoops: number; hatchStepMs: number; particles: number }
25+
> = {
26+
EX: {
27+
glow: '#ffd54a',
28+
confetti: ['#ff5edf', '#ffd54a', '#4ad9ff', '#7cff6b', '#ffffff'],
29+
rockLoops: 3,
30+
hatchStepMs: 105,
31+
particles: 200,
32+
},
33+
S_PLUS: {
34+
glow: '#b18cff',
35+
confetti: ['#c084fc', '#b18cff', '#e9d5ff', '#7cff6b'],
36+
rockLoops: 2,
37+
hatchStepMs: 95,
38+
particles: 150,
39+
},
40+
A_PLUS: {
41+
glow: '#ffe08a',
42+
confetti: ['#ffe08a', '#f5b73f', '#fff6d6'],
43+
rockLoops: 1,
44+
hatchStepMs: 85,
45+
particles: 80,
46+
},
47+
B_MINUS: {
48+
glow: '#c3d3e6',
49+
confetti: ['#c3d3e6', '#9fb8d6', '#ffffff'],
50+
rockLoops: 0,
51+
hatchStepMs: 75,
52+
particles: 36,
53+
},
54+
};
55+
56+
const isRareTier = (tier: AnimalTierType) => tier === 'EX' || tier === 'S_PLUS';
57+
58+
// 세로 스트립에서 f번째 프레임만 보이도록 하는 배경 스타일
59+
const eggFrameStyle = (f: number) => ({
60+
backgroundImage: `url(${EGG_SPRITE})`,
61+
backgroundSize: '100% auto',
62+
backgroundRepeat: 'no-repeat' as const,
63+
backgroundPositionY: `${(f / LAST_FRAME) * 100}%`,
64+
imageRendering: 'pixelated' as const,
65+
});
66+
67+
type Phase = 'idle' | 'dropping' | 'hatching' | 'result';
68+
69+
interface Persona {
70+
name: string;
71+
dropRate: string;
72+
tier: AnimalTierType;
73+
}
74+
75+
interface Props {
76+
onDraw: () => Promise<{ name: string; dropRate: string } | undefined>;
77+
onClose: () => void;
78+
// 뽑는 중(포인트 차감~결과 전)엔 true → 부모가 닫기(X/Esc/오버레이)를 막음
79+
onBusyChange?: (busy: boolean) => void;
80+
}
81+
82+
export function GachaHatchGame({ onDraw, onClose, onBusyChange }: Props) {
83+
const t = useTranslations('Gotcha');
84+
const reduce = useReducedMotion();
85+
86+
const [phase, setPhase] = useState<Phase>('idle');
87+
const [persona, setPersona] = useState<Persona | null>(null);
88+
const [frame, setFrame] = useState(0);
89+
const shakeControls = useAnimationControls();
90+
91+
const skippedRef = useRef(false);
92+
const confettiFiredRef = useRef(false);
93+
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
94+
95+
useEffect(() => {
96+
return () => timers.current.forEach(clearTimeout);
97+
}, []);
98+
99+
// 뽑기 시작 후 결과 전까지는 닫기 불가
100+
useEffect(() => {
101+
onBusyChange?.(phase === 'dropping' || phase === 'hatching');
102+
}, [phase, onBusyChange]);
103+
104+
const wait = (ms: number) =>
105+
new Promise<void>((resolve) => {
106+
const id = setTimeout(resolve, skippedRef.current ? 0 : ms);
107+
timers.current.push(id);
108+
});
109+
110+
const fireConfetti = async (tier: AnimalTierType) => {
111+
if (confettiFiredRef.current || reduce) return;
112+
confettiFiredRef.current = true;
113+
const theme = TIER_THEME[tier];
114+
const confetti = (await import('canvas-confetti')).default;
115+
// 픽셀 톤: 사각형 + flat 으로 픽셀 조각처럼
116+
const base = { colors: theme.confetti, shapes: ['square' as const], flat: true, scalar: 1.2, zIndex: 9200 };
117+
confetti({
118+
...base,
119+
particleCount: theme.particles,
120+
spread: isRareTier(tier) ? 120 : 70,
121+
startVelocity: isRareTier(tier) ? 55 : 40,
122+
origin: { y: 0.5 },
123+
});
124+
if (isRareTier(tier)) {
125+
const id = setTimeout(() => {
126+
confetti({ ...base, particleCount: 70, angle: 60, spread: 70, origin: { x: 0, y: 0.65 } });
127+
confetti({ ...base, particleCount: 70, angle: 120, spread: 70, origin: { x: 1, y: 0.65 } });
128+
}, 220);
129+
timers.current.push(id);
130+
}
131+
};
132+
133+
const runReveal = async (drawn: Persona) => {
134+
setPhase('hatching');
135+
const theme = TIER_THEME[drawn.tier];
136+
137+
// 고티어일수록 껍질을 여러 번 흔든 뒤 부화 (긴장감)
138+
for (let r = 0; r < theme.rockLoops; r++) {
139+
for (let f = 0; f <= 3; f++) {
140+
setFrame(f);
141+
await wait(70);
142+
if (skippedRef.current) return;
143+
}
144+
}
145+
146+
// 본 부화: 0 → 11 프레임 재생. 깨지는 순간(9~11)은 조금 느리게 눌러 임팩트.
147+
for (let f = 0; f < EGG_FRAMES; f++) {
148+
setFrame(f);
149+
if (f === SHATTER_FRAME) {
150+
fireConfetti(drawn.tier);
151+
if (!reduce) shakeControls.start({ x: [0, -7, 7, -6, 6, -3, 3, 0], transition: { duration: 0.35 } });
152+
}
153+
await wait(f >= SHATTER_FRAME ? Math.round(theme.hatchStepMs * 1.5) : theme.hatchStepMs);
154+
if (skippedRef.current) return;
155+
}
156+
157+
await wait(450);
158+
if (skippedRef.current) return;
159+
setPhase('result');
160+
};
161+
162+
const draw = async () => {
163+
if (phase !== 'idle') return;
164+
setPhase('dropping');
165+
try {
166+
const res = await onDraw();
167+
if (!res) {
168+
onClose(); // onDraw가 에러 토스트/닫기를 처리함
169+
return;
170+
}
171+
const tier = getAnimalTierInfo(Number(res.dropRate.replace('%', '')));
172+
const drawn: Persona = { ...res, tier };
173+
setPersona(drawn);
174+
await runReveal(drawn);
175+
} catch {
176+
onClose();
177+
}
178+
};
179+
180+
// 화면 탭: 결과면 닫기, 부화 중이면(결과 확정 후) 스킵
181+
const handleStageClick = () => {
182+
if (phase === 'result') {
183+
onClose();
184+
return;
185+
}
186+
if (persona && phase === 'hatching') {
187+
skippedRef.current = true;
188+
timers.current.forEach(clearTimeout);
189+
timers.current = [];
190+
fireConfetti(persona.tier);
191+
setPhase('result');
192+
}
193+
};
194+
195+
const theme = persona ? TIER_THEME[persona.tier] : TIER_THEME.B_MINUS;
196+
const showEgg = phase !== 'result';
197+
198+
return (
199+
<div
200+
className="relative flex h-full min-h-[560px] w-full flex-col items-center justify-center overflow-hidden"
201+
onClick={handleStageClick}
202+
>
203+
{/* 픽셀 GOTCHA 워드마크 */}
204+
{phase !== 'result' && (
205+
<Image
206+
src="/shop/gotcha.svg"
207+
alt="GOTCHA"
208+
width={240}
209+
height={50}
210+
className="absolute top-[72px] h-auto w-[200px] mobile:top-[48px] mobile:w-[160px]"
211+
style={{ imageRendering: 'pixelated' }}
212+
/>
213+
)}
214+
215+
{/* 티어 오라 배경광 */}
216+
<AnimatePresence>
217+
{persona && phase !== 'dropping' && (
218+
<motion.div
219+
key="aura"
220+
className="pointer-events-none absolute inset-0"
221+
initial={{ opacity: 0 }}
222+
animate={{ opacity: phase === 'result' ? 0.9 : 0.5 }}
223+
exit={{ opacity: 0 }}
224+
style={{ background: `radial-gradient(circle at center, ${theme.glow}44 0%, transparent 62%)` }}
225+
/>
226+
)}
227+
</AnimatePresence>
228+
229+
{showEgg && <PixelEgg phase={phase} frame={frame} reduce={Boolean(reduce)} onDraw={draw} shake={shakeControls} />}
230+
231+
{/* 부화 후 남은 껍질 — 펫 카드가 여기서 솟아오르며 껍질은 스르륵 사라짐 */}
232+
{phase === 'result' && (
233+
<motion.div
234+
aria-hidden
235+
className="pointer-events-none absolute z-20 aspect-square w-[220px] mobile:w-[160px]"
236+
initial={{ opacity: 1, y: 0 }}
237+
animate={{ opacity: 0, y: 16 }}
238+
transition={{ duration: 0.7, ease: 'easeOut' }}
239+
style={eggFrameStyle(LAST_FRAME)}
240+
/>
241+
)}
242+
243+
{phase === 'result' && persona && <Result persona={persona} theme={theme} tapLabel={t('click-to-close')} />}
244+
245+
{/* 하단 안내 문구 */}
246+
<div className="absolute bottom-4 left-0 w-full text-center">
247+
{phase === 'idle' && <p className="glyph16-regular text-white/80">{t('pull-lever')}</p>}
248+
{phase === 'dropping' && <p className="glyph16-regular text-white/60">{t('gotcha-in-progress')}</p>}
249+
{persona && phase === 'hatching' && <p className="glyph16-regular text-white/50">{t('tap-to-skip')}</p>}
250+
</div>
251+
</div>
252+
);
253+
}
254+
255+
function PixelEgg({
256+
phase,
257+
frame,
258+
reduce,
259+
onDraw,
260+
shake,
261+
}: {
262+
phase: Phase;
263+
frame: number;
264+
reduce: boolean;
265+
onDraw: () => void;
266+
shake: ReturnType<typeof useAnimationControls>;
267+
}) {
268+
const idle = phase === 'idle';
269+
const wobble = phase === 'dropping';
270+
const hatching = phase === 'hatching';
271+
272+
const anim = reduce ? {} : idle ? { y: [0, -6, 0] } : wobble ? { rotate: [0, -5, 5, -5, 5, 0] } : {};
273+
const transition = idle
274+
? { duration: 1.6, repeat: Infinity, ease: 'easeInOut' as const }
275+
: { duration: 0.6, repeat: wobble ? Infinity : 0 };
276+
277+
return (
278+
<motion.button
279+
type="button"
280+
className="relative z-10 aspect-square w-[220px] disabled:cursor-default mobile:w-[160px]"
281+
onClick={(e) => {
282+
e.stopPropagation();
283+
if (idle) onDraw();
284+
}}
285+
disabled={!idle}
286+
aria-label="draw pet"
287+
// 부화 단계에선 껍질 깨질 때 흔들림 컨트롤을, 그 외엔 idle/대기 모션을 사용
288+
animate={hatching ? shake : anim}
289+
transition={transition}
290+
whileHover={idle ? { scale: 1.05 } : undefined}
291+
whileTap={idle ? { scaleY: 0.9 } : undefined}
292+
style={eggFrameStyle(frame)}
293+
/>
294+
);
295+
}
296+
297+
function Result({
298+
persona,
299+
theme,
300+
tapLabel,
301+
}: {
302+
persona: Persona;
303+
theme: (typeof TIER_THEME)[AnimalTierType];
304+
tapLabel: string;
305+
}) {
306+
return (
307+
<motion.div
308+
className="relative z-30 flex flex-col items-center gap-5"
309+
initial={{ scale: 0.6, opacity: 0, y: 64 }}
310+
animate={{ scale: 1, opacity: 1, y: 0 }}
311+
transition={{ type: 'spring', stiffness: 120, damping: 13 }}
312+
>
313+
<div className="relative">
314+
{isRareTier(persona.tier) && (
315+
<motion.div
316+
className="pointer-events-none absolute left-1/2 top-1/2 -z-10 h-[360px] w-[360px] -translate-x-1/2 -translate-y-1/2"
317+
animate={{ rotate: 360 }}
318+
transition={{ duration: 8, repeat: Infinity, ease: 'linear' }}
319+
style={{
320+
background: `conic-gradient(${theme.glow}00, ${theme.glow}66, ${theme.glow}00, ${theme.glow}66, ${theme.glow}00)`,
321+
borderRadius: '9999px',
322+
maskImage: 'radial-gradient(circle, transparent 30%, black 32%, transparent 70%)',
323+
}}
324+
/>
325+
)}
326+
<div className="w-[220px] max-w-[60vw]">
327+
<AnimalCard type={persona.name} dropRate={persona.dropRate} />
328+
</div>
329+
</div>
330+
331+
<p className="glyph16-regular text-center text-white/80">{tapLabel}</p>
332+
</motion.div>
333+
);
334+
}

0 commit comments

Comments
 (0)