-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Implement game result podium page (#191)
* feat: Add podium GIF * feat: Add ResultPage routing * feat: Add useTimeout custom hook * feat: Add game result podium page - Add victory podium display showing top 3 players - Responsive design for mobile and desktop - Auto-redirect to lobby after 20s timeout - Position-based styling for 1st, 2nd, 3rd places * feat: remove game reset action * feat: Add game termination handler with notifications - Add toast message for game end notification - Handle player disconnect scenarios - Add game state cleanup on termination * feat: Improve ResultPage code organization and styling - Destructure player properties for better readability - Add background color to profile image - Remove redundant style constants * docs: Add useTimeout typedoc * feat: Change room status to WAITING after game ends * feat: Exclude participants with zero score from awards * feat: Add host transfer when player leaves game - Update host ID when current host leaves - Set isHost flag based on current hostId - Ensure game continues with new host * feat: Add tied scores in game result podium - Add logic to group players with identical scores into same rank - Adjust podium layout to accommodate multiple players - Update score display positions for tied players * refactor: Extract PodiumPlayers component from ResultPage * refactor: Extract ranking logic from ResultPage
- Loading branch information
Showing
11 changed files
with
272 additions
and
11 deletions.
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
import { Player } from '@troublepainter/core'; | ||
import { cn } from '@/utils/cn'; | ||
|
||
const positionStyles = { | ||
first: { | ||
containerStyle: 'absolute w-[40%] left-[30%] top-[29%]', | ||
scoreStyle: 'bottom-[36%] left-[48%]', | ||
}, | ||
second: { | ||
containerStyle: 'absolute w-[40%] left-[1%] bottom-[37%]', | ||
scoreStyle: 'bottom-[23%] left-[18%]', | ||
}, | ||
third: { | ||
containerStyle: 'absolute w-[40%] right-[1%] bottom-[28%]', | ||
scoreStyle: 'bottom-[18%] right-[17.5%]', | ||
}, | ||
}; | ||
|
||
interface PodiumPlayersProps { | ||
players: Player[]; | ||
position: 'first' | 'second' | 'third'; | ||
} | ||
|
||
const PodiumPlayers = ({ players, position }: PodiumPlayersProps) => { | ||
if (!players || players.length === 0 || players[0].score === 0) return null; | ||
|
||
const { containerStyle, scoreStyle } = positionStyles[position]; | ||
|
||
return ( | ||
<> | ||
<span className={cn(`absolute text-2xl sm:text-3xl`, scoreStyle)}> | ||
{String(players[0].score).padStart(2, '0')} | ||
</span> | ||
<div className={cn('flex justify-center gap-2', containerStyle)}> | ||
{players.map((player) => ( | ||
<div key={player.playerId} className={cn('flex animate-bounce flex-col items-center justify-center')}> | ||
<img | ||
src={player.profileImage} | ||
alt={`${player.nickname} 프로필 사진`} | ||
className={cn( | ||
'rounded-[0.3rem] border-2 border-chartreuseyellow-500 bg-eastbay-50', | ||
'h-10 w-10', | ||
'sm:h-16 sm:w-16', | ||
)} | ||
/> | ||
<span className="truncate text-xs text-stroke-sm sm:text-base"> | ||
<span className="text-chartreuseyellow-400">{player.nickname}</span> | ||
</span> | ||
</div> | ||
))} | ||
</div> | ||
</> | ||
); | ||
}; | ||
|
||
export default PodiumPlayers; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import { useMemo } from 'react'; | ||
import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; | ||
|
||
/** | ||
* 플레이어 점수를 기준으로 상위 3위까지의 순위를 계산하고 반환하는 커스텀 훅입니다. | ||
* | ||
* 이 훅은 `useGameSocketStore`를 통해 플레이어 데이터를 가져온 뒤, | ||
* 점수가 0보다 큰 플레이어만 고려하여 1위, 2위, 3위 그룹으로 나눕니다. | ||
* | ||
* @returns {Object} 각 순위별 플레이어 그룹을 포함한 객체: | ||
* - `firstPlacePlayers`: 가장 높은 점수를 가진 플레이어 배열. | ||
* - `secondPlacePlayers`: 두 번째로 높은 점수를 가진 플레이어 배열. | ||
* - `thirdPlacePlayers`: 세 번째로 높은 점수를 가진 플레이어 배열. | ||
* | ||
* 각 배열은 해당 순위에 플레이어가 없을 경우 빈 배열로 반환됩니다. | ||
* | ||
* @example | ||
* // React 컴포넌트에서의 사용 예 | ||
* const { firstPlacePlayers, secondPlacePlayers, thirdPlacePlayers } = usePlayerRankings(); | ||
* | ||
* console.log('1위 플레이어:', firstPlacePlayers); | ||
* console.log('2위 플레이어:', secondPlacePlayers); | ||
* console.log('3위 플레이어:', thirdPlacePlayers); | ||
* | ||
* @category Hooks | ||
*/ | ||
|
||
export const usePlayerRankings = () => { | ||
const players = useGameSocketStore((state) => state.players ?? []); | ||
|
||
const rankedPlayers = useMemo(() => { | ||
const validPlayers = players.filter((player) => player.score > 0); | ||
const sortedScores = [...new Set(validPlayers.map((p) => p.score))].sort((a, b) => b - a); | ||
return sortedScores.slice(0, 3).map((score) => validPlayers.filter((player) => player.score === score)); | ||
}, [players]); | ||
|
||
return { | ||
firstPlacePlayers: rankedPlayers[0] ?? [], | ||
secondPlacePlayers: rankedPlayers[1] ?? [], | ||
thirdPlacePlayers: rankedPlayers[2] ?? [], | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
import { useEffect } from 'react'; | ||
|
||
/** | ||
* 지정된 시간이 지난 후 콜백 함수를 실행하는 커스텀 훅입니다. | ||
* setTimeout과 유사하게 동작하지만 더 정확한 타이밍을 위해 내부적으로 setInterval을 사용합니다. | ||
* | ||
* @param callback - 지연 시간 후 실행할 함수 | ||
* @param delay - 콜백 실행 전 대기할 시간 (밀리초) | ||
* | ||
* @example | ||
* ```tsx | ||
* // 5초 후에 콜백 실행 | ||
* useTimeout(() => { | ||
* console.log('5초가 지났습니다'); | ||
* }, 5000); | ||
* ``` | ||
* | ||
* @remarks | ||
* - 경과 시간을 확인하기 위해 내부적으로 setInterval을 사용합니다 | ||
* - 컴포넌트 언마운트 시 자동으로 정리(cleanup)됩니다 | ||
* - callback이나 delay가 변경되면 타이머가 재설정됩니다 | ||
* | ||
* @category Hooks | ||
*/ | ||
|
||
export function useTimeout(callback: () => void, delay: number) { | ||
useEffect(() => { | ||
const startTime = Date.now(); | ||
|
||
const timer = setInterval(() => { | ||
const elapsedTime = Date.now() - startTime; | ||
|
||
if (elapsedTime >= delay) { | ||
clearInterval(timer); | ||
callback(); | ||
} | ||
}, 1000); | ||
|
||
return () => { | ||
clearInterval(timer); | ||
}; | ||
}, [callback, delay]); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
import { useCallback, useEffect } from 'react'; | ||
import { TerminationType } from '@troublepainter/core'; | ||
import { useNavigate } from 'react-router-dom'; | ||
import podium from '@/assets/podium.gif'; | ||
import PodiumPlayers from '@/components/result/PodiumPlayers'; | ||
import { usePlayerRankings } from '@/hooks/usePlayerRanking'; | ||
import { useTimeout } from '@/hooks/useTimeout'; | ||
import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; | ||
import { useToastStore } from '@/stores/toast.store'; | ||
|
||
const ResultPage = () => { | ||
const navigate = useNavigate(); | ||
const roomId = useGameSocketStore((state) => state.room?.roomId); | ||
const terminateType = useGameSocketStore((state) => state.gameTerminateType); | ||
const gameActions = useGameSocketStore((state) => state.actions); | ||
const toastActions = useToastStore((state) => state.actions); | ||
const { firstPlacePlayers, secondPlacePlayers, thirdPlacePlayers } = usePlayerRankings(); | ||
|
||
useEffect(() => { | ||
const description = | ||
terminateType === TerminationType.PLAYER_DISCONNECT | ||
? '나간 플레이어가 있어요. 20초 후 대기실로 이동합니다!' | ||
: '20초 후 대기실로 이동합니다!'; | ||
|
||
toastActions.addToast({ | ||
title: '게임 종료', | ||
description, | ||
variant: 'success', | ||
duration: 20000, | ||
}); | ||
}, [terminateType, toastActions]); | ||
|
||
const handleTimeout = useCallback(() => { | ||
gameActions.resetGame(); | ||
navigate(`/lobby/${roomId}`); | ||
}, [gameActions, navigate, roomId]); | ||
|
||
useTimeout(handleTimeout, 20000); | ||
|
||
return ( | ||
<section className="relative"> | ||
<img src={podium} alt="" aria-hidden={true} className="w-[25rem] sm:w-[33.75rem]" /> | ||
<span className="absolute left-14 top-[25%] text-4xl text-stroke-md sm:left-12 sm:text-7xl sm:text-stroke-lg"> | ||
GAME | ||
</span> | ||
<span className="absolute right-14 top-[25%] text-4xl text-stroke-md sm:right-12 sm:text-7xl sm:text-stroke-lg"> | ||
ENDS | ||
</span> | ||
|
||
<PodiumPlayers players={firstPlacePlayers} position="first" /> | ||
<PodiumPlayers players={secondPlacePlayers} position="second" /> | ||
<PodiumPlayers players={thirdPlacePlayers} position="third" /> | ||
</section> | ||
); | ||
}; | ||
|
||
export default ResultPage; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.