diff --git a/apps/web/messages/en-US.json b/apps/web/messages/en-US.json index 00ed33e9..0f611d45 100644 --- a/apps/web/messages/en-US.json +++ b/apps/web/messages/en-US.json @@ -65,6 +65,7 @@ "not-enough-points": "You don't have enough points to draw a pet.", "click-to-close": "Touch the screen to close.", "pet-gotcha-title": "Pet Gotcha", + "pull-lever": "Tap the egg to hatch your pet!", "open-pack": "Tap the pack to open it!", "tap-to-skip": "Tap the screen to skip" }, diff --git a/apps/web/messages/ko-KR.json b/apps/web/messages/ko-KR.json index cef5cfee..8e64610b 100644 --- a/apps/web/messages/ko-KR.json +++ b/apps/web/messages/ko-KR.json @@ -65,6 +65,7 @@ "not-enough-points": "포인트가 부족합니다.", "click-to-close": "결과를 확인하셨다면 화면을 터치해주세요", "pet-gotcha-title": "펫 뽑기", + "pull-lever": "알을 눌러 펫을 부화시켜보세요!", "open-pack": "카드팩을 눌러 개봉하세요!", "tap-to-skip": "화면을 탭하면 건너뛰기" }, diff --git a/apps/web/public/shop/egg-hatch.png b/apps/web/public/shop/egg-hatch.png new file mode 100644 index 00000000..128958ee Binary files /dev/null and b/apps/web/public/shop/egg-hatch.png differ diff --git a/apps/web/src/app/[locale]/shop/_petGotcha/GachaHatchGame.tsx b/apps/web/src/app/[locale]/shop/_petGotcha/GachaHatchGame.tsx new file mode 100644 index 00000000..05526aa6 --- /dev/null +++ b/apps/web/src/app/[locale]/shop/_petGotcha/GachaHatchGame.tsx @@ -0,0 +1,334 @@ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import Image from 'next/image'; +import { useTranslations } from 'next-intl'; +import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from 'framer-motion'; + +import { AnimalCard } from '@/components/AnimalCard'; +import type { AnimalTierType } from '@/constants/animalTier'; +import { getAnimalTierInfo } from '@/utils/animals'; + +// 알 부화 스프라이트 (12프레임, 32x384 세로 스트립) — VIERGACHT/iamcrog 픽셀 에셋. +const EGG_SPRITE = '/shop/egg-hatch.png'; +const EGG_FRAMES = 12; +const SHATTER_FRAME = 9; // 껍질이 깨지기 시작하는 프레임 → 이때 컨페티 + 화면 흔들림 +const LAST_FRAME = EGG_FRAMES - 1; // 부화 후 남는 껍질 프레임 + +// ponytail: 티어(EX/S+/A+/B-)가 연출의 유일한 변수. 새 API 없이 dropRate에서 파생. +// 알 스프라이트는 회색 단일색 → 티어는 오라/배지/컨페티로 표현. +const TIER_THEME: Record< + AnimalTierType, + { glow: string; confetti: string[]; rockLoops: number; hatchStepMs: number; particles: number } +> = { + EX: { + glow: '#ffd54a', + confetti: ['#ff5edf', '#ffd54a', '#4ad9ff', '#7cff6b', '#ffffff'], + rockLoops: 3, + hatchStepMs: 105, + particles: 200, + }, + S_PLUS: { + glow: '#b18cff', + confetti: ['#c084fc', '#b18cff', '#e9d5ff', '#7cff6b'], + rockLoops: 2, + hatchStepMs: 95, + particles: 150, + }, + A_PLUS: { + glow: '#ffe08a', + confetti: ['#ffe08a', '#f5b73f', '#fff6d6'], + rockLoops: 1, + hatchStepMs: 85, + particles: 80, + }, + B_MINUS: { + glow: '#c3d3e6', + confetti: ['#c3d3e6', '#9fb8d6', '#ffffff'], + rockLoops: 0, + hatchStepMs: 75, + particles: 36, + }, +}; + +const isRareTier = (tier: AnimalTierType) => tier === 'EX' || tier === 'S_PLUS'; + +// 세로 스트립에서 f번째 프레임만 보이도록 하는 배경 스타일 +const eggFrameStyle = (f: number) => ({ + backgroundImage: `url(${EGG_SPRITE})`, + backgroundSize: '100% auto', + backgroundRepeat: 'no-repeat' as const, + backgroundPositionY: `${(f / LAST_FRAME) * 100}%`, + imageRendering: 'pixelated' as const, +}); + +type Phase = 'idle' | 'dropping' | 'hatching' | 'result'; + +interface Persona { + name: string; + dropRate: string; + tier: AnimalTierType; +} + +interface Props { + onDraw: () => Promise<{ name: string; dropRate: string } | undefined>; + onClose: () => void; + // 뽑는 중(포인트 차감~결과 전)엔 true → 부모가 닫기(X/Esc/오버레이)를 막음 + onBusyChange?: (busy: boolean) => void; +} + +export function GachaHatchGame({ onDraw, onClose, onBusyChange }: Props) { + const t = useTranslations('Gotcha'); + const reduce = useReducedMotion(); + + const [phase, setPhase] = useState('idle'); + const [persona, setPersona] = useState(null); + const [frame, setFrame] = useState(0); + const shakeControls = useAnimationControls(); + + const skippedRef = useRef(false); + const confettiFiredRef = useRef(false); + const timers = useRef[]>([]); + + useEffect(() => { + return () => timers.current.forEach(clearTimeout); + }, []); + + // 뽑기 시작 후 결과 전까지는 닫기 불가 + useEffect(() => { + onBusyChange?.(phase === 'dropping' || phase === 'hatching'); + }, [phase, onBusyChange]); + + const wait = (ms: number) => + new Promise((resolve) => { + const id = setTimeout(resolve, skippedRef.current ? 0 : ms); + timers.current.push(id); + }); + + const fireConfetti = async (tier: AnimalTierType) => { + if (confettiFiredRef.current || reduce) return; + confettiFiredRef.current = true; + const theme = TIER_THEME[tier]; + const confetti = (await import('canvas-confetti')).default; + // 픽셀 톤: 사각형 + flat 으로 픽셀 조각처럼 + const base = { colors: theme.confetti, shapes: ['square' as const], flat: true, scalar: 1.2, zIndex: 9200 }; + confetti({ + ...base, + particleCount: theme.particles, + spread: isRareTier(tier) ? 120 : 70, + startVelocity: isRareTier(tier) ? 55 : 40, + origin: { y: 0.5 }, + }); + if (isRareTier(tier)) { + const id = setTimeout(() => { + confetti({ ...base, particleCount: 70, angle: 60, spread: 70, origin: { x: 0, y: 0.65 } }); + confetti({ ...base, particleCount: 70, angle: 120, spread: 70, origin: { x: 1, y: 0.65 } }); + }, 220); + timers.current.push(id); + } + }; + + const runReveal = async (drawn: Persona) => { + setPhase('hatching'); + const theme = TIER_THEME[drawn.tier]; + + // 고티어일수록 껍질을 여러 번 흔든 뒤 부화 (긴장감) + for (let r = 0; r < theme.rockLoops; r++) { + for (let f = 0; f <= 3; f++) { + setFrame(f); + await wait(70); + if (skippedRef.current) return; + } + } + + // 본 부화: 0 → 11 프레임 재생. 깨지는 순간(9~11)은 조금 느리게 눌러 임팩트. + for (let f = 0; f < EGG_FRAMES; f++) { + setFrame(f); + if (f === SHATTER_FRAME) { + fireConfetti(drawn.tier); + if (!reduce) shakeControls.start({ x: [0, -7, 7, -6, 6, -3, 3, 0], transition: { duration: 0.35 } }); + } + await wait(f >= SHATTER_FRAME ? Math.round(theme.hatchStepMs * 1.5) : theme.hatchStepMs); + if (skippedRef.current) return; + } + + await wait(450); + if (skippedRef.current) return; + setPhase('result'); + }; + + const draw = async () => { + if (phase !== 'idle') return; + setPhase('dropping'); + try { + const res = await onDraw(); + if (!res) { + onClose(); // onDraw가 에러 토스트/닫기를 처리함 + return; + } + const tier = getAnimalTierInfo(Number(res.dropRate.replace('%', ''))); + const drawn: Persona = { ...res, tier }; + setPersona(drawn); + await runReveal(drawn); + } catch { + onClose(); + } + }; + + // 화면 탭: 결과면 닫기, 부화 중이면(결과 확정 후) 스킵 + const handleStageClick = () => { + if (phase === 'result') { + onClose(); + return; + } + if (persona && phase === 'hatching') { + skippedRef.current = true; + timers.current.forEach(clearTimeout); + timers.current = []; + fireConfetti(persona.tier); + setPhase('result'); + } + }; + + const theme = persona ? TIER_THEME[persona.tier] : TIER_THEME.B_MINUS; + const showEgg = phase !== 'result'; + + return ( +
+ {/* 픽셀 GOTCHA 워드마크 */} + {phase !== 'result' && ( + GOTCHA + )} + + {/* 티어 오라 배경광 */} + + {persona && phase !== 'dropping' && ( + + )} + + + {showEgg && } + + {/* 부화 후 남은 껍질 — 펫 카드가 여기서 솟아오르며 껍질은 스르륵 사라짐 */} + {phase === 'result' && ( + + )} + + {phase === 'result' && persona && } + + {/* 하단 안내 문구 */} +
+ {phase === 'idle' &&

{t('pull-lever')}

} + {phase === 'dropping' &&

{t('gotcha-in-progress')}

} + {persona && phase === 'hatching' &&

{t('tap-to-skip')}

} +
+
+ ); +} + +function PixelEgg({ + phase, + frame, + reduce, + onDraw, + shake, +}: { + phase: Phase; + frame: number; + reduce: boolean; + onDraw: () => void; + shake: ReturnType; +}) { + const idle = phase === 'idle'; + const wobble = phase === 'dropping'; + const hatching = phase === 'hatching'; + + const anim = reduce ? {} : idle ? { y: [0, -6, 0] } : wobble ? { rotate: [0, -5, 5, -5, 5, 0] } : {}; + const transition = idle + ? { duration: 1.6, repeat: Infinity, ease: 'easeInOut' as const } + : { duration: 0.6, repeat: wobble ? Infinity : 0 }; + + return ( + { + e.stopPropagation(); + if (idle) onDraw(); + }} + disabled={!idle} + aria-label="draw pet" + // 부화 단계에선 껍질 깨질 때 흔들림 컨트롤을, 그 외엔 idle/대기 모션을 사용 + animate={hatching ? shake : anim} + transition={transition} + whileHover={idle ? { scale: 1.05 } : undefined} + whileTap={idle ? { scaleY: 0.9 } : undefined} + style={eggFrameStyle(frame)} + /> + ); +} + +function Result({ + persona, + theme, + tapLabel, +}: { + persona: Persona; + theme: (typeof TIER_THEME)[AnimalTierType]; + tapLabel: string; +}) { + return ( + +
+ {isRareTier(persona.tier) && ( + + )} +
+ +
+
+ +

{tapLabel}

+
+ ); +} diff --git a/apps/web/src/app/[locale]/shop/_petGotcha/OnePet.tsx b/apps/web/src/app/[locale]/shop/_petGotcha/OnePet.tsx index 106eccf1..ac411cd5 100644 --- a/apps/web/src/app/[locale]/shop/_petGotcha/OnePet.tsx +++ b/apps/web/src/app/[locale]/shop/_petGotcha/OnePet.tsx @@ -1,21 +1,18 @@ -import React from 'react'; +import React, { useState } from 'react'; import { signOut, useSession } from 'next-auth/react'; import { useTranslations } from 'next-intl'; -import type { GotchaResult } from '@gitanimals/api'; import { postGotcha } from '@gitanimals/api'; import { CustomException } from '@gitanimals/exception'; import { userQueries } from '@gitanimals/react-query'; import { Dialog } from '@gitanimals/ui-tailwind'; import { useQueryClient } from '@tanstack/react-query'; -import { overlay } from 'overlay-kit'; import { toast } from 'sonner'; import { sendMessageToErrorChannel } from '@/apis/slack/sendMessage'; -import { SelectedCardMotion } from '@/components/CardGame/FanDrawingGame/CardMotion'; -import { CardDrawingGame, DetailedCard } from '@/components/CardGame/FanDrawingGame/FanDrawingGame'; import { GITHUB_ISSUE_URL } from '@/constants/outlink'; import { trackEvent } from '@/lib/analytics'; +import { GachaHatchGame } from './GachaHatchGame'; import { useCheckEnoughMoney } from './useCheckEnoughMoney'; const ONE_PET_POINT = 1000 as const; @@ -31,7 +28,10 @@ function OnePet({ onClose }: Props) { const { data } = useSession(); const { checkEnoughMoney } = useCheckEnoughMoney({ enoughPoint: ONE_PET_POINT }); - const onAction = async () => { + // 뽑는 중(포인트 차감~결과 전)엔 닫기 차단 — 실수로 닫아 결과를 못 보는 것 방지 + const [busy, setBusy] = useState(false); + + const onDraw = async () => { try { if (!checkEnoughMoney()) { throw new Error(t('not-enough-points')); @@ -41,17 +41,14 @@ function OnePet({ onClose }: Props) { const resultPersona = res.gotchaResults[0]; queryClient.invalidateQueries({ queryKey: userQueries.userKey() }); - toast.success(t('get-persona-success')); - onClose(); - - handleShowResult(resultPersona); trackEvent('gotcha', { type: '1-pet', status: 'success', }); - return { type: resultPersona.name, dropRate: resultPersona.dropRate }; + // 성공 토스트/결과 오버레이는 GachaHatchGame의 연출이 담당한다. + return { name: resultPersona.name, dropRate: resultPersona.dropRate }; } catch (error) { trackEvent('gotcha', { type: '1-pet', @@ -92,35 +89,20 @@ Token: ${data?.user.accessToken} } }; - const handleShowResult = (resultPersona: GotchaResult) => { - overlay.open( - ({ isOpen, close }) => - isOpen && ( -
{ - close(); - }} - > - - - -

{t('click-to-close')}

-
- ), - ); - }; - return ( - - - {t('choose-one-card')} - - + { + // 뽑는 중엔 Esc/오버레이/X 로 닫히지 않도록 차단 + if (!open && !busy) onClose(); + }} + > + {/* 가챠 연출은 풀스크린 몰입형 — 박스 모달 대신 screen 변형 사용 */} + + {/* 타이틀은 GOTCHA 워드마크가 대신 — a11y용으로만 유지 */} + {t('pet-gotcha-title')} + + ); diff --git a/apps/web/src/components/CardGame/FanDrawingGame/CardMotion.tsx b/apps/web/src/components/CardGame/FanDrawingGame/CardMotion.tsx deleted file mode 100644 index f486efb0..00000000 --- a/apps/web/src/components/CardGame/FanDrawingGame/CardMotion.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import type { PropsWithChildren } from 'react'; -import { cn } from '@gitanimals/ui-tailwind'; -import { motion } from 'framer-motion'; - -interface CardMotionProps { - x: number; - y: number; - rotate: number; - index: number; -} - -export function NonSelectedCardMotion({ children, x, y, rotate }: PropsWithChildren) { - return ( - - {children} - - ); -} - -export function SelectedCardMotion({ children, x, y, rotate }: PropsWithChildren) { - return ( - - {children} - - ); -} - -const selectedCardStyle = 'w-[40vw] max-w-[400px]'; - -export function DrawingCardMotion({ - children, - x, - y, - rotate, - index, - onClick, -}: PropsWithChildren void }>) { - return ( - - {children} - - ); -} - -const cardStyle = 'absolute cursor-pointer'; diff --git a/apps/web/src/components/CardGame/FanDrawingGame/FanDrawingGame.tsx b/apps/web/src/components/CardGame/FanDrawingGame/FanDrawingGame.tsx deleted file mode 100644 index 697d0672..00000000 --- a/apps/web/src/components/CardGame/FanDrawingGame/FanDrawingGame.tsx +++ /dev/null @@ -1,257 +0,0 @@ -/* eslint-disable jsx-a11y/click-events-have-key-events */ -/* eslint-disable jsx-a11y/no-static-element-interactions */ -'use client'; - -import { useEffect, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { CardBack as CardBackUi } from '@gitanimals/ui-tailwind'; -import { motion } from 'framer-motion'; - -import { AnimalCard } from '@/components/AnimalCard'; - -import { DrawingCardMotion, NonSelectedCardMotion } from './CardMotion'; - -interface CardDrawingGameProps { - characters: { id: number }[]; - onSelectCard: (index: number) => Promise<{ type: string; dropRate: string } | undefined>; - onClose: () => void; -} - -export function CardDrawingGame({ characters, onSelectCard, onClose }: CardDrawingGameProps) { - const t = useTranslations('Gotcha'); - - const [gameState, setGameState] = useState<'ready' | 'drawing' | 'selected' | 'revealing'>('ready'); - const [selectedCards, setSelectedCards] = useState([]); - const [selectedCardIndex, setSelectedCardIndex] = useState(null); - const [isAnimating, setIsAnimating] = useState(false); - const [cardData, setCardData] = useState<{ type: string; dropRate: string } | null>(null); - - const startDrawing = () => { - if (isAnimating) return; - - setIsAnimating(true); - setGameState('drawing'); - - const allCardIds = characters.map((char) => char.id); - - setSelectedCards(allCardIds); - setSelectedCardIndex(null); - - setTimeout(() => { - setIsAnimating(false); - }, 1000); - }; - - const resetGame = () => { - if (isAnimating) return; - - setIsAnimating(true); - setGameState('ready'); - setSelectedCards([]); - setSelectedCardIndex(null); - - setTimeout(() => { - setIsAnimating(false); - }, 500); - }; - - const closeGame = () => { - console.log('closeGame'); - onClose(); - resetGame(); - }; - - const getFanPosition = (index: number, total: number) => { - const radius = 60; - const totalAngle = 120; - const startAngle = -60; - - const angle = startAngle + (index / (total - 1)) * totalAngle; - - const radians = (angle * Math.PI) / 180; - - const x = Math.sin(radians) * radius * 2; - const y = (-Math.cos(radians) * radius) / 2; - - const rotate = angle / 1.5; - - return { x, y, rotate }; - }; - - const selectCard = async (index: number) => { - if (isAnimating || gameState !== 'drawing') return; - - setIsAnimating(true); - setSelectedCardIndex(index); - setGameState('revealing'); - - try { - // 카드 선택 시 API 호출 및 결과 대기 - // 이 시간 동안 카드가 흔들리는 애니메이션 표시 - const result = await onSelectCard(index); - if (!result) { - throw new Error('카드를 뽑을 수 없습니다.'); - } - - setCardData(result); - setGameState('selected'); - } catch (error) { - console.error('카드 선택 중 오류 발생:', error); - setGameState('drawing'); - } finally { - setIsAnimating(false); - } - }; - - useEffect(() => { - startDrawing(); - }, []); - - return ( -
-
- {gameState === 'drawing' && ( -
- {selectedCards.map((cardId, index) => { - const { x, y, rotate } = getFanPosition(index, selectedCards.length); - - return ( - selectCard(index)} - > - - - ); - })} -
- )} - - {gameState === 'revealing' && selectedCardIndex !== null && ( -
- {selectedCards.map((cardId, index) => { - const isSelected = index === selectedCardIndex; - const { x, y, rotate } = getFanPosition(index, selectedCards.length); - - if (isSelected) { - return ( - - - - ); - } else { - return ( - - - - ); - } - })} -
- )} - - {gameState === 'selected' && selectedCardIndex !== null && ( -
- {selectedCards.map((cardId, index) => { - const isSelected = index === selectedCardIndex; - const { x, y, rotate } = getFanPosition(index, selectedCards.length); - - if (isSelected && cardData) { - return null; - // - //
- // - // - // - //

{t('click-to-close')}

- //
- //
- } else { - return ( - - - - ); - } - })} -
- )} -
-
- ); -} - -function CardBack() { - return ( -
- -
- ); -} - -export function DetailedCard({ cardData }: { cardData: { type: string; dropRate: string } }) { - // cardData가 없으면 기본값 사용 - const getPersona = cardData || { - tier: 'S_PLUS' as const, - type: 'SCREAM' as const, - dropRate: '10%' as const, - }; - - return ( -
- -
- ); -} - -// RevealingCardMotion 컴포넌트 추가 (카드 흔들림 효과) -function RevealingCardMotion({ - children, - x, - y, - rotate, -}: { - children: React.ReactNode; - x: number; - y: number; - rotate: number; - index: number; -}) { - return ( - - {children} - - ); -} - -const containerStyle = 'w-full mx-auto'; - -const gameAreaStyle = 'relative h-[360px] flex items-center justify-center'; - -const cardContainerStyle = 'relative z-0 w-full h-full flex items-center justify-center'; - -const overlayStyle = - 'fixed top-0 left-0 w-full h-full bg-black-50 flex items-center justify-center backdrop-blur-[10px] flex-col gap-[100px] z-[9001]'; - -const revealingCardMotionStyle = 'absolute z-10 [transform-style:preserve-3d] cursor-pointer'; - -const cardBackStyle = 'w-[220px] h-[272px] overflow-hidden'; - -const detailedCardStyle = 'h-auto overflow-hidden relative [transform-style:preserve-3d] aspect-[220/272]';