Skip to content

Commit 9e59ba3

Browse files
sumi-0011claude
andauthored
feat(shop): 10펫 뽑기 UX 개편 — 카드팩 개봉 연출 (#397)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b4b1771 commit 9e59ba3

7 files changed

Lines changed: 380 additions & 164 deletions

File tree

apps/web/messages/en-US.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@
6363
"gotcha-in-progress": "Drawing in progress. Please wait a moment.",
6464
"click-card-to-flip": "Click the card to flip!",
6565
"not-enough-points": "You don't have enough points to draw a pet.",
66-
"click-to-close": "Touch the screen to close."
66+
"click-to-close": "Touch the screen to close.",
67+
"pet-gotcha-title": "Pet Gotcha",
68+
"open-pack": "Tap the pack to open it!",
69+
"tap-to-skip": "Tap the screen to skip"
6770
},
6871
"Shop": {
6972
"no-mobile-support": "Currently, mobile is not supported. Please connect to the desktop.",

apps/web/messages/ko-KR.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@
6363
"gotcha-in-progress": "뽑기를 진행중입니다. 잠시만 기다려주세요.",
6464
"click-card-to-flip": "카드를 클릭해서 뒤집어주세요!",
6565
"not-enough-points": "포인트가 부족합니다.",
66-
"click-to-close": "결과를 확인하셨다면 화면을 터치해주세요"
66+
"click-to-close": "결과를 확인하셨다면 화면을 터치해주세요",
67+
"pet-gotcha-title": "펫 뽑기",
68+
"open-pack": "카드팩을 눌러 개봉하세요!",
69+
"tap-to-skip": "화면을 탭하면 건너뛰기"
6770
},
6871
"Shop": {
6972
"no-mobile-support": "현재 모바일은 지원하지 않습니다. 데스크탑으로 접속해주세요.",

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@tanstack/react-query": "*",
3838
"@vercel/analytics": "^1.5.0",
3939
"axios": "^1.6.8",
40+
"canvas-confetti": "^1.9.4",
4041
"embla-carousel": "^8.6.0",
4142
"embla-carousel-react": "^8.6.0",
4243
"framer-motion": "^11.1.7",
@@ -72,6 +73,7 @@
7273
"@storybook/test": "^8.0.9",
7374
"@tanstack/eslint-plugin-query": "^5.28.11",
7475
"@tanstack/react-query-devtools": "^5.32.0",
76+
"@types/canvas-confetti": "^1.9.0",
7577
"@types/google-spreadsheet": "^4.0.0",
7678
"@types/gtag.js": "^0.0.19",
7779
"@types/js-cookie": "^3.0.6",
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
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 { useTranslations } from 'next-intl';
7+
import type { GotchaResult } from '@gitanimals/api';
8+
import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from 'framer-motion';
9+
10+
import AnimalCard, { AnimalCardBack } from '@/components/AnimalCard/AnimalCard';
11+
import type { AnimalTierType } from '@/constants/animalTier';
12+
import { getAnimalTierInfo } from '@/utils/animals';
13+
14+
const TIER_GLOW: Record<AnimalTierType, string> = {
15+
EX: '#ffd54a',
16+
S_PLUS: '#b18cff',
17+
A_PLUS: '#ffe08a',
18+
B_MINUS: '#7a8aa0',
19+
};
20+
const TIER_RANK: Record<AnimalTierType, number> = { EX: 0, S_PLUS: 1, A_PLUS: 2, B_MINUS: 3 };
21+
22+
const tierOf = (p: GotchaResult) => getAnimalTierInfo(Number(p.dropRate.replace('%', '')));
23+
const isRare = (p: GotchaResult) => tierOf(p) === 'EX' || tierOf(p) === 'S_PLUS';
24+
25+
type Phase = 'pack' | 'opening' | 'revealing' | 'finale' | 'done';
26+
27+
interface Props {
28+
onOpen: () => void; // 덱 개봉 = API 호출 트리거
29+
results: GotchaResult[] | null;
30+
onClose: () => void;
31+
onBusyChange?: (busy: boolean) => void;
32+
}
33+
34+
export function CardPackGame({ onOpen, results, onClose, onBusyChange }: Props) {
35+
const t = useTranslations('Gotcha');
36+
const reduce = useReducedMotion();
37+
38+
const [phase, setPhase] = useState<Phase>('pack');
39+
const [flipped, setFlipped] = useState<boolean[]>(Array(10).fill(false));
40+
const shake = useAnimationControls();
41+
42+
const skippedRef = useRef(false);
43+
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
44+
45+
useEffect(() => () => timers.current.forEach(clearTimeout), []);
46+
47+
// 개봉~공개 완료 전엔 닫기 차단 (포인트 차감 후 결과 미확인 방지)
48+
useEffect(() => {
49+
onBusyChange?.(phase === 'opening' || phase === 'revealing' || phase === 'finale');
50+
}, [phase, onBusyChange]);
51+
52+
const wait = (ms: number) =>
53+
new Promise<void>((resolve) => {
54+
const id = setTimeout(resolve, skippedRef.current ? 0 : ms);
55+
timers.current.push(id);
56+
});
57+
58+
const fireConfetti = async (opts: Record<string, unknown>) => {
59+
if (reduce) return;
60+
const confetti = (await import('canvas-confetti')).default;
61+
confetti({ shapes: ['square'], flat: true, scalar: 1.1, zIndex: 9200, ...opts });
62+
};
63+
64+
const doShake = () => {
65+
if (!reduce) shake.start({ x: [0, -8, 8, -6, 6, -3, 3, 0], transition: { duration: 0.35 } });
66+
};
67+
68+
const bestIndex = results
69+
? results.reduce((best, p, i, arr) => (TIER_RANK[tierOf(p)] < TIER_RANK[tierOf(arr[best])] ? i : best), 0)
70+
: -1;
71+
72+
const runReveal = async (res: GotchaResult[]) => {
73+
setPhase('revealing');
74+
await wait(650); // 카드 딜(펼침)이 끝난 뒤 플립 시작
75+
if (skippedRef.current) return;
76+
77+
// 순차 플립 — 레어는 팝/글로우/흔들림/컨페티로 강조하고 잠깐 머문다
78+
for (let i = 0; i < 10; i++) {
79+
setFlipped((prev) => {
80+
const next = [...prev];
81+
next[i] = true;
82+
return next;
83+
});
84+
const rare = isRare(res[i]);
85+
if (rare) {
86+
doShake();
87+
fireConfetti({
88+
particleCount: 50,
89+
spread: 70,
90+
startVelocity: 38,
91+
origin: { y: 0.5 },
92+
colors: [TIER_GLOW[tierOf(res[i])], '#ffffff'],
93+
});
94+
}
95+
await wait(rare ? 340 : 150);
96+
if (skippedRef.current) return;
97+
}
98+
99+
await wait(250);
100+
if (skippedRef.current) return;
101+
102+
// 피날레 — 최고 등급 카드를 중앙으로 확대
103+
setPhase('finale');
104+
const best = res[bestIndex];
105+
if (isRare(best)) {
106+
doShake();
107+
fireConfetti({
108+
particleCount: 200,
109+
spread: 130,
110+
startVelocity: 58,
111+
origin: { y: 0.5 },
112+
colors: ['#ffd54a', '#b18cff', '#4ad9ff', '#7cff6b', '#ffffff'],
113+
});
114+
}
115+
await wait(1600);
116+
if (skippedRef.current) return;
117+
setPhase('done');
118+
};
119+
120+
const openPack = () => {
121+
if (phase !== 'pack') return;
122+
setPhase('opening');
123+
onOpen();
124+
};
125+
126+
// 결과 도착 시 공개 시퀀스 시작
127+
useEffect(() => {
128+
if (results && phase === 'opening') runReveal(results);
129+
// eslint-disable-next-line react-hooks/exhaustive-deps
130+
}, [results]);
131+
132+
const finishNow = () => {
133+
skippedRef.current = true;
134+
timers.current.forEach(clearTimeout);
135+
timers.current = [];
136+
setFlipped(Array(10).fill(true));
137+
setPhase('done');
138+
};
139+
140+
const handleStageClick = () => {
141+
if (phase === 'done') onClose();
142+
else if ((phase === 'revealing' || phase === 'finale') && results) finishNow();
143+
};
144+
145+
const best = results && bestIndex >= 0 ? results[bestIndex] : null;
146+
147+
return (
148+
<div
149+
className="relative flex h-full min-h-[560px] w-full flex-col items-center justify-center overflow-hidden"
150+
onClick={handleStageClick}
151+
>
152+
{/* 닫힌 덱(×10) */}
153+
<AnimatePresence>
154+
{phase === 'pack' && (
155+
<motion.button
156+
type="button"
157+
key="deck"
158+
className="relative z-10"
159+
onClick={(e) => {
160+
e.stopPropagation();
161+
openPack();
162+
}}
163+
aria-label="open pack"
164+
initial={{ scale: 0.9, opacity: 0 }}
165+
animate={reduce ? { scale: 1, opacity: 1 } : { scale: 1, opacity: 1, y: [0, -10, 0] }}
166+
exit={{ scale: 1.15, opacity: 0, transition: { duration: 0.2 } }}
167+
transition={{ y: { duration: 1.8, repeat: Infinity, ease: 'easeInOut' }, default: { duration: 0.3 } }}
168+
whileHover={{ scale: 1.05 }}
169+
>
170+
<CardDeck />
171+
</motion.button>
172+
)}
173+
</AnimatePresence>
174+
175+
{/* 개봉 순간 — 중앙에서 부드럽게 퍼지는 빛 (전체 화이트 플래시 대신) */}
176+
{phase === 'opening' && (
177+
<motion.div
178+
className="pointer-events-none absolute left-1/2 top-1/2 z-20 h-[340px] w-[340px] -translate-x-1/2 -translate-y-1/2"
179+
initial={{ opacity: 0.6, scale: 0.2 }}
180+
animate={{ opacity: 0, scale: 2.2 }}
181+
transition={{ duration: 0.55, ease: 'easeOut' }}
182+
style={{ background: 'radial-gradient(circle, #ffffff 0%, rgba(255,255,255,0.4) 30%, transparent 65%)' }}
183+
/>
184+
)}
185+
186+
{/* 카드 그리드 */}
187+
{phase !== 'pack' && (
188+
<motion.div className="z-10 grid grid-cols-5 gap-[14px] px-4 mobile:gap-[6px]" animate={shake}>
189+
{Array.from({ length: 10 }).map((_, i) => (
190+
<PackCard
191+
key={i}
192+
index={i}
193+
persona={results?.[i] ?? null}
194+
isFlipped={flipped[i]}
195+
isBest={phase === 'done' && i === bestIndex}
196+
dimmed={phase === 'finale'}
197+
/>
198+
))}
199+
</motion.div>
200+
)}
201+
202+
{/* 피날레 — 최고 등급 카드 확대 */}
203+
<AnimatePresence>
204+
{phase === 'finale' && best && (
205+
<motion.div
206+
key="finale"
207+
className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center"
208+
initial={{ opacity: 0 }}
209+
animate={{ opacity: 1 }}
210+
exit={{ opacity: 0 }}
211+
>
212+
<motion.div
213+
className="relative w-[260px] max-w-[70vw]"
214+
initial={{ scale: 0.3, y: 40, opacity: 0 }}
215+
animate={{ scale: 1, y: 0, opacity: 1 }}
216+
transition={{ type: 'spring', stiffness: 140, damping: 13 }}
217+
>
218+
{isRare(best) && (
219+
<motion.div
220+
className="pointer-events-none absolute left-1/2 top-1/2 -z-10 h-[420px] w-[420px] -translate-x-1/2 -translate-y-1/2"
221+
animate={{ rotate: 360 }}
222+
transition={{ duration: 8, repeat: Infinity, ease: 'linear' }}
223+
style={{
224+
background: `conic-gradient(${TIER_GLOW[tierOf(best)]}00, ${TIER_GLOW[tierOf(best)]}88, ${TIER_GLOW[tierOf(best)]}00, ${TIER_GLOW[tierOf(best)]}88, ${TIER_GLOW[tierOf(best)]}00)`,
225+
borderRadius: '9999px',
226+
maskImage: 'radial-gradient(circle, transparent 32%, black 34%, transparent 70%)',
227+
}}
228+
/>
229+
)}
230+
<AnimalCard type={best.name} dropRate={best.dropRate} />
231+
</motion.div>
232+
</motion.div>
233+
)}
234+
</AnimatePresence>
235+
236+
{/* 안내 문구 */}
237+
<div className="absolute bottom-6 left-0 w-full text-center">
238+
{phase === 'pack' && <p className="glyph16-regular text-white/80">{t('open-pack')}</p>}
239+
{phase === 'opening' && <p className="glyph16-regular text-white/60">{t('gotcha-in-progress')}</p>}
240+
{(phase === 'revealing' || phase === 'finale') && (
241+
<p className="glyph16-regular text-white/50">{t('tap-to-skip')}</p>
242+
)}
243+
{phase === 'done' && <p className="glyph16-regular text-white/80">{t('click-to-close')}</p>}
244+
</div>
245+
</div>
246+
);
247+
}
248+
249+
// 픽셀 카드 뒷면을 겹쳐 만든 덱(×10)
250+
function CardDeck() {
251+
return (
252+
<div className="relative aspect-[109/135] w-[200px] mobile:w-[150px]">
253+
<div className="absolute inset-0 translate-x-[10px] translate-y-[10px] opacity-60">
254+
<AnimalCardBack tier="A_PLUS" />
255+
</div>
256+
<div className="absolute inset-0 translate-x-[5px] translate-y-[5px] opacity-80">
257+
<AnimalCardBack tier="S_PLUS" />
258+
</div>
259+
<div className="absolute inset-0">
260+
<AnimalCardBack tier="EX" />
261+
</div>
262+
<div
263+
className="glyph20-bold absolute -right-2 -top-2 rounded-md px-2 py-0.5 text-white"
264+
style={{ background: '#232336', boxShadow: '3px 3px 0 rgba(0,0,0,0.4)' }}
265+
>
266+
×10
267+
</div>
268+
</div>
269+
);
270+
}
271+
272+
function PackCard({
273+
index,
274+
persona,
275+
isFlipped,
276+
isBest,
277+
dimmed,
278+
}: {
279+
index: number;
280+
persona: GotchaResult | null;
281+
isFlipped: boolean;
282+
isBest: boolean;
283+
dimmed: boolean;
284+
}) {
285+
const tier = persona ? tierOf(persona) : 'S_PLUS';
286+
const rare = persona ? isRare(persona) : false;
287+
288+
// 덱이 있던 그리드 중앙에서 각 슬롯으로 날아오도록 시작 오프셋 계산
289+
const col = index % 5;
290+
const row = Math.floor(index / 5);
291+
const originX = -(col - 2) * 132;
292+
const originY = -(row - 0.5) * 160 - 20;
293+
294+
return (
295+
<motion.div
296+
className="relative aspect-[109/135] w-[116px] [perspective:1000px] mobile:w-[58px]"
297+
initial={{ x: originX, y: originY, rotate: (col - 2) * 7, scale: 0.35, opacity: 0 }}
298+
animate={{ x: 0, y: 0, rotate: 0, scale: isBest ? 1.08 : 1, opacity: dimmed ? 0.35 : 1 }}
299+
transition={{
300+
default: { type: 'spring', stiffness: 260, damping: 24, delay: index * 0.045 },
301+
opacity: { duration: 0.3, delay: index * 0.045 },
302+
}}
303+
>
304+
{/* 레어/최고 등급 글로우 */}
305+
{((rare && isFlipped) || isBest) && (
306+
<motion.div
307+
className="pointer-events-none absolute -inset-1.5 -z-10 rounded-2xl"
308+
initial={{ opacity: 0 }}
309+
animate={{ opacity: 1 }}
310+
style={{
311+
boxShadow: `0 0 ${isBest ? 28 : 18}px ${isBest ? 8 : 4}px ${TIER_GLOW[tier]}`,
312+
background: `${TIER_GLOW[tier]}22`,
313+
}}
314+
/>
315+
)}
316+
<motion.div
317+
className="relative h-full w-full [transform-style:preserve-3d]"
318+
initial={false}
319+
animate={{ rotateY: isFlipped ? 180 : 0, scale: isFlipped && rare ? [1, 1.16, 1] : 1 }}
320+
transition={{ rotateY: { duration: 0.5, ease: 'easeInOut' }, scale: { duration: 0.45 } }}
321+
>
322+
<div className="absolute h-full w-full [backface-visibility:hidden]">
323+
<AnimalCardBack tier="S_PLUS" />
324+
</div>
325+
<div className="absolute h-full w-full [backface-visibility:hidden] [transform:rotateY(180deg)]">
326+
{persona && <AnimalCard type={persona.name} dropRate={persona.dropRate} />}
327+
</div>
328+
</motion.div>
329+
</motion.div>
330+
);
331+
}

0 commit comments

Comments
 (0)