diff --git a/fe/README.md b/fe/README.md index 4a1a236..6fbde2e 100644 --- a/fe/README.md +++ b/fe/README.md @@ -36,3 +36,13 @@ {...props} /> ``` + +### 새로고침 지옥에서 꺼내줘 + +- 개발 시작 단계부터 날 괴롭게 했던 새로고침.. 재입장 처리로 어떻게 넘어갔었는데, 게임 중일 때는 막아야 함 +- 키보드 동작은 막을 수 있는데 브라우저 새로고침 버튼 클릭은 막을 수 없음. Alert 띄우는 게 최선인데 이 Alert도 메시지 수정 불가. +- 채점 중에서 안 넘어가는 문제가 해결되지 않았다. + - 계속 테스트해 보는데 음성 데이터 전달 중에 새로고침 하면 채점이 안 되고 결과를 못 받아와서 그런 것 같다. +- `beforeunload` 이벤트의 브라우저 기본 alert보다 먼저 혹은 동시에 CustomAlertDialog을 띄우는 것은 불가능함 + - 강퇴처럼 방 목록 페이지에 왔을 때 알림을 띄우기로 함 + - 이게 왜 잘 안되는 건지 모르겠다.. 강퇴랑 별다를 게 없는 거 같은데..🤯 나중에 고쳐보는 걸로 diff --git a/fe/src/components/common/CustomAlertDialog.tsx b/fe/src/components/common/CustomAlertDialog.tsx index 9421be0..8d5c1b3 100644 --- a/fe/src/components/common/CustomAlertDialog.tsx +++ b/fe/src/components/common/CustomAlertDialog.tsx @@ -14,6 +14,7 @@ interface CustomAlertDialogProps { title: string; description?: string; actionText?: string; + handleClick?: () => void; } const CustomAlertDialog = ({ @@ -22,6 +23,7 @@ const CustomAlertDialog = ({ title, description, actionText = '확인', + handleClick, }: CustomAlertDialogProps) => { return ( @@ -33,7 +35,10 @@ const CustomAlertDialog = ({ )} - + {actionText} diff --git a/fe/src/hooks/useBackExit.ts b/fe/src/hooks/useBackExit.ts index a37c00c..32c459b 100644 --- a/fe/src/hooks/useBackExit.ts +++ b/fe/src/hooks/useBackExit.ts @@ -1,5 +1,4 @@ import { useEffect, useRef } from 'react'; -import { useLocation } from 'react-router-dom'; export const useBackExit = ({ setShowExitDialog }) => { const popStateListenerRef = useRef<(() => void) | null>(null); diff --git a/fe/src/hooks/usePreventRefresh.ts b/fe/src/hooks/usePreventRefresh.ts new file mode 100644 index 0000000..b295be4 --- /dev/null +++ b/fe/src/hooks/usePreventRefresh.ts @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; +import { toast } from 'react-toastify'; + +export const usePreventRefresh = (isPlaying: boolean) => { + useEffect(() => { + if (!isPlaying) return; + + const preventKeyboardRefresh = (e: KeyboardEvent) => { + if ( + e.key === 'F5' || + (e.ctrlKey && e.key === 'r') || + (e.metaKey && e.key === 'r') + ) { + e.preventDefault(); + + toast.error('게임 중에는 새로고침 할 수 없습니다!', { + position: 'top-left', + autoClose: 1000, + style: { + fontFamily: 'Galmuri11, monospace', + width: '25rem', + minWidth: '25rem', + }, + }); + } + }; + + document.addEventListener('keydown', preventKeyboardRefresh); + + return () => { + document.removeEventListener('keydown', preventKeyboardRefresh); + }; + }, [isPlaying]); +}; diff --git a/fe/src/hooks/useReconnect.ts b/fe/src/hooks/useReconnect.ts index 20d8809..78763c9 100644 --- a/fe/src/hooks/useReconnect.ts +++ b/fe/src/hooks/useReconnect.ts @@ -6,6 +6,7 @@ import { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { useAudioPermission } from './useAudioPermission'; import { useAudioManager } from './useAudioManager'; +import useGameStore from '@/stores/zustand/useGameStore'; export const useReconnect = ({ currentRoom }) => { const { roomId } = useParams(); @@ -14,6 +15,7 @@ export const useReconnect = ({ currentRoom }) => { const { data: room } = getCurrentRoomQuery(roomId); const { requestPermission } = useAudioPermission(); const audioManager = useAudioManager(); + const { setGameInProgressError } = useGameStore(); useEffect(() => { const handleReconnect = async () => { @@ -42,19 +44,24 @@ export const useReconnect = ({ currentRoom }) => { signalingSocket.setupLocalStream(stream); // 5. 방 참가 - gameSocket.joinRoom(roomId, nickname); - signalingSocket.joinRoom(room, nickname); + await gameSocket.joinRoom(roomId, nickname); + await signalingSocket.joinRoom(room, nickname); } } catch (error) { console.error('Reconnection failed:', error); // 실패 시 audioManager 제거 signalingSocket.setAudioManager(null); + + if (error === 'GameAlreadyInProgress') { + setGameInProgressError(true); + + window.location.href = '/'; + } } }; handleReconnect(); - // cleanup return () => { signalingSocket.setAudioManager(null); }; diff --git a/fe/src/pages/GamePage/GameScreen/EndScreen.tsx b/fe/src/pages/GamePage/GameScreen/EndScreen.tsx index e01e88d..ee2bb96 100644 --- a/fe/src/pages/GamePage/GameScreen/EndScreen.tsx +++ b/fe/src/pages/GamePage/GameScreen/EndScreen.tsx @@ -3,13 +3,29 @@ import podiumAnimation from '@/assets/lottie/podium.json'; import useGameStore from '@/stores/zustand/useGameStore'; import { motion } from 'framer-motion'; import { Button } from '@/components/ui/button'; +import { useParams } from 'react-router-dom'; +import { getCurrentRoomQuery } from '@/stores/queries/getCurrentRoomQuery'; +import useRoomStore from '@/stores/zustand/useRoomStore'; const EndScreen = () => { const rank = useGameStore((state) => state.rank); const resetGame = useGameStore((state) => state.resetGame); + const { roomId } = useParams(); + const { refetch } = getCurrentRoomQuery(roomId); + const { setCurrentRoom } = useRoomStore(); - const handleGameEnd = () => { - resetGame(); + const handleGameEnd = async () => { + try { + resetGame(); + // room 정보 다시 가져오기 + const { data } = await refetch(); + // 새로운 room 정보로 상태 업데이트 + if (data) { + setCurrentRoom(data); + } + } catch (error) { + console.error('Failed to refresh room data:', error); + } }; const positions = { @@ -43,7 +59,7 @@ const EndScreen = () => {
{rank.slice(0, 3).map((playerName, index) => ( { ))}
- {/* 최종 순위 리스트 */} { - {/* 게임 종료 버튼 */} { setCurrentPlayer(nickname); } } - }, [currentPlayer, setCurrentPlayer]); + }, [currentPlayer]); return turnData ? : ; }; diff --git a/fe/src/pages/GamePage/GameScreen/PlayScreen.tsx b/fe/src/pages/GamePage/GameScreen/PlayScreen.tsx index 27bfeb4..56a1caf 100644 --- a/fe/src/pages/GamePage/GameScreen/PlayScreen.tsx +++ b/fe/src/pages/GamePage/GameScreen/PlayScreen.tsx @@ -12,6 +12,7 @@ import EndScreen from './EndScreen'; import ReadyScreen from './ReadyScreen'; import PitchVisualizer from '@/components/game/PitchVisualizer'; import { useParams } from 'react-router-dom'; +import { usePreventRefresh } from '@/hooks/usePreventRefresh'; type GamePhase = 'intro' | 'gameplay' | 'grading' | 'result'; @@ -23,11 +24,14 @@ const PlayScreen = () => { const rank = useGameStore((state) => state.rank); const [gamePhase, setGamePhase] = useState('intro'); const [timeLeft, setTimeLeft] = useState(0); - const { roomId: roomIdParam } = useParams(); + const { roomId } = useParams(); const INTRO_TIME = 2000; const RESULT_TIME = 3000; + // 새로고침 방지 + usePreventRefresh(Boolean(turnData && rank.length === 0)); + // 턴 데이터 변경 시 게임 초기화 useEffect(() => { if (!turnData) return; @@ -36,16 +40,16 @@ const PlayScreen = () => { setGamePhase('intro'); setGameResult(null); - const introTimer = setTimeout(() => { + const introTimer = setTimeout(async () => { setGamePhase('gameplay'); setTimeLeft(turnData.timeLimit); // gameplay 페이즈로 전환될 때 시간 설정 // 현재 플레이어 차례이고 게임 참여 가능한 경우에만 녹음 시작 if (currentPlayer === turnData.playerNickname) { - voiceSocket + await voiceSocket .startRecording( signalingSocket.getLocalStream(), - roomIdParam, + roomId, currentPlayer ) .catch(console.error); @@ -73,7 +77,7 @@ const PlayScreen = () => { }, 1000); return () => clearInterval(timer); - }, [gamePhase, currentPlayer, turnData]); + }, [gamePhase, turnData]); // 채점 중 -> 결과 화면 전환 useEffect(() => { diff --git a/fe/src/pages/GamePage/PlayerList/Player.tsx b/fe/src/pages/GamePage/PlayerList/Player.tsx index ef90152..a86f9fb 100644 --- a/fe/src/pages/GamePage/PlayerList/Player.tsx +++ b/fe/src/pages/GamePage/PlayerList/Player.tsx @@ -1,5 +1,5 @@ import { Card, CardContent } from '@/components/ui/card'; -import { FaCrown, FaMicrophone, FaMicrophoneSlash } from 'react-icons/fa6'; +import { FaCrown, FaMicrophoneSlash, FaRegFaceSmile } from 'react-icons/fa6'; import VolumeBar from './VolumeBar'; import { PlayerProps } from '@/types/roomTypes'; import { isHost } from '@/utils/playerUtils'; @@ -12,7 +12,7 @@ import { gameSocket } from '@/services/gameSocket'; import MikeButton from '@/components/common/MikeButton'; import useGameStore from '@/stores/zustand/useGameStore'; -const Player = ({ playerNickname, isReady }: PlayerProps) => { +const Player = ({ playerNickname, isReady, isDead, isLeft }: PlayerProps) => { const { currentRoom, currentPlayer } = useRoomStore(); // 본인이 방장인지 const isCurrentPlayerHost = currentPlayer === currentRoom?.hostNickname; @@ -54,11 +54,34 @@ const Player = ({ playerNickname, isReady }: PlayerProps) => { return ( - +
- {isPlayerHost ? : ''} + {isPlayerHost ? ( + + ) : ( + + )} {playerNickname}
+ +
+ {isLeft ? ( + 탈주 + ) : isDead ? ( + 탈락 + ) : ( + '' + )} +
+
{isCurrentPlayer ? ( diff --git a/fe/src/pages/GamePage/index.tsx b/fe/src/pages/GamePage/index.tsx index 631b328..1de0865 100644 --- a/fe/src/pages/GamePage/index.tsx +++ b/fe/src/pages/GamePage/index.tsx @@ -17,12 +17,16 @@ import JoinDialog from '../RoomListPage/RoomDialog/JoinDialog'; const GamePage = () => { const [showJoinDialog, setShowJoinDialog] = useState(false); const [showExitDialog, setShowExitDialog] = useState(false); - const { currentRoom, kickedPlayer, setKickedPlayer } = useRoomStore(); + const { kickedPlayer, setKickedPlayer } = useRoomStore(); + const currentRoom = useRoomStore((state) => state.currentRoom); const audioManager = useAudioManager(); const { roomId } = useParams(); const { data: room } = getCurrentRoomQuery(roomId); const nickname = sessionStorage.getItem('user_nickname'); + useReconnect({ currentRoom }); + useBackExit({ setShowExitDialog }); + useEffect(() => { if (room && !currentRoom) { if (!nickname) { @@ -31,9 +35,6 @@ const GamePage = () => { } }, [room, currentRoom, nickname]); - useReconnect({ currentRoom }); - useBackExit({ setShowExitDialog }); - // 오디오 매니저 설정 useEffect(() => { signalingSocket.setAudioManager(audioManager); @@ -113,6 +114,8 @@ const GamePage = () => { players={currentRoom.players.map((player) => ({ playerNickname: player.playerNickname, isReady: player.isReady, + isDead: player.isDead, + isLeft: player.isLeft, }))} />
diff --git a/fe/src/pages/RoomListPage/RoomList/RoomList.tsx b/fe/src/pages/RoomListPage/RoomList/RoomList.tsx index 48106f0..9ee0270 100644 --- a/fe/src/pages/RoomListPage/RoomList/RoomList.tsx +++ b/fe/src/pages/RoomListPage/RoomList/RoomList.tsx @@ -11,7 +11,6 @@ const RoomList = () => { const [isJoinDialogOpen, setIsJoinDialogOpen] = useState(false); const [selectedRoomId, setSelectedRoomId] = useState(null); const [showPagination, setShowPagination] = useState(false); - const isEmpty = rooms.length === 0; useEffect(() => { if (pagination?.totalPages > 1) { diff --git a/fe/src/pages/RoomListPage/index.tsx b/fe/src/pages/RoomListPage/index.tsx index 70bba4b..f17dd02 100644 --- a/fe/src/pages/RoomListPage/index.tsx +++ b/fe/src/pages/RoomListPage/index.tsx @@ -5,12 +5,14 @@ import { useEffect, useState } from 'react'; import CustomAlertDialog from '@/components/common/CustomAlertDialog'; import useRoomStore from '@/stores/zustand/useRoomStore'; import { useRoomsSSE } from '@/hooks/useRoomsSSE'; +import useGameStore from '@/stores/zustand/useGameStore'; const RoomListPage = () => { const [showAlert, setShowAlert] = useState(false); const [kickedRoomName, setKickedRoomName] = useState(''); const { rooms } = useRoomStore(); const isEmpty = rooms.length === 0; + const { setGameInProgressError } = useGameStore(); useRoomsSSE(); @@ -24,6 +26,10 @@ const RoomListPage = () => { } }, []); + const handleGameInProgressError = () => { + setGameInProgressError(false); + }; + return (
diff --git a/fe/src/stores/zustand/useGameStore.ts b/fe/src/stores/zustand/useGameStore.ts index 3032c67..93e7249 100644 --- a/fe/src/stores/zustand/useGameStore.ts +++ b/fe/src/stores/zustand/useGameStore.ts @@ -7,7 +7,7 @@ interface GameStore { resultData: GameResultProps; muteStatus: MuteStatus; rank: string[]; - isGameStarted: boolean; + gameInProgressError: boolean; } interface GameActions { @@ -15,7 +15,7 @@ interface GameActions { setGameResult: (resultData: GameResultProps) => void; setMuteStatus: (muteStatus: MuteStatus) => void; setRank: (rank: string[]) => void; - setIsGameStarted: (isGameStarted: boolean) => void; + setGameInProgressError: (value: boolean) => void; resetGame: () => void; } @@ -23,8 +23,8 @@ const initialState: GameStore = { turnData: null, resultData: null, muteStatus: {}, - isGameStarted: false, rank: [], + gameInProgressError: false, }; const useGameStore = create()( @@ -41,13 +41,11 @@ const useGameStore = create()( resultData, })), - setIsGameStarted: (isGameStarted) => - set(() => ({ - isGameStarted, - })), - setRank: (rank) => set(() => ({ rank })), + setGameInProgressError: (gameInProgressError) => + set({ gameInProgressError }), + resetGame: () => set({ // 초기화 로직 diff --git a/fe/src/types/roomTypes.ts b/fe/src/types/roomTypes.ts index af205da..99a424f 100644 --- a/fe/src/types/roomTypes.ts +++ b/fe/src/types/roomTypes.ts @@ -1,6 +1,8 @@ export interface PlayerProps { playerNickname: string; isReady: boolean; + isDead: boolean; + isLeft: boolean; } export interface Room {