11import { motion } from "motion/react" ;
22import { Progress as ProgressPrimitive } from "radix-ui" ;
3- import { useCallback , useEffect , useState } from "react" ;
3+ import { useCallback , useEffect , useRef , useState } from "react" ;
44import { match } from "ts-pattern" ;
55import assetLevel1 from "@/assets/images/ic_lv1.png" ;
66import assetLevel2 from "@/assets/images/ic_lv2.png" ;
77import assetLevel3 from "@/assets/images/ic_lv3.png" ;
88import assetLevel4 from "@/assets/images/ic_lv4.png" ;
99import assetLevel5 from "@/assets/images/ic_lv5.png" ;
10+ import { LEVEL_SCORE_STANDARD } from "@/constants/level" ;
1011import { cn } from "@/utils/cn" ;
1112
1213interface 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