Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: quiz (오답 터뜨리기) #284

Merged
merged 8 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/images/bomb-fire.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/bomb-not-fire.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/count-device.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/empty-bomb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/images/star-background.png
Binary file not shown.
Binary file removed public/images/stars-in-box.png
Binary file not shown.
Binary file removed public/images/stars-in-pocket.png
Binary file not shown.
2 changes: 1 addition & 1 deletion src/app/(routes)/main/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const Home = () => {
{/* 오답 / 랜덤 퀴즈 */}
<div className="mt-[16px] flex gap-[9px]">
<Link
href={''}
href={'/quiz/bomb'}
className="flex w-1/2 flex-col rounded-[20px] bg-background-base-01 px-[20px] pb-[7px] pt-[16px]"
>
<Text typography="subtitle1-bold" className="mb-[2px]">
Expand Down
2 changes: 0 additions & 2 deletions src/app/(routes)/quiz/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { quizzes } from '@/features/quiz/config'
import BombQuizView from '@/features/quiz/screen/bomb-quiz-view'
import QuizView from '@/features/quiz/screen/quiz-view'
import RandomQuizView from '@/features/quiz/screen/random-quiz-view'
import { fetchQuizSet } from '@/requests/quiz'
Expand Down Expand Up @@ -28,7 +27,6 @@ const QuizDetailPage = ({ params, searchParams }: Props) => {
{quizType === 'today' && <QuizView quizzes={quizzes} />}
{/* {quizType === 'today' && <QuizView quizzes={quizSet.quizzes} />} */}
{quizType === 'random' && <RandomQuizView />}
{quizType === 'bomb' && <BombQuizView />}
</>
)
}
Expand Down
17 changes: 17 additions & 0 deletions src/app/(routes)/quiz/bomb/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { FunctionComponent, PropsWithChildren, Suspense } from 'react'
import type { Metadata } from 'next'
import Loading from '@/shared/components/custom/loading'

export const metadata: Metadata = {}

interface LayoutProps extends PropsWithChildren {}

const Layout: FunctionComponent<LayoutProps> = ({ children }) => {
return (
<main>
<Suspense fallback={<Loading center />}>{children}</Suspense>
</main>
)
}

export default Layout
7 changes: 7 additions & 0 deletions src/app/(routes)/quiz/bomb/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import BombQuizView from '@/features/quiz/screen/bomb-quiz-view'

const BombQuizPage = () => {
return <BombQuizView />
}

export default BombQuizPage
8 changes: 5 additions & 3 deletions src/app/(routes)/quiz/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ const QuizPage = () => {
// const { quizSetId, type } = await fetchTodayQuizSetId()

return (
<Link href={`/quiz/1?quizType=today`}>
<Button>오늘의 퀴즈 풀러가기</Button>
</Link>
<>
<Link href={`/quiz/1?quizType=today`}>
<Button>오늘의 퀴즈 풀러가기</Button>
</Link>
</>

// <Link href={type === 'READY' ? `/quiz/${quizSetId}?quizType=today` : '#'}>
// <Button>오늘의 퀴즈 풀러가기</Button>
Expand Down
121 changes: 121 additions & 0 deletions src/features/quiz/components/bomb-animation-fail/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import Image from 'next/image'
import { motion } from 'framer-motion'

interface Props {
leftQuizCount: number
setOpenExplanation: (value: boolean) => void
}

const BombAnimationFail = ({ leftQuizCount, setOpenExplanation }: Props) => {
const bombPositions = [
// 이전 쌓여있는 폭탄들 (고정 이동)
{ x: '-210px', targetX: '-275px', rotate: -90, delay: 0.5 },
{ x: '-145px', targetX: '-210px', rotate: -90, delay: 0.5 },
{ x: '-80px', targetX: '-145px', rotate: -90, delay: 0.5 },
// 회전 및 이동 폭탄
{ x: '50%', targetX: '-80px', rotate: -90, delay: 0.5, isAnimated: true },
// 남은 폭탄
{ x: '120px', targetX: '50%', delay: 1, condition: leftQuizCount >= 2 },
{ x: '200px', targetX: '120px', delay: 1, condition: leftQuizCount >= 3 },
{ x: '280px', targetX: '200px', delay: 1, condition: leftQuizCount > 3 },
]

return (
<div className="relative size-full overflow-x-hidden overflow-y-visible">
{/* 이전 폭탄 */}
{bombPositions.slice(0, 3).map((bomb, index) => (
<Bomb
key={index}
x={bomb.x}
targetX={bomb.targetX}
rotate={bomb.rotate ?? 0}
delay={bomb.delay}
/>
))}

{/* 회전 및 이동하는 폭탄 */}
{bombPositions.slice(3, 4).map((bomb, index) => (
<AnimatedBomb
key={`animated-${index}`}
x={bomb.x}
targetX={bomb.targetX}
rotate={bomb.rotate ?? 0}
delay={bomb.delay}
onAnimationComplete={() => setOpenExplanation(true)}
/>
))}

{/* 남은 폭탄 */}
{bombPositions
.slice(4)
.map(
(bomb, index) =>
bomb.condition && (
<Bomb
key={`conditional-${index}`}
x={bomb.x}
targetX={bomb.targetX}
rotate={bomb.rotate ?? 0}
delay={bomb.delay}
/>
)
)}
</div>
)
}

export default BombAnimationFail

/** 기본 폭탄 컴포넌트 */
const Bomb = ({
x,
targetX,
rotate,
delay,
}: {
x: string
targetX: string
rotate: number
delay: number
}) => (
<motion.div
className="center"
initial={{ x, y: '50%', rotate, opacity: 0.5 }}
animate={{ x: targetX, y: '50%' }}
transition={{
duration: 0.5,
delay,
ease: 'easeInOut',
}}
>
<Image src={'/images/bomb-not-fire.png'} alt="" width={55} height={67.65} />
</motion.div>
)

/** 회전 및 이동 폭탄 컴포넌트 */
const AnimatedBomb = ({
x,
targetX,
rotate,
delay,
onAnimationComplete,
}: {
x: string
targetX: string
rotate: number
delay: number
onAnimationComplete: () => void
}) => (
<motion.div
className="center"
initial={{ x, y: '50%', opacity: 1, rotate: 0 }}
animate={{ x: targetX, y: '50%', opacity: 0.5, rotate }}
transition={{
rotate: { duration: 0.5, ease: 'easeInOut' },
default: { duration: 0.5, delay, ease: 'easeInOut' },
}}
onAnimationComplete={onAnimationComplete}
>
<Image src={'/images/bomb-not-fire.png'} alt="" width={55} height={67.65} />
</motion.div>
)
133 changes: 133 additions & 0 deletions src/features/quiz/components/bomb-animation-success/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
'use client'

import Icon from '@/shared/components/custom/icon'
import Image from 'next/image'
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'

interface Props {
leftQuizCount: number
onNext: () => void
}

const BombAnimationSuccess = ({ leftQuizCount, onNext }: Props) => {
const bombPositions = [
{ x: '-210px' },
{ x: '-145px' },
{ x: '-80px' },
{ x: '120px', targetX: '50%', condition: leftQuizCount >= 2 },
{ x: '200px', targetX: '120px', condition: leftQuizCount >= 3 },
{ x: '280px', targetX: '200px', condition: leftQuizCount > 3 },
]

return (
<div className="relative size-full overflow-x-hidden overflow-y-visible">
{bombPositions.slice(0, 3).map((pos, index) => (
<Bomb key={index} x={pos.x} />
))}

{/* 점화 -> 폭탄 제거 */}
<FlameBomb leftQuizCount={leftQuizCount} onNext={onNext} />

{bombPositions.slice(3).map((pos, index) => {
const actualIndex = index + 3

return (
pos.condition && (
<MovingBomb
key={actualIndex}
initialX={pos.x}
targetX={pos.targetX}
delay={1}
onNext={index === 0 ? onNext : undefined}
/>
)
)
})}
</div>
)
}

export default BombAnimationSuccess

/** 이전에 쌓여있는 폭탄 컴포넌트 */
const Bomb = ({ x }: { x: string }) => (
<motion.div className="center" initial={{ x, y: '50%', rotate: -90, opacity: 0.5 }}>
<Image src={'/images/bomb-not-fire.png'} alt="" width={55} height={67.65} />
</motion.div>
)

/** 점화 후 폭탄 제거 애니메이션 */
const FlameBomb = ({ leftQuizCount, onNext }: { leftQuizCount: number; onNext: () => void }) => {
const [bombSrc, setBombSrc] = useState('/images/bomb-not-fire.png')

useEffect(() => {
const timer = setTimeout(() => {
setBombSrc('/images/bomb-fire.png')
}, 350)

return () => clearTimeout(timer)
}, [])

return (
<>
<motion.div
className="relative size-full"
initial={{ opacity: 1 }}
animate={{
opacity: [1, 0, 1, 0, 0],
}}
transition={{
duration: 0.5,
delay: 0.5,
}}
onAnimationComplete={() => leftQuizCount === 1 && onNext()}
>
<AnimatedFlame />
<Image src={bombSrc} alt="" width={55} height={67.65} className="center" />
</motion.div>
</>
)
}

/** 움직이는 폭탄 */
const MovingBomb = ({
initialX,
targetX,
delay,
onNext,
}: {
initialX: string
targetX: string
delay: number
onNext?: () => void
}) => (
<motion.div
className="center"
initial={{ x: initialX, y: '50%' }}
animate={{ x: targetX, y: '50%' }}
transition={{
duration: 0.5,
delay,
ease: 'easeOut',
}}
onAnimationComplete={onNext}
>
<Image src={'/images/bomb-not-fire.png'} alt="" width={55} height={67.65} />
</motion.div>
)

/** AnimatedFlame 컴포넌트 */
const AnimatedFlame = () => (
<motion.div
className="absolute bottom-1/2 right-1/2 z-50"
initial={{ x: '30px', y: '-150%', opacity: 0 }}
animate={{ x: '30px', y: '-55%', opacity: 1 }}
transition={{
duration: 0.5,
ease: 'easeOut',
}}
>
<Icon name="today-quiz" className="size-[47px]" />
</motion.div>
)
Loading