Skip to content

Commit edca54a

Browse files
committed
feat: 점수 관련 데이터 구조 개선 및 애니메이션 로직 수정
1 parent 12cf5cb commit edca54a

File tree

5 files changed

+161
-85
lines changed

5 files changed

+161
-85
lines changed

src/assets/images/ic_score.gif

-17.3 KB
Binary file not shown.

src/assets/images/ic_score.png

244 Bytes
Loading

src/mocks/handlers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,8 @@ export const handlers = [
528528
endedAt: "2024-01-15T12:45:00Z",
529529
totalDuration: 8100, // 2시간 15분 (초 단위)
530530
srGained: 180,
531+
previousSr: 900,
532+
currentSr: 1080,
531533
completedCount: 6,
532534
attemptedCount: 10,
533535
},
@@ -537,6 +539,8 @@ export const handlers = [
537539
endedAt: "2024-01-14T16:00:00Z",
538540
totalDuration: 6000, // 1시간 40분
539541
srGained: 1001,
542+
previousSr: 1200,
543+
currentSr: 2201,
540544
completedCount: 3,
541545
attemptedCount: 7,
542546
},
@@ -546,6 +550,8 @@ export const handlers = [
546550
endedAt: "2024-01-13T11:30:00Z",
547551
totalDuration: 8100, // 2시간 15분
548552
srGained: 250,
553+
previousSr: 1500,
554+
currentSr: 1750,
549555
completedCount: 8,
550556
attemptedCount: 12,
551557
},
@@ -556,6 +562,8 @@ export const handlers = [
556562
endedAt: undefined,
557563
totalDuration: 3600, // 1시간
558564
srGained: 45,
565+
previousSr: 800,
566+
currentSr: 845,
559567
completedCount: 2,
560568
attemptedCount: 4,
561569
},
@@ -566,6 +574,8 @@ export const handlers = [
566574
endedAt: "2024-01-12T21:15:00Z",
567575
totalDuration: 8100, // 2시간 15분
568576
srGained: 30,
577+
previousSr: 640,
578+
currentSr: 670,
569579
completedCount: 1,
570580
attemptedCount: 8,
571581
},

src/routes/session/$sessionId/index.tsx

Lines changed: 47 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,20 @@
11
import { useQuery } from "@tanstack/react-query";
22
import { createFileRoute, Link } from "@tanstack/react-router";
33
import { Dialog } from "radix-ui";
4-
import { useState } from "react";
4+
import { useEffect, useState } from "react";
55

66
import assetFailIcon from "@/assets/images/ic_failure.png";
7-
import assetScoreIcon from "@/assets/images/ic_score.gif";
7+
import assetScoreIcon from "@/assets/images/ic_score.png";
88
import assetSuccessIcon from "@/assets/images/ic_success.png";
99
import Button from "@/components/Button";
1010
import { DialogLevelDescriptionContent } from "@/components/dialog-level-description-content/DialogLevelDescriptionContent";
1111
import { MotionNumberFlow } from "@/components/motion-number-flow/MotionNumberFlow";
1212
import { Timer } from "@/components/timer/Timer";
1313
import { getUserSession } from "@/generated/user-session/user-session";
14-
import { useToast } from "@/hooks/useToast";
1514
import { getHeaderToken } from "@/utils/cookie";
1615
import { getLevelInfo } from "@/utils/level";
1716
import { SessionLevelProgress } from "../-components/session-level-progress";
1817

19-
// 날짜를 한국어 형식으로 포맷팅하는 함수
2018
function formatDateToKorean(dateString?: string): string {
2119
if (!dateString) return "";
2220

@@ -37,10 +35,6 @@ export const Route = createFileRoute("/session/$sessionId/")({
3735

3836
function RouteComponent() {
3937
const { sessionId } = Route.useParams();
40-
const { showToast } = useToast();
41-
42-
// currentExp 제어를 위한 상태 (훅은 최상단에 위치)
43-
const [currentExp, setCurrentExp] = useState(0);
4438

4539
const {
4640
data: sessionInfo,
@@ -52,16 +46,45 @@ function RouteComponent() {
5246
select: (data) => data.data,
5347
});
5448

55-
// 점수 애니메이션 완료 후 currentExp 적용
56-
const handleScoreAnimationComplete = () => {
57-
setCurrentExp(levelInfo.currentExp);
58-
};
49+
// 서버 데이터를 사용하여 값 계산
50+
const scoreValue = sessionInfo?.srGained ?? 0;
51+
const successValue = sessionInfo?.completedCount ?? 0;
52+
const failureValue =
53+
(sessionInfo?.attemptedCount ?? 0) - (sessionInfo?.completedCount ?? 0);
5954

60-
// 레벨업 축하 메시지
61-
const handleLevelUp = () => {
62-
showToast(
63-
`🎉 레벨업!\n축하합니다! 레벨 ${levelInfo.displayLevel}에 도달했습니다!`
64-
);
55+
// 이전/현재 점수 기반 레벨 정보 계산
56+
const previousSr = sessionInfo?.previousSr ?? 0;
57+
const currentSr = sessionInfo?.currentSr ?? previousSr;
58+
const prevLevelInfo = getLevelInfo(previousSr);
59+
const nextLevelInfo = getLevelInfo(currentSr);
60+
61+
// Progress 표시용 상태: 이전 점수 상태로 시작 → 점수 애니메이션 완료 후 현재 점수 상태로 전환
62+
const [displayLevel, setDisplayLevel] = useState(prevLevelInfo.displayLevel);
63+
const [displayCurrentExp, setDisplayCurrentExp] = useState(
64+
prevLevelInfo.currentExp
65+
);
66+
const [displayLevelExp, setDisplayLevelExp] = useState(
67+
prevLevelInfo.levelExp
68+
);
69+
70+
// 세션 정보가 바뀌면 초기 표시 상태를 이전 점수 기준으로 재설정
71+
useEffect(() => {
72+
setDisplayLevel(prevLevelInfo.displayLevel);
73+
setDisplayCurrentExp(prevLevelInfo.currentExp);
74+
setDisplayLevelExp(prevLevelInfo.levelExp);
75+
}, [
76+
prevLevelInfo.displayLevel,
77+
prevLevelInfo.currentExp,
78+
prevLevelInfo.levelExp,
79+
]);
80+
81+
// 점수 애니메이션 완료 후 0.5초 지연 뒤 현재 점수 기준으로 Progress 갱신
82+
const handleScoreAnimationComplete = () => {
83+
setTimeout(() => {
84+
setDisplayLevel(nextLevelInfo.displayLevel);
85+
setDisplayCurrentExp(nextLevelInfo.currentExp);
86+
setDisplayLevelExp(nextLevelInfo.levelExp);
87+
}, 500);
6588
};
6689

6790
// 로딩 상태 처리
@@ -84,19 +107,6 @@ function RouteComponent() {
84107
);
85108
}
86109

87-
// 서버 데이터를 사용하여 값 계산
88-
const scoreValue = sessionInfo?.srGained ?? 0;
89-
const successValue = sessionInfo?.completedCount ?? 0;
90-
const failureValue =
91-
(sessionInfo?.attemptedCount ?? 0) - (sessionInfo?.completedCount ?? 0);
92-
93-
// TODO: sessionInfo에 totalScore 필드가 추가되면 해당 값을 사용
94-
// 현재는 임시로 srGained 값을 totalScore로 사용
95-
const totalScore = (sessionInfo as any)?.totalScore ?? scoreValue;
96-
97-
// 레벨 정보 계산
98-
const levelInfo = getLevelInfo(totalScore);
99-
100110
return (
101111
<div className=" h-dvh px-4 flex flex-col">
102112
<header className="flex justify-end items-center h-[52px] w-full">
@@ -137,8 +147,8 @@ function RouteComponent() {
137147
className="pt-2 t-m-48-b"
138148
/>
139149
</div>
140-
<div className="flex items-center w-full rounded-[24px] bg-neutral-100 px-4 py-6 gap-4 mt-6">
141-
<div className="flex flex-col flex-1 items-center justify-center">
150+
<div className="flex items-center w-full rounded-[24px] bg-neutral-100 px-4 py-6 gap-4 mt-6 relative">
151+
<div className="flex flex-col flex-1 items-center justify-center relative">
142152
<img
143153
src={assetSuccessIcon}
144154
alt="성공 횟수"
@@ -162,7 +172,7 @@ function RouteComponent() {
162172
/>
163173
<p className="t-p-14-m pt-1 text-neutral-500">실패</p>
164174
</div>
165-
<div className="flex flex-col flex-1 items-center justify-center">
175+
<div className="flex flex-col flex-1 items-center justify-center relative">
166176
<img
167177
src={assetScoreIcon}
168178
alt="점수"
@@ -180,11 +190,11 @@ function RouteComponent() {
180190
<div className="flex items-center w-full rounded-[24px] bg-neutral-100 px-4 py-6 gap-4 mt-6">
181191
<div className="flex flex-col flex-1">
182192
<SessionLevelProgress
183-
level={levelInfo.displayLevel}
184-
currentExp={currentExp}
185-
levelExp={levelInfo.levelExp}
193+
level={displayLevel}
194+
currentExp={displayCurrentExp}
195+
levelExp={displayLevelExp}
186196
progressWrapperClassName="w-full"
187-
onLevelUp={handleLevelUp}
197+
onLevelUp={() => {}}
188198
/>
189199
</div>
190200
</div>

src/routes/session/-components/session-level-progress.tsx

Lines changed: 104 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { motion } from "motion/react";
22
import { Progress as ProgressPrimitive } from "radix-ui";
3-
import { useCallback, useEffect, useState } from "react";
3+
import { useCallback, useEffect, useRef, useState } from "react";
44
import { match } from "ts-pattern";
55
import assetLevel1 from "@/assets/images/ic_lv1.png";
66
import assetLevel2 from "@/assets/images/ic_lv2.png";
77
import assetLevel3 from "@/assets/images/ic_lv3.png";
88
import assetLevel4 from "@/assets/images/ic_lv4.png";
99
import assetLevel5 from "@/assets/images/ic_lv5.png";
10+
import { LEVEL_SCORE_STANDARD } from "@/constants/level";
1011
import { cn } from "@/utils/cn";
1112

1213
interface LevelProgressProps {
@@ -30,11 +31,34 @@ export const SessionLevelProgress = ({
3031
}: LevelProgressProps) => {
3132
const [internalProgress, setInternalProgress] = useState(0);
3233
const [showLevelUp, setShowLevelUp] = useState(false);
33-
const [prevLevel, setPrevLevel] = useState(level);
34+
const [displayedLevel, setDisplayedLevel] = useState(level);
3435
const [isLevelingUp, setIsLevelingUp] = useState(false);
3536

3637
const progressPercentage = Math.min((currentExp / levelExp) * 100, 100);
3738

39+
const mountedRef = useRef(true);
40+
useEffect(() => {
41+
mountedRef.current = true;
42+
return () => {
43+
mountedRef.current = false;
44+
};
45+
}, []);
46+
47+
const getLevelExpByDisplayLevel = useCallback(
48+
(displayLevel: number) => {
49+
for (const [, config] of Object.entries(LEVEL_SCORE_STANDARD)) {
50+
if (config.displayLevel === displayLevel) {
51+
if (config.max === null) {
52+
return 1000;
53+
}
54+
return config.max - config.min + 1;
55+
}
56+
}
57+
return levelExp;
58+
},
59+
[levelExp]
60+
);
61+
3862
// controlled 또는 uncontrolled 결정
3963
const progress =
4064
controlledProgress !== undefined ? controlledProgress : internalProgress;
@@ -49,34 +73,88 @@ export const SessionLevelProgress = ({
4973
[controlledProgress, onProgressChange]
5074
);
5175

76+
// 레벨 업 애니메이션 시퀀스 실행
5277
useEffect(() => {
53-
// 레벨업 체크
54-
if (level > prevLevel) {
78+
// 초기/일반 진행률 갱신 (레벨 변동 없음, 또는 애니메이션 중 아님)
79+
if (!isLevelingUp && level === displayedLevel) {
80+
setProgress(progressPercentage);
81+
return;
82+
}
83+
84+
// 레벨 업 발생: displayedLevel < level 인 경우 시퀀스 실행
85+
if (!isLevelingUp && displayedLevel < level) {
5586
setIsLevelingUp(true);
56-
setShowLevelUp(true);
57-
onLevelUp?.();
58-
// 축하 애니메이션 후 초기화
59-
setTimeout(() => {
60-
setShowLevelUp(false);
61-
setIsLevelingUp(false);
62-
}, 3000); // 3초로 연장
63-
setProgress(0);
64-
} else {
65-
// 일반 진행률 애니메이션
66-
if (!isLevelingUp) {
67-
setProgress(progressPercentage);
68-
}
87+
88+
const runSequence = (fromLevel: number) => {
89+
// 1) 현재 레벨에서 progress를 100%까지 채우기
90+
setProgress(100);
91+
92+
// 채움 애니메이션(transition 300ms) 완료 후 아이콘 변경 및 흔들기
93+
const afterFill = window.setTimeout(() => {
94+
// 2) 레벨 아이콘 교체 + 좌우 로테이트 애니메이션
95+
setDisplayedLevel(fromLevel + 1);
96+
setShowLevelUp(true);
97+
onLevelUp?.();
98+
99+
// 3) 1초 후 progress 0으로 초기화 후 남은 경험치 반영 또는 다음 레벨업 반복
100+
const afterWiggle = window.setTimeout(() => {
101+
setShowLevelUp(false);
102+
setProgress(0);
103+
104+
const nextLevel = fromLevel + 1;
105+
const isLastLevel = nextLevel >= level;
106+
107+
// 텍스트 표시에 사용할 목표 레벨 경험치 계산 (실제 바 너비는 progress로 제어)
108+
const nextLevelExp = getLevelExpByDisplayLevel(nextLevel);
109+
// 마지막 레벨이면 남은 경험치 반영, 아니면 바로 다음 사이클로 진행
110+
timeoutsRef.current.push(
111+
window.setTimeout(() => {
112+
if (!mountedRef.current) return;
113+
if (isLastLevel) {
114+
// 마지막 레벨: 남은 경험치 비율로 진행률 설정
115+
setProgress(progressPercentage);
116+
setIsLevelingUp(false);
117+
// displayedLevel은 이미 nextLevel로 맞춰져 있음
118+
} else {
119+
// 중간 레벨: 0에서 다시 꽉 채우는 사이클 반복
120+
runSequence(nextLevel);
121+
}
122+
}, 50)
123+
);
124+
125+
// nextLevelExp는 현재 표시 텍스트용으로만 활용 가능하지만,
126+
// 텍스트는 상위에서 전달되는 props를 사용하므로 추가 상태 변경은 생략
127+
void nextLevelExp;
128+
}, 1000);
129+
130+
// 정리: 위 타이머를 클린업에서 취소
131+
timeoutsRef.current.push(afterWiggle);
132+
}, 800);
133+
134+
timeoutsRef.current.push(afterFill);
135+
};
136+
137+
runSequence(displayedLevel);
69138
}
70-
setPrevLevel(level);
71139
}, [
72140
level,
141+
displayedLevel,
142+
isLevelingUp,
73143
progressPercentage,
74-
prevLevel,
75144
onLevelUp,
76-
isLevelingUp,
77145
setProgress,
146+
getLevelExpByDisplayLevel,
78147
]);
79148

149+
// 타이머 정리용 ref
150+
const timeoutsRef = useRef<number[]>([]);
151+
useEffect(() => {
152+
return () => {
153+
timeoutsRef.current.forEach((id) => window.clearTimeout(id));
154+
timeoutsRef.current = [];
155+
};
156+
}, []);
157+
80158
return (
81159
<div className={cn("flex gap-1 w-full")}>
82160
{/* 왼쪽 영역: 말풍선과 진행률바 */}
@@ -162,10 +240,12 @@ export const SessionLevelProgress = ({
162240
<div className="flex items-center">
163241
<motion.div
164242
className="relative w-16 h-16 shrink-0"
165-
animate={showLevelUp ? { scale: [1, 1.2, 1], rotate: [0, 360] } : {}}
166-
transition={{ duration: 0.8, ease: "easeOut" }}
243+
animate={
244+
showLevelUp ? { rotate: [0, 20, -20, 10, -10, 0] } : { rotate: 0 }
245+
}
246+
transition={{ duration: 1, ease: "easeOut" }}
167247
>
168-
{match(level)
248+
{match(displayedLevel)
169249
.with(1, () => (
170250
<img src={assetLevel1} className="size-full" alt="레벨 1" />
171251
))
@@ -182,31 +262,7 @@ export const SessionLevelProgress = ({
182262
<img src={assetLevel5} className="size-full" alt="레벨 5" />
183263
))
184264
.otherwise(() => (
185-
<>
186-
<div className="w-full h-full bg-neutral-100 border-2 border-neutral-300 rounded-full" />
187-
{/* 다이아몬드 아이콘 */}
188-
<i className="absolute top-[8.47px] left-[5.65px] w-[14.12px] h-[11.29px]">
189-
<svg
190-
width="14"
191-
height="12"
192-
viewBox="0 0 14 12"
193-
fill="none"
194-
xmlns="http://www.w3.org/2000/svg"
195-
>
196-
<title>Diamond Icon</title>
197-
<path
198-
d="M7 0L13.0622 6L7 12L0.937822 6L7 0Z"
199-
fill="#D2D6DB"
200-
/>
201-
</svg>
202-
</i>
203-
{/* 레벨 숫자 */}
204-
<div className="absolute bottom-[8.47px] right-[6.59px] w-[8.47px] h-[8.47px] flex items-center justify-center">
205-
<span className="text-[8px] font-semibold text-neutral-700 leading-none">
206-
{level}
207-
</span>
208-
</div>
209-
</>
265+
<img src={assetLevel1} className="size-full" alt="레벨 1" />
210266
))}
211267
</motion.div>
212268
</div>

0 commit comments

Comments
 (0)