-
- )
+ )
+ })
+ )}
+
+
)}
{/* 입력 영역 */}
@@ -594,6 +654,7 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => {
borderTop: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
+ pointerEvents: 'auto',
}}
>
{
color: 'white',
width: 32,
height: 32,
- '&:hover': {bgcolor: 'primary.dark'},
- '&:disabled': {bgcolor: 'grey.300'},
+ '&:hover': { bgcolor: 'primary.dark' },
+ '&:disabled': { bgcolor: 'grey.300' },
}}
>
- {sendingMessage ? :
- }
+ {sendingMessage ? :
+ }
>
diff --git a/src/domains/freetalk/components/CommandAutocomplete.jsx b/src/domains/freetalk/components/CommandAutocomplete.jsx
new file mode 100644
index 0000000..09b83e3
--- /dev/null
+++ b/src/domains/freetalk/components/CommandAutocomplete.jsx
@@ -0,0 +1,150 @@
+import { useEffect, useState } from 'react'
+import { Box, Paper, Typography, List, ListItem, ListItemButton, ListItemText } from '@mui/material'
+import { useThemeMode } from '../../../contexts/ThemeContext'
+import { searchCommands } from '../types/chatCommandTypes'
+
+/**
+ * 채팅 명령어 자동완성 컴포넌트
+ * 사용자가 "/"를 입력하면 사용 가능한 명령어 목록을 표시합니다.
+ *
+ * @param {Object} props
+ * @param {string} props.input - 현재 입력 값
+ * @param {function} props.onSelect - 명령어 선택 시 호출되는 콜백
+ * @param {boolean} props.show - 표시 여부
+ * @returns {JSX.Element|null}
+ */
+const CommandAutocomplete = ({ input, onSelect, show }) => {
+ const { mode } = useThemeMode()
+ const isDark = mode === 'dark'
+ const [selectedIndex, setSelectedIndex] = useState(0)
+ const [filteredCommands, setFilteredCommands] = useState([])
+
+ // 입력값에 따라 명령어 필터링
+ useEffect(() => {
+ if (!show || !input.startsWith('/')) {
+ setFilteredCommands([])
+ setSelectedIndex(0)
+ return
+ }
+
+ const commands = searchCommands(input)
+ setFilteredCommands(commands)
+ setSelectedIndex(0)
+ }, [input, show])
+
+ // 키보드 이벤트 처리
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (!show || filteredCommands.length === 0) return
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ setSelectedIndex((prev) => (prev + 1) % filteredCommands.length)
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ setSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length)
+ } else if (e.key === 'Enter' && filteredCommands[selectedIndex]) {
+ e.preventDefault()
+ onSelect(filteredCommands[selectedIndex].command)
+ } else if (e.key === 'Escape') {
+ onSelect('')
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [show, filteredCommands, selectedIndex, onSelect])
+
+ // 표시할 항목이 없으면 렌더링하지 않음
+ if (!show || filteredCommands.length === 0) {
+ return null
+ }
+
+ return (
+
+
+
+ 사용 가능한 명령어 ({filteredCommands.length})
+
+
+
+
+ {filteredCommands.map((cmd, index) => (
+
+ onSelect(cmd.command)}
+ sx={{
+ py: 1.5,
+ px: 2,
+ '&.Mui-selected': {
+ bgcolor: isDark ? 'rgba(250, 204, 21, 0.2)' : 'rgba(254, 229, 0, 0.3)',
+ '&:hover': {
+ bgcolor: isDark ? 'rgba(250, 204, 21, 0.25)' : 'rgba(254, 229, 0, 0.4)',
+ },
+ },
+ '&:hover': {
+ bgcolor: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
+ },
+ }}
+ >
+
+
+ {cmd.command}
+
+
+ {cmd.usage}
+
+
+ }
+ secondary={
+
+ {cmd.description}
+
+ }
+ />
+
+
+ ))}
+
+
+
+
+ 화살표로 선택, Enter로 입력, Esc로 닫기
+
+
+
+ )
+}
+
+export default CommandAutocomplete
diff --git a/src/domains/freetalk/components/GameModePanel.jsx b/src/domains/freetalk/components/GameModePanel.jsx
index f77c21d..7891cb4 100644
--- a/src/domains/freetalk/components/GameModePanel.jsx
+++ b/src/domains/freetalk/components/GameModePanel.jsx
@@ -8,18 +8,23 @@ import {
SkipNext as SkipIcon,
Stop as StopIcon,
} from '@mui/icons-material'
-import {GAME_STATUS, gameService, TEMP_USER_ID} from '../../chat/services/chatService'
+import {GAME_STATUS, gameService} from '../../chat/services/chatService'
+import {useAuth} from '../../../contexts/AuthContext'
+import {useThemeMode} from '../../../contexts/ThemeContext'
import {DESIGN_TOKENS} from '../../../theme/theme'
const CANVAS_WIDTH = 340
const CANVAS_HEIGHT = 200
-const GameModePanel = ({roomId, onGameMessage}) => {
+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 {mode} = useThemeMode()
+ const isDark = mode === 'dark'
+ const {user} = useAuth()
+ const currentUserId = user?.userId || user?.username
const canvasRef = useRef(null)
const [gameState, setGameState] = useState({
- gameStatus: GAME_STATUS.NONE,
+ gameStatus: initialGameStatus || GAME_STATUS.NONE,
currentRound: 0,
totalRounds: 5,
currentDrawerId: null,
@@ -34,32 +39,105 @@ const GameModePanel = ({roomId, onGameMessage}) => {
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 === TEMP_USER_ID
+ 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 {
const response = await gameService.getStatus(roomId)
const data = response.data || response
- setGameState(data)
+ setGameState(prev => ({
+ ...prev,
+ ...data,
+ gameStatus: data.gameStatus || GAME_STATUS.NONE,
+ }))
} catch (err) {
console.error('Failed to fetch game status:', err)
}
}, [roomId])
- // 타이머
+ // 마운트 시 게임 상태 조회
+ useEffect(() => {
+ if (roomId) {
+ fetchGameStatus()
+ }
+ }, [roomId, fetchGameStatus])
+
+ // 부모 컴포넌트의 게임 상태 변경 반영
+ useEffect(() => {
+ if (initialGameStatus) {
+ setGameState(prev => ({
+ ...prev,
+ gameStatus: initialGameStatus,
+ }))
+ }
+ }, [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)
@@ -67,39 +145,49 @@ const GameModePanel = ({roomId, onGameMessage}) => {
return () => clearInterval(interval)
}, [isGameActive, gameState.roundStartTime, gameState.roundTimeLimit])
- // 게임 시작
- const handleStartGame = async () => {
- setLoading(true)
- try {
- const response = await gameService.start(roomId)
- const data = response.data || response
- setGameState(data)
- onGameMessage?.({
- type: 'game_start',
- content: `🎮 게임 시작! 총 ${data.totalRounds}라운드\n출제자: ${data.currentDrawerId}`,
- })
- } catch (err) {
- console.error('Failed to start game:', err)
- } finally {
- setLoading(false)
+ // 게임 시작 - WebSocket을 통해 /start 명령어 전송 (서버에서 인원 검증)
+ const handleStartGame = () => {
+ console.log('[GameModePanel] handleStartGame called, onStartGame:', !!onStartGame)
+ if (onStartGame) {
+ // WebSocket으로 /start 명령어 전송 (채팅창에서 /start 입력과 동일)
+ const result = onStartGame()
+ console.log('[GameModePanel] onStartGame result:', result)
+ } else {
+ // fallback: REST API 사용 (WebSocket 미연결 시)
+ setLoading(true)
+ gameService.start(roomId)
+ .then(response => {
+ const data = response.data || response
+ setGameState(data)
+ onGameMessage?.({
+ type: 'game_start',
+ content: `🎮 게임 시작! 총 ${data.totalRounds}라운드\n출제자: ${data.currentDrawerId}`,
+ })
+ })
+ .catch(err => console.error('Failed to start game:', err))
+ .finally(() => setLoading(false))
}
}
- // 게임 종료
- const handleStopGame = async () => {
- setLoading(true)
- try {
- const response = await gameService.stop(roomId)
- const data = response.data || response
- setGameState(prev => ({...prev, gameStatus: GAME_STATUS.NONE}))
- onGameMessage?.({
- type: 'game_end',
- content: `🎮 게임 종료!\n${data.message}`,
- })
- } catch (err) {
- console.error('Failed to stop game:', err)
- } finally {
- setLoading(false)
+ // 게임 종료 - WebSocket을 통해 /stop 명령어 전송
+ const handleStopGame = () => {
+ if (onStopGame) {
+ // WebSocket으로 /stop 명령어 전송 (채팅창에서 /stop 입력과 동일)
+ onStopGame()
+ } else {
+ // fallback: REST API 사용 (WebSocket 미연결 시)
+ setLoading(true)
+ gameService.stop(roomId)
+ .then(response => {
+ const data = response.data || response
+ setGameState(prev => ({...prev, gameStatus: GAME_STATUS.NONE}))
+ onGameMessage?.({
+ type: 'game_end',
+ content: `🎮 게임 종료!\n${data.message}`,
+ })
+ })
+ .catch(err => console.error('Failed to stop game:', err))
+ .finally(() => setLoading(false))
}
}
@@ -147,28 +235,57 @@ const GameModePanel = ({roomId, onGameMessage}) => {
}
// 캔버스 초기화
- 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) => {
@@ -188,15 +305,115 @@ const GameModePanel = ({roomId, onGameMessage}) => {
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으로 정답 버블 생성 (정답일 때 특별 효과)
+ // 백엔드가 정답 시 자동으로 ROUND_END를 보내므로 프론트엔드에서 /skip 불필요
+ useEffect(() => {
+ if (correctAnswerBubble) {
+ createBubble(correctAnswerBubble.userId, correctAnswerBubble.content, true) // isCorrect = true
+ onBubbleProcessed?.()
+ // 백엔드가 정답 처리 후 자동으로 라운드 전환하므로 /skip 제거
+ }
+ }, [correctAnswerBubble, createBubble, onBubbleProcessed])
+
+ // 게임 중 모든 채팅 메시지를 비눗방울로 표시
+ 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)
@@ -211,7 +428,10 @@ const GameModePanel = ({roomId, onGameMessage}) => {