From 97d458f939411f6505810c0695d4ce404d7c2771 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 20 Jan 2026 11:07:10 +0900 Subject: [PATCH] =?UTF-8?q?[FIX]=20=EC=BA=90=EC=B9=98=EB=A7=88=EC=9D=B8?= =?UTF-8?q?=EB=93=9C=20=EA=B2=8C=EC=9E=84=20=EB=9D=BC=EC=9A=B4=EB=93=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EB=B0=8F=20WebSocket=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20(#165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebSocket 이벤트 핸들러에서 중첩 데이터 구조 처리 (data.data || data) - 메시지 타입 대소문자 모두 처리 (round_end/ROUND_END 등) - 타이머 자동 스킵 시 stale closure 문제 해결 (useRef 사용) - 연결 안 된 상태에서 에러 대신 조용히 실패 처리 - 게임 중 채팅 메시지 비눗방울 애니메이션 추가 --- .../freetalk/components/ChatRoomModal.jsx | 27 +- .../freetalk/components/GameModePanel.jsx | 297 +++++++++++++++++- .../freetalk/hooks/useChatWebSocket.js | 143 +++++++-- src/domains/freetalk/pages/ChatRoomPage.jsx | 59 +++- .../freetalk/services/chatWebSocketService.js | 69 +++- 5 files changed, 546 insertions(+), 49 deletions(-) diff --git a/src/domains/freetalk/components/ChatRoomModal.jsx b/src/domains/freetalk/components/ChatRoomModal.jsx index 73bfad5..e9e3ef5 100644 --- a/src/domains/freetalk/components/ChatRoomModal.jsx +++ b/src/domains/freetalk/components/ChatRoomModal.jsx @@ -58,13 +58,21 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { messages, gameState: wsGameState, error: wsError, + receivedDrawing, + shouldClearCanvas, + correctAnswerBubble, connect: wsConnect, disconnect: wsDisconnect, sendMessage: wsSendMessage, startGame: wsStartGame, stopGame: wsStopGame, + sendDrawing: wsSendDrawing, + clearDrawing: wsClearDrawing, clearError: wsClearError, setMessages, + setReceivedDrawing, + setShouldClearCanvas, + setCorrectAnswerBubble, } = useChatWebSocket(room?.id, currentUserId) const [newMessage, setNewMessage] = useState('') @@ -126,19 +134,20 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { } }, [wsError]) - // WebSocket 게임 상태 변경 감지 - 탭 자동 전환 + // WebSocket 게임 상태 변경 감지 - 게임 시작 시만 게임 탭으로 전환 useEffect(() => { if (wsGameState?.status === 'PLAYING') { setGameStatus(GAME_STATUS.PLAYING) - setActiveTab(1) + setActiveTab(1) // 게임 시작 시 게임 탭으로 전환 } else if (wsGameState?.status === 'FINISHED') { setGameStatus(GAME_STATUS.NONE) - setActiveTab(0) + // 게임 종료 시에는 탭 유지 (사용자가 직접 전환하도록) } }, [wsGameState?.status]) // 초기 로드 useEffect(() => { + console.log('[ChatRoomModal] useEffect triggered:', {open, roomId: room?.id, currentUserId}) if (open && room?.id && currentUserId) { console.log('[ChatRoomModal] Initializing...', {roomId: room.id, userId: currentUserId}) setLoading(true) @@ -518,8 +527,20 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { roomId={room?.id} onGameMessage={handleGameMessage} initialGameStatus={gameStatus} + wsGameState={wsGameState} + isConnected={isConnected} + messages={messages} onStartGame={wsStartGame} onStopGame={wsStopGame} + onSendMessage={wsSendMessage} + onSendDrawing={wsSendDrawing} + onClearDrawing={wsClearDrawing} + receivedDrawing={receivedDrawing} + onDrawingProcessed={() => setReceivedDrawing(null)} + shouldClearCanvas={shouldClearCanvas} + onCanvasCleared={() => setShouldClearCanvas(false)} + correctAnswerBubble={correctAnswerBubble} + onBubbleProcessed={() => setCorrectAnswerBubble(null)} /> )} diff --git a/src/domains/freetalk/components/GameModePanel.jsx b/src/domains/freetalk/components/GameModePanel.jsx index 9918226..032aab4 100644 --- a/src/domains/freetalk/components/GameModePanel.jsx +++ b/src/domains/freetalk/components/GameModePanel.jsx @@ -15,7 +15,7 @@ import {DESIGN_TOKENS} from '../../../theme/theme' const CANVAS_WIDTH = 340 const CANVAS_HEIGHT = 200 -const GameModePanel = ({roomId, onGameMessage, initialGameStatus, onStartGame, onStopGame}) => { +const GameModePanel = ({roomId, onGameMessage, initialGameStatus, wsGameState, isConnected, onStartGame, onStopGame, onSendMessage, onSendDrawing, onClearDrawing, receivedDrawing, onDrawingProcessed, shouldClearCanvas, onCanvasCleared, messages, correctAnswerBubble, onBubbleProcessed}) => { const theme = useTheme() const isDark = theme.palette.mode === 'dark' const {user} = useAuth() @@ -37,10 +37,31 @@ const GameModePanel = ({roomId, onGameMessage, initialGameStatus, onStartGame, o const [brushColor, setBrushColor] = useState('#000000') const [brushSize, setBrushSize] = useState(3) const [loading, setLoading] = useState(false) + const [currentStroke, setCurrentStroke] = useState([]) // 현재 그리는 스트로크 + const [bubbles, setBubbles] = useState([]) // 비눗방울 애니메이션 const isDrawer = gameState.currentDrawerId === currentUserId const isGameActive = gameState.gameStatus === GAME_STATUS.PLAYING + // 최신 값을 interval에서 사용하기 위한 ref + const isDrawerRef = useRef(isDrawer) + const isConnectedRef = useRef(isConnected) + const onSendMessageRef = useRef(onSendMessage) + useEffect(() => { + isDrawerRef.current = isDrawer + isConnectedRef.current = isConnected + onSendMessageRef.current = onSendMessage + }, [isDrawer, isConnected, onSendMessage]) + + // 디버깅 로그 + console.log('[GameModePanel] State:', { + isDrawer, + isGameActive, + currentDrawerId: gameState.currentDrawerId, + currentUserId, + gameStatus: gameState.gameStatus + }) + // 게임 상태 조회 const fetchGameStatus = useCallback(async () => { try { @@ -73,17 +94,48 @@ const GameModePanel = ({roomId, onGameMessage, initialGameStatus, onStartGame, o } }, [initialGameStatus]) - // 타이머 + // WebSocket 게임 상태 동기화 (제시어, 출제자, 라운드 등) + useEffect(() => { + if (wsGameState) { + console.log('[GameModePanel] Syncing wsGameState:', wsGameState) + setGameState(prev => ({ + ...prev, + gameStatus: wsGameState.status === 'PLAYING' ? GAME_STATUS.PLAYING : + wsGameState.status === 'FINISHED' ? GAME_STATUS.NONE : prev.gameStatus, + currentRound: wsGameState.currentRound ?? prev.currentRound, + totalRounds: wsGameState.totalRounds ?? prev.totalRounds, + currentDrawerId: wsGameState.currentDrawerId ?? prev.currentDrawerId, + currentWord: wsGameState.currentWord ?? prev.currentWord, + roundStartTime: wsGameState.roundStartTime ?? prev.roundStartTime, + scores: wsGameState.scores ?? prev.scores, + hintUsed: wsGameState.hintUsed ?? prev.hintUsed, + hint: wsGameState.hint ?? prev.hint, + })) + } + }, [wsGameState]) + + // 타이머 + 자동 스킵 (출제자만) + const autoSkipSentRef = useRef(false) useEffect(() => { if (!isGameActive || !gameState.roundStartTime) return + // 새 라운드 시작 시 autoSkip 플래그 초기화 + autoSkipSentRef.current = false + const interval = setInterval(() => { const elapsed = Math.floor((Date.now() - gameState.roundStartTime) / 1000) const remaining = Math.max(0, gameState.roundTimeLimit - elapsed) setTimeLeft(remaining) - if (remaining === 0) { - // 시간 초과 처리 + // 시간 초과 시 자동 스킵 (출제자만, 연결된 경우만, 한 번만 전송) + // ref를 사용해서 최신 값 확인 + if (remaining === 0 && isDrawerRef.current && isConnectedRef.current && !autoSkipSentRef.current && onSendMessageRef.current) { + console.log('[GameModePanel] Time expired, auto-skipping... isDrawer:', isDrawerRef.current, 'isConnected:', isConnectedRef.current) + autoSkipSentRef.current = true + onSendMessageRef.current('/skip', 'TEXT') + clearInterval(interval) + } else if (remaining === 0) { + console.log('[GameModePanel] Time expired, but cannot skip. isDrawer:', isDrawerRef.current, 'isConnected:', isConnectedRef.current) clearInterval(interval) } }, 1000) @@ -93,9 +145,11 @@ const GameModePanel = ({roomId, onGameMessage, initialGameStatus, onStartGame, o // 게임 시작 - WebSocket을 통해 /start 명령어 전송 (서버에서 인원 검증) const handleStartGame = () => { + console.log('[GameModePanel] handleStartGame called, onStartGame:', !!onStartGame) if (onStartGame) { // WebSocket으로 /start 명령어 전송 (채팅창에서 /start 입력과 동일) - onStartGame() + const result = onStartGame() + console.log('[GameModePanel] onStartGame result:', result) } else { // fallback: REST API 사용 (WebSocket 미연결 시) setLoading(true) @@ -179,28 +233,57 @@ const GameModePanel = ({roomId, onGameMessage, initialGameStatus, onStartGame, o } // 캔버스 초기화 - const clearCanvas = () => { + const clearCanvas = (sendToOthers = false) => { const canvas = canvasRef.current if (!canvas) return const ctx = canvas.getContext('2d') ctx.fillStyle = '#ffffff' ctx.fillRect(0, 0, canvas.width, canvas.height) + + // 다른 사용자에게도 초기화 메시지 전송 + if (sendToOthers && onClearDrawing) { + onClearDrawing() + } } - // 캔버스 드로잉 + // 캔버스 드로잉 - 스트로크 배열 방식 const startDrawing = (e) => { if (!isDrawer) return + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + setIsDrawing(true) - draw(e) + // 스트로크 시작 + setCurrentStroke([{x, y, type: 'start', color: brushColor, width: brushSize}]) + + const ctx = canvas.getContext('2d') + ctx.beginPath() + ctx.moveTo(x, y) } const stopDrawing = () => { + if (!isDrawing) return setIsDrawing(false) + const canvas = canvasRef.current if (canvas) { const ctx = canvas.getContext('2d') ctx.beginPath() } + + // 스트로크 완료 시 WebSocket으로 전송 + if (currentStroke.length > 0 && onSendDrawing) { + const strokeData = [...currentStroke, {type: 'end'}] + console.log('[GameModePanel] Sending stroke:', strokeData) + onSendDrawing(JSON.stringify(strokeData)) + } else { + console.log('[GameModePanel] Not sending:', {strokeLength: currentStroke.length, hasOnSendDrawing: !!onSendDrawing}) + } + setCurrentStroke([]) } const draw = (e) => { @@ -220,15 +303,126 @@ const GameModePanel = ({roomId, onGameMessage, initialGameStatus, onStartGame, o ctx.stroke() ctx.beginPath() ctx.moveTo(x, y) + + // 스트로크에 좌표 추가 + setCurrentStroke(prev => [...prev, {x, y, type: 'move'}]) } - // 캔버스 초기화 + // 캔버스 초기화 (라운드 변경 시) useEffect(() => { if (canvasRef.current) { clearCanvas() } }, [gameState.currentRound]) + // 원격 캔버스 초기화 (다른 사용자가 초기화 시) + useEffect(() => { + if (shouldClearCanvas && canvasRef.current) { + console.log('[GameModePanel] Clearing canvas from remote') + clearCanvas(false) // 다시 전송하지 않음 + onCanvasCleared?.() + } + }, [shouldClearCanvas, onCanvasCleared]) + + // 수신된 그리기 데이터를 캔버스에 그리기 + useEffect(() => { + if (!receivedDrawing || !canvasRef.current) return + + const canvas = canvasRef.current + const ctx = canvas.getContext('2d') + + try { + // content에서 스트로크 데이터 파싱 + const content = receivedDrawing.content + const strokeData = typeof content === 'string' ? JSON.parse(content) : content + + console.log('[GameModePanel] Drawing received stroke:', strokeData) + + if (!Array.isArray(strokeData)) return + + // 스트로크 그리기 + strokeData.forEach((point, index) => { + if (point.type === 'start') { + ctx.beginPath() + ctx.moveTo(point.x, point.y) + ctx.lineWidth = point.width || 3 + ctx.lineCap = 'round' + ctx.strokeStyle = point.color || '#000000' + } else if (point.type === 'move') { + ctx.lineTo(point.x, point.y) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(point.x, point.y) + } else if (point.type === 'end') { + ctx.beginPath() + } + }) + + // 처리 완료 알림 + onDrawingProcessed?.() + } catch (err) { + console.error('[GameModePanel] Error drawing received data:', err) + } + }, [receivedDrawing, onDrawingProcessed]) + + // 비눗방울 애니메이션 생성 + const createBubble = useCallback((userId, content, isCorrect = false) => { + const id = `bubble-${Date.now()}-${Math.random()}` + const bubble = { + id, + userId, + content, + isCorrect, + x: Math.random() * (CANVAS_WIDTH - 100) + 50, // 랜덤 x 위치 + startTime: Date.now(), + } + setBubbles(prev => [...prev, bubble]) + + // 3초 후 자동 삭제 + setTimeout(() => { + setBubbles(prev => prev.filter(b => b.id !== id)) + }, 3000) + }, []) + + // correctAnswerBubble prop으로 정답 버블 생성 (정답일 때 특별 효과) + // + 정답 시 자동으로 다음 라운드로 이동 (출제자만) + useEffect(() => { + if (correctAnswerBubble) { + createBubble(correctAnswerBubble.userId, correctAnswerBubble.content, true) // isCorrect = true + onBubbleProcessed?.() + + // 출제자인 경우 2초 후 자동으로 다음 라운드로 이동 (연결된 경우만) + if (isDrawer && isConnected && onSendMessage) { + console.log('[GameModePanel] Correct answer! Auto-skipping in 2 seconds...') + setTimeout(() => { + // 다시 한번 연결 상태 확인 + if (isConnectedRef.current && onSendMessageRef.current) { + console.log('[GameModePanel] Auto-skipping to next round...') + onSendMessageRef.current('/skip', 'TEXT') + } + }, 2000) // 2초 대기 후 스킵 (정답 효과 보여주기 위해) + } + } + }, [correctAnswerBubble, createBubble, onBubbleProcessed, isDrawer, isConnected, onSendMessage]) + + // 게임 중 모든 채팅 메시지를 비눗방울로 표시 + const lastMessageIdRef = useRef(null) + useEffect(() => { + if (!isGameActive || !messages || messages.length === 0) return + + // 마지막 메시지만 처리 (중복 방지) + const lastMessage = messages[messages.length - 1] + if (!lastMessage || lastMessage.id === lastMessageIdRef.current) return + if (lastMessage.isSystem || lastMessage.messageType === 'SYSTEM') return + if (lastMessage.messageType === 'DRAWING' || lastMessage.messageType === 'DRAWING_CLEAR') return + + // 출제자의 메시지는 제외 (출제자가 답을 알려줘선 안 됨) + if (lastMessage.userId === gameState.currentDrawerId) return + + lastMessageIdRef.current = lastMessage.id + createBubble(lastMessage.userId, lastMessage.content, false) // isCorrect = false + }, [messages, isGameActive, gameState.currentDrawerId, createBubble]) + // 점수 정렬 const sortedScores = Object.entries(gameState.scores || {}) .sort(([, a], [, b]) => b - a) @@ -243,7 +437,10 @@ const GameModePanel = ({roomId, onGameMessage, initialGameStatus, onStartGame, o