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