Skip to content

Commit

Permalink
feat: Implement game result podium page (#191)
Browse files Browse the repository at this point in the history
* 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
choiseona authored Dec 3, 2024
1 parent 91d8eb5 commit 72d0129
Show file tree
Hide file tree
Showing 11 changed files with 272 additions and 11 deletions.
Binary file added client/src/assets/podium.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
56 changes: 56 additions & 0 deletions client/src/components/result/PodiumPlayers.tsx
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;
20 changes: 16 additions & 4 deletions client/src/hooks/socket/useGameSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
RoomStatus,
TimerType,
PlayerStatus,
RoomEndResponse,
TerminationType,
} from '@troublepainter/core';
import { useNavigate, useParams } from 'react-router-dom';
import entrySound from '@/assets/sounds/entry-sound-effect.mp3';
Expand Down Expand Up @@ -145,9 +147,11 @@ export const useGameSocket = () => {
},

playerLeft: (response: PlayerLeftResponse) => {
const { leftPlayerId, players } = response;
const { leftPlayerId, players, hostId } = response;
gameActions.removePlayer(leftPlayerId);
gameActions.updatePlayers(players);
gameActions.updateHost(hostId);
gameActions.updateIsHost(hostId === useGameSocketStore.getState().currentPlayerId);
},

settingsUpdated: (response: UpdateSettingsResponse) => {
Expand Down Expand Up @@ -207,9 +211,17 @@ export const useGameSocket = () => {
gameActions.updatePlayers(players);
},

gameEnded: () => {
gameActions.resetGame();
navigate(`/lobby/${roomId}`, { replace: true });
gameEnded: (response: RoomEndResponse) => {
const { terminationType, leftPlayerId, hostId } = response;
if (terminationType === TerminationType.PLAYER_DISCONNECT && leftPlayerId && hostId) {
gameActions.removePlayer(leftPlayerId);
gameActions.updateHost(hostId);
gameActions.updateIsHost(hostId === useGameSocketStore.getState().currentPlayerId);
}
gameActions.updateRoomStatus(RoomStatus.WAITING);
gameActions.resetRound();
gameActions.updateGameTerminateType(terminationType);
navigate(`/game/${roomId}/result`, { replace: true });
},
};

Expand Down
42 changes: 42 additions & 0 deletions client/src/hooks/usePlayerRanking.ts
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] ?? [],
};
};
43 changes: 43 additions & 0 deletions client/src/hooks/useTimeout.ts
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]);
}
57 changes: 57 additions & 0 deletions client/src/pages/ResultPage.tsx
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;
10 changes: 5 additions & 5 deletions client/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import RootLayout from '@/layouts/RootLayout';
import GameRoomPage from '@/pages/GameRoomPage';
import LobbyPage from '@/pages/LobbyPage';
import MainPage from '@/pages/MainPage';
// import ResultPage from '@/pages/ResultPage';
import ResultPage from '@/pages/ResultPage';

export const router = createBrowserRouter(
[
Expand All @@ -26,12 +26,12 @@ export const router = createBrowserRouter(
path: '/game/:roomId',
element: <GameRoomPage />,
},
{
path: '/game/:roomId/result',
element: <ResultPage />,
},
],
},
// {
// path: '/game/:roomId/result',
// element: <ResultPage />,
// },
],
},
],
Expand Down
27 changes: 26 additions & 1 deletion client/src/stores/socket/gameSocket.store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { Player, PlayerRole, PlayerStatus, Room, RoomSettings, RoomStatus, TimerType } from '@troublepainter/core';
import {
Player,
PlayerRole,
PlayerStatus,
Room,
RoomSettings,
RoomStatus,
TerminationType,
TimerType,
} from '@troublepainter/core';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

Expand All @@ -8,6 +17,7 @@ interface GameState {
players: Player[];
roundWinners: Player[] | null;
roundAssignedRole: PlayerRole | null;
gameTerminateType: TerminationType | null;
currentPlayerId: string | null;
isHost: boolean | null;
timers: Record<TimerType, number | null>;
Expand All @@ -21,6 +31,7 @@ interface GameActions {
updateCurrentRound: (currentRound: number) => void;
updateCurrentWord: (currentWord: string) => void;
updateRoomStatus: (status: RoomStatus) => void;
updateHost: (hostId: string) => void;

// roomSetting 상태 업데이트
updateRoomSettings: (settings: RoomSettings) => void;
Expand All @@ -43,6 +54,9 @@ interface GameActions {
// 승자 상태 업데이트
updateRoundWinners: (players: Player[]) => void;

// 종료 타입 업데이트
updateGameTerminateType: (terminateType: TerminationType) => void;

// 상태 초기화
resetRound: () => void;
resetGame: () => void;
Expand All @@ -58,6 +72,7 @@ const initialState: GameState = {
timers: { DRAWING: null, ENDING: null, GUESSING: null },
roundWinners: null,
roundAssignedRole: null,
gameTerminateType: null,
};

const resetCommonState = () => ({
Expand Down Expand Up @@ -114,6 +129,12 @@ export const useGameSocketStore = create<GameState & { actions: GameActions }>()
}));
},

updateHost: (hostId) => {
set((state) => ({
room: state.room && { ...state.room, hostId },
}));
},

updateRoomStatus: (status) => {
set((state) => ({ room: state.room && { ...state.room, status } }));
},
Expand Down Expand Up @@ -154,6 +175,10 @@ export const useGameSocketStore = create<GameState & { actions: GameActions }>()
set({ roundAssignedRole: playerRole });
},

updateGameTerminateType: (type) => {
set({ gameTerminateType: type });
},

updateTimer: (timerType, time) => {
set((state) => ({
timers: {
Expand Down
13 changes: 13 additions & 0 deletions client/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,19 @@ export default {
color: '#EBE9FF',
'text-shadow': `2px 0 0 #200940,-2px 0 0 #200940,0 2px 0 #200940,0 -2px 0 #200940,1px 1px #200940,-1px -1px 0 #200940,1px -1px 0 #200940,-1px 1px 0 #200940`,
},
'.text-stroke-lg': {
color: '#EBE9FF',
'text-shadow': `
-4px -4px 0 #200940, 4px -4px 0 #200940,
-4px 4px 0 #200940, 4px 4px 0 #200940,
-4px 0 0 #200940, 4px 0 0 #200940,
0 -4px 0 #200940, 0 4px 0 #200940,
-3px -3px 0 #200940, 3px -3px 0 #200940,
-3px 3px 0 #200940, 3px 3px 0 #200940,
-3px 0 0 #200940, 3px 0 0 #200940,
0 -3px 0 #200940, 0 3px 0 #200940
`,
},
});
},
],
Expand Down
5 changes: 5 additions & 0 deletions core/types/game.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,8 @@ export enum TimerType {
GUESSING = 'GUESSING',
ENDING = 'ENDING',
}

export enum TerminationType {
SUCCESS = 'SUCCESS',
PLAYER_DISCONNECT = 'PLAYER_DISCONNECT',
}
Loading

0 comments on commit 72d0129

Please sign in to comment.