diff --git a/packages/frontend/src/components/gamePage/leftSection/GamePhases/Voting.tsx b/packages/frontend/src/components/gamePage/leftSection/GamePhases/Voting.tsx index a87bd61..f610d9d 100644 --- a/packages/frontend/src/components/gamePage/leftSection/GamePhases/Voting.tsx +++ b/packages/frontend/src/components/gamePage/leftSection/GamePhases/Voting.tsx @@ -1,4 +1,5 @@ import Button from '@/components/common/Button'; +import { memo } from 'react'; interface IVotingPhaseProps { userId: string; @@ -9,7 +10,33 @@ interface IVotingPhaseProps { handleVote: () => void; } -export default function Voting({ +const VoteButton = memo( + ({ + userId, + isSelected, + isVoteSubmitted, + onClick, + }: { + userId: string; + isSelected: boolean; + isVoteSubmitted: boolean; + onClick: () => void; + }) => ( + + ), +); + +const Voting = memo(function Voting({ userId, allUsers, selectedVote, @@ -30,19 +57,14 @@ export default function Voting({

피노코를 지목해주세요!

- {Array.from(allUsers).map((userId: string) => ( - + {Array.from(allUsers).map((voterId: string) => ( + !isVoteSubmitted && setSelectedVote(voterId)} + /> ))}
{isVoteSubmitted ? ( @@ -64,4 +86,6 @@ export default function Voting({ )}
); -} +}); + +export default Voting; diff --git a/packages/frontend/src/components/gamePage/leftSection/GuessInput.tsx b/packages/frontend/src/components/gamePage/leftSection/GuessInput.tsx index 91b8a83..678c711 100644 --- a/packages/frontend/src/components/gamePage/leftSection/GuessInput.tsx +++ b/packages/frontend/src/components/gamePage/leftSection/GuessInput.tsx @@ -1,23 +1,30 @@ -import { useState } from 'react'; +import { useState, useCallback, memo } from 'react'; interface IGuessInputProps { onSubmitGuess: (word: string) => void; } -export default function GuessInput({ onSubmitGuess }: IGuessInputProps) { +const GuessInput = memo(function GuessInput({ onSubmitGuess }: IGuessInputProps) { const [inputValue, setInputValue] = useState(''); - const handleSubmit = () => { + const handleSubmit = useCallback(() => { if (!inputValue.trim()) return; onSubmitGuess(inputValue); setInputValue(''); - }; + }, [inputValue, onSubmitGuess]); - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSubmit(); - } - }; + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSubmit(); + } + }, + [handleSubmit], + ); + + const handleChange = useCallback((e: React.ChangeEvent) => { + setInputValue(e.target.value); + }, []); return (
@@ -26,7 +33,7 @@ export default function GuessInput({ onSubmitGuess }: IGuessInputProps) { type="text" placeholder="제시어 입력" value={inputValue} - onChange={(e) => setInputValue(e.target.value)} + onChange={handleChange} onKeyDown={handleKeyDown} className="p-2 text-lg rounded-md bg-gray-800 text-white-default outline-none" /> @@ -38,4 +45,6 @@ export default function GuessInput({ onSubmitGuess }: IGuessInputProps) {
); -} +}); + +export default GuessInput; diff --git a/packages/frontend/src/components/gamePage/leftSection/MainDisplay.tsx b/packages/frontend/src/components/gamePage/leftSection/MainDisplay.tsx index 891cb04..a7b817e 100644 --- a/packages/frontend/src/components/gamePage/leftSection/MainDisplay.tsx +++ b/packages/frontend/src/components/gamePage/leftSection/MainDisplay.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback, useMemo, memo } from 'react'; import { useRoomStore } from '@/store/roomStore'; import StartButton from './GameButtons/StartButton'; import ReadyButton from './GameButtons/ReadyButton'; @@ -20,7 +20,7 @@ import VideoStream from '@/components/gamePage/stream/VideoStream'; import { useLocalStreamStore } from '@/store/localStreamStore'; import { useSpeakingControl } from '@/hooks/useSpeakingControl'; -export default function MainDisplay() { +const MainDisplay = memo(function MainDisplay() { const { userId } = useAuthStore(); const { isHost, isPinoco, allUsers } = useRoomStore(); const [gamePhase, setGamePhase] = useState(GAME_PHASE.WAITING); @@ -38,13 +38,21 @@ export default function MainDisplay() { setGamePhase, setSelectedVote, ); - function getCurrentStream() { - const localStream = useLocalStreamStore.getState().localStream; - const remoteStreams = usePeerConnectionStore.getState().remoteStreams; - if (currentSpeaker === userId) return localStream; - const currentStream = remoteStreams.get(currentSpeaker || ''); - return currentStream; - } + + const handleCountdownEnd = useCallback(() => { + setGamePhase(GAME_PHASE.WORD_REVEAL); + const timer = setTimeout(() => { + setGamePhase(GAME_PHASE.SPEAKING); + }, 3000); + return () => clearTimeout(timer); + }, []); + + const handleVote = useCallback(() => { + if (!isVoteSubmitted) { + votePinoco(selectedVote ?? ''); + setIsVoteSubmitted(true); + } + }, [isVoteSubmitted, selectedVote, votePinoco]); useEffect(() => { if (gameStartData) { @@ -54,23 +62,76 @@ export default function MainDisplay() { } }, [gameStartData]); - const handleCountdownEnd = () => { - setGamePhase(GAME_PHASE.WORD_REVEAL); - setTimeout(() => { - setGamePhase(GAME_PHASE.SPEAKING); - }, 3000); - }; + const currentStreamData = useMemo(() => { + if (currentSpeaker === userId) { + return useLocalStreamStore.getState().localStream; + } + return usePeerConnectionStore.getState().remoteStreams.get(currentSpeaker || ''); + }, [currentSpeaker, userId]); - const handleVote = () => { - if (!isVoteSubmitted) { - if (selectedVote === null) { - votePinoco(''); - } else { - votePinoco(selectedVote); - } - setIsVoteSubmitted(true); + const renderGamePhaseContent = useMemo(() => { + switch (gamePhase) { + case GAME_PHASE.WAITING: + return ( +
+ {isHost ? : } +
+ ); + case GAME_PHASE.COUNTDOWN: + return ; + case GAME_PHASE.VOTING: + return ( + + ); + case GAME_PHASE.VOTING_RESULT: + return ( + + ); + case GAME_PHASE.GUESSING: + return ( +
+ {isPinoco ? ( + + ) : ( +

+ 피노코가 제시어를 추측 중입니다 🤔 +

+ )} +
+ ); + case GAME_PHASE.ENDING: + return ; + default: + return null; } - }; + }, [ + gamePhase, + isHost, + userId, + allUsers, + selectedVote, + isVoteSubmitted, + handleVote, + handleCountdownEnd, + endingResult, + deadPerson, + voteResult, + isDeadPersonPinoco, + isPinoco, + submitGuess, + ]); + return (
-
- {gamePhase === GAME_PHASE.WAITING && ( -
- {isHost ? : } -
- )} - {gamePhase === GAME_PHASE.COUNTDOWN && } - {gamePhase === GAME_PHASE.VOTING && ( - - )} - {gamePhase === GAME_PHASE.VOTING_RESULT && ( - - )} - {gamePhase === GAME_PHASE.GUESSING && ( -
- {isPinoco ? ( - - ) : ( -

- 피노코가 제시어를 추측 중입니다 🤔 -

- )} -
- )} - {gamePhase === GAME_PHASE.ENDING && } -
+
{renderGamePhaseContent}
{gamePhase === GAME_PHASE.SPEAKING && currentSpeaker === userId && ( @@ -170,4 +195,6 @@ export default function MainDisplay() {
); -} +}); + +export default MainDisplay; diff --git a/packages/frontend/src/components/gamePage/leftSection/VideoFeed.tsx b/packages/frontend/src/components/gamePage/leftSection/VideoFeed.tsx index 541a533..453e89b 100644 --- a/packages/frontend/src/components/gamePage/leftSection/VideoFeed.tsx +++ b/packages/frontend/src/components/gamePage/leftSection/VideoFeed.tsx @@ -1,11 +1,17 @@ -import { useEffect } from 'react'; +import { memo } from 'react'; import VideoStream from '@/components/gamePage/stream/VideoStream'; import { useAuthStore } from '@/store/authStore'; import { useLocalStreamStore } from '@/store/localStreamStore'; import { usePeerConnectionStore } from '@/store/peerConnectionStore'; import { useRoomStore } from '@/store/roomStore'; -export default function VideoFeed() { +const MemoizedVideoStream = memo(VideoStream); + +const EmptySlot = memo(({ idx, height }: { idx: number; height: string }) => ( + +)); + +const VideoFeed = memo(function VideoFeed() { const localStream = useLocalStreamStore((state) => state.localStream); const remoteStreams = usePeerConnectionStore((state) => state.remoteStreams); const { userId } = useAuthStore(); @@ -21,10 +27,15 @@ export default function VideoFeed() { boxShadow: isUserReady(userId || '') ? '0 0 0 4px white' : '', }} > - + {isUserReady(userId || '') && (
- READY + READY
)} @@ -35,7 +46,7 @@ export default function VideoFeed() { isUserReady(remoteUserId) ? 'border-4 border-white' : '' } rounded-lg`} > - {isUserReady(remoteUserId) && (
- + READY
@@ -51,13 +62,10 @@ export default function VideoFeed() { ))} {[...Array(Math.max(0, 5 - remoteStreams.size))].map((_, idx) => ( - + ))} ); -} +}); + +export default VideoFeed; diff --git a/packages/frontend/src/hooks/useGameSocket.ts b/packages/frontend/src/hooks/useGameSocket.ts index 241dba8..cefaed1 100644 --- a/packages/frontend/src/hooks/useGameSocket.ts +++ b/packages/frontend/src/hooks/useGameSocket.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { useSocketStore } from '@/store/socketStore'; import { useRoomStore } from '@/store/roomStore'; import { GAME_PHASE, GamePhase } from '@/constants'; @@ -30,74 +30,83 @@ export const useGameSocket = (onPhaseChange?: (phase: GamePhase) => void) => { const [gameStartData, setGameStartData] = useState(null); const [currentSpeaker, setCurrentSpeaker] = useState(null); - useEffect(() => { - if (!socket) return; - - const handleUpdateReady = (data: IReadyUsers) => { + const handleUpdateReady = useCallback( + (data: IReadyUsers) => { setReadyUsers(data.readyUsers); - }; + }, + [setReadyUsers], + ); - const handleStartSpeaking = (data: ISpeakingStart) => { + const handleStartSpeaking = useCallback( + (data: ISpeakingStart) => { setCurrentSpeaker(data.speakerId); - if (onPhaseChange) { - onPhaseChange(GAME_PHASE.SPEAKING); - } - }; + onPhaseChange?.(GAME_PHASE.SPEAKING); + }, + [onPhaseChange], + ); - const handleStartGame = (data: IGameStart) => { + const handleStartGame = useCallback( + (data: IGameStart) => { setAllUsers(data.allUserIds); setGameStartData(data); setCurrentSpeaker(data.speakerId); setIsPinoco(data.isPinoco); setReadyUsers([]); - }; + }, + [setAllUsers, setIsPinoco, setReadyUsers], + ); - const handleStartVote = () => { - if (onPhaseChange) { - onPhaseChange(GAME_PHASE.VOTING); - } - }; + const handleStartVote = useCallback(() => { + onPhaseChange?.(GAME_PHASE.VOTING); + }, [onPhaseChange]); + + useEffect(() => { + if (!socket) return; socket.on('update_ready', handleUpdateReady); + socket.on('start_game_success', handleStartGame); + socket.on('start_speaking', handleStartSpeaking); + socket.on('start_vote', handleStartVote); socket.on('error', (data: IGameErrorMessage) => { setError(data.errorMessage); setTimeout(() => setError(null), 3000); }); - socket.on('start_game_success', handleStartGame); - socket.on('start_speaking', handleStartSpeaking); - socket.on('start_vote', handleStartVote); return () => { socket.off('update_ready', handleUpdateReady); - socket.off('error'); socket.off('start_game_success', handleStartGame); socket.off('start_speaking', handleStartSpeaking); socket.off('start_vote', handleStartVote); + socket.off('error'); }; - }, [socket, setIsPinoco, onPhaseChange, setCurrentSpeaker, setAllUsers, setReadyUsers]); - - const sendReady = (isReady: boolean) => { - if (!socket) return; - socket.emit('send_ready', { isReady }); - }; - - const startGame = () => { - if (!socket) return; - socket.emit('start_game'); - }; - - const endSpeaking = (userId: string) => { - if (!socket) return; - if (userId === currentSpeaker) { - console.log('endSpeaking 호출'); - socket.emit('end_speaking'); - } - }; - - const votePinoco = (voteUserId: string) => { - if (!socket) return; - socket.emit('vote_pinoco', { voteUserId }); - }; + }, [socket, handleUpdateReady, handleStartGame, handleStartSpeaking, handleStartVote]); + + const sendReady = useCallback( + (isReady: boolean) => { + socket?.emit('send_ready', { isReady }); + }, + [socket], + ); + + const startGame = useCallback(() => { + socket?.emit('start_game'); + }, [socket]); + + const endSpeaking = useCallback( + (userId: string) => { + if (userId === currentSpeaker) { + socket?.emit('end_speaking'); + } + }, + [socket, currentSpeaker], + ); + + const votePinoco = useCallback( + (voteUserId: string) => { + socket?.emit('vote_pinoco', { voteUserId }); + }, + [socket], + ); return { sendReady, diff --git a/packages/frontend/src/store/roomStore.ts b/packages/frontend/src/store/roomStore.ts index 75c6825..12eef71 100644 --- a/packages/frontend/src/store/roomStore.ts +++ b/packages/frontend/src/store/roomStore.ts @@ -17,37 +17,84 @@ interface IRoomState { addReadyUser: (userId: string) => void; removeReadyUser: (userId: string) => void; } + export const useRoomStore = create()( persist( - (set) => ({ + (set, get) => ({ isHost: false, gsid: null, isPinoco: false, allUsers: new Set(), readyUsers: [], - setRoomData: (gsid, isHost, isPinoco) => set({ gsid, isHost, isPinoco }), - setIsPinoco: (isPinoco) => set({ isPinoco }), - setAllUsers: (allUsers) => set({ allUsers: new Set(allUsers) }), + setRoomData: (gsid, isHost, isPinoco) => + set((state) => { + if (state.gsid === gsid && state.isHost === isHost && state.isPinoco === isPinoco) { + return state; + } + return { gsid, isHost, isPinoco }; + }), + + setIsPinoco: (isPinoco) => + set((state) => { + if (state.isPinoco === isPinoco) return state; + return { isPinoco }; + }), + + setAllUsers: (allUsers) => + set((state) => { + const newUsers = new Set(allUsers); + if (JSON.stringify([...state.allUsers]) === JSON.stringify([...newUsers])) { + return state; + } + return { allUsers: newUsers }; + }), + addUser: (userId) => - set((state) => ({ - allUsers: new Set([...state.allUsers, userId]), - })), + set((state) => { + if (state.allUsers.has(userId)) return state; + return { + allUsers: new Set([...state.allUsers, userId]), + }; + }), + removeUser: (userId) => - set((state) => ({ - allUsers: new Set([...state.allUsers].filter((id) => id !== userId)), - })), - setIsHost: (isHost) => set({ isHost }), + set((state) => { + if (!state.allUsers.has(userId)) return state; + const newUsers = new Set([...state.allUsers]); + newUsers.delete(userId); + return { allUsers: newUsers }; + }), + + setIsHost: (isHost) => + set((state) => { + if (state.isHost === isHost) return state; + return { isHost }; + }), + + setReadyUsers: (readyUsers) => + set((state) => { + if (JSON.stringify(state.readyUsers) === JSON.stringify(readyUsers)) { + return state; + } + return { readyUsers }; + }), - setReadyUsers: (readyUsers) => set({ readyUsers }), addReadyUser: (userId) => - set((state) => ({ - readyUsers: [...state.readyUsers, userId], - })), + set((state) => { + if (state.readyUsers.includes(userId)) return state; + return { + readyUsers: [...state.readyUsers, userId], + }; + }), + removeReadyUser: (userId) => - set((state) => ({ - readyUsers: state.readyUsers.filter((id) => id !== userId), - })), + set((state) => { + if (!state.readyUsers.includes(userId)) return state; + return { + readyUsers: state.readyUsers.filter((id) => id !== userId), + }; + }), }), { name: 'room-storage' }, ),