diff --git a/client/src/components/canvas/CanvasUI.tsx b/client/src/components/canvas/CanvasUI.tsx index 12ed766b5..7f60e6481 100644 --- a/client/src/components/canvas/CanvasUI.tsx +++ b/client/src/components/canvas/CanvasUI.tsx @@ -95,7 +95,6 @@ interface CanvasProps extends HTMLAttributes { inkRemaining: number; maxPixels: number; canvasEvents: CanvasEventHandlers; - isHidden: boolean; showInkRemaining: boolean; } @@ -119,7 +118,6 @@ const Canvas = forwardRef( inkRemaining, maxPixels, canvasEvents, - isHidden, showInkRemaining, ...props }, @@ -131,7 +129,6 @@ const Canvas = forwardRef( className={cn( 'relative flex w-full max-w-screen-sm flex-col border-violet-500 bg-white', 'sm:rounded-lg sm:border-4 sm:shadow-xl', - isHidden && 'hidden', className, )} {...props} diff --git a/client/src/components/canvas/GameCanvas.tsx b/client/src/components/canvas/GameCanvas.tsx index 541ef6b1b..53092032f 100644 --- a/client/src/components/canvas/GameCanvas.tsx +++ b/client/src/components/canvas/GameCanvas.tsx @@ -1,5 +1,5 @@ -import { MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent, useCallback, useEffect, useRef } from 'react'; -import { PlayerRole, RoomStatus } from '@troublepainter/core'; +import { PointerEvent, useCallback, useEffect, useRef } from 'react'; +import { PlayerRole } from '@troublepainter/core'; import { Canvas } from '@/components/canvas/CanvasUI'; import { COLORS_INFO, DEFAULT_MAX_PIXELS, MAINCANVAS_RESOLUTION_WIDTH } from '@/constants/canvasConstants'; import { handleInCanvas, handleOutCanvas } from '@/handlers/canvas/cursorInOutHandler'; @@ -13,12 +13,10 @@ import { getCanvasContext } from '@/utils/getCanvasContext'; import { getDrawPoint } from '@/utils/getDrawPoint'; interface GameCanvasProps { - isHost: boolean; role: PlayerRole; maxPixels?: number; currentRound: number; - roomStatus: RoomStatus; - isHidden: boolean; + isDrawable: boolean; } /** @@ -50,7 +48,7 @@ interface GameCanvasProps { * * @category Components */ -const GameCanvas = ({ role, maxPixels = DEFAULT_MAX_PIXELS, currentRound, roomStatus, isHidden }: GameCanvasProps) => { +const GameCanvas = ({ maxPixels = DEFAULT_MAX_PIXELS, currentRound, isDrawable }: GameCanvasProps) => { const canvasRef = useRef(null); const cursorCanvasRef = useRef(null); const { convertCoordinate } = useCoordinateScale(MAINCANVAS_RESOLUTION_WIDTH, canvasRef); @@ -73,7 +71,7 @@ const GameCanvas = ({ role, maxPixels = DEFAULT_MAX_PIXELS, currentRound, roomSt redo, makeCRDTSyncMessage, resetCanvas, - } = useDrawing(canvasRef, roomStatus, { + } = useDrawing(canvasRef, isDrawable, { maxPixels, }); @@ -120,7 +118,7 @@ const GameCanvas = ({ role, maxPixels = DEFAULT_MAX_PIXELS, currentRound, roomSt })); const handleDrawStart = useCallback( - (e: ReactMouseEvent | ReactTouchEvent) => { + (e: PointerEvent) => { if (!isConnected) return; const { canvas } = getCanvasContext(canvasRef); @@ -136,7 +134,7 @@ const GameCanvas = ({ role, maxPixels = DEFAULT_MAX_PIXELS, currentRound, roomSt ); const handleDrawMove = useCallback( - (e: ReactMouseEvent | ReactTouchEvent) => { + (e: PointerEvent) => { const { canvas } = getCanvasContext(canvasRef); const point = getDrawPoint(e, canvas); const convertPoint = convertCoordinate(point); @@ -152,7 +150,7 @@ const GameCanvas = ({ role, maxPixels = DEFAULT_MAX_PIXELS, currentRound, roomSt ); const handleDrawLeave = useCallback( - (e: ReactMouseEvent | ReactTouchEvent) => { + (e: PointerEvent) => { const { canvas } = getCanvasContext(canvasRef); const point = getDrawPoint(e, canvas); const convertPoint = convertCoordinate(point); @@ -202,8 +200,7 @@ const GameCanvas = ({ role, maxPixels = DEFAULT_MAX_PIXELS, currentRound, roomSt { - const [inputMessage, setInputMessage] = useState(''); - const inputRef = useRef(null); - - // 개별 Selector - const isConnected = useSocketStore((state) => state.connected.chat); - const currentPlayerId = useGameSocketStore((state) => state.currentPlayerId); - const players = useGameSocketStore((state) => state.players); - const roomStatus = useGameSocketStore((state) => state.room?.status); - const roundAssignedRole = useGameSocketStore((state) => state.roundAssignedRole); - // 챗 액션 - const chatActions = useChatSocketStore((state) => state.actions); - - const shouldDisableInput = useMemo(() => { - const ispainters = roundAssignedRole !== PlayerRole.GUESSER; - const isDrawing = roomStatus === 'DRAWING' || roomStatus === 'GUESSING'; - return ispainters && isDrawing; - }, [roundAssignedRole, roomStatus]); - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - if (!isConnected || !inputMessage.trim()) return; - void chatSocketHandlers.sendMessage(inputMessage); + const { submitMessage, checkDisableInput, changeMessage, inputMessage } = useChat(); - const currentPlayer = players?.find((player) => player.playerId === currentPlayerId); - if (!currentPlayer || !currentPlayerId) throw new Error('Current player not found'); - - const messageData: ChatResponse = { - playerId: currentPlayerId as string, - nickname: currentPlayer.nickname, - message: inputMessage.trim(), - createdAt: new Date().toISOString(), - }; - chatActions.addMessage(messageData); - - if (roomStatus === RoomStatus.GUESSING) { - void gameSocketHandlers.checkAnswer({ answer: inputMessage }); - } - - setInputMessage(''); - }; + const inputRef = useRef(null); useShortcuts([ { @@ -68,13 +26,13 @@ export const ChatInput = memo(() => { ]); return ( -
+ setInputMessage(e.target.value)} + onChange={changeMessage} placeholder="메시지를 입력하세요" - disabled={!isConnected || shouldDisableInput} + disabled={checkDisableInput()} autoComplete="off" />
diff --git a/client/src/components/chat/ChatList.tsx b/client/src/components/chat/ChatList.tsx index 04f1854d5..bf341f19a 100644 --- a/client/src/components/chat/ChatList.tsx +++ b/client/src/components/chat/ChatList.tsx @@ -6,7 +6,7 @@ import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; export const ChatList = memo(() => { const messages = useChatSocketStore((state) => state.messages); - const currentPlayerId = useGameSocketStore((state) => state.currentPlayerId); + const playerId = useGameSocketStore((state) => state.currentPlayerId); const { containerRef } = useScrollToBottom([messages]); return ( @@ -17,7 +17,7 @@ export const ChatList = memo(() => {

{messages.map((message) => { - const isOthers = message.playerId !== currentPlayerId; + const isOthers = message.playerId !== playerId; return ( { - const { isHost, buttonConfig, handleStartGame, isStarting } = useGameStart(); + const { startGame, checkCanStart, getStartButtonStatus, isStarting } = useGameStart(); + const { disabled, title, content } = getStartButtonStatus(); + + useShortcuts([ + { + key: 'GAME_START', + action: () => startGame(), + }, + ]); + return ( ); }; diff --git a/client/src/components/modal/RoleModal.tsx b/client/src/components/modal/RoleModal.tsx index 0518ab794..8ce4d8002 100644 --- a/client/src/components/modal/RoleModal.tsx +++ b/client/src/components/modal/RoleModal.tsx @@ -1,17 +1,11 @@ -import { useEffect } from 'react'; import { Modal } from '@/components/ui/Modal'; import { PLAYING_ROLE_TEXT } from '@/constants/gameConstant'; -import { useModal } from '@/hooks/useModal'; +import { useRoleModal } from '@/hooks/game/useRoleModal'; import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; const RoleModal = () => { - const room = useGameSocketStore((state) => state.room); + const { isModalOpened, closeModal, handleKeyDown } = useRoleModal(); const roundAssignedRole = useGameSocketStore((state) => state.roundAssignedRole); - const { isModalOpened, closeModal, handleKeyDown, openModal } = useModal(5000); - - useEffect(() => { - if (roundAssignedRole) openModal(); - }, [roundAssignedRole, room?.currentRound]); return ( { - const room = useGameSocketStore((state) => state.room); - const roundWinners = useGameSocketStore((state) => state.roundWinners); - const players = useGameSocketStore((state) => state.players); - const currentPlayerId = useGameSocketStore((state) => state.currentPlayerId); - const timer = useTimerStore((state) => state.timers.ENDING); - - const { isModalOpened, openModal, closeModal } = useModal(); - const [showAnimation, setShowAnimation] = useState(false); - const [isAnimationFading, setIsAnimationFading] = useState(false); - - const devil = players.find((player) => player.role === PlayerRole.DEVIL); - const isDevilWin = roundWinners?.some((winner) => winner.role === PlayerRole.DEVIL); - const isCurrentPlayerWinner = roundWinners?.some((winner) => winner.playerId === currentPlayerId); - - // 컴포넌트 마운트 시 사운드 미리 로드 - const soundManager = SoundManager.getInstance(); - useEffect(() => { - soundManager.preloadSound(SOUND_IDS.WIN, gameWin); - soundManager.preloadSound(SOUND_IDS.LOSS, gameLoss); - }, [soundManager]); - - useEffect(() => { - if (roundWinners) { - setIsAnimationFading(false); - setShowAnimation(true); - openModal(); - - if (isCurrentPlayerWinner) { - void soundManager.playSound(SOUND_IDS.WIN, 0.3); - } else { - void soundManager.playSound(SOUND_IDS.LOSS, 0.3); - } - } - }, [roundWinners]); - - useEffect(() => { - if (room && room.status === RoomStatus.DRAWING) closeModal(); - }, [room]); - - useEffect(() => { - if (showAnimation) { - // 3초 후에 페이드아웃 시작 - const fadeTimer = setTimeout(() => { - setIsAnimationFading(true); - }, 3000); - - // 3.5초 후에 컴포넌트 제거 - const removeTimer = setTimeout(() => { - setShowAnimation(false); - }, 3500); - - return () => { - clearTimeout(fadeTimer); - clearTimeout(removeTimer); - }; - } - }, [showAnimation]); + const { showAnimation, isPlayerWinner, isAnimationFading, isModalOpened, timer, isDevilWin, solver, devil } = + useRoundEndModal(); + const currentWord = useGameSocketStore((state) => state.room?.currentWord); return ( <> {/* 승리/패배 애니메이션 */} {showAnimation && - (isCurrentPlayerWinner ? ( + (isPlayerWinner ? ( { /> ))} - +
{timer} @@ -111,10 +47,7 @@ const RoundEndModal = () => { <> 정답을 맞춘 구경꾼이 없습니다 ) : ( <> - 구경꾼{' '} - - {roundWinners?.find((winner) => winner.role === PlayerRole.GUESSER)?.nickname} - + 구경꾼 {solver?.nickname} 이(가) 정답을 맞혔습니다 )} diff --git a/client/src/components/player/PlayerCardList.tsx b/client/src/components/player/PlayerCardList.tsx index 16a017ad9..2b79f71ba 100644 --- a/client/src/components/player/PlayerCardList.tsx +++ b/client/src/components/player/PlayerCardList.tsx @@ -1,26 +1,22 @@ import { memo } from 'react'; -import { PlayerRole, PlayerStatus } from '@troublepainter/core'; +import { PlayerStatus } from '@troublepainter/core'; import { PlayerCard } from '@/components/ui/player-card/PlayerCard'; +import { usePlayers } from '@/hooks/game/usePlayers'; import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; const PlayerCardList = memo(() => { - // 개별 selector 사용으로 변경 - const players = useGameSocketStore((state) => state.players); + // roomSettings 제외하고 필요한 것만 구독 const hostId = useGameSocketStore((state) => state.room?.hostId); - const roundAssignedRole = useGameSocketStore((state) => state.roundAssignedRole); - const currentPlayerId = useGameSocketStore((state) => state.currentPlayerId); + const players = useGameSocketStore((state) => state.players); + const playerId = useGameSocketStore((state) => state.currentPlayerId); + const { getDisplayRoleText } = usePlayers(); if (!players?.length) return null; - const getPlayerRole = (playerRole: PlayerRole | undefined, myRole: PlayerRole | null) => { - if (myRole === PlayerRole.GUESSER) return playerRole === PlayerRole.GUESSER ? playerRole : null; - return playerRole; - }; - return ( <> {players.map((player) => { - const playerRole = getPlayerRole(player.role, roundAssignedRole) || null; + const playerRole = getDisplayRoleText(player.role) || null; return ( { role={playerRole} score={player.score} isHost={player.status === PlayerStatus.NOT_PLAYING && player.playerId === hostId} // 이 플레이어가 방장인지 - isMe={player.playerId === currentPlayerId} + isMe={player.playerId === playerId} /> ); })} diff --git a/client/src/components/quiz/QuizStage.tsx b/client/src/components/quiz/QuizStage.tsx index a2a60f92d..dc40b68af 100644 --- a/client/src/components/quiz/QuizStage.tsx +++ b/client/src/components/quiz/QuizStage.tsx @@ -1,93 +1,51 @@ -import { useMemo } from 'react'; -import { PlayerRole, RoomStatus } from '@troublepainter/core'; +import { PlayerRole } from '@troublepainter/core'; import { GameCanvas } from '../canvas/GameCanvas'; import { QuizTitle } from '../ui/QuizTitle'; import sizzlingTimer from '@/assets/big-timer.gif'; import { DEFAULT_MAX_PIXELS } from '@/constants/canvasConstants'; -import { useTimer } from '@/hooks/useTimer'; +import { useQuizStageUI } from '@/hooks/game/useQuizStageUI'; +import { useTimer } from '@/hooks/game/useTimer'; import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; -import { cn } from '@/utils/cn'; const QuizStageContainer = () => { - const room = useGameSocketStore((state) => state.room); + const { checkShowBigTimer, checkShowCanvasAndQuizTitle, getQuizTitleText, checkCanCanvasDraw } = useQuizStageUI(); + const { getRemainingTime } = useTimer(); const roomSettings = useGameSocketStore((state) => state.roomSettings); + const room = useGameSocketStore((state) => state.room); const roundAssignedRole = useGameSocketStore((state) => state.roundAssignedRole); - const isHost = useGameSocketStore((state) => state.isHost); - - if (!room || !roomSettings || isHost === null) return null; - - const timers = useTimer(); - - const shouldHideCanvas = useMemo(() => { - const isGuesser = roundAssignedRole === PlayerRole.GUESSER; - const isDrawing = room?.status === 'DRAWING'; - return isGuesser && isDrawing; - }, [roundAssignedRole, room?.status]); - - const shouldHideQuizTitle = useMemo(() => { - const isGuesser = roundAssignedRole === PlayerRole.GUESSER; - const isDrawing = room?.status === 'DRAWING'; - return isGuesser && isDrawing; - }, [roundAssignedRole, room?.status]); - - const shouldHideSizzlingTimer = useMemo(() => { - const isPainters = roundAssignedRole === PlayerRole.DEVIL || roundAssignedRole === PlayerRole.PAINTER; - const isDrawing = room?.status === 'DRAWING'; - const isGuessing = room?.status === 'GUESSING'; - return (isPainters && isDrawing) || isGuessing; - }, [roundAssignedRole, room?.status]); - - const remainingTime = useMemo(() => { - switch (room?.status) { - case 'DRAWING': - return timers.DRAWING ?? roomSettings?.drawTime; - case 'GUESSING': - return timers.GUESSING ?? 15; - default: - return 0; - } - }, [room?.status, timers, roomSettings?.drawTime]); - - const quizTitleText = useMemo(() => { - if (room.status === RoomStatus.DRAWING) { - return roundAssignedRole !== PlayerRole.GUESSER ? `${room.currentWord}` : ''; - } - if (room.status === RoomStatus.GUESSING) { - return roundAssignedRole !== PlayerRole.GUESSER ? `${room.currentWord} (맞히는중...)` : '맞혀보세요-!'; - } - }, [room.status, room.currentWord, roundAssignedRole]); return ( <> {/* 구경꾼 전용 타이머 */} -
-

- 화가들이 실력을 뽐내는중... -

-
- 구경꾼 전용 타이머 - - {timers.DRAWING ?? roomSettings.drawTime - 5} - + {checkShowBigTimer() && ( +
+

+ 화가들이 실력을 뽐내는중... +

+
+ 구경꾼 전용 타이머 + + {getRemainingTime() ?? 0} + +
+ )} + +
+ + +
- - - - ); }; diff --git a/client/src/components/room-setting/Setting.tsx b/client/src/components/room-setting/Setting.tsx index 191d2426b..e354fe048 100644 --- a/client/src/components/room-setting/Setting.tsx +++ b/client/src/components/room-setting/Setting.tsx @@ -1,11 +1,11 @@ -import { HTMLAttributes, memo, useCallback, useEffect, useState } from 'react'; +import { HTMLAttributes, memo } from 'react'; import { RoomSettings } from '@troublepainter/core'; import { SettingContent } from '@/components/room-setting/SettingContent'; import { WordsThemeModalContent } from '@/components/room-setting/WordsThemeModalContent'; import { Button } from '@/components/ui/Button'; import { Modal } from '@/components/ui/Modal'; import { SHORTCUT_KEYS } from '@/constants/shortcutKeys'; -import { gameSocketHandlers } from '@/handlers/socket/gameSocket.handler'; +import { useGameSetting } from '@/hooks/game/useGameSetting'; import { useModal } from '@/hooks/useModal'; import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; import { cn } from '@/utils/cn'; @@ -27,44 +27,14 @@ export const ROOM_SETTINGS: RoomSettingItem[] = [ ]; const Setting = memo(({ className, ...props }: HTMLAttributes) => { - // 개별 selector로 필요한 상태만 구독 + const settingTool = useGameSetting(); + const { checkCanSettingEdit, updateSetting, selectedValues } = settingTool; + const roomSettings = useGameSocketStore((state) => state.roomSettings); - const isHost = useGameSocketStore((state) => state.isHost); - const actions = useGameSocketStore((state) => state.actions); + // 모달 const { isModalOpened, openModal, closeModal, handleKeyDown } = useModal(); - const [selectedValues, setSelectedValues] = useState( - roomSettings ?? { - totalRounds: 5, - maxPlayers: 5, - drawTime: 30, - }, - ); - - useEffect(() => { - if (!roomSettings) return; - setSelectedValues(roomSettings); - }, [roomSettings]); - - const handleSettingChange = useCallback( - (key: keyof RoomSettings, value: string) => { - const newSettings = { - ...selectedValues, - [key]: Number(value), - }; - setSelectedValues(newSettings); - void gameSocketHandlers.updateSettings({ - settings: { ...newSettings, drawTime: newSettings.drawTime + 5 }, - }); - actions.updateRoomSettings(newSettings); - }, - [selectedValues, actions], - ); - - // 제시어 테마 - const headerText = roomSettings?.wordsTheme ? roomSettings.wordsTheme : 'Setting'; - return (
) = > {/* Setting title */}
-

{headerText}

- {isHost && ( +

+ {roomSettings?.wordsTheme || 'Setting'} +

+ {checkCanSettingEdit() && (
); diff --git a/client/src/components/room-setting/SettingContent.tsx b/client/src/components/room-setting/SettingContent.tsx index 5b12430f8..61895cb14 100644 --- a/client/src/components/room-setting/SettingContent.tsx +++ b/client/src/components/room-setting/SettingContent.tsx @@ -6,11 +6,11 @@ import { SettingItem } from '@/components/room-setting/SettingItem'; interface SettingContentProps { settings: RoomSettingItem[]; values: Partial; - isHost: boolean; + canEdit: boolean; onSettingChange: (key: keyof RoomSettings, value: string) => void; } -export const SettingContent = memo(({ settings, values, isHost, onSettingChange }: SettingContentProps) => ( +export const SettingContent = memo(({ settings, values, canEdit, onSettingChange }: SettingContentProps) => (
{settings.map(({ label, key, options, shortcutKey }) => ( @@ -21,7 +21,7 @@ export const SettingContent = memo(({ settings, values, isHost, onSettingChange value={values[key] as number} options={options} onSettingChange={onSettingChange} - isHost={isHost} + canEdit={canEdit} shortcutKey={shortcutKey} /> ))} diff --git a/client/src/components/room-setting/SettingItem.tsx b/client/src/components/room-setting/SettingItem.tsx index 61d21c7e3..ad73730ee 100644 --- a/client/src/components/room-setting/SettingItem.tsx +++ b/client/src/components/room-setting/SettingItem.tsx @@ -9,12 +9,12 @@ interface SettingItemProps { value?: number; options: number[]; onSettingChange: (key: keyof RoomSettings, value: string) => void; - isHost: boolean; + canEdit: boolean; shortcutKey: keyof typeof SHORTCUT_KEYS; } export const SettingItem = memo( - ({ label, settingKey, value, options, onSettingChange, isHost, shortcutKey }: SettingItemProps) => { + ({ label, settingKey, value, options, onSettingChange, canEdit, shortcutKey }: SettingItemProps) => { const handleChange = useCallback( (value: string) => { onSettingChange(settingKey, value); @@ -25,7 +25,7 @@ export const SettingItem = memo( return (
{label} - {!isHost ? ( + {!canEdit ? ( {value || ''} ) : ( void; + settingTool: ReturnType; } -const WordsThemeModalContent = ({ isModalOpened, closeModal }: WordsThemeModalContentProps) => { - const roomSettings = useGameSocketStore((state) => state.roomSettings); - const actions = useGameSocketStore((state) => state.actions); - const [wordsTheme, setWordsTheme] = useState(roomSettings?.wordsTheme || ''); - const [validationMessages, setValidationMessages] = useState([]); - const [isSubmitting, setIsSubmitting] = useState(false); - const addToast = useToastStore((state) => state.actions.addToast); - - useEffect(() => { - if (isModalOpened) { - setWordsTheme(roomSettings?.wordsTheme || ''); - } - }, [isModalOpened, roomSettings?.wordsTheme]); - - // 실시간 입력 검증 - const handleThemeChange = useCallback((e: React.ChangeEvent) => { - const value = e.target.value.replace(/\s+/g, ' '); - setWordsTheme(value); - setValidationMessages(validateWordsTheme(value)); - }, []); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (isSubmitting) return; - - // 현재 validationMessages 상태를 활용하여 검증 - const hasErrors = validationMessages.some((msg) => msg.type === 'error'); - if (hasErrors || !wordsTheme.trim()) { - addToast({ - title: '입력 오류', - description: '모든 입력 조건을 만족해야 합니다.', - variant: 'error', - duration: 3000, - }); - return; - } - - try { - setIsSubmitting(true); - - await gameSocketHandlers.updateSettings({ - settings: { wordsTheme: wordsTheme.trim() }, - }); - - if (roomSettings) { - actions.updateRoomSettings({ - ...roomSettings, - wordsTheme: wordsTheme.trim(), - }); - } - - addToast({ - title: '테마 설정 완료', - description: `제시어 테마가 '${wordsTheme.trim()}'(으)로 설정되었습니다.`, - variant: 'success', - duration: 2000, - }); - - closeModal(); - } catch (err) { - console.error(err); - addToast({ - title: '설정 실패', - description: '테마 설정 중 오류가 발생했습니다. 다시 시도해주세요.', - variant: 'error', - }); - } finally { - setIsSubmitting(false); - } - }; - - // 제출 가능 여부 확인 - const isSubmitDisabled = validationMessages.some((msg) => msg.type === 'error') || !wordsTheme.trim() || isSubmitting; +const WordsThemeModalContent = ({ closeModal, settingTool }: WordsThemeModalContentProps) => { + const { changeTheme, submitTheme, isThemeSubmitDisabled, wordsTheme, isThemeSubmitting, themeValidationMessages } = + settingTool; return ( -
) => void handleSubmit(e)} className="flex flex-col"> + ) => void submitTheme(e, closeModal)} className="flex flex-col"> 게임에서 사용될 제시어의 테마를 설정해보세요!
@@ -99,12 +24,12 @@ const WordsThemeModalContent = ({ isModalOpened, closeModal }: WordsThemeModalCo msg.type === 'error') && 'border-red-500', - validationMessages.some((msg) => msg.type === 'success') && 'border-green-500', + themeValidationMessages.some((msg) => msg.type === 'error') && 'border-red-500', + themeValidationMessages.some((msg) => msg.type === 'success') && 'border-green-500', )} /> @@ -121,7 +46,7 @@ const WordsThemeModalContent = ({ isModalOpened, closeModal }: WordsThemeModalCo {/* 실시간 검증 메시지 */}
- {validationMessages.map((msg, index) => ( + {themeValidationMessages.map((msg, index) => (

- -
diff --git a/client/src/components/ui/QuizTitle.tsx b/client/src/components/ui/QuizTitle.tsx index d3f2faef7..d52d1e4d1 100644 --- a/client/src/components/ui/QuizTitle.tsx +++ b/client/src/components/ui/QuizTitle.tsx @@ -8,24 +8,14 @@ export interface QuizTitleProps extends HTMLAttributes { totalRound: number; title: string; remainingTime: number; - isHidden: boolean; } -const QuizTitle = ({ - className, - currentRound, - totalRound, - remainingTime, - title, - isHidden, - ...props -}: QuizTitleProps) => { +const QuizTitle = ({ className, currentRound, totalRound, remainingTime, title, ...props }: QuizTitleProps) => { return ( <>
=> { - const socket = useSocketStore.getState().sockets.chat; - if (!socket) throw new Error('Chat Socket not connected'); - - return new Promise((resolve) => { - socket.emit('sendMessage', { message: message.trim() }); - resolve(); - }); + return sendChatMessage('sendMessage', { message: message.trim() }); }, }; diff --git a/client/src/handlers/socket/drawingSocket.handler.ts b/client/src/handlers/socket/drawingSocket.handler.ts index 10d6d874d..3e3dd2cec 100644 --- a/client/src/handlers/socket/drawingSocket.handler.ts +++ b/client/src/handlers/socket/drawingSocket.handler.ts @@ -1,16 +1,10 @@ -import type { CRDTMessage, DrawingData } from '@troublepainter/core'; -import { useSocketStore } from '@/stores/socket/socket.store'; +import { sendDrawingMessage } from './socket.helper'; +import type { CRDTUpdateMessage, DrawingData } from '@troublepainter/core'; export const drawingSocketHandlers = { // 드로잉 데이터 전송 - sendDrawing: (drawingData: CRDTMessage): Promise => { - const socket = useSocketStore.getState().sockets.drawing; - if (!socket) throw new Error('Socket not connected'); - - return new Promise((resolve) => { - socket.emit('draw', { drawingData }); - resolve(); - }); + sendDrawing: (message: CRDTUpdateMessage): Promise => { + return sendDrawingMessage('draw', { drawingData: message }); }, }; diff --git a/client/src/handlers/socket/gameSocket.handler.ts b/client/src/handlers/socket/gameSocket.handler.ts index 670ece1be..aed200446 100644 --- a/client/src/handlers/socket/gameSocket.handler.ts +++ b/client/src/handlers/socket/gameSocket.handler.ts @@ -1,112 +1,43 @@ import { CheckDrawingRequest } from 'node_modules/@troublepainter/core'; +import { sendGameMessage } from './socket.helper'; import type { CRDTSyncMessage, CheckAnswerRequest, DrawingData, JoinRoomRequest, - JoinRoomResponse, ReconnectRequest, UpdateSettingsRequest, } from '@troublepainter/core'; -import { useSocketStore } from '@/stores/socket/socket.store'; // socket 요청만 처리하는 핸들러 export const gameSocketHandlers = { - joinRoom: (request: JoinRoomRequest): Promise => { - const socket = useSocketStore.getState().sockets.game; - if (!socket) throw new Error('Socket not connected'); - - return new Promise(() => { - socket.emit('joinRoom', request); - }); + joinRoom: (message: JoinRoomRequest): Promise => { + return sendGameMessage('joinRoom', message); }, - reconnect: (request: ReconnectRequest): Promise => { - const socket = useSocketStore.getState().sockets.game; - if (!socket) throw new Error('Socket not connected'); - - return new Promise(() => { - socket.emit('reconnect', request); - }); + reconnect: (message: ReconnectRequest): Promise => { + return sendGameMessage('reconnect', message); }, - updateSettings: (request: UpdateSettingsRequest): Promise => { - const socket = useSocketStore.getState().sockets.game; - if (!socket) throw new Error('Socket not connected'); - - return new Promise((resolve) => { - socket.emit('updateSettings', request); - resolve(); - }); + updateSettings: (message: UpdateSettingsRequest): Promise => { + return sendGameMessage('updateSettings', message); }, gameStart: (): Promise => { - const socket = useSocketStore.getState().sockets.game; - if (!socket) throw new Error('Socket not connected'); - - return new Promise((resolve) => { - socket.emit('gameStart'); - resolve(); - }); + return sendGameMessage('gameStart'); }, - checkAnswer: (request: CheckAnswerRequest): Promise => { - const socket = useSocketStore.getState().sockets.game; - if (!socket) throw new Error('Socket not connected'); - - return new Promise((resolve) => { - socket.emit('checkAnswer', request); - resolve(); - }); + checkAnswer: (message: CheckAnswerRequest): Promise => { + return sendGameMessage('checkAnswer', message); }, - submittedDrawing: (drawing: CRDTSyncMessage): Promise => { - const socket = useSocketStore.getState().sockets.game; - if (!socket) throw new Error('Socket not connected'); - - return new Promise((resolve) => { - socket.emit('submittedDrawing', { drawing }); - resolve(); - }); + submittedDrawing: (message: CRDTSyncMessage): Promise => { + return sendGameMessage('submittedDrawing', message); }, - checkDrawing: (request: CheckDrawingRequest): Promise => { - const socket = useSocketStore.getState().sockets.game; - if (!socket) throw new Error('Socket not connected'); - - return new Promise((resolve) => { - socket.emit('checkDrawing', request); - resolve(); - }); + checkDrawing: (message: CheckDrawingRequest): Promise => { + return sendGameMessage('checkDrawing', message); }, - - // updatePlayerStatus: async (request) => { - // const socket = useSocketStore.getState().sockets.game; - // if (!socket) throw new Error('Socket not connected'); - - // return new Promise((resolve, reject) => { - // socket.emit('updatePlayerStatus', request, (error?: SocketError) => { - // if (error) { - // set({ error }); - // reject(error); - // } else { - // resolve(); - // } - // }); - // }); - // }, - - // leaveRoom: async () => { - // const socket = useSocketStore.getState().sockets.game; - // if (!socket) throw new Error('Socket not connected'); - - // return new Promise((resolve) => { - // socket.emit('leaveRoom', () => { - // get().actions.reset(); - // resolve(); - // }); - // }); - // }, }; export type GameSocketHandlers = typeof gameSocketHandlers; diff --git a/client/src/handlers/socket/socket.helper.ts b/client/src/handlers/socket/socket.helper.ts new file mode 100644 index 000000000..5764feb08 --- /dev/null +++ b/client/src/handlers/socket/socket.helper.ts @@ -0,0 +1,20 @@ +import { SocketNamespace } from '@/stores/socket/socket.config'; +import { useSocketStore } from '@/stores/socket/socket.store'; + +export const sendSocketMessage = (socketType: SocketNamespace, eventName: string, message: unknown): Promise => { + const socket = useSocketStore.getState().sockets[socketType]; + if (!socket) throw new Error(`${socketType} socket not connected`); + return new Promise((resolve) => { + socket.emit(eventName, message); + resolve(); + }); +}; + +export const sendGameMessage = (eventName: string, message?: unknown) => + sendSocketMessage(SocketNamespace.GAME, eventName, message); + +export const sendChatMessage = (eventName: string, message?: unknown) => + sendSocketMessage(SocketNamespace.CHAT, eventName, message); + +export const sendDrawingMessage = (eventName: string, message?: unknown) => + sendSocketMessage(SocketNamespace.DRAWING, eventName, message); diff --git a/client/src/hooks/canvas/useDrawing.ts b/client/src/hooks/canvas/useDrawing.ts index 462ec29ea..d0fc68d31 100644 --- a/client/src/hooks/canvas/useDrawing.ts +++ b/client/src/hooks/canvas/useDrawing.ts @@ -5,7 +5,6 @@ import { CRDTMessageTypes, CRDTUpdateMessage, CRDTSyncMessage, - RoomStatus, DrawingData, DrawType, } from '@troublepainter/core'; @@ -82,7 +81,7 @@ import { DRAWING_MODE } from '@/constants/canvasConstants'; */ export const useDrawing = ( canvasRef: RefObject, - roomStatus: RoomStatus, + isDrawable: boolean, options?: { maxPixels?: number }, ) => { const state = useDrawingState(options); @@ -274,7 +273,7 @@ export const useDrawing = ( const { requiresRedraw } = crdt.mergeMap(crdtMessage.state); if (requiresRedraw) operation.redrawCanvas(); - if (roomStatus === 'DRAWING') { + if (isDrawable) { state.strokeHistoryRef.current = []; state.historyPointerRef.current = -1; state.updateHistoryState(); @@ -292,7 +291,7 @@ export const useDrawing = ( else if (register.value) operation.renderStroke(register.value); } }, - [state.currentPlayerId, operation, roomStatus], + [state.currentPlayerId, operation, isDrawable], ); const resetCanvas = useCallback(() => { diff --git a/client/src/hooks/game/useChat.ts b/client/src/hooks/game/useChat.ts new file mode 100644 index 000000000..ceafe7580 --- /dev/null +++ b/client/src/hooks/game/useChat.ts @@ -0,0 +1,91 @@ +import { ChangeEvent, FormEvent, useState } from 'react'; +import { ChatRequest, PlayerRole, RoomStatus } from '@troublepainter/core'; +import { chatSocketHandlers } from '@/handlers/socket/chatSocket.handler'; +import { gameSocketHandlers } from '@/handlers/socket/gameSocket.handler'; +import { useChatSocketStore } from '@/stores/socket/chatSocket.store'; +import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; +import { useSocketStore } from '@/stores/socket/socket.store'; + +/** + * useChat 훅은 채팅 기능에 필요한 상태 및 동작을 제공하는 커스텀 훅입니다. + * + * 이 훅은 채팅 입력값 상태를 관리하고, 채팅 메시지를 전송하며, + * 입력 필드의 활성화 여부를 판단합니다. + * + * @remarks + * - `RoomStatus.DRAWING` 또는 `RoomStatus.GUESSING` 상태일 때 GUESSER 역할이 아니라면 채팅 입력이 비활성화됩니다. + * - 채팅 메시지를 전송하면 `chatSocketHandlers.sendMessage`를 통해 서버로 전송되고, + * 로컬 상태에는 `chatActions.addMessage`를 통해 즉시 반영됩니다. + * - 게임 상태가 `GUESSING`일 경우, `gameSocketHandlers.checkAnswer`를 통해 정답 여부를 체크합니다. + * + * @returns 채팅 기능에 필요한 상태 및 동작을 포함하는 객체: + * - `submitMessage`: 폼 제출 시 메시지를 전송합니다. + * - `changeMessage`: 인풋 값이 변경될 때 상태를 업데이트합니다. + * - `checkDisableInput`: 인풋 필드가 비활성화되어야 하는지 여부를 반환합니다. + * - `inputMessage`: 현재 입력된 메시지 문자열입니다. + * + * @example + * ```tsx + * const { submitMessage, checkDisableInput, changeMessage, inputMessage } = useChat(); + * + * return ( + *
+ * + *
+ * ); + * ``` + */ + +export const useChat = () => { + // 개별 Selector + const isConnected = useSocketStore((state) => state.connected.chat); + const currentPlayerId = useGameSocketStore((state) => state.currentPlayerId); + const players = useGameSocketStore((state) => state.players); + const roomStatus = useGameSocketStore((state) => state.room?.status); + const roundAssignedRole = useGameSocketStore((state) => state.roundAssignedRole); + const chatActions = useChatSocketStore((state) => state.actions); + + const [inputMessage, setInputMessage] = useState(''); + + const submitMessage = (e: FormEvent) => { + e.preventDefault(); + sendChat(inputMessage.trim()); + setInputMessage(''); + }; + + const changeMessage = (e: ChangeEvent) => { + setInputMessage(e.target.value); + }; + + const checkDisableInput = () => { + if (roomStatus !== RoomStatus.DRAWING && roomStatus !== RoomStatus.GUESSING) return false; + if (roundAssignedRole === PlayerRole.GUESSER) return false; + return true; + }; + + const sendChat = (message: string) => { + if (!isConnected || !message.trim()) return; + void chatSocketHandlers.sendMessage(message); + + const currentPlayer = players?.find((player) => player.playerId === currentPlayerId); + if (!currentPlayer || !currentPlayerId) throw new Error('Current player not found'); + + const messageData: ChatRequest = { + playerId: String(currentPlayerId), + nickname: currentPlayer.nickname, + message: message.trim(), + createdAt: new Date().toISOString(), + }; + chatActions.addMessage(messageData); + + if (roomStatus === RoomStatus.GUESSING) { + void gameSocketHandlers.checkAnswer({ answer: message }); + } + }; + + return { submitMessage, changeMessage, checkDisableInput, inputMessage }; +}; diff --git a/client/src/hooks/game/useGameResult.ts b/client/src/hooks/game/useGameResult.ts new file mode 100644 index 000000000..066284298 --- /dev/null +++ b/client/src/hooks/game/useGameResult.ts @@ -0,0 +1,92 @@ +import { useEffect } from 'react'; +import { RoomStatus, TerminationType } from '@troublepainter/core'; +import { useNavigate } from 'react-router-dom'; +import { useTimeout } from '../useTimeout'; +import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; +import { useToastStore } from '@/stores/toast.store'; + +/** + * 게임 종료 후 결과 처리와 관련된 로직을 담당하는 커스텀 훅입니다. + * + * @remarks + * 이 훅은 모든 라운드가 종료되어 방 상태가 `POST_END`가 되었을 때 다음과 같은 작업을 수행합니다: + * + * - 플레이어들의 점수를 기준으로 1등, 2등, 3등 플레이어 목록을 계산합니다. + * - 게임 종료 사유에 따라 토스트 메시지를 표시합니다. + * - 20초 후 자동으로 대기실(`/lobby/:roomId`)로 이동하며, 게임 상태를 초기화합니다. + * + * + * @returns 게임 종료 후 결과 처리와 관련된 함수를 포함하는 객체: + * - `getRankedPlayers`: 각 등수별 플레이어 목록을 가져옵니다. + * + * @example + * ```tsx + * const { getRankedPlayers } = useGameResult(); + * const { firstPlacePlayers, secondPlacePlayers, thirdPlacePlayers } = getRankedPlayers(); + * + * return ( + *
+ *

1등

+ * {firstPlacePlayers.map(p => )} + * + *

2등

+ * {secondPlacePlayers.map(p => )} + * + *

3등

+ * {thirdPlacePlayers.map(p => )} + *
+ * ); + * ``` + */ + +export const useGameResult = () => { + 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 roomStatus = useGameSocketStore((state) => state.room?.status); + const players = useGameSocketStore((state) => state.players); + + const getRankedPlayers = () => { + const validPlayers = players.filter((player) => player.score > 0); + const sortedScores = [...new Set(validPlayers.map((p) => p.score))].sort((a, b) => b - a); + const rankedPlayers = sortedScores + .slice(0, 3) + .map((score) => validPlayers.filter((player) => player.score === score)); + + return { + firstPlacePlayers: rankedPlayers[0] ?? [], + secondPlacePlayers: rankedPlayers[1] ?? [], + thirdPlacePlayers: rankedPlayers[2] ?? [], + }; + }; + + useEffect(() => { + if (roomStatus !== RoomStatus.POST_END) return; + const description = + terminateType === TerminationType.PLAYER_DISCONNECT + ? '나간 플레이어가 있어요. 20초 후 대기실로 이동합니다!' + : '20초 후 대기실로 이동합니다!'; + const variant = terminateType === TerminationType.PLAYER_DISCONNECT ? 'warning' : 'success'; + + toastActions.addToast({ + title: '게임 종료', + description, + variant, + duration: 20000, + }); + }, [roomStatus]); + + const handleTimeout = () => { + if (roomStatus !== RoomStatus.POST_END) return; + gameActions.resetGame(); + navigate(`/lobby/${roomId}`); + }; + + useTimeout(handleTimeout, 20000); + + return { + getRankedPlayers, + }; +}; diff --git a/client/src/hooks/game/useGameSetting.ts b/client/src/hooks/game/useGameSetting.ts new file mode 100644 index 000000000..c13ef5c53 --- /dev/null +++ b/client/src/hooks/game/useGameSetting.ts @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useState } from 'react'; +import { RoomSettings } from '@troublepainter/core'; +import { gameSocketHandlers } from '@/handlers/socket/gameSocket.handler'; +import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; +import { useToastStore } from '@/stores/toast.store'; +import { WordsThemeValidationMessage } from '@/types/wordsTheme.types'; +import { validateWordsTheme } from '@/utils/wordsThemeValidation'; + +/** + * 게임 설정 관련 상태와 동작을 제공하는 커스텀 훅입니다. + * + * @remarks + * 이 훅은 게임 방의 설정 정보를 관리하며, 다음과 같은 기능을 포함합니다: + * - 방 설정 값(라운드 수, 최대 플레이어 수, 제한 시간 등)의 상태 관리 및 업데이트 + * - 제시어 테마(wordsTheme)의 입력 검증 및 제출 처리 + * - 호스트 권한 여부에 따른 설정 편집 가능 여부 확인 + * - 서버와의 설정 동기화 및 상태 관리(store) 업데이트 + * - 설정 변경 시 토스트 메시지로 사용자 피드백 제공 + * + * 내부적으로 React 상태 관리(`useState`, `useEffect`), 콜백(`useCallback`), + * Zustand 기반의 전역 상태 관리, 그리고 비동기 설정 업데이트 핸들러를 사용합니다. + * +/** + * @returns 게임 설정 관련 상태와 동작을 포함하는 객체: + * - `changeTheme`: 제시어 테마 입력 변경 핸들러입니다. (`e: React.ChangeEvent`) => void + * - `submitTheme`: 제시어 테마를 제출하는 함수입니다. (`e: React.FormEvent, onSuccess: () => void`) => Promise + * - `isThemeSubmitDisabled`: 테마 제출 버튼이 비활성화되어야 하는지를 나타내는 불리언 값입니다. + * - `wordsTheme`: 현재 입력된 제시어 테마 문자열입니다. + * - `isThemeSubmitting`: 테마가 제출 중인지 여부를 나타내는 상태 값입니다. + * - `themeValidationMessages`: 테마 입력에 대한 검증 메시지 리스트입니다. + * - `updateSetting`: 게임 설정 값을 변경하는 함수입니다. (`key: keyof RoomSettings`, `value: string`) => void + * - `checkCanSettingEdit`: 현재 사용자가 설정을 편집할 수 있는 권한이 있는지를 반환하는 함수입니다. + * - `selectedValues`: 현재 선택된 게임 설정 값(RoomSettings)입니다. + * + * + * @example + * ```tsx + * const { + * changeTheme, + * submitTheme, + * isThemeSubmitDisabled, + * wordsTheme, + * themeValidationMessages, + * updateSetting, + * checkCanSettingEdit, + * selectedValues, + * } = useGameSetting(); + * + * return ( + *
submitTheme(e, () => console.log('테마 제출 성공'))}> + * + * {themeValidationMessages.map((msg, i) => ( + *

+ * {msg.message} + *

+ * ))} + * + * + * + *
+ * ); + * ``` + */ + +export const useGameSetting = () => { + const addToast = useToastStore((state) => state.actions.addToast); + const actions = useGameSocketStore((state) => state.actions); + const roomSettings = useGameSocketStore((state) => state.roomSettings); + const isHost = useGameSocketStore((state) => state.isHost); + + const [wordsTheme, setWordsTheme] = useState(roomSettings?.wordsTheme || ''); + const [themeValidationMessages, setThemeValidationMessages] = useState([]); + const [isThemeSubmitting, setIsThemeSubmitting] = useState(false); + + const [selectedValues, setSelectedValues] = useState( + roomSettings ?? { + totalRounds: 5, + maxPlayers: 5, + drawTime: 30, + }, + ); + + useEffect(() => { + if (!roomSettings) return; + setSelectedValues(roomSettings); + }, [roomSettings]); + + const updateSetting = useCallback( + (key: keyof RoomSettings, value: string) => { + const newSettings = { + ...selectedValues, + [key]: Number(value), + }; + setSelectedValues(newSettings); + void gameSocketHandlers.updateSettings({ + settings: { ...newSettings, drawTime: newSettings.drawTime + 5 }, + }); + actions.updateRoomSettings(newSettings); + }, + [selectedValues], + ); + + useEffect(() => { + setWordsTheme(roomSettings?.wordsTheme || ''); + }, [roomSettings?.wordsTheme]); + + const checkCanSettingEdit = () => isHost; + + // 실시간 입력 검증 + const changeTheme = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/\s+/g, ' '); + setWordsTheme(value); + setThemeValidationMessages(validateWordsTheme(value)); + }; + + const submitTheme = async (e: React.FormEvent, onSuccess: () => void) => { + e.preventDefault(); + if (isThemeSubmitting) return; + + // 현재 validationMessages 상태를 활용하여 검증 + const hasErrors = themeValidationMessages.some((msg) => msg.type === 'error'); + if (hasErrors || !wordsTheme.trim()) { + addToast({ + title: '입력 오류', + description: '모든 입력 조건을 만족해야 합니다.', + variant: 'error', + duration: 3000, + }); + return; + } + + try { + setIsThemeSubmitting(true); + + await gameSocketHandlers.updateSettings({ + settings: { wordsTheme: wordsTheme.trim() }, + }); + + if (roomSettings) { + actions.updateRoomSettings({ + ...roomSettings, + wordsTheme: wordsTheme.trim(), + }); + } + + addToast({ + title: '테마 설정 완료', + description: `제시어 테마가 '${wordsTheme.trim()}'(으)로 설정되었습니다.`, + variant: 'success', + duration: 2000, + }); + + onSuccess(); + } catch (err) { + console.error(err); + addToast({ + title: '설정 실패', + description: '테마 설정 중 오류가 발생했습니다. 다시 시도해주세요.', + variant: 'error', + }); + } finally { + setIsThemeSubmitting(false); + } + }; + + // 제출 가능 여부 확인 + const isThemeSubmitDisabled = + themeValidationMessages.some((msg) => msg.type === 'error') || !wordsTheme.trim() || isThemeSubmitting; + + return { + changeTheme, + submitTheme, + isThemeSubmitDisabled, + wordsTheme, + isThemeSubmitting, + themeValidationMessages, + updateSetting, + checkCanSettingEdit, + selectedValues, + }; +}; diff --git a/client/src/hooks/game/useGameStart.ts b/client/src/hooks/game/useGameStart.ts new file mode 100644 index 000000000..f238105ff --- /dev/null +++ b/client/src/hooks/game/useGameStart.ts @@ -0,0 +1,95 @@ +import { useEffect, useState } from 'react'; +import { RoomStatus } from '@troublepainter/core'; +import { gameSocketHandlers } from '@/handlers/socket/gameSocket.handler'; +import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; + +export const START_BUTTON_STATUS = { + NOT_HOST: { + title: '방장만 게임을 시작할 수 있습니다', + content: '방장만 시작 가능', + disabled: true, + }, + NOT_ENOUGH_PLAYERS: { + title: '게임을 시작하려면 최소 4명의 플레이어가 필요합니다', + content: '4명 이상 게임 시작 가능', + disabled: true, + }, + CAN_START: { + title: '', + content: '게임 시작', + disabled: false, + }, +} as const; + +const MIN_PLAYER = 4; + +/** + * 게임 시작 버튼 상태를 관리하는 커스텀 훅입니다. + * + * @remarks + * - 방장 여부, 플레이어 수, 방 상태에 따라 게임 시작 버튼 상태를 결정합니다. + * - 게임 시작 요청 시 소켓 핸들러를 호출하고, 시작 상태를 관리합니다. + * - 방 상태가 WAITING일 때는 시작 상태를 리셋합니다. + * + * @returns + * - `isStarting`: 게임 시작 요청 중인지 여부 (boolean) + * - `getStartButtonStatus`: 현재 게임 시작 버튼의 상태를 반환하는 함수 + * - `startGame`: 게임 시작을 요청하는 함수 + * - `checkCanStart`: 게임 시작이 가능한지 확인하는 함수 (현재는 방장 여부만 확인) + * + * @example + * ```tsx + * const StartGameButton = () => { + * const { isStarting, getStartButtonStatus, startGame, checkCanStart } = useGameStart(); + * const status = getStartButtonStatus(); + * + * return ( + * + * ); + * }; + * ``` + */ + +export const useGameStart = () => { + const isHost = useGameSocketStore((state) => state.isHost); + const players = useGameSocketStore((state) => state.players); + const roomStatus = useGameSocketStore((state) => state.room?.status); + + const [isStarting, setIsStarting] = useState(false); + + const getStartButtonStatus = () => { + if (!isHost) return START_BUTTON_STATUS.NOT_HOST; + if (players.length < MIN_PLAYER) return START_BUTTON_STATUS.NOT_ENOUGH_PLAYERS; + return START_BUTTON_STATUS.CAN_START; + }; + + const startGame = () => { + void gameSocketHandlers.gameStart(); + setIsStarting(true); + }; + + const checkCanStart = () => isHost; + + useEffect(() => { + if (roomStatus === RoomStatus.WAITING) { + setIsStarting(false); + } + }, [roomStatus]); + + return { + isStarting, + getStartButtonStatus, + startGame, + checkCanStart, + }; +}; diff --git a/client/src/hooks/game/usePlayers.ts b/client/src/hooks/game/usePlayers.ts new file mode 100644 index 000000000..4f0422433 --- /dev/null +++ b/client/src/hooks/game/usePlayers.ts @@ -0,0 +1,37 @@ +import { PlayerRole } from '@troublepainter/core'; +import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; + +/** + * 플레이어 관련 상태 및 역할 표시 로직을 제공하는 커스텀 훅입니다. + * + * @remarks + * - 현재 사용자의 역할(`roundAssignedRole`)에 따라 상대 플레이어의 역할 표시를 제한합니다. + * - 사용자가 `GUESSER` 역할일 경우, 상대방이 `GUESSER`일 때만 역할을 표시하고, 그렇지 않으면 `null`을 반환합니다. + * - 사용자가 `GUESSER`가 아닐 경우에는 상대 플레이어 역할을 그대로 반환합니다. + * + * @returns + * - `getDisplayRoleText`: 플레이어 역할을 받아서 화면에 표시할 역할 텍스트를 반환하는 함수 + * + * @example + * ```tsx + * const PlayerRoleDisplay = ({ role }: { role: PlayerRole | undefined }) => { + * const { getDisplayRoleText } = usePlayers(); + * const displayRole = getDisplayRoleText(role); + * + * return {displayRole ?? '비공개'}; + * }; + * ``` + */ + +export const usePlayers = () => { + const myRole = useGameSocketStore((state) => state.roundAssignedRole); + + const getDisplayRoleText = (playerRole: PlayerRole | undefined) => { + if (myRole === PlayerRole.GUESSER) return playerRole === PlayerRole.GUESSER ? playerRole : null; + return playerRole; + }; + + return { + getDisplayRoleText, + }; +}; diff --git a/client/src/hooks/game/useQuizStageUI.ts b/client/src/hooks/game/useQuizStageUI.ts new file mode 100644 index 000000000..86ac1c35f --- /dev/null +++ b/client/src/hooks/game/useQuizStageUI.ts @@ -0,0 +1,79 @@ +import { PlayerRole, RoomStatus } from '@troublepainter/core'; +import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; + +/** + * 퀴즈 게임의 단계별 UI 표시 여부 및 텍스트를 관리하는 커스텀 훅입니다. + * + * @remarks + * - 현재 라운드 상태(`roomStatus`)와 플레이어 역할(`roundAssignedRole`)에 따라 + * 캔버스, 퀴즈 제목, 타이머 등의 UI 표시 여부를 결정합니다. + * - `currentWord`에 기반해 퀴즈 제목 텍스트를 동적으로 생성합니다. + * + * @returns + * - `checkShowCanvasAndQuizTitle`: 캔버스와 퀴즈 단어를 보여줄지 여부를 반환하는 함수 + * - `checkShowBigTimer`: 큰 타이머를 보여줄지 여부를 반환하는 함수 + * - `getQuizTitleText`: 퀴즈 제목에 표시할 텍스트를 반환하는 함수 + * - `checkCanCanvasDraw`: 현재 캔버스에 그림을 그릴 수 있는지 여부를 반환하는 함수 + * + * @example + * ```tsx + * import React from 'react'; + * import { useQuizStageUI } from './useQuizStageUI'; + * + * const QuizStage = () => { + * const { + * checkShowCanvasAndQuizTitle, + * checkShowBigTimer, + * getQuizTitleText, + * checkCanCanvasDraw, + * } = useQuizStageUI(); + * + * return ( + *
+ * {checkShowCanvasAndQuizTitle() && } + *

{getQuizTitleText()}

+ * {checkShowBigTimer() && } + * {checkCanCanvasDraw() && } + *
+ * ); + * }; + * ``` + */ + +export const useQuizStageUI = () => { + const roundAssignedRole = useGameSocketStore((state) => state.roundAssignedRole); + const roomStatus = useGameSocketStore((state) => state.room?.status); + const currentWord = useGameSocketStore((state) => state.room?.currentWord); + + const checkShowCanvasAndQuizTitle = () => { + if (roomStatus === RoomStatus.GUESSING || roomStatus === RoomStatus.POST_ROUND) return true; + if (roomStatus === RoomStatus.DRAWING && roundAssignedRole !== PlayerRole.GUESSER) return true; + return false; + }; + + const checkShowBigTimer = () => { + if (roomStatus === RoomStatus.DRAWING && roundAssignedRole === PlayerRole.GUESSER) return true; + return false; + }; + + const getQuizTitleText = () => { + if (roomStatus === RoomStatus.DRAWING) { + return roundAssignedRole !== PlayerRole.GUESSER ? `${currentWord}` : ''; + } + if (roomStatus === RoomStatus.GUESSING || roomStatus === RoomStatus.POST_ROUND) { + return roundAssignedRole !== PlayerRole.GUESSER ? `${currentWord} (맞히는중...)` : '맞혀보세요-!'; + } + }; + + const checkCanCanvasDraw = () => { + if (roomStatus === RoomStatus.DRAWING && roundAssignedRole !== PlayerRole.GUESSER) return true; + return false; + }; + + return { + checkShowCanvasAndQuizTitle, + checkShowBigTimer, + getQuizTitleText, + checkCanCanvasDraw, + }; +}; diff --git a/client/src/hooks/game/useRoleModal.ts b/client/src/hooks/game/useRoleModal.ts new file mode 100644 index 000000000..00e086a42 --- /dev/null +++ b/client/src/hooks/game/useRoleModal.ts @@ -0,0 +1,47 @@ +import { useEffect } from 'react'; +import { useModal } from '../useModal'; +import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; + +/** + * 역할 모달의 열림 상태와 동작을 관리하는 커스텀 훅입니다. + * + * @remarks + * - `currentRound`가 변경될 때마다 역할 모달을 자동으로 엽니다. + * - 내부적으로 `useModal` 훅을 사용하여 모달 열림 상태, 닫기 함수, 키다운 이벤트 핸들러, 모달 열기 함수를 제공합니다. + * - `DRAWING_TIME`으로 지정된 시간(5초) 동안 모달이 자동으로 닫힐 수 있도록 설정되어 있습니다. + * + * @returns + * - `isModalOpened`: 모달 열림 상태 (boolean) + * - `closeModal`: 모달을 닫는 함수 + * - `handleKeyDown`: 모달 내 키다운 이벤트 핸들러 함수 + * - `openModal`: 모달을 여는 함수 + * + * @example + * ```tsx + * const RoleModalComponent = () => { + * const { isModalOpened, closeModal, handleKeyDown } = useRoleModal(); + * + * if (!isModalOpened) return null; + * + * return ( + *
+ *

역할 안내 모달

+ * + *
+ * ); + * }; + * ``` + */ + +const DRAWING_TIME = 5000; + +export const useRoleModal = () => { + const currentRound = useGameSocketStore((state) => state.room?.currentRound); + const { isModalOpened, closeModal, handleKeyDown, openModal } = useModal(DRAWING_TIME); + + useEffect(() => { + openModal(); + }, [currentRound]); + + return { isModalOpened, closeModal, handleKeyDown, openModal }; +}; diff --git a/client/src/hooks/game/useRoundEndModal.ts b/client/src/hooks/game/useRoundEndModal.ts new file mode 100644 index 000000000..d844d5a45 --- /dev/null +++ b/client/src/hooks/game/useRoundEndModal.ts @@ -0,0 +1,130 @@ +import { useEffect, useState } from 'react'; +import { PlayerRole, RoomStatus } from '@troublepainter/core'; +import gameLoss from '@/assets/sounds/game-loss.mp3'; +import gameWin from '@/assets/sounds/game-win.mp3'; +import { useModal } from '@/hooks/useModal'; +import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; +import { useTimerStore } from '@/stores/timer.store'; +import { SOUND_IDS, SoundManager } from '@/utils/soundManager'; + +/** + * 라운드 종료 모달과 관련 애니메이션, 사운드를 관리하는 커스텀 훅입니다. + * + * @remarks + * - 라운드 종료(`POST_ROUND`) 시 모달을 자동으로 열고, 승패에 따른 사운드를 재생합니다. + * - 라운드 진행 중(`DRAWING`)에는 모달을 닫습니다. + * - 애니메이션 효과를 3초간 보여주고, 이후 페이드아웃 처리 및 상태 초기화를 수행합니다. + * - `SoundManager`를 통해 승리 및 패배 사운드를 미리 로드 및 재생합니다. + * - 게임 내 플레이어, 승자, 역할 등의 상태를 함께 제공합니다. + * + * @returns + * - `isModalOpened`: 모달 열림 상태 (boolean) + * - `isAnimationFading`: 애니메이션 페이드아웃 상태 (boolean) + * - `isDevilWin`: 악마(DEVIL) 역할이 승리했는지 여부 (boolean) + * - `isPlayerWinner`: 현재 플레이어가 라운드 승자인지 여부 (boolean) + * - `devil`: 악마 역할을 가진 플레이어 객체 (없을 수 있음) + * - `showAnimation`: 애니메이션을 보여줄지 여부 (boolean) + * - `solver`: 문제를 맞힌 플레이어(추측자) 객체 (없을 수 있음) + * - `timer`: 라운드 종료 타이머 상태 (숫자, 초 단위) + * + * @example + * ```tsx + * const RoundEndModal = () => { + * const { + * isModalOpened, + * isAnimationFading, + * isDevilWin, + * isPlayerWinner, + * devil, + * showAnimation, + * solver, + * timer, + * } = useRoundEndModal(); + * + * if (!isModalOpened) return null; + * + * return ( + *
+ * {showAnimation &&
라운드 종료 애니메이션
} + *

{isDevilWin ? '악마가 이겼습니다!' : '추측자가 이겼습니다!'}

+ *

악마: {devil?.name}

+ *

승자: {isPlayerWinner ? '당신' : solver?.name}

+ *

다음 라운드까지: {timer}초

+ *
+ * ); + * }; + * ``` + */ + +export const useRoundEndModal = () => { + const players = useGameSocketStore((state) => state.players); + const roundWinners = useGameSocketStore((state) => state.roundWinners); + const playerId = useGameSocketStore((state) => state.currentPlayerId); + const roomStatus = useGameSocketStore((state) => state.room?.status); + const timer = useTimerStore((state) => state.timers.ENDING); + + const { isModalOpened, openModal, closeModal } = useModal(); + + const [showAnimation, setShowAnimation] = useState(false); + const [isAnimationFading, setIsAnimationFading] = useState(false); + + const devil = players.find((player) => player.role === PlayerRole.DEVIL); + const isDevilWin = roundWinners?.some((winner) => winner.role === PlayerRole.DEVIL); + const isPlayerWinner = roundWinners?.some((winner) => winner.playerId === playerId); + const solver = roundWinners?.find((winner) => winner.role === PlayerRole.GUESSER); + + // 컴포넌트 마운트 시 사운드 미리 로드 + const soundManager = SoundManager.getInstance(); + useEffect(() => { + soundManager.preloadSound(SOUND_IDS.WIN, gameWin); + soundManager.preloadSound(SOUND_IDS.LOSS, gameLoss); + }, [soundManager]); + + useEffect(() => { + if (roomStatus === RoomStatus.POST_ROUND) { + setIsAnimationFading(false); + setShowAnimation(true); + openModal(); + + if (isPlayerWinner) { + void soundManager.playSound(SOUND_IDS.WIN, 0.3); + } else { + void soundManager.playSound(SOUND_IDS.LOSS, 0.3); + } + } + }, [roomStatus]); + + useEffect(() => { + if (roomStatus === RoomStatus.DRAWING) closeModal(); + }, [roomStatus]); + + useEffect(() => { + if (showAnimation) { + // 3초 후에 페이드아웃 시작 + const fadeTimer = setTimeout(() => { + setIsAnimationFading(true); + }, 3000); + + // 3.5초 후에 컴포넌트 제거 + const removeTimer = setTimeout(() => { + setShowAnimation(false); + }, 3500); + + return () => { + clearTimeout(fadeTimer); + clearTimeout(removeTimer); + }; + } + }, [showAnimation]); + + return { + isModalOpened, + isAnimationFading, + isDevilWin, + isPlayerWinner, + devil, + showAnimation, + solver, + timer, + }; +}; diff --git a/client/src/hooks/game/useTimer.ts b/client/src/hooks/game/useTimer.ts new file mode 100644 index 000000000..f3cfa18da --- /dev/null +++ b/client/src/hooks/game/useTimer.ts @@ -0,0 +1,99 @@ +import { useEffect, useRef } from 'react'; +import { TimerType } from '@troublepainter/core'; +import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; +import { useTimerStore } from '@/stores/timer.store'; + +/** + * 게임 내 여러 단계별 타이머를 관리하는 커스텀 훅입니다. + * + * @remarks + * - `timers` 상태를 감지하여 각 타이머 타입별로 인터벌을 설정하고 1초마다 감소시키는 동작을 수행합니다. + * - 기존 인터벌은 중복 실행을 막기 위해 정리하며, 타이머 값이 0 이하이거나 null일 경우 인터벌을 제거합니다. + * - `roomStatus`에 따라 현재 남은 시간을 반환하는 함수도 제공합니다. + * - `drawTime`은 기본 드로잉 타이머 시간이며, 서버에서 받아온 설정값입니다. + * + * @returns + * - `getRemainingTime`: 현재 라운드 상태에 따른 남은 시간을 반환하는 함수 (number) + * + * @example + * ```tsx + * + * const TimerDisplay = () => { + * const { getRemainingTime } = useTimer(); + * + * useEffect(() => { + * const interval = setInterval(() => { + * console.log('남은 시간:', getRemainingTime()); + * }, 1000); + * + * return () => clearInterval(interval); + * }, [getRemainingTime]); + * + * return
남은 시간: {getRemainingTime()}초
; + * }; + * ``` + */ + +export const useTimer = () => { + const actions = useTimerStore((state) => state.actions); + const timers = useTimerStore((state) => state.timers); + const roomStatus = useGameSocketStore((state) => state.room?.status); + const drawTime = useGameSocketStore((state) => state.roomSettings?.drawTime); + + const intervalRefs = useRef>({ + [TimerType.DRAWING]: null, + [TimerType.GUESSING]: null, + [TimerType.ENDING]: null, + }); + + useEffect(() => { + const manageTimer = (timerType: TimerType, value: number | null) => { + // 이전 인터벌 정리 + if (intervalRefs.current[timerType]) { + clearInterval(intervalRefs.current[timerType]!); + intervalRefs.current[timerType] = null; + } + + // 새로운 타이머 설정 + if (value !== null && value > 0) { + intervalRefs.current[timerType] = setInterval(() => { + actions.decreaseTimer(timerType); + }, 1000); + } + }; + + // 각 타이머 타입에 대해 처리 + Object.entries(timers).forEach(([type, value]) => { + if (type in TimerType) { + manageTimer(type as TimerType, value); + } + }); + + // 클린업 + return () => { + Object.values(intervalRefs.current).forEach((interval) => { + if (interval) clearInterval(interval); + }); + }; + }, [ + timers.DRAWING !== null && timers.DRAWING > 0, + timers.GUESSING !== null && timers.GUESSING > 0, + timers.ENDING !== null && timers.ENDING > 0, + actions, + ]); // timers와 actions만 의존성으로 설정 + + const getRemainingTime = () => { + switch (roomStatus) { + case 'DRAWING': + return timers.DRAWING ?? drawTime; + case 'GUESSING': + return timers.GUESSING ?? 15; + default: + return 0; + } + }; + + return { + getRemainingTime, + }; +}; diff --git a/client/src/hooks/socket/useGameSocket.ts b/client/src/hooks/socket/useGameSocket.ts index f63a751df..61d382cdb 100644 --- a/client/src/hooks/socket/useGameSocket.ts +++ b/client/src/hooks/socket/useGameSocket.ts @@ -206,6 +206,7 @@ export const useGameSocket = () => { gameActions.updateCurrentRound(roundNumber); gameActions.updateCurrentWord(word); gameActions.updateRoundWinners(winners); + gameActions.updateRoomStatus(RoomStatus.POST_ROUND); timerActions.updateTimer(TimerType.ENDING, 10); gameActions.updatePlayers(players); }, @@ -217,7 +218,7 @@ export const useGameSocket = () => { gameActions.updateHost(hostId); gameActions.updateIsHost(hostId === useGameSocketStore.getState().currentPlayerId); } - gameActions.updateRoomStatus(RoomStatus.WAITING); + gameActions.updateRoomStatus(RoomStatus.POST_END); gameActions.resetRound(); gameActions.updateGameTerminateType(terminationType); navigate(`/game/${roomId}/result`, { replace: true }); diff --git a/client/src/hooks/useStartButton.tsx b/client/src/hooks/useStartButton.tsx deleted file mode 100644 index 667322aee..000000000 --- a/client/src/hooks/useStartButton.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useCallback, useMemo, useState } from 'react'; -import { gameSocketHandlers } from '@/handlers/socket/gameSocket.handler'; -import { useShortcuts } from '@/hooks/useShortcuts'; -import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; - -export const START_BUTTON_STATUS = { - NOT_HOST: { - title: '방장만 게임을 시작할 수 있습니다', - content: '방장만 시작 가능', - disabled: true, - }, - NOT_ENOUGH_PLAYERS: { - title: '게임을 시작하려면 최소 4명의 플레이어가 필요합니다', - content: '4명 이상 게임 시작 가능', - disabled: true, - }, - CAN_START: { - title: undefined, - content: '게임 시작', - disabled: false, - }, -} as const; - -export const useGameStart = () => { - const [isStarting, setIsStarting] = useState(false); - - const players = useGameSocketStore((state) => state.players); - const isHost = useGameSocketStore((state) => state.isHost); - const room = useGameSocketStore((state) => state.room); - const currentPlayerId = useGameSocketStore((state) => state.currentPlayerId); - - const buttonConfig = useMemo(() => { - if (!isHost) return START_BUTTON_STATUS.NOT_HOST; - if (players.length < 4) return START_BUTTON_STATUS.NOT_ENOUGH_PLAYERS; - return START_BUTTON_STATUS.CAN_START; - }, [isHost, players.length]); - - const handleStartGame = useCallback(() => { - if (!room || buttonConfig.disabled || !room.roomId || !currentPlayerId) return; - void gameSocketHandlers.gameStart(); - setIsStarting(true); - }, [room, buttonConfig.disabled, room?.roomId, currentPlayerId]); - - // 게임 초대 단축키 적용 - useShortcuts([ - { - key: 'GAME_START', - action: () => void handleStartGame(), - }, - ]); - - return { - isHost, - buttonConfig, - handleStartGame, - isStarting, - }; -}; diff --git a/client/src/hooks/useTimer.ts b/client/src/hooks/useTimer.ts deleted file mode 100644 index d7b9ba765..000000000 --- a/client/src/hooks/useTimer.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { TimerType } from '@troublepainter/core'; -import { useTimerStore } from '@/stores/timer.store'; - -export const useTimer = () => { - const actions = useTimerStore((state) => state.actions); - const timers = useTimerStore((state) => state.timers); - - const intervalRefs = useRef>({ - [TimerType.DRAWING]: null, - [TimerType.GUESSING]: null, - [TimerType.ENDING]: null, - }); - - useEffect(() => { - const manageTimer = (timerType: TimerType, value: number | null) => { - // 이전 인터벌 정리 - if (intervalRefs.current[timerType]) { - clearInterval(intervalRefs.current[timerType]!); - intervalRefs.current[timerType] = null; - } - - // 새로운 타이머 설정 - if (value !== null && value > 0) { - intervalRefs.current[timerType] = setInterval(() => { - actions.decreaseTimer(timerType); - }, 1000); - } - }; - - // 각 타이머 타입에 대해 처리 - Object.entries(timers).forEach(([type, value]) => { - if (type in TimerType) { - manageTimer(type as TimerType, value); - } - }); - - // 클린업 - return () => { - Object.values(intervalRefs.current).forEach((interval) => { - if (interval) clearInterval(interval); - }); - }; - }, [ - timers.DRAWING !== null && timers.DRAWING > 0, - timers.GUESSING !== null && timers.GUESSING > 0, - timers.ENDING !== null && timers.ENDING > 0, - actions, - ]); // timers와 actions만 의존성으로 설정 - - return timers; -}; diff --git a/client/src/pages/PlaygroundPage.tsx b/client/src/pages/PlaygroundPage.tsx index 3241bc93d..1ddc2a1c5 100644 --- a/client/src/pages/PlaygroundPage.tsx +++ b/client/src/pages/PlaygroundPage.tsx @@ -1,5 +1,4 @@ -import { MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent, useRef } from 'react'; -import { RoomStatus } from '@troublepainter/core'; +import { PointerEvent, useRef } from 'react'; import { Link } from 'react-router-dom'; import { Canvas } from '@/components/canvas/CanvasUI'; import { Logo } from '@/components/ui/Logo'; @@ -31,7 +30,7 @@ const PlaygroundPage = () => { canUndo, undo, redo, - } = useDrawing(canvasRef, RoomStatus.DRAWING, { + } = useDrawing(canvasRef, true, { maxPixels: Number.MAX_SAFE_INTEGER, }); @@ -41,14 +40,14 @@ const PlaygroundPage = () => { onClick: () => setCurrentColor(color.backgroundColor), })); - const handleDrawStart = (e: ReactMouseEvent | ReactTouchEvent) => { + const handleDrawStart = (e: PointerEvent) => { const { canvas } = getCanvasContext(canvasRef); const point = getDrawPoint(e, canvas); const convertPoint = convertCoordinate(point); startDrawing(convertPoint); }; - const handleDrawMove = (e: ReactMouseEvent | ReactTouchEvent) => { + const handleDrawMove = (e: PointerEvent) => { const { canvas } = getCanvasContext(canvasRef); const point = getDrawPoint(e, canvas); const convertPoint = convertCoordinate(point); @@ -57,7 +56,7 @@ const PlaygroundPage = () => { continueDrawing(convertPoint); }; - const handleDrawLeave = (e: ReactMouseEvent | ReactTouchEvent) => { + const handleDrawLeave = (e: PointerEvent) => { const { canvas } = getCanvasContext(canvasRef); const point = getDrawPoint(e, canvas); const convertPoint = convertCoordinate(point); @@ -98,7 +97,6 @@ const PlaygroundPage = () => { canvasRef={canvasRef} cursorCanvasRef={cursorCanvasRef} isDrawable={true} - isHidden={false} colors={colorsWithSelect} brushSize={brushSize} setBrushSize={setBrushSize} diff --git a/client/src/pages/ResultPage.tsx b/client/src/pages/ResultPage.tsx index dd9a92252..43f4f6712 100644 --- a/client/src/pages/ResultPage.tsx +++ b/client/src/pages/ResultPage.tsx @@ -1,42 +1,10 @@ -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'; +import { useGameResult } from '@/hooks/game/useGameResult'; 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초 후 대기실로 이동합니다!'; - const variant = terminateType === TerminationType.PLAYER_DISCONNECT ? 'warning' : 'success'; - - toastActions.addToast({ - title: '게임 종료', - description, - variant, - duration: 20000, - }); - }, [terminateType, toastActions]); - - const handleTimeout = useCallback(() => { - gameActions.resetGame(); - navigate(`/lobby/${roomId}`); - }, [gameActions, navigate, roomId]); - - useTimeout(handleTimeout, 20000); + const { getRankedPlayers } = useGameResult(); + const { firstPlacePlayers, secondPlacePlayers, thirdPlacePlayers } = getRankedPlayers(); return (
diff --git a/core/types/game.types.ts b/core/types/game.types.ts index e1de7ec2a..d08495c0f 100644 --- a/core/types/game.types.ts +++ b/core/types/game.types.ts @@ -35,6 +35,8 @@ export enum RoomStatus { WAITING = 'WAITING', DRAWING = 'DRAWING', GUESSING = 'GUESSING', + POST_ROUND = 'POST_ROUND', + POST_END = 'POST_END', } export enum TimerType { diff --git a/core/types/socket.types.ts b/core/types/socket.types.ts index b5e665084..1c21d2122 100644 --- a/core/types/socket.types.ts +++ b/core/types/socket.types.ts @@ -131,7 +131,10 @@ export interface RoundEndResponse { } export interface ChatRequest { + playerId: string; + nickname: string; message: string; + createdAt: string; } export interface ChatResponse {