|
| 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