diff --git a/src/App.jsx b/src/App.jsx index 03e8989..bb70c3a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -13,6 +13,7 @@ import { Quiz as QuizIcon, School as LearnIcon, SmartToy as AiIcon, + SportsEsports as GameIcon, WavingHand as WaveIcon, } from '@mui/icons-material' import MainLayout from './layouts/MainLayout' @@ -26,6 +27,9 @@ import WordListPage from './domains/vocab/pages/WordListPage' import StatsPage from './domains/vocab/pages/StatsPage' import {WritingPage} from './domains/grammar' import {BadgeSection} from './domains/badge' +import CatchmindLobbyPage from './domains/games/pages/CatchmindLobbyPage' +import CatchmindWaitingPage from './domains/games/pages/CatchmindWaitingPage' +import CatchmindPlayPage from './domains/games/pages/CatchmindPlayPage' import {useChat} from './contexts/ChatContext' import {useSettings} from './contexts/SettingsContext' import {useAuth} from './contexts/AuthContext' @@ -166,6 +170,23 @@ function Dashboard() { }, ], }, + { + id: 'games', + title: t('games.title'), + description: t('games.description'), + icon: GameIcon, + color: '#8b5cf6', + bgColor: '#f3e8ff', + children: [ + { + id: 'catchmind', + title: t('games.catchmindTitle'), + icon: GameIcon, + path: '/games/catchmind', + description: t('games.catchmindDesc') + }, + ], + }, ] const handleCardHover = (modeId) => { @@ -851,6 +872,9 @@ function App() { }/> }/> }/> + }/> + }/> + }/> {/* 404 */} diff --git a/src/domains/freetalk/components/ChatRoomModal.jsx b/src/domains/freetalk/components/ChatRoomModal.jsx index e9e3ef5..38da7e8 100644 --- a/src/domains/freetalk/components/ChatRoomModal.jsx +++ b/src/domains/freetalk/components/ChatRoomModal.jsx @@ -10,14 +10,11 @@ import { Paper, Popover, Slider, - Tab, - Tabs, TextField, Typography, useTheme, } from '@mui/material' import { - Chat as ChatIcon, Circle as CircleIcon, Close as CloseIcon, ExitToApp as ExitToAppIcon, @@ -26,21 +23,16 @@ import { OpenInFull as MaximizeIcon, Refresh as RefreshIcon, Send as SendIcon, - SportsEsports as GameIcon, VolumeUp as VolumeUpIcon, } from '@mui/icons-material' import { chatRoomService, - GAME_STATUS, - gameService, - MESSAGE_TYPES, messageService, voiceService } from '../../chat/services/chatService' import {useSettings} from '../../../contexts/SettingsContext' import {useAuth} from '../../../contexts/AuthContext' import {DESIGN_TOKENS, getChatStyles} from '../../../theme/theme' -import GameModePanel from './GameModePanel' import {useChatWebSocket} from '../hooks/useChatWebSocket' const ChatRoomModal = ({open, onClose, room, onLeave}) => { @@ -56,23 +48,11 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { const { isConnected, 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('') @@ -85,8 +65,6 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { const [savedPosition, setSavedPosition] = useState({x: 0, y: 0}) const [isDragging, setIsDragging] = useState(false) const [dragOffset, setDragOffset] = useState({x: 0, y: 0}) - const [activeTab, setActiveTab] = useState(0) // 0: 채팅, 1: 게임 - const [gameStatus, setGameStatus] = useState(GAME_STATUS.NONE) const [opacity, setOpacity] = useState(100) const [opacityAnchorEl, setOpacityAnchorEl] = useState(null) // 메시지 목록 조회 @@ -111,22 +89,6 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { } }, [room?.id]) - // 게임 상태 조회 - const fetchGameStatus = useCallback(async () => { - if (!room?.id) return - try { - const response = await gameService.getStatus(room.id) - const data = response.data || response - setGameStatus(data.gameStatus || GAME_STATUS.NONE) - // 게임 중이면 게임 탭으로 전환 - if (data.gameStatus === GAME_STATUS.PLAYING) { - setActiveTab(1) - } - } catch (err) { - console.error('Failed to fetch game status:', err) - } - }, [room?.id]) - // WebSocket 에러 통합 useEffect(() => { if (wsError) { @@ -134,17 +96,6 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { } }, [wsError]) - // WebSocket 게임 상태 변경 감지 - 게임 시작 시만 게임 탭으로 전환 - useEffect(() => { - if (wsGameState?.status === 'PLAYING') { - setGameStatus(GAME_STATUS.PLAYING) - setActiveTab(1) // 게임 시작 시 게임 탭으로 전환 - } else if (wsGameState?.status === 'FINISHED') { - setGameStatus(GAME_STATUS.NONE) - // 게임 종료 시에는 탭 유지 (사용자가 직접 전환하도록) - } - }, [wsGameState?.status]) - // 초기 로드 useEffect(() => { console.log('[ChatRoomModal] useEffect triggered:', {open, roomId: room?.id, currentUserId}) @@ -152,16 +103,12 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { console.log('[ChatRoomModal] Initializing...', {roomId: room.id, userId: currentUserId}) setLoading(true) setMinimized(false) - setActiveTab(0) // WebSocket 연결 wsConnect() - // 기존 메시지 및 게임 상태 로드 - Promise.all([ - fetchMessages(), - fetchGameStatus(), - ]).finally(() => setLoading(false)) + // 기존 메시지 로드 + fetchMessages().finally(() => setLoading(false)) } return () => { @@ -172,29 +119,6 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { } }, [open, room?.id, currentUserId]) // eslint-disable-line react-hooks/exhaustive-deps - // 게임 메시지 처리 - const handleGameMessage = (gameMessage) => { - const systemMessage = { - id: `game-${Date.now()}`, - content: gameMessage.content, - userId: 'SYSTEM', - messageType: gameMessage.type, - createdAt: new Date(), - isOwn: false, - isSystem: true, - } - setMessages((prev) => [...prev, systemMessage]) - - // 게임 시작 시 게임 탭으로 전환 - if (gameMessage.type === MESSAGE_TYPES.GAME_START) { - setGameStatus(GAME_STATUS.PLAYING) - setActiveTab(1) - } else if (gameMessage.type === MESSAGE_TYPES.GAME_END) { - setGameStatus(GAME_STATUS.NONE) - setActiveTab(0) - } - } - // 스크롤 맨 아래로 const scrollToBottom = (instant = false) => { messagesEndRef.current?.scrollIntoView({behavior: instant ? 'instant' : 'smooth'}) @@ -215,13 +139,6 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { } }, [messages.length]) - // 탭 전환 시 채팅 탭으로 돌아오면 스크롤 맨 아래로 - useEffect(() => { - if (activeTab === 0 && messages.length > 0 && !loading) { - setTimeout(() => scrollToBottom(true), 100) - } - }, [activeTab]) - // 드래그 핸들러 const handleMouseDown = (e) => { // 버튼, 입력창, 슬라이더, 팝오버 클릭 시 드래그 방지 @@ -489,30 +406,6 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { {!minimized && ( <> - {/* 탭 (채팅/게임) */} - setActiveTab(v)} - variant="fullWidth" - sx={{ - minHeight: 36, - '& .MuiTab-root': {minHeight: 36, py: 0.5}, - }} - > - } - iconPosition="start" - label="채팅" - sx={{fontSize: '0.75rem'}} - /> - } - iconPosition="start" - label="캐치마인드" - sx={{fontSize: '0.75rem'}} - /> - - {/* 에러 메시지 */} {error && ( setError(null)} sx={{borderRadius: 0}}> @@ -520,34 +413,8 @@ const ChatRoomModal = ({open, onClose, room, onLeave}) => { )} - {/* 게임 모드 */} - {activeTab === 1 && ( - - setReceivedDrawing(null)} - shouldClearCanvas={shouldClearCanvas} - onCanvasCleared={() => setShouldClearCanvas(false)} - correctAnswerBubble={correctAnswerBubble} - onBubbleProcessed={() => setCorrectAnswerBubble(null)} - /> - - )} - - {/* 채팅 모드 - 메시지 영역 */} - {activeTab === 0 && ( - loading ? ( + {/* 메시지 영역 */} + {loading ? ( { )}
- ) - )} + )} - {/* 입력 영역 - 항상 조작 가능 */} + {/* 입력 영역 */} { - console.log('[useChatWebSocket] Attempting to connect...', {roomId, userId}) + const connect = useCallback(async (forceNewToken = false) => { + console.log('[useChatWebSocket] Attempting to connect...', {roomId, userId, forceNewToken}) if (!roomId || !userId) { console.error('[useChatWebSocket] roomId and userId are required', {roomId, userId}) @@ -36,30 +36,55 @@ export function useChatWebSocket(roomId, userId) { try { setError(null) - // 1. 먼저 REST API로 roomToken 발급받기 - console.log('[useChatWebSocket] Getting roomToken via join API...') - const joinResponse = await chatRoomService.join(roomId) - const roomToken = joinResponse.roomToken || joinResponse.data?.roomToken - console.log('[useChatWebSocket] Got roomToken:', roomToken ? 'exists' : 'missing') + // forceNewToken이 true면 기존 토큰 삭제 + if (forceNewToken) { + console.log('[useChatWebSocket] Forcing new token, clearing old one') + sessionStorage.removeItem(`roomToken_${roomId}`) + } + + // 1. 먼저 sessionStorage에서 roomToken 확인 (이미 발급받은 경우) + let roomToken = sessionStorage.getItem(`roomToken_${roomId}`) + console.log('[useChatWebSocket] Checking sessionStorage for roomToken:', roomToken ? 'found' : 'not found') + + // 2. 없으면 REST API로 roomToken 발급받기 + if (!roomToken) { + console.log('[useChatWebSocket] Getting roomToken via join API...') + const joinResponse = await chatRoomService.join(roomId) + roomToken = joinResponse.roomToken || joinResponse.data?.roomToken + console.log('[useChatWebSocket] Got roomToken from API:', roomToken ? 'exists' : 'missing') + + // sessionStorage에 저장 + if (roomToken) { + sessionStorage.setItem(`roomToken_${roomId}`, roomToken) + } + } if (!roomToken) { - throw new Error('roomToken not received from join API') + throw new Error('roomToken not received') } + console.log('[useChatWebSocket] Using roomToken:', roomToken.substring(0, 20) + '...') roomTokenRef.current = roomToken // 콜백 설정 chatWebSocketService.setCallbacks({ onMessage: (data) => { + const messageId = data.messageId || `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` const newMessage = { - id: data.messageId || `msg-${Date.now()}`, + id: messageId, content: data.content, userId: data.userId, messageType: data.messageType || 'TEXT', createdAt: data.createdAt || new Date().toISOString(), isOwn: data.userId === userId, } - setMessages((prev) => [...prev, newMessage]) + setMessages((prev) => { + // 중복 메시지 방지 (같은 messageId가 이미 있으면 추가하지 않음) + if (data.messageId && prev.some(m => m.id === data.messageId)) { + return prev + } + return [...prev, newMessage] + }) }, onGameStart: (data) => { @@ -119,7 +144,6 @@ export function useChatWebSocket(roomId, userId) { // ROUND_END 처리 - 다음 라운드 정보 추출 const nextRoundNum = roundData.nextRound ?? ((roundData.currentRound || 0) + 1) - const nextDrawer = roundData.nextDrawer ?? roundData.nextDrawerId ?? roundData.currentDrawerId const nextWord = roundData.nextWord const answer = roundData.answer // 이번 라운드 정답 @@ -132,17 +156,41 @@ export function useChatWebSocket(roomId, userId) { }) } - console.log('[useChatWebSocket] Calling setGameState with:', { - nextRoundNum, - nextDrawer, - nextWord, - answer, - scores, - }) - setGameState((prev) => { console.log('[useChatWebSocket] setGameState callback, prev:', prev) + // 다음 출제자 계산 - 서버에서 제공하지 않으면 drawerOrder 기반으로 계산 + let nextDrawer = roundData.nextDrawer ?? roundData.nextDrawerId + + // 서버에서 다음 출제자를 제공하지 않은 경우, drawerOrder 기반으로 계산 + if (!nextDrawer && prev?.drawerOrder && prev.drawerOrder.length > 0) { + const drawerOrder = prev.drawerOrder + const currentDrawerIndex = drawerOrder.indexOf(prev.currentDrawerId) + const nextDrawerIndex = (currentDrawerIndex + 1) % drawerOrder.length + nextDrawer = drawerOrder[nextDrawerIndex] + console.log('[useChatWebSocket] Calculated nextDrawer from drawerOrder:', { + drawerOrder, + currentDrawerIndex, + nextDrawerIndex, + nextDrawer, + }) + } + + // 그래도 없으면 currentDrawerId 사용 (fallback, 이 경우 문제가 있음) + if (!nextDrawer) { + nextDrawer = roundData.currentDrawerId || prev?.currentDrawerId + console.warn('[useChatWebSocket] Could not determine nextDrawer, using fallback:', nextDrawer) + } + + console.log('[useChatWebSocket] Calling setGameState with:', { + nextRoundNum, + nextDrawer, + nextWord, + answer, + scores, + prevDrawerId: prev?.currentDrawerId, + }) + if (!prev) { console.warn('[useChatWebSocket] prev is null, creating new state') // prev가 null이면 새 상태 생성 @@ -160,15 +208,6 @@ export function useChatWebSocket(roomId, userId) { } } - console.log('[useChatWebSocket] Updating gameState:', { - prevRound: prev.currentRound, - nextRoundNum, - nextDrawer, - nextWord, - answer, - scores, - }) - // 항상 다음 라운드로 전환 (ROUND_END는 라운드가 끝났다는 의미) return { ...prev, @@ -187,11 +226,17 @@ export function useChatWebSocket(roomId, userId) { onCorrectAnswer: (data) => { console.log('[useChatWebSocket] Correct answer - FULL DATA:', JSON.stringify(data, null, 2)) const answerData = data.data || data - setGameState((prev) => ({ - ...prev, - correctGuessers: [...(prev?.correctGuessers || []), answerData.userId], - scores: answerData.scores || prev?.scores, - })) + setGameState((prev) => { + // prev가 null이면 기본 상태 유지 + if (!prev) return prev + + return { + ...prev, + correctGuessers: [...(prev.correctGuessers || []), answerData.userId], + // scores는 기존 값 유지 (answerData.scores가 있을 때만 업데이트) + scores: answerData.scores ? answerData.scores : prev.scores, + } + }) // 정답 비눗방울 표시 데이터 설정 setCorrectAnswerBubble({ userId: answerData.userId, @@ -220,9 +265,12 @@ export function useChatWebSocket(roomId, userId) { onDrawing: (data) => { // 캔버스에 그리기 데이터 적용 console.log('[useChatWebSocket] Drawing received:', data) - // data.data가 있으면 그것을 사용, 없으면 원본 data 사용 - const drawingData = data.data || data - setReceivedDrawing(drawingData) + // 실제 stroke 데이터는 content 필드에 있음 (전송 시 content에 넣었으므로) + // 서버 응답 구조: { messageType: 'DRAWING', data: { content: '...' }, ... } + // 또는: { messageType: 'DRAWING', content: '...', ... } + const strokeData = data.content || data.data?.content || data.data || data + console.log('[useChatWebSocket] Extracted stroke data:', strokeData) + setReceivedDrawing(strokeData) }, onDrawingClear: () => { @@ -261,6 +309,13 @@ export function useChatWebSocket(roomId, userId) { setIsConnected(false) isConnectedRef.current = false }, + + onReconnectNeeded: () => { + console.log('[useChatWebSocket] Reconnect needed, getting new token...') + // 기존 토큰 삭제 후 새 토큰으로 재연결 + sessionStorage.removeItem(`roomToken_${roomId}`) + connect(true) + }, }) // 2. roomToken으로 WebSocket 연결 @@ -273,9 +328,19 @@ export function useChatWebSocket(roomId, userId) { message: err.message, stack: err.stack, roomId, - userId + userId, + forceNewToken }) - setError('연결에 실패했습니다: ' + err.message) + + // 기존 토큰으로 연결 실패 시, 새 토큰으로 재시도 (1회만) + if (!forceNewToken && sessionStorage.getItem(`roomToken_${roomId}`)) { + console.log('[useChatWebSocket] Connection failed with cached token, retrying with new token...') + sessionStorage.removeItem(`roomToken_${roomId}`) + // 재귀 호출로 새 토큰 발급 후 재시도 + return connect(true) + } + + setError('연결에 실패했습니다: ' + (err.message || '알 수 없는 오류')) setIsConnected(false) isConnectedRef.current = false } diff --git a/src/domains/freetalk/services/chatWebSocketService.js b/src/domains/freetalk/services/chatWebSocketService.js index 0c6e059..4ceefb2 100644 --- a/src/domains/freetalk/services/chatWebSocketService.js +++ b/src/domains/freetalk/services/chatWebSocketService.js @@ -42,12 +42,16 @@ class ChatWebSocketConnection { // roomToken 파라미터로 연결 (token이 아님!) const url = roomToken ? `${WS_URL}?roomToken=${roomToken}` : WS_URL console.log('[ChatWebSocket] Connecting to:', url) + console.log('[ChatWebSocket] roomToken:', roomToken ? roomToken.substring(0, 30) + '...' : 'MISSING') + console.log('[ChatWebSocket] roomId:', roomId) + console.log('[ChatWebSocket] userId:', userId) this.ws = new WebSocket(url) this.ws.onopen = () => { this.isConnected = true this.reconnectAttempts = 0 - console.log('[ChatWebSocket] Connected') + console.log('[ChatWebSocket] Connected successfully!') + console.log('[ChatWebSocket] WebSocket readyState:', this.ws.readyState) // 연결 후 방 입장은 roomToken으로 이미 처리됨 // joinRoom action은 서버에서 지원하지 않음 (Forbidden) @@ -57,7 +61,8 @@ class ChatWebSocketConnection { this.ws.onclose = (event) => { this.isConnected = false - console.log('[ChatWebSocket] Disconnected:', event.code) + console.log('[ChatWebSocket] Disconnected:', event.code, event.reason) + this.callbacks.onClose?.(event) // 비정상 종료 시 재연결 시도 (roomToken 만료 시 재발급 필요할 수 있음) @@ -68,6 +73,7 @@ class ChatWebSocketConnection { this.ws.onerror = (error) => { console.error('[ChatWebSocket] Error:', error) + console.error('[ChatWebSocket] WebSocket readyState:', this.ws?.readyState) this.callbacks.onError?.({type: 'error', message: 'WebSocket connection error'}) reject(error) } @@ -87,17 +93,25 @@ class ChatWebSocketConnection { } /** - * 재연결 시도 + * 재연결 시도 - 기존 토큰 무효화하고 onReconnectNeeded 콜백 호출 */ attemptReconnect(roomToken, roomId, userId) { this.reconnectAttempts++ - console.log(`[ChatWebSocket] Reconnecting... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`) + console.log(`[ChatWebSocket] Reconnect needed (${this.reconnectAttempts}/${this.maxReconnectAttempts})`) - setTimeout(() => { - this.connect(roomToken, roomId, userId).catch(() => { - console.error('[ChatWebSocket] Reconnect failed') - }) - }, this.reconnectDelay * this.reconnectAttempts) + // 최대 재연결 시도 횟수 초과 시 중단 + if (this.reconnectAttempts > this.maxReconnectAttempts) { + console.error('[ChatWebSocket] Max reconnect attempts reached') + this.callbacks.onError?.({ message: '연결이 끊어졌습니다. 페이지를 새로고침해주세요.' }) + return + } + + // 재연결이 필요함을 알림 (useChatWebSocket에서 새 토큰 발급 후 재연결) + if (this.callbacks.onReconnectNeeded) { + setTimeout(() => { + this.callbacks.onReconnectNeeded() + }, this.reconnectDelay * this.reconnectAttempts) + } } /** diff --git a/src/domains/games/components/BubbleOverlay.jsx b/src/domains/games/components/BubbleOverlay.jsx new file mode 100644 index 0000000..b41ed91 --- /dev/null +++ b/src/domains/games/components/BubbleOverlay.jsx @@ -0,0 +1,135 @@ +import {useEffect, useState} from 'react' +import {Box, Typography} from '@mui/material' +import {GAME_COLORS} from '../theme/gameTheme' + +const BubbleOverlay = ({ bubbles, containerWidth, containerHeight }) => { + const [activeBubbles, setActiveBubbles] = useState([]) + + useEffect(() => { + if (!bubbles || bubbles.length === 0) return + + // 새 버블 추가 - 항상 고유한 내부 ID 생성 + const now = Date.now() + const newBubbles = bubbles.map((bubble, index) => ({ + ...bubble, + internalId: `${now}-${index}-${Math.random().toString(36).substr(2, 9)}`, + x: Math.random() * (containerWidth - 120) + 60, + startTime: now, + })) + + setActiveBubbles(prev => [...prev, ...newBubbles]) + + // 3초 후 자동 제거 + const timeoutIds = newBubbles.map(bubble => + setTimeout(() => { + setActiveBubbles(prev => prev.filter(b => b.internalId !== bubble.internalId)) + }, 3000) + ) + + return () => { + timeoutIds.forEach(id => clearTimeout(id)) + } + }, [bubbles, containerWidth]) + + // 애니메이션 프레임 + const [, setFrame] = useState(0) + useEffect(() => { + if (activeBubbles.length === 0) return + + const interval = setInterval(() => { + setFrame(f => f + 1) + }, 50) + + return () => clearInterval(interval) + }, [activeBubbles.length]) + + return ( + + {activeBubbles.map(bubble => { + const elapsed = (Date.now() - bubble.startTime) / 1000 + const progress = Math.min(elapsed / 3, 1) + const y = containerHeight * (1 - progress * 0.8) + const opacity = 1 - progress * 0.7 + const scale = 1 + progress * 0.2 + + return ( + + + + {bubble.isCorrect && '🎉 '} + {bubble.nickname || bubble.userId} + + + {bubble.content} + + {bubble.isCorrect && bubble.score && ( + + +{bubble.score}점 + + )} + + + ) + })} + + ) +} + +export default BubbleOverlay diff --git a/src/domains/games/components/CreateGameRoomModal.jsx b/src/domains/games/components/CreateGameRoomModal.jsx new file mode 100644 index 0000000..4545f24 --- /dev/null +++ b/src/domains/games/components/CreateGameRoomModal.jsx @@ -0,0 +1,279 @@ +import {useState} from 'react' +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Slider, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material' +import {Close as CloseIcon} from '@mui/icons-material' +import {GAME_COLORS} from '../theme/gameTheme' + +const levelOptions = [ + { value: 'BEGINNER', label: '초급', color: '#10B981' }, + { value: 'INTERMEDIATE', label: '중급', color: '#F59E0B' }, + { value: 'ADVANCED', label: '고급', color: '#EF4444' }, +] + +const CreateGameRoomModal = ({ open, onClose, onCreate, loading }) => { + const [formData, setFormData] = useState({ + name: '', + description: '', + level: 'BEGINNER', + maxParticipants: 4, + maxRounds: 5, + roundTimeLimit: 60, + }) + + const handleChange = (field, value) => { + setFormData(prev => ({ ...prev, [field]: value })) + } + + const handleSubmit = () => { + if (!formData.name.trim()) return + onCreate?.(formData) + } + + const handleClose = () => { + setFormData({ + name: '', + description: '', + level: 'BEGINNER', + maxParticipants: 4, + maxRounds: 5, + roundTimeLimit: 60, + }) + onClose?.() + } + + return ( + + {/* 헤더 */} + + + 새 게임방 만들기 + + + + + + + + {/* 방 이름 */} + + + 방 이름 * + + handleChange('name', e.target.value)} + size="small" + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: '10px', + }, + }} + /> + + + {/* 설명 */} + + + 설명 (선택) + + handleChange('description', e.target.value)} + size="small" + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: '10px', + }, + }} + /> + + + {/* 난이도 */} + + + 난이도 + + value && handleChange('level', value)} + fullWidth + sx={{ + '& .MuiToggleButton-root': { + borderRadius: '10px', + textTransform: 'none', + fontWeight: 600, + py: 1, + }, + }} + > + {levelOptions.map((opt) => ( + + {opt.label} + + ))} + + + + {/* 최대 인원 */} + + + + 최대 인원 + + + {formData.maxParticipants}명 + + + handleChange('maxParticipants', value)} + min={2} + max={8} + step={1} + marks + sx={{ + color: GAME_COLORS.primary, + '& .MuiSlider-markLabel': { + fontSize: '0.7rem', + }, + }} + /> + + + {/* 라운드 수 */} + + + + 라운드 수 + + + {formData.maxRounds}라운드 + + + handleChange('maxRounds', value)} + min={3} + max={10} + step={1} + marks + sx={{ + color: GAME_COLORS.primary, + }} + /> + + + {/* 라운드 시간 */} + + + + 라운드 시간 + + + {formData.roundTimeLimit}초 + + + handleChange('roundTimeLimit', value)} + min={30} + max={120} + step={15} + marks={[ + { value: 30, label: '30초' }, + { value: 60, label: '60초' }, + { value: 90, label: '90초' }, + { value: 120, label: '120초' }, + ]} + sx={{ + color: GAME_COLORS.primary, + '& .MuiSlider-markLabel': { + fontSize: '0.65rem', + }, + }} + /> + + + + + + + + + ) +} + +export default CreateGameRoomModal diff --git a/src/domains/games/components/GameCanvas.jsx b/src/domains/games/components/GameCanvas.jsx new file mode 100644 index 0000000..5c854e3 --- /dev/null +++ b/src/domains/games/components/GameCanvas.jsx @@ -0,0 +1,312 @@ +import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react' +import {Box, IconButton, Slider, Tooltip, Typography} from '@mui/material' +import {Delete as ClearIcon} from '@mui/icons-material' +import {GAME_COLORS, GAME_LAYOUT} from '../theme/gameTheme' + +const COLORS = [ + '#000000', '#FFFFFF', '#EF4444', '#F97316', '#EAB308', + '#22C55E', '#3B82F6', '#8B5CF6', '#EC4899', '#78716C', +] + +const GameCanvas = forwardRef(({ + isDrawer, + onDraw, + onClear, + disabled, + timerDanger, +}, ref) => { + const canvasRef = useRef(null) + const [isDrawing, setIsDrawing] = useState(false) + const [brushColor, setBrushColor] = useState('#000000') + const [brushSize, setBrushSize] = useState(4) + const [currentStroke, setCurrentStroke] = useState([]) + + // 외부에서 캔버스 조작 가능하도록 ref 노출 + useImperativeHandle(ref, () => ({ + clear: () => clearCanvas(false), + drawStroke: (strokeData) => drawRemoteStroke(strokeData), + getImageData: () => { + const canvas = canvasRef.current + return canvas?.toDataURL() + }, + })) + + // 캔버스 초기화 + const clearCanvas = useCallback((sendToOthers = true) => { + 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 && onClear) { + onClear() + } + }, [onClear]) + + // 원격 스트로크 그리기 + const drawRemoteStroke = useCallback((strokeData) => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext('2d') + let data = strokeData + + if (typeof data === 'string') { + try { + data = JSON.parse(data) + } catch (e) { + console.error('Failed to parse stroke data:', e) + return + } + } + + if (!Array.isArray(data)) return + + data.forEach((point) => { + if (point.type === 'start') { + ctx.beginPath() + ctx.moveTo(point.x, point.y) + ctx.lineWidth = point.width || 4 + ctx.lineCap = 'round' + ctx.lineJoin = '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() + } + }) + }, []) + + // 캔버스 초기화 (마운트 시) + useEffect(() => { + clearCanvas(false) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // 그리기 시작 + const startDrawing = (e) => { + if (!isDrawer || disabled) return + + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const scaleX = canvas.width / rect.width + const scaleY = canvas.height / rect.height + const x = (e.clientX - rect.left) * scaleX + const y = (e.clientY - rect.top) * scaleY + + setIsDrawing(true) + setCurrentStroke([{ x, y, type: 'start', color: brushColor, width: brushSize }]) + + const ctx = canvas.getContext('2d') + ctx.beginPath() + ctx.moveTo(x, y) + ctx.lineWidth = brushSize + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.strokeStyle = brushColor + } + + // 그리기 + const draw = (e) => { + if (!isDrawing || !isDrawer || disabled) return + + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext('2d') + const rect = canvas.getBoundingClientRect() + const scaleX = canvas.width / rect.width + const scaleY = canvas.height / rect.height + const x = (e.clientX - rect.left) * scaleX + const y = (e.clientY - rect.top) * scaleY + + ctx.lineTo(x, y) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(x, y) + + setCurrentStroke(prev => [...prev, { x, y, type: 'move' }]) + } + + // 그리기 종료 + const stopDrawing = () => { + if (!isDrawing) return + + setIsDrawing(false) + + const canvas = canvasRef.current + if (canvas) { + const ctx = canvas.getContext('2d') + ctx.beginPath() + } + + // 스트로크 전송 + if (currentStroke.length > 0 && onDraw) { + const strokeData = [...currentStroke, { type: 'end' }] + onDraw(JSON.stringify(strokeData)) + } + + setCurrentStroke([]) + } + + // 터치 이벤트 핸들러 + const handleTouchStart = (e) => { + e.preventDefault() + const touch = e.touches[0] + startDrawing({ clientX: touch.clientX, clientY: touch.clientY }) + } + + const handleTouchMove = (e) => { + e.preventDefault() + const touch = e.touches[0] + draw({ clientX: touch.clientX, clientY: touch.clientY }) + } + + const handleTouchEnd = (e) => { + e.preventDefault() + stopDrawing() + } + + return ( + + {/* 캔버스 */} + + + + + + + {/* 그리기 도구 (출제자만) - 컴팩트 */} + {isDrawer && ( + + {/* 색상 팔레트 */} + + {COLORS.map((color) => ( + + setBrushColor(color)} + sx={{ + width: 22, + height: 22, + bgcolor: color, + borderRadius: '50%', + cursor: 'pointer', + border: brushColor === color + ? '2px solid #1F2937' + : '1px solid #E5E7EB', + transition: 'transform 0.15s ease', + '&:hover': { + transform: 'scale(1.1)', + }, + }} + /> + + ))} + + + {/* 브러시 크기 */} + + + 굵기 + + setBrushSize(value)} + min={2} + max={20} + size="small" + sx={{ + color: GAME_COLORS.primary, + width: 60, + '& .MuiSlider-thumb': { width: 12, height: 12 }, + }} + /> + + + {/* 지우기 버튼 */} + + clearCanvas(true)} + size="small" + sx={{ + bgcolor: '#FEE2E2', + color: '#EF4444', + '&:hover': { bgcolor: '#FECACA' }, + p: 0.5, + }} + > + + + + + )} + + ) +}) + +GameCanvas.displayName = 'GameCanvas' + +export default GameCanvas diff --git a/src/domains/games/components/GameChat.jsx b/src/domains/games/components/GameChat.jsx new file mode 100644 index 0000000..403f6a5 --- /dev/null +++ b/src/domains/games/components/GameChat.jsx @@ -0,0 +1,321 @@ +import {useEffect, useRef, useState} from 'react' +import {Avatar, Box, Chip, IconButton, Paper, TextField, Typography} from '@mui/material' +import {Send as SendIcon} from '@mui/icons-material' +import {GAME_COLORS} from '../theme/gameTheme' + +const GameChat = ({ + messages, + onSendMessage, + currentUserId, + isDrawer, + currentDrawerId, +}) => { + const [newMessage, setNewMessage] = useState('') + const messagesEndRef = useRef(null) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + const handleSend = () => { + if (!newMessage.trim() || isDrawer) return + onSendMessage?.(newMessage.trim()) + setNewMessage('') + } + + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + const formatTime = (date) => { + const d = new Date(date) + return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }) + } + + // 메시지 타입에 따른 스타일 + const getMessageStyle = (message) => { + if (message.isCorrect) { + return { + bgcolor: GAME_COLORS.answer.correctBg, + borderLeft: `4px solid ${GAME_COLORS.answer.correct}`, + } + } + if (message.isWrong) { + return { + opacity: 0.6, + } + } + if (message.isSystem) { + return { + bgcolor: 'transparent', + } + } + return {} + } + + return ( + + {/* 헤더 - 컴팩트 */} + + + 채팅 + + + 정답을 입력하세요 + + + + {/* 메시지 영역 - 컴팩트 */} + + {messages.map((message) => { + const isOwn = message.userId === currentUserId + const isSystem = message.isSystem + const isFromDrawer = message.userId === currentDrawerId + + // 시스템 메시지 + if (isSystem) { + return ( + + + + ) + } + + // 정답 메시지 + if (message.isCorrect) { + return ( + + + + {message.nickname || message.userId} + + + + + {message.content} + + + ) + } + + // 일반 메시지 + return ( + + {!isOwn && ( + + {(message.nickname || message.userId)?.charAt(0) || 'U'} + + )} + + + {!isOwn && ( + + + {message.nickname || message.userId} + + {isFromDrawer && ( + + )} + + )} + + + {isOwn && ( + + {formatTime(message.createdAt)} + + )} + + + + {message.isWrong && '❌ '} + {message.content} + + + + {!isOwn && ( + + {formatTime(message.createdAt)} + + )} + + + + ) + })} +
+ + + {/* 입력 영역 - 컴팩트 */} + + setNewMessage(e.target.value)} + onKeyPress={handleKeyPress} + disabled={isDrawer} + size="small" + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: '8px', + fontSize: '0.8rem', + }, + '& .MuiOutlinedInput-input': { + py: 0.5, + px: 1, + }, + }} + /> + + + + + + ) +} + +export default GameChat diff --git a/src/domains/games/components/GameRoomCard.jsx b/src/domains/games/components/GameRoomCard.jsx new file mode 100644 index 0000000..60899a4 --- /dev/null +++ b/src/domains/games/components/GameRoomCard.jsx @@ -0,0 +1,223 @@ +import {Avatar, AvatarGroup, Box, Button, Card, CardContent, Chip, Typography} from '@mui/material' +import { + Person as PersonIcon, + PlayArrow as PlayIcon, + Visibility as SpectateIcon, +} from '@mui/icons-material' +import {GAME_COLORS} from '../theme/gameTheme' + +const levelConfig = { + // 대문자 + BEGINNER: { label: '초급', color: '#10B981', bg: '#D1FAE5' }, + INTERMEDIATE: { label: '중급', color: '#F59E0B', bg: '#FEF3C7' }, + ADVANCED: { label: '고급', color: '#EF4444', bg: '#FEE2E2' }, + // 소문자 (백엔드 응답) + beginner: { label: '초급', color: '#10B981', bg: '#D1FAE5' }, + intermediate: { label: '중급', color: '#F59E0B', bg: '#FEF3C7' }, + advanced: { label: '고급', color: '#EF4444', bg: '#FEE2E2' }, +} + +const statusConfig = { + WAITING: { label: '대기중', color: GAME_COLORS.status.waiting, bg: GAME_COLORS.status.waitingBg }, + PLAYING: { label: '게임중', color: GAME_COLORS.status.playing, bg: GAME_COLORS.status.playingBg }, + FINISHED: { label: '종료', color: GAME_COLORS.status.finished, bg: GAME_COLORS.status.finishedBg }, +} + +const GameRoomCard = ({ room, onJoin, onSpectate }) => { + const level = levelConfig[room.level] || levelConfig.beginner + const status = statusConfig[room.status] || statusConfig.WAITING + + // 백엔드/프론트엔드 필드명 호환 + const maxParticipants = room.maxParticipants || room.maxMembers || 6 + const currentParticipants = room.currentParticipants || room.currentMembers || 1 + + const isFull = currentParticipants >= maxParticipants + const isPlaying = room.status === 'PLAYING' + const canJoin = !isFull && !isPlaying + + return ( + + + {/* 상단: 방 이름 + 상태 */} + + + + {room.name} + + + {room.description || '\u00A0'} + + + + + + {/* 중간: 방장 + 인원 + 레벨 */} + + {/* 방장 - 고정 너비 */} + + + {room.hostNickname?.charAt(0) || 'H'} + + + {room.hostNickname} + + + + {/* 인원 */} + + + + {currentParticipants}/{maxParticipants} + + + + {/* 레벨 */} + + + + {/* 참가자 아바타 - 항상 고정 높이 */} + + {room.participants && room.participants.length > 0 ? ( + + {room.participants.map((p) => ( + + {p.nickname?.charAt(0) || 'U'} + + ))} + + ) : ( + + 참가자 대기중... + + )} + + + {/* 하단: 버튼 */} + + {canJoin ? ( + + ) : isPlaying ? ( + + ) : ( + + )} + + + + ) +} + +export default GameRoomCard diff --git a/src/domains/games/components/ParticipantList.jsx b/src/domains/games/components/ParticipantList.jsx new file mode 100644 index 0000000..92c6dc3 --- /dev/null +++ b/src/domains/games/components/ParticipantList.jsx @@ -0,0 +1,134 @@ +import {Avatar, Box, Chip, List, ListItem, ListItemAvatar, ListItemText, Typography} from '@mui/material' +import {GAME_COLORS} from '../theme/gameTheme' + +const ParticipantList = ({ participants, maxParticipants, currentUserId }) => { + const emptySlots = maxParticipants - participants.length + + return ( + + + + 참가자 + + + + + + {participants.map((participant) => ( + + + + {participant.nickname?.charAt(0) || 'U'} + + + + + {participant.nickname} + + {participant.isHost && ( + + )} + {participant.userId === currentUserId && ( + + )} + + } + /> + + ))} + + {/* 빈 슬롯 */} + {Array.from({ length: emptySlots }).map((_, index) => ( + + + + ? + + + + 대기중... + + } + /> + + ))} + + + ) +} + +export default ParticipantList diff --git a/src/domains/games/components/WaitingChat.jsx b/src/domains/games/components/WaitingChat.jsx new file mode 100644 index 0000000..4318418 --- /dev/null +++ b/src/domains/games/components/WaitingChat.jsx @@ -0,0 +1,199 @@ +import {useEffect, useRef, useState} from 'react' +import {Avatar, Box, IconButton, Paper, TextField, Typography} from '@mui/material' +import {Send as SendIcon} from '@mui/icons-material' +import {GAME_COLORS} from '../theme/gameTheme' + +const WaitingChat = ({ messages, onSendMessage, currentUserId, disabled }) => { + const [newMessage, setNewMessage] = useState('') + const messagesEndRef = useRef(null) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + const handleSend = () => { + if (!newMessage.trim() || disabled) return + onSendMessage?.(newMessage.trim()) + setNewMessage('') + } + + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + const formatTime = (date) => { + const d = new Date(date) + return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }) + } + + return ( + + {/* 채팅 메시지 영역 */} + + {messages.length === 0 ? ( + + + 대기 중 채팅을 시작해보세요! + + + ) : ( + messages.map((message) => { + const isOwn = message.userId === currentUserId + const isSystem = message.isSystem + + if (isSystem) { + return ( + + + {message.content} + + + ) + } + + return ( + + {!isOwn && ( + + {message.nickname?.charAt(0) || message.userId?.charAt(0) || 'U'} + + )} + + + {!isOwn && ( + + {message.nickname || message.userId} + + )} + + + {isOwn && ( + + {formatTime(message.createdAt)} + + )} + + + + {message.content} + + + + {!isOwn && ( + + {formatTime(message.createdAt)} + + )} + + + + ) + }) + )} +
+ + + {/* 입력 영역 */} + + setNewMessage(e.target.value)} + onKeyPress={handleKeyPress} + disabled={disabled} + size="small" + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: '10px', + }, + }} + /> + + + + + + ) +} + +export default WaitingChat diff --git a/src/domains/games/pages/CatchmindLobbyPage.jsx b/src/domains/games/pages/CatchmindLobbyPage.jsx new file mode 100644 index 0000000..d44b063 --- /dev/null +++ b/src/domains/games/pages/CatchmindLobbyPage.jsx @@ -0,0 +1,303 @@ +import {useCallback, useEffect, useState} from 'react' +import {useNavigate} from 'react-router-dom' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Container, + FormControlLabel, + Grid, + IconButton, + MenuItem, + Select, + Switch, + Typography, +} from '@mui/material' +import { + Add as AddIcon, + Brush as BrushIcon, + Refresh as RefreshIcon, +} from '@mui/icons-material' +import GameRoomCard from '../components/GameRoomCard' +import CreateGameRoomModal from '../components/CreateGameRoomModal' +import {gameService} from '../services/gameService' +import {GAME_COLORS} from '../theme/gameTheme' +import {useAuth} from '../../../contexts/AuthContext' + +const CatchmindLobbyPage = () => { + const navigate = useNavigate() + const {user} = useAuth() + + const [rooms, setRooms] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [createModalOpen, setCreateModalOpen] = useState(false) + const [creating, setCreating] = useState(false) + + // 필터 + const [filters, setFilters] = useState({ + level: '', + waitingOnly: true, + }) + + // 방 목록 조회 + const fetchRooms = useCallback(async () => { + try { + setLoading(true) + setError(null) + + const params = {} + if (filters.level) params.level = filters.level + if (filters.waitingOnly) params.status = 'WAITING' + + const response = await gameService.getRooms(params) + setRooms(response.data.rooms) + } catch (err) { + console.error('Failed to fetch rooms:', err) + setError('방 목록을 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + }, [filters]) + + useEffect(() => { + fetchRooms() + }, [fetchRooms]) + + // 방 생성 + const handleCreateRoom = async (data) => { + try { + setCreating(true) + const response = await gameService.createRoom(data) + setCreateModalOpen(false) + // 생성된 방의 대기실로 이동 + navigate(`/games/catchmind/${response.data.roomId}/waiting`) + } catch (err) { + console.error('Failed to create room:', err) + setError('방 생성에 실패했습니다') + } finally { + setCreating(false) + } + } + + // 방 참가 + const handleJoinRoom = async (room) => { + try { + const response = await gameService.joinRoom(room.roomId) + // roomToken을 sessionStorage에 저장 (WebSocket 연결 시 사용) + if (response.data?.roomToken) { + sessionStorage.setItem(`roomToken_${room.roomId}`, response.data.roomToken) + } + navigate(`/games/catchmind/${room.roomId}/waiting`) + } catch (err) { + console.error('Failed to join room:', err) + setError(err.message || '방 참가에 실패했습니다') + } + } + + // 관전 + const handleSpectateRoom = (room) => { + navigate(`/games/catchmind/${room.roomId}/play?spectate=true`) + } + + return ( + + {/* 헤더 */} + + + + + + + + 캐치마인드 + + + 그림으로 단어를 맞춰보세요 + + + + + + {/* 필터 + 액션 */} + + {/* 필터 */} + + + + setFilters(prev => ({ ...prev, waitingOnly: e.target.checked }))} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { + color: GAME_COLORS.primary, + }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + bgcolor: GAME_COLORS.primary, + }, + }} + /> + } + label={대기중만} + /> + + + + + + + + + {/* 방 만들기 버튼 */} + + + + {/* 에러 */} + {error && ( + setError(null)} sx={{ mb: 3, borderRadius: '12px' }}> + {error} + + )} + + {/* 방 목록 */} + {loading ? ( + + + + ) : rooms.length === 0 ? ( + + + + + + 대기 중인 방이 없습니다 + + + 새로운 게임방을 만들어보세요! + + + + ) : ( + + {rooms.map((room) => ( + + + + ))} + + )} + + {/* 방 생성 모달 */} + setCreateModalOpen(false)} + onCreate={handleCreateRoom} + loading={creating} + /> + + ) +} + +export default CatchmindLobbyPage diff --git a/src/domains/games/pages/CatchmindPlayPage.jsx b/src/domains/games/pages/CatchmindPlayPage.jsx new file mode 100644 index 0000000..2ea07be --- /dev/null +++ b/src/domains/games/pages/CatchmindPlayPage.jsx @@ -0,0 +1,934 @@ +import {useCallback, useEffect, useRef, useState} from 'react' +import {useNavigate, useParams} from 'react-router-dom' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + LinearProgress, + Typography, +} from '@mui/material' +import { + EmojiEvents as TrophyIcon, + ExitToApp as ExitIcon, + Lightbulb as HintIcon, + Replay as ReplayIcon, + Stop as StopIcon, +} from '@mui/icons-material' +import GameCanvas from '../components/GameCanvas' +import GameChat from '../components/GameChat' +import BubbleOverlay from '../components/BubbleOverlay' +import {gameService} from '../services/gameService' +import {GAME_COLORS, GAME_LAYOUT, GAME_TYPOGRAPHY} from '../theme/gameTheme' +import {useAuth} from '../../../contexts/AuthContext' +import {useChatWebSocket} from '../../freetalk/hooks/useChatWebSocket' + +const CatchmindPlayPage = () => { + const { roomId } = useParams() + const navigate = useNavigate() + const { user } = useAuth() + const currentUserId = user?.userId || user?.username || user?.sub + const canvasRef = useRef(null) + + // WebSocket 연결 + const { + isConnected, + messages: wsMessages, + gameState: wsGameState, + error: wsError, + receivedDrawing, + shouldClearCanvas, + correctAnswerBubble, + connect, + disconnect, + sendMessage: wsSendMessage, + sendDrawing, + clearDrawing, + setReceivedDrawing, + setShouldClearCanvas, + setCorrectAnswerBubble, + } = useChatWebSocket(roomId, currentUserId) + + // 로컬 게임 상태 (WebSocket에서 받은 값으로 업데이트) + const [localGameState, setLocalGameState] = useState({ + status: 'PLAYING', + currentRound: 1, + totalRounds: 5, + currentDrawerId: null, + currentWord: null, + roundStartTime: Date.now(), + roundTimeLimit: 60, + scores: {}, + hintUsed: false, + hint: null, + }) + + // WebSocket gameState가 업데이트되면 로컬 상태에 병합 + useEffect(() => { + if (wsGameState) { + console.log('[CatchmindPlayPage] Received wsGameState:', wsGameState) + + // 라운드가 변경되면 전환 상태 해제 및 타이머 리셋 + setLocalGameState(prev => { + // undefined 값은 무시하고 기존 값 유지 + const newRound = wsGameState.currentRound ?? prev.currentRound + const newDrawer = wsGameState.currentDrawerId ?? prev.currentDrawerId + const newRoundTimeLimit = wsGameState.roundTimeLimit ?? prev.roundTimeLimit ?? 60 + + // 실제로 값이 변경되었는지 확인 (undefined가 아닌 새로운 값이 왔을 때만) + const roundChanged = wsGameState.currentRound !== undefined && prev.currentRound !== wsGameState.currentRound + const drawerChanged = wsGameState.currentDrawerId !== undefined && prev.currentDrawerId !== wsGameState.currentDrawerId + + if (roundChanged || drawerChanged) { + console.log('[CatchmindPlayPage] Round or drawer changed:', { + prevRound: prev.currentRound, + newRound: newRound, + prevDrawer: prev.currentDrawerId, + newDrawer: newDrawer, + }) + // 라운드 전환 완료 - 타이머 리셋 + setIsRoundTransitioning(false) + setTimeLeft(newRoundTimeLimit) + } + + // undefined 값은 기존 값으로 유지 + return { + ...prev, + status: wsGameState.status ?? prev.status, + currentRound: newRound, + totalRounds: wsGameState.totalRounds ?? prev.totalRounds, + currentDrawerId: newDrawer, + currentWord: wsGameState.currentWord ?? prev.currentWord, + roundStartTime: wsGameState.roundStartTime ?? prev.roundStartTime, + roundTimeLimit: newRoundTimeLimit, + scores: wsGameState.scores ?? prev.scores, + hintUsed: wsGameState.hintUsed ?? prev.hintUsed, + hint: wsGameState.hint ?? prev.hint, + correctGuessers: wsGameState.correctGuessers ?? prev.correctGuessers, + drawerOrder: wsGameState.drawerOrder ?? prev.drawerOrder, + lastAnswer: wsGameState.lastAnswer ?? prev.lastAnswer, + finalScores: wsGameState.finalScores ?? prev.finalScores, + } + }) + } + }, [wsGameState]) + + const gameState = localGameState + + const [room, setRoom] = useState(null) + const [loading, setLoading] = useState(true) + const [timeLeft, setTimeLeft] = useState(60) + const [messages, setMessages] = useState([]) + const [bubbles, setBubbles] = useState([]) + const [showEndModal, setShowEndModal] = useState(false) + const [finalRanking, setFinalRanking] = useState([]) + const [isRoundTransitioning, setIsRoundTransitioning] = useState(false) + + const isDrawer = gameState.currentDrawerId === currentUserId + const timerDanger = timeLeft <= 10 + // 라운드 전환 중이거나 타이머가 0이면 그리기 비활성화 + const canDraw = isDrawer && !isRoundTransitioning && timeLeft > 0 + + // WebSocket 메시지를 로컬 messages에 동기화 (중복 제거) + useEffect(() => { + if (wsMessages && wsMessages.length > 0) { + setMessages(prev => { + // 로컬 시스템 메시지 유지 (ID가 system-으로 시작하는 것) + const localSystemMessages = prev.filter(m => + m.isSystem && m.id && m.id.startsWith('system-') + ) + + // wsMessages와 로컬 시스템 메시지 병합 후 ID 기준 중복 제거 + const merged = [...localSystemMessages, ...wsMessages] + const seen = new Set() + return merged.filter(m => { + if (!m.id || seen.has(m.id)) return false + seen.add(m.id) + return true + }).sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) + }) + } + }, [wsMessages]) + + // 수신된 그리기 데이터 캔버스에 적용 + useEffect(() => { + if (receivedDrawing && canvasRef.current) { + console.log('[CatchmindPlayPage] Applying received drawing:', receivedDrawing) + canvasRef.current.drawStroke(receivedDrawing) + setReceivedDrawing(null) + } + }, [receivedDrawing, setReceivedDrawing]) + + // 캔버스 클리어 명령 처리 + useEffect(() => { + if (shouldClearCanvas && canvasRef.current) { + console.log('[CatchmindPlayPage] Clearing canvas from WebSocket') + canvasRef.current.clear() + setShouldClearCanvas(false) + } + }, [shouldClearCanvas, setShouldClearCanvas]) + + // 정답 비눗방울 처리 + useEffect(() => { + if (correctAnswerBubble) { + const participant = room?.participants?.find(p => p.userId === correctAnswerBubble.userId) + setBubbles([{ + id: `bubble-${correctAnswerBubble.timestamp}`, + userId: correctAnswerBubble.userId, + nickname: participant?.nickname || correctAnswerBubble.userId, + content: correctAnswerBubble.content, + isCorrect: true, + }]) + setCorrectAnswerBubble(null) + } + }, [correctAnswerBubble, room?.participants, setCorrectAnswerBubble]) + + // 게임 종료 처리 (WebSocket에서 GAME_END 이벤트 수신 시) + useEffect(() => { + if (gameState.status === 'FINISHED' && !showEndModal) { + console.log('[CatchmindPlayPage] Game finished, showing end modal') + const ranking = Object.entries(gameState.scores || {}) + .sort(([, a], [, b]) => b - a) + .map(([odUserId, score], index) => ({ + rank: index + 1, + userId: odUserId, + nickname: room?.participants?.find(p => p.userId === odUserId)?.nickname || odUserId, + score, + })) + + setFinalRanking(ranking) + setShowEndModal(true) + } + }, [gameState.status, gameState.scores, room?.participants, showEndModal]) + + // 라운드 변경 시 타이머 리셋 및 캔버스 초기화 + useEffect(() => { + if (gameState.currentRound > 1) { + setTimeLeft(gameState.roundTimeLimit || 60) + canvasRef.current?.clear() + } + }, [gameState.currentRound, gameState.roundTimeLimit]) + + // 초기 로드 및 WebSocket 연결 + useEffect(() => { + const initGame = async () => { + try { + setLoading(true) + + // 방 정보 조회 + const roomResponse = await gameService.getRoom(roomId) + setRoom(roomResponse.data) + + // 게임 상태 조회 (이미 시작된 게임인 경우) + let gameData + try { + const statusResponse = await gameService.getGameStatus(roomId) + gameData = statusResponse.data + console.log('[CatchmindPlayPage] Game status from API:', JSON.stringify(gameData, null, 2)) + } catch { + // 게임 상태가 없으면 시작 + const gameResponse = await gameService.startGame(roomId) + gameData = gameResponse.data + console.log('[CatchmindPlayPage] Game start response:', JSON.stringify(gameData, null, 2)) + } + + // 제시어 추출 (다양한 필드명 시도) + const extractedWord = gameData.currentWord?.word + || gameData.currentWord?.korean + || gameData.currentWord + || gameData.word + || gameData.answer + || null + console.log('[CatchmindPlayPage] Extracted word:', extractedWord, 'from gameData.currentWord:', gameData.currentWord) + + setLocalGameState({ + status: 'PLAYING', + currentRound: gameData.currentRound || 1, + totalRounds: gameData.totalRounds || 5, + currentDrawerId: gameData.currentDrawerId, + currentWord: extractedWord, + roundStartTime: gameData.roundStartTime, + roundTimeLimit: gameData.roundDuration || 60, + scores: {}, + hintUsed: false, + hint: null, + }) + + setTimeLeft(gameData.roundDuration || 60) + + // 시스템 메시지 + setMessages([ + { + id: 'system-start', + content: `🎮 게임 시작! 총 ${gameData.totalRounds || 5}라운드`, + isSystem: true, + createdAt: new Date().toISOString(), + }, + { + id: 'system-round', + content: `라운드 ${gameData.currentRound || 1} 시작! 출제자: ${gameData.currentDrawerId}`, + isSystem: true, + createdAt: new Date().toISOString(), + }, + ]) + + // WebSocket 연결 + console.log('[CatchmindPlayPage] Connecting WebSocket...') + await connect() + console.log('[CatchmindPlayPage] WebSocket connected') + } catch (err) { + console.error('Failed to init game:', err) + } finally { + setLoading(false) + } + } + + initGame() + + // 컴포넌트 언마운트 시 WebSocket 연결 해제 + return () => { + console.log('[CatchmindPlayPage] Disconnecting WebSocket...') + disconnect() + } + }, [roomId, currentUserId, user, connect, disconnect]) + + // 라운드 종료 처리 (WebSocket에서 ROUND_END/GAME_END 이벤트를 받으면 자동 처리됨) + // 이 함수는 클라이언트 측 타이머 만료 시 fallback으로만 사용 + const handleRoundEnd = useCallback((answer, reason) => { + // WebSocket 연결 시에는 서버에서 ROUND_END 이벤트를 받으므로 + // 클라이언트 측 라운드 종료 처리는 최소화 + if (isConnected) { + console.log('[CatchmindPlayPage] Round end triggered locally, waiting for server event') + return + } + + const isLastRound = gameState.currentRound >= gameState.totalRounds + + if (isLastRound) { + // 게임 종료 + const ranking = Object.entries(gameState.scores) + .sort(([, a], [, b]) => b - a) + .map(([userId, score], index) => ({ + rank: index + 1, + userId, + nickname: room?.participants?.find(p => p.userId === userId)?.nickname || userId, + score, + })) + + setFinalRanking(ranking) + setShowEndModal(true) + setLocalGameState(prev => ({ ...prev, status: 'FINISHED' })) + + setMessages(prev => [...prev, { + id: `system-end-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + content: '🎮 게임 종료!', + isSystem: true, + createdAt: new Date().toISOString(), + }]) + } else { + // 다음 라운드 (WebSocket 연결 없을 때 fallback) + const nextRound = gameState.currentRound + 1 + const participants = room?.participants || [] + const nextDrawerIndex = (nextRound - 1) % participants.length + const nextDrawer = participants[nextDrawerIndex]?.userId || currentUserId + const uniqueSuffix = Math.random().toString(36).substr(2, 9) + + setMessages(prev => [...prev, { + id: `system-round-end-${Date.now()}-${uniqueSuffix}`, + content: `${reason || '라운드 종료!'} 정답: ${gameState.currentWord || '???'}`, + isSystem: true, + createdAt: new Date().toISOString(), + }]) + + // 캔버스 초기화 + canvasRef.current?.clear() + + setTimeout(() => { + const uniqueSuffix2 = Math.random().toString(36).substr(2, 9) + setLocalGameState(prev => ({ + ...prev, + currentRound: nextRound, + currentDrawerId: nextDrawer, + currentWord: null, + roundStartTime: Date.now(), + hintUsed: false, + hint: null, + })) + + setTimeLeft(gameState.roundTimeLimit) + setIsRoundTransitioning(false) + + setMessages(prev => [...prev, { + id: `system-next-${Date.now()}-${uniqueSuffix2}`, + content: `라운드 ${nextRound} 시작! 출제자: ${nextDrawer}`, + isSystem: true, + createdAt: new Date().toISOString(), + }]) + }, 2000) + } + }, [gameState, room, currentUserId, isConnected]) + + // 타이머 + useEffect(() => { + if (gameState.status !== 'PLAYING') return + if (isRoundTransitioning) return // 라운드 전환 중에는 타이머 중지 + + const interval = setInterval(() => { + setTimeLeft(prev => { + if (prev <= 1) { + // 시간 초과 - 서버에 ROUND_TIMEOUT 전송 + if (isConnected) { + console.log('[CatchmindPlayPage] Timer expired, sending ROUND_TIMEOUT to server') + setIsRoundTransitioning(true) // 라운드 전환 상태로 변경 + // 서버에 타임아웃 알림 전송 + wsSendMessage('', 'ROUND_TIMEOUT') + return 0 // 타이머를 0으로 유지, 서버 ROUND_END 이벤트 대기 + } + // 연결 안 됐으면 로컬 fallback + handleRoundEnd(null, '시간 초과!') + return gameState.roundTimeLimit + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(interval) + }, [gameState.status, gameState.roundTimeLimit, isConnected, isRoundTransitioning, handleRoundEnd, wsSendMessage]) + + // 메시지 전송 (WebSocket으로 전송, 서버에서 정답 체크) + const handleSendMessage = useCallback((content) => { + if (isConnected) { + // WebSocket 연결 시: 서버로 메시지 전송 (GUESS 타입) + // 서버에서 정답 체크 후 CORRECT_ANSWER 또는 일반 메시지로 브로드캐스트 + console.log('[CatchmindPlayPage] Sending message via WebSocket:', content) + wsSendMessage(content, 'GUESS') + + // 오답 비눗방울은 로컬에서 표시 (서버 응답 기다리지 않음) + setBubbles([{ + id: `bubble-${Date.now()}`, + userId: currentUserId, + nickname: user?.nickname || '플레이어', + content, + isCorrect: false, + }]) + } else { + // WebSocket 연결 안 됐을 때: 로컬에서 처리 (fallback) + const newMessage = { + id: `msg-${Date.now()}`, + userId: currentUserId, + nickname: user?.nickname || user?.username || '플레이어', + content, + createdAt: new Date().toISOString(), + } + + // 정답 체크 (로컬 fallback) + const isCorrect = gameState.currentWord && + content.toLowerCase().includes(gameState.currentWord.toLowerCase()) + + if (isCorrect) { + const score = Math.max(10, Math.floor(timeLeft * 0.5) + 10) + + newMessage.isCorrect = true + newMessage.score = score + + // 점수 업데이트 + setLocalGameState(prev => ({ + ...prev, + scores: { + ...prev.scores, + [currentUserId]: (prev.scores[currentUserId] || 0) + score, + }, + })) + + // 비눗방울 (정답) + setBubbles([{ + id: `bubble-${Date.now()}`, + userId: currentUserId, + nickname: user?.nickname || '플레이어', + content, + isCorrect: true, + score, + }]) + + // 시스템 메시지 + setMessages(prev => [...prev, { + id: `system-correct-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + content: `🎉 ${user?.nickname || currentUserId}님이 정답을 맞혔습니다! (+${score}점)`, + isSystem: true, + isCorrect: true, + createdAt: new Date().toISOString(), + }]) + + // 다음 라운드로 + setTimeout(() => { + handleRoundEnd(content, '정답!') + }, 1500) + } else { + newMessage.isWrong = true + + // 비눗방울 (오답) + setBubbles([{ + id: `bubble-${Date.now()}`, + userId: currentUserId, + nickname: user?.nickname || '플레이어', + content, + isCorrect: false, + }]) + } + + setMessages(prev => [...prev, newMessage]) + } + }, [currentUserId, user, gameState.currentWord, timeLeft, handleRoundEnd, isConnected, wsSendMessage]) + + // 그리기 데이터 전송 (WebSocket으로 전송) + const handleDraw = useCallback((strokeData) => { + if (isConnected && canDraw) { + console.log('[CatchmindPlayPage] Sending drawing via WebSocket') + sendDrawing(strokeData) + } + }, [isConnected, canDraw, sendDrawing]) + + // 캔버스 클리어 (WebSocket으로 전송) + const handleClearCanvas = useCallback(() => { + if (isConnected && canDraw) { + console.log('[CatchmindPlayPage] Sending clear canvas via WebSocket') + clearDrawing() + } + }, [isConnected, canDraw, clearDrawing]) + + // 게임 종료 + const handleStopGame = async () => { + try { + disconnect() + await gameService.stopGame(roomId) + sessionStorage.removeItem(`roomToken_${roomId}`) + navigate('/games/catchmind') + } catch (err) { + console.error('Failed to stop game:', err) + } + } + + // 재시작 + const handleRestart = async () => { + try { + disconnect() + await gameService.restartGame(roomId) + setShowEndModal(false) + navigate(`/games/catchmind/${roomId}/waiting`) + } catch (err) { + console.error('Failed to restart:', err) + // 실패해도 대기실로 이동 + setShowEndModal(false) + navigate(`/games/catchmind/${roomId}/waiting`) + } + } + + // 나가기 + const handleLeave = async () => { + try { + disconnect() + await gameService.leaveRoom(roomId) + } catch (err) { + console.error('Failed to leave room:', err) + } + sessionStorage.removeItem(`roomToken_${roomId}`) + navigate('/games/catchmind') + } + + // 타이머 포맷 + const formatTime = (seconds) => { + const min = Math.floor(seconds / 60) + const sec = seconds % 60 + return `${min}:${sec.toString().padStart(2, '0')}` + } + + // 점수 순위 - 모든 참가자를 0점부터 시작하여 표시 + const sortedScores = (room?.participants || []) + .map(participant => ({ + userId: participant.userId, + nickname: participant.nickname || participant.userId, + score: (gameState.scores || {})[participant.userId] || 0, + })) + .sort((a, b) => b.score - a.score) + .map((item, index) => ({ + ...item, + rank: index + 1, + })) + + if (loading) { + return ( + + + + ) + } + + return ( + + {/* 헤더 - 컴팩트 */} + + + + {room?.name || '캐치마인드'} + + + + 출제자: {room?.participants?.find(p => p.userId === gameState.currentDrawerId)?.nickname || gameState.currentDrawerId} + + + + + {/* 타이머 */} + + + {formatTime(timeLeft)} + + + + + + + + + + {/* 타이머 프로그레스 */} + + + {/* WebSocket 에러 */} + {wsError && ( + + {wsError} + + )} + + {/* 제시어 배너 (출제자만) - 컴팩트 */} + {isDrawer && gameState.currentWord && ( + + + 제시어: + + + {gameState.currentWord} + + + )} + + {/* 메인 영역: Split View */} + + {/* 좌측: 캔버스 영역 (65%) */} + + + + {/* 비눗방울 오버레이 */} + + + {/* 힌트 영역 - 컴팩트 */} + {!isDrawer && ( + + + + )} + + + {/* 우측: 채팅 영역 (35%) */} + + {/* 스코어 대시보드 - 컴팩트 */} + + + + + 점수판 + + + + {sortedScores.map((item) => ( + + + + {item.rank === 1 ? '🥇' : item.rank === 2 ? '🥈' : item.rank === 3 ? '🥉' : `${item.rank}.`} + + + {item.nickname} + {item.userId === currentUserId && ' (나)'} + + + + {item.score}점 + + + ))} + + + + {/* 채팅 영역 */} + + + + + + + {/* 게임 종료 모달 */} + + + + + 게임 종료! + + + + + + 최종 순위 + + + + {finalRanking.map((item) => ( + + + + {item.rank === 1 ? '🥇' : item.rank === 2 ? '🥈' : item.rank === 3 ? '🥉' : `${item.rank}위`} + + + {item.nickname} + {item.userId === currentUserId && ' (나)'} + + + + {item.score}점 + + + ))} + + + + + + + + + + ) +} + +export default CatchmindPlayPage diff --git a/src/domains/games/pages/CatchmindWaitingPage.jsx b/src/domains/games/pages/CatchmindWaitingPage.jsx new file mode 100644 index 0000000..7652ef6 --- /dev/null +++ b/src/domains/games/pages/CatchmindWaitingPage.jsx @@ -0,0 +1,403 @@ +import {useCallback, useEffect, useState} from 'react' +import {useNavigate, useParams} from 'react-router-dom' +import { + Alert, + Box, + Button, + Card, + Chip, + CircularProgress, + Container, + IconButton, + Typography, +} from '@mui/material' +import { + ArrowBack as ArrowBackIcon, + PlayArrow as PlayIcon, + Settings as SettingsIcon, +} from '@mui/icons-material' +import ParticipantList from '../components/ParticipantList' +import WaitingChat from '../components/WaitingChat' +import {gameService} from '../services/gameService' +import {GAME_COLORS} from '../theme/gameTheme' +import {useAuth} from '../../../contexts/AuthContext' +import {useChatWebSocket} from '../../freetalk/hooks/useChatWebSocket' + +const CatchmindWaitingPage = () => { + const { roomId } = useParams() + const navigate = useNavigate() + const { user } = useAuth() + const currentUserId = user?.userId || user?.username || user?.sub + + // WebSocket 연결 + const { + isConnected, + messages: wsMessages, + gameState: wsGameState, + error: wsError, + connect, + disconnect, + sendMessage: wsSendMessage, + } = useChatWebSocket(roomId, currentUserId) + + const [room, setRoom] = useState(null) + const [loading, setLoading] = useState(true) + const [isInitialLoad, setIsInitialLoad] = useState(true) + const [error, setError] = useState(null) + const [starting, setStarting] = useState(false) + + // 채팅 메시지 (WebSocket 연동) + const [messages, setMessages] = useState([ + { + id: 'system-1', + content: '게임 대기실에 입장했습니다.', + isSystem: true, + createdAt: new Date().toISOString(), + }, + ]) + + // WebSocket 메시지 동기화 + useEffect(() => { + if (wsMessages && wsMessages.length > 0) { + setMessages(prev => { + // 시스템 메시지는 유지 + const systemMessages = prev.filter(m => m.isSystem && m.id === 'system-1') + // wsMessages에서 중복 제거 (ID 기준) + const existingIds = new Set(systemMessages.map(m => m.id)) + const uniqueWsMessages = wsMessages.filter(m => !existingIds.has(m.id)) + // ID 기준으로 중복 제거된 최종 배열 + const allMessages = [...systemMessages, ...uniqueWsMessages] + // 혹시 wsMessages 내에서도 중복이 있을 수 있으므로 다시 한번 ID 기준 중복 제거 + const seen = new Set() + return allMessages.filter(m => { + if (seen.has(m.id)) return false + seen.add(m.id) + return true + }) + }) + } + }, [wsMessages]) + + // 게임 시작 감지 (WebSocket GAME_START 이벤트) + useEffect(() => { + if (wsGameState?.status === 'PLAYING') { + console.log('[CatchmindWaitingPage] Game started via WebSocket, navigating to play page') + navigate(`/games/catchmind/${roomId}/play`) + } + }, [wsGameState?.status, roomId, navigate]) + + // 방 정보 조회 + const fetchRoom = useCallback(async (showLoading = false) => { + try { + if (showLoading) { + setLoading(true) + } + const response = await gameService.getRoom(roomId) + setRoom(response.data) + + // 게임이 시작되면 플레이 페이지로 이동 + if (response.data.status === 'PLAYING') { + navigate(`/games/catchmind/${roomId}/play`) + } + } catch (err) { + console.error('Failed to fetch room:', err) + setError('방 정보를 불러오는데 실패했습니다') + } finally { + if (showLoading) { + setLoading(false) + } + setIsInitialLoad(false) + } + }, [roomId, navigate]) + + // 초기 로딩 및 WebSocket 연결 + useEffect(() => { + const init = async () => { + await fetchRoom(true) + // WebSocket 연결 + console.log('[CatchmindWaitingPage] Connecting WebSocket...') + try { + await connect() + console.log('[CatchmindWaitingPage] WebSocket connected') + } catch (err) { + console.error('[CatchmindWaitingPage] WebSocket connection failed:', err) + } + } + init() + + // 컴포넌트 언마운트 시 WebSocket 연결 해제 + return () => { + console.log('[CatchmindWaitingPage] Disconnecting WebSocket...') + disconnect() + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // 주기적 새로고침 (로딩 표시 없이 백그라운드 갱신) + useEffect(() => { + const interval = setInterval(() => fetchRoom(false), 3000) + return () => clearInterval(interval) + }, [fetchRoom]) + + // 게임 시작 + const handleStartGame = async () => { + try { + setStarting(true) + await gameService.startGame(roomId) + // WebSocket GAME_START 이벤트로 자동 이동하지만, fallback으로 직접 이동 + navigate(`/games/catchmind/${roomId}/play`) + } catch (err) { + console.error('Failed to start game:', err) + // 백엔드 에러 메시지 추출 + const errorMessage = err.response?.data?.message || err.message || '게임 시작에 실패했습니다' + setError(errorMessage) + } finally { + setStarting(false) + } + } + + // 방 나가기 + const handleLeaveRoom = async () => { + try { + disconnect() + await gameService.leaveRoom(roomId) + // 저장된 roomToken 삭제 + sessionStorage.removeItem(`roomToken_${roomId}`) + navigate('/games/catchmind') + } catch (err) { + console.error('Failed to leave room:', err) + } + } + + // 채팅 메시지 전송 (WebSocket 연동) + const handleSendMessage = (content) => { + if (isConnected) { + // WebSocket으로 메시지 전송 + console.log('[CatchmindWaitingPage] Sending message via WebSocket:', content) + wsSendMessage(content, 'TEXT') + } else { + // WebSocket 연결 안 됐을 때 fallback (로컬만) + const newMessage = { + id: `msg-${Date.now()}`, + userId: currentUserId, + nickname: user?.nickname || user?.username || '플레이어', + content, + createdAt: new Date().toISOString(), + } + setMessages(prev => [...prev, newMessage]) + } + } + + const isHost = room?.hostId === currentUserId + const canStart = isHost && room?.currentParticipants >= 2 + + if (loading) { + return ( + + + + ) + } + + if (!room) { + return ( + + + 방을 찾을 수 없습니다 + + + + ) + } + + return ( + + {/* 헤더 */} + + + + + + + + + + {room.name} + + + + + + + + + + {isHost && ( + + + + )} + + + + + {/* 에러 */} + {(error || wsError) && ( + + setError(null)} sx={{ borderRadius: '12px' }}> + {error || wsError} + + + )} + + {/* 메인 컨텐츠 */} + + + {/* 좌측: 참가자 목록 */} + + + + + {/* 우측: 대기 채팅 */} + + + + 대기 채팅 + + + + + + + + + {/* 하단: 게임 설정 + 시작 버튼 */} + + + + + 라운드 + + + {room.gameSettings?.maxRounds || 5}라운드 + + + + + 시간 제한 + + + {room.gameSettings?.roundTimeLimit || 60}초 + + + + + {isHost ? ( + + ) : ( + + + 방장이 게임을 시작할 때까지 기다려주세요 + + + + )} + + + + ) +} + +export default CatchmindWaitingPage diff --git a/src/domains/games/services/gameService.js b/src/domains/games/services/gameService.js new file mode 100644 index 0000000..394fb20 --- /dev/null +++ b/src/domains/games/services/gameService.js @@ -0,0 +1,336 @@ +/** + * Game Service - 백엔드 API 연동 + * 캐치마인드 게임 관련 REST API 호출 + */ +import chatApi from '../../../api/chatApi' + +/** + * 게임방 관련 API + */ +export const gameRoomService = { + /** + * 게임방 목록 조회 + * @param {Object} filters - 필터 옵션 + * @param {string} filters.status - WAITING, PLAYING, FINISHED + * @param {string} filters.level - BEGINNER, INTERMEDIATE, ADVANCED + * @param {number} filters.limit - 조회 개수 (기본 20) + * @param {string} filters.cursor - 페이지네이션 커서 + */ + getList: async (filters = {}) => { + const params = new URLSearchParams() + // 게임방 필터 + params.append('type', 'GAME') + params.append('gameType', 'CATCHMIND') + + // 백엔드는 소문자 level 값 사용 + if (filters.status) params.append('status', filters.status) + if (filters.level) params.append('level', filters.level.toLowerCase()) + if (filters.limit) params.append('limit', filters.limit || 20) + if (filters.cursor) params.append('cursor', filters.cursor) + + console.log('[gameService] getList params:', params.toString()) + const response = await chatApi.get(`/chat/rooms?${params.toString()}`) + console.log('[gameService] getList full response:', response) + console.log('[gameService] getList data:', response.data) + + // 디버그: 필터 없이 전체 조회 테스트 + const allRoomsResponse = await chatApi.get('/chat/rooms?limit=20') + console.log('[gameService] ALL rooms (no filter):', allRoomsResponse) + + return response.data + }, + + /** + * 게임방 상세 조회 + * @param {string} roomId + */ + getDetail: async (roomId) => { + const response = await chatApi.get(`/chat/rooms/${roomId}`) + return response.data + }, + + /** + * 게임방 생성 + * @param {Object} data + * @param {string} data.name - 방 이름 + * @param {string} data.description - 방 설명 + * @param {string} data.level - BEGINNER, INTERMEDIATE, ADVANCED + * @param {number} data.maxMembers - 최대 인원 (2-10) + * @param {boolean} data.isPrivate - 비공개 여부 + * @param {string} data.password - 비공개 방 비밀번호 + * @param {Object} data.gameSettings - 게임 설정 + */ + create: async (data) => { + const payload = { + name: data.name, + description: data.description || '', + level: (data.level || 'beginner').toLowerCase(), // 백엔드는 소문자 사용 + maxMembers: data.maxParticipants || data.maxMembers || 6, + isPrivate: data.isPrivate || false, + password: data.password, + type: 'GAME', + gameType: 'CATCHMIND', + gameSettings: { + maxRounds: data.maxRounds || 5, + roundTimeLimit: data.roundTimeLimit || 60, + }, + } + + console.log('[gameService] create payload:', payload) + const response = await chatApi.post('/chat/rooms', payload) + console.log('[gameService] create response:', response) + return response.data + }, + + /** + * 게임방 참가 + * @param {string} roomId + * @param {string} password - 비공개 방인 경우 + * @returns {Promise<{room: Object, roomToken: string, tokenExpiresAt: number}>} + */ + join: async (roomId, password) => { + const payload = password ? { password } : {} + const response = await chatApi.post(`/chat/rooms/${roomId}/join`, payload) + return response.data + }, + + /** + * 게임방 나가기 + * @param {string} roomId + */ + leave: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/leave`, {}) + return response.data + }, +} + +/** + * 게임 진행 관련 API + */ +export const gamePlayService = { + /** + * 게임 시작 + * @param {string} roomId + */ + start: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/game/start`, {}) + return response.data + }, + + /** + * 게임 중단 + * @param {string} roomId + */ + stop: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/game/stop`, {}) + return response.data + }, + + /** + * 게임 상태 조회 + * @param {string} roomId + */ + getStatus: async (roomId) => { + const response = await chatApi.get(`/chat/rooms/${roomId}/game/status`) + return response.data + }, + + /** + * 점수 조회 + * @param {string} roomId + */ + getScores: async (roomId) => { + const response = await chatApi.get(`/chat/rooms/${roomId}/game/scores`) + return response.data + }, + + /** + * 게임 재시작 + * @param {string} roomId + */ + restart: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/game/restart`, {}) + return response.data + }, +} + +/** + * 통합 게임 서비스 (mockGameService 호환 인터페이스) + */ +export const gameService = { + // 방 목록 조회 + getRooms: async (filters = {}) => { + try { + const data = await gameRoomService.getList(filters) + console.log('[gameService] getRooms data:', data) + // 백엔드 응답: { rooms: [...], nextCursor, hasMore } 또는 직접 배열 + const rooms = Array.isArray(data) ? data : (data?.rooms || []) + return { + success: true, + data: { + rooms, + totalCount: rooms.length, + nextCursor: data?.nextCursor, + hasMore: data?.hasMore, + }, + } + } catch (error) { + console.error('[gameService] getRooms error:', error) + throw error + } + }, + + // 방 상세 조회 + getRoom: async (roomId) => { + try { + const data = await gameRoomService.getDetail(roomId) + // 백엔드 응답을 프론트엔드 형식으로 변환 + const room = data.room || data + return { + success: true, + data: { + roomId: room.roomId, + name: room.name, + description: room.description, + type: room.type, + gameType: room.gameType, + level: room.level, + status: room.status, + hostId: room.hostId, + hostNickname: data.hostNickname, + maxParticipants: room.maxMembers, + currentParticipants: room.currentMembers, + participants: data.participants || room.memberIds?.map(id => ({ userId: id })) || [], + gameSettings: room.gameSettings || { maxRounds: 5, roundTimeLimit: 60 }, + createdAt: room.createdAt, + }, + } + } catch (error) { + console.error('[gameService] getRoom error:', error) + throw error + } + }, + + // 방 생성 + createRoom: async (data) => { + try { + const response = await gameRoomService.create(data) + console.log('[gameService] createRoom response:', response) + // 백엔드 응답 구조에 따라 room 추출 + const room = response?.roomId ? response : response + return { + success: true, + data: { + ...room, + // 프론트엔드 필드명 맞추기 + maxParticipants: room.maxMembers || room.maxParticipants, + currentParticipants: room.currentMembers || room.currentParticipants || 1, + }, + } + } catch (error) { + console.error('[gameService] createRoom error:', error) + throw error + } + }, + + // 방 참가 + joinRoom: async (roomId, password) => { + try { + const data = await gameRoomService.join(roomId, password) + return { + success: true, + data: { + room: data.room, + roomToken: data.roomToken, + tokenExpiresAt: data.tokenExpiresAt, + }, + } + } catch (error) { + console.error('[gameService] joinRoom error:', error) + throw error + } + }, + + // 방 나가기 + leaveRoom: async (roomId) => { + try { + await gameRoomService.leave(roomId) + return { success: true } + } catch (error) { + console.error('[gameService] leaveRoom error:', error) + throw error + } + }, + + // 게임 시작 + startGame: async (roomId) => { + try { + const data = await gamePlayService.start(roomId) + return { + success: true, + data, + } + } catch (error) { + console.error('[gameService] startGame error:', error) + throw error + } + }, + + // 게임 중단 + stopGame: async (roomId) => { + try { + const data = await gamePlayService.stop(roomId) + return { + success: true, + data, + } + } catch (error) { + console.error('[gameService] stopGame error:', error) + throw error + } + }, + + // 게임 상태 조회 + getGameStatus: async (roomId) => { + try { + const data = await gamePlayService.getStatus(roomId) + return { + success: true, + data, + } + } catch (error) { + console.error('[gameService] getGameStatus error:', error) + throw error + } + }, + + // 점수 조회 + getScores: async (roomId) => { + try { + const data = await gamePlayService.getScores(roomId) + return { + success: true, + data, + } + } catch (error) { + console.error('[gameService] getScores error:', error) + throw error + } + }, + + // 게임 재시작 + restartGame: async (roomId) => { + try { + const data = await gamePlayService.restart(roomId) + return { + success: true, + data, + } + } catch (error) { + console.error('[gameService] restartGame error:', error) + throw error + } + }, +} + +export default gameService diff --git a/src/domains/games/services/mockGameService.js b/src/domains/games/services/mockGameService.js new file mode 100644 index 0000000..ea08eda --- /dev/null +++ b/src/domains/games/services/mockGameService.js @@ -0,0 +1,321 @@ +/** + * Mock Game Service + * 백엔드 API 구현 전 테스트용 목 서비스 + */ + +// 목 데이터 +const mockRooms = [ + { + roomId: 'game-room-1', + name: '초보 환영방', + description: '편하게 오세요~', + type: 'GAME', + gameType: 'CATCHMIND', + level: 'BEGINNER', + status: 'WAITING', + hostId: 'user-1', + hostNickname: '홍길동', + maxParticipants: 6, + currentParticipants: 2, + participants: [ + { userId: 'user-1', nickname: '홍길동', isHost: true }, + { userId: 'user-2', nickname: '김철수', isHost: false }, + ], + gameSettings: { + maxRounds: 5, + roundTimeLimit: 60, + }, + createdAt: new Date().toISOString(), + }, + { + roomId: 'game-room-2', + name: '고수만 오세요', + description: '빠른 게임 진행', + type: 'GAME', + gameType: 'CATCHMIND', + level: 'ADVANCED', + status: 'PLAYING', + hostId: 'user-3', + hostNickname: '이영희', + maxParticipants: 4, + currentParticipants: 4, + participants: [ + { userId: 'user-3', nickname: '이영희', isHost: true }, + { userId: 'user-4', nickname: '박민수', isHost: false }, + { userId: 'user-5', nickname: '정수진', isHost: false }, + { userId: 'user-6', nickname: '최동욱', isHost: false }, + ], + gameSettings: { + maxRounds: 3, + roundTimeLimit: 45, + }, + createdAt: new Date(Date.now() - 600000).toISOString(), + }, + { + roomId: 'game-room-3', + name: '아무나 ㄱㄱ', + description: '', + type: 'GAME', + gameType: 'CATCHMIND', + level: 'INTERMEDIATE', + status: 'WAITING', + hostId: 'user-7', + hostNickname: '강지훈', + maxParticipants: 6, + currentParticipants: 1, + participants: [ + { userId: 'user-7', nickname: '강지훈', isHost: true }, + ], + gameSettings: { + maxRounds: 5, + roundTimeLimit: 60, + }, + createdAt: new Date(Date.now() - 300000).toISOString(), + }, +] + +const mockWords = [ + '사과', '바나나', '호랑이', '자동차', '비행기', + '컴퓨터', '햄버거', '피자', '축구', '농구', + '기타', '피아노', '나비', '꽃', '태양', +] + +// 지연 시뮬레이션 +const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)) + +// 현재 사용자 (테스트용) +let currentUserId = 'test-user' +let currentUserNickname = '테스트유저' + +export const mockGameService = { + // 현재 사용자 설정 (테스트용) + setCurrentUser: (userId, nickname) => { + currentUserId = userId + currentUserNickname = nickname + }, + + // 방 목록 조회 + getRooms: async (filters = {}) => { + await delay(300) + + let rooms = [...mockRooms] + + if (filters.status) { + rooms = rooms.filter(r => r.status === filters.status) + } + if (filters.level) { + rooms = rooms.filter(r => r.level === filters.level) + } + + return { + success: true, + data: { + rooms, + totalCount: rooms.length, + }, + } + }, + + // 방 상세 조회 + getRoom: async (roomId) => { + await delay(200) + + const room = mockRooms.find(r => r.roomId === roomId) + if (!room) { + throw new Error('방을 찾을 수 없습니다') + } + + return { + success: true, + data: room, + } + }, + + // 방 생성 + createRoom: async (data) => { + await delay(500) + + const newRoom = { + roomId: `game-room-${Date.now()}`, + name: data.name, + description: data.description || '', + type: 'GAME', + gameType: 'CATCHMIND', + level: data.level || 'BEGINNER', + status: 'WAITING', + hostId: currentUserId, + hostNickname: currentUserNickname, + maxParticipants: data.maxParticipants || 6, + currentParticipants: 1, + participants: [ + { userId: currentUserId, nickname: currentUserNickname, isHost: true }, + ], + gameSettings: { + maxRounds: data.maxRounds || 5, + roundTimeLimit: data.roundTimeLimit || 60, + }, + createdAt: new Date().toISOString(), + } + + mockRooms.unshift(newRoom) + + return { + success: true, + data: newRoom, + } + }, + + // 방 참가 + joinRoom: async (roomId) => { + await delay(300) + + const room = mockRooms.find(r => r.roomId === roomId) + if (!room) { + throw new Error('방을 찾을 수 없습니다') + } + + if (room.currentParticipants >= room.maxParticipants) { + throw new Error('방이 가득 찼습니다') + } + + if (room.status === 'PLAYING') { + throw new Error('게임이 진행 중입니다') + } + + // 이미 참가 중인지 확인 + const alreadyJoined = room.participants.some(p => p.userId === currentUserId) + if (!alreadyJoined) { + room.participants.push({ + userId: currentUserId, + nickname: currentUserNickname, + isHost: false, + }) + room.currentParticipants++ + } + + return { + success: true, + data: { + room, + roomToken: `mock-token-${roomId}-${Date.now()}`, + }, + } + }, + + // 방 나가기 + leaveRoom: async (roomId) => { + await delay(200) + + const room = mockRooms.find(r => r.roomId === roomId) + if (!room) { + throw new Error('방을 찾을 수 없습니다') + } + + const participantIndex = room.participants.findIndex(p => p.userId === currentUserId) + if (participantIndex !== -1) { + const wasHost = room.participants[participantIndex].isHost + room.participants.splice(participantIndex, 1) + room.currentParticipants-- + + // 방장이 나가면 다음 사람이 방장 + if (wasHost && room.participants.length > 0) { + room.participants[0].isHost = true + room.hostId = room.participants[0].userId + room.hostNickname = room.participants[0].nickname + } + + // 모두 나가면 방 삭제 + if (room.participants.length === 0) { + const roomIndex = mockRooms.findIndex(r => r.roomId === roomId) + if (roomIndex !== -1) { + mockRooms.splice(roomIndex, 1) + } + } + } + + return { success: true } + }, + + // 게임 시작 + startGame: async (roomId) => { + await delay(300) + + const room = mockRooms.find(r => r.roomId === roomId) + if (!room) { + throw new Error('방을 찾을 수 없습니다') + } + + if (room.hostId !== currentUserId) { + throw new Error('방장만 게임을 시작할 수 있습니다') + } + + if (room.currentParticipants < 2) { + throw new Error('최소 2명이 필요합니다') + } + + room.status = 'PLAYING' + + const drawerOrder = room.participants.map(p => p.userId) + const randomWord = mockWords[Math.floor(Math.random() * mockWords.length)] + + return { + success: true, + data: { + gameSessionId: `session-${Date.now()}`, + roomId, + status: 'PLAYING', + currentRound: 1, + totalRounds: room.gameSettings.maxRounds, + currentDrawerId: drawerOrder[0], + drawerOrder, + roundStartTime: Date.now(), + serverTime: Date.now(), + roundDuration: room.gameSettings.roundTimeLimit, + currentWord: drawerOrder[0] === currentUserId ? { word: randomWord } : null, + }, + } + }, + + // 게임 종료 + stopGame: async (roomId) => { + await delay(200) + + const room = mockRooms.find(r => r.roomId === roomId) + if (room) { + room.status = 'FINISHED' + } + + return { + success: true, + data: { + roomId, + status: 'FINISHED', + reason: 'STOPPED', + }, + } + }, + + // 게임 재시작 + restartGame: async (roomId) => { + await delay(300) + + const room = mockRooms.find(r => r.roomId === roomId) + if (!room) { + throw new Error('방을 찾을 수 없습니다') + } + + room.status = 'WAITING' + + return { + success: true, + data: room, + } + }, + + // 랜덤 단어 가져오기 (테스트용) + getRandomWord: () => { + return mockWords[Math.floor(Math.random() * mockWords.length)] + }, +} + +export default mockGameService diff --git a/src/domains/games/theme/gameTheme.js b/src/domains/games/theme/gameTheme.js new file mode 100644 index 0000000..c2d2322 --- /dev/null +++ b/src/domains/games/theme/gameTheme.js @@ -0,0 +1,184 @@ +/** + * Game Design System + * 캐치마인드 게임 전용 디자인 토큰 + */ + +export const GAME_COLORS = { + // 브랜드 + primary: '#8B5CF6', + primaryLight: '#A78BFA', + primaryBg: '#F3E8FF', + + // 게임 상태 + status: { + waiting: '#10B981', + waitingBg: '#D1FAE5', + playing: '#F59E0B', + playingBg: '#FEF3C7', + finished: '#6B7280', + finishedBg: '#F3F4F6', + }, + + // 정답/오답 + answer: { + correct: '#F59E0B', + correctBg: '#FEF3C7', + correctText: '#92400E', + wrong: '#EF4444', + wrongBg: '#FEE2E2', + }, + + // 타이머 + timer: { + normal: '#10B981', + warning: '#F59E0B', + danger: '#EF4444', + }, + + // 캔버스 + canvas: { + border: '#E5E7EB', + borderActive: '#10B981', + borderDanger: '#EF4444', + background: '#FFFFFF', + }, + + // 비눗방울 + bubble: { + normal: 'linear-gradient(135deg, rgba(224,242,254,0.95) 0%, rgba(186,230,253,0.9) 100%)', + correct: 'linear-gradient(135deg, rgba(254,243,199,0.95) 0%, rgba(253,230,138,0.9) 100%)', + }, + + // 순위 + rank: { + gold: '#F59E0B', + silver: '#9CA3AF', + bronze: '#CD7F32', + }, +} + +export const GAME_TYPOGRAPHY = { + word: { + fontFamily: 'Pretendard, -apple-system, sans-serif', + fontWeight: 700, + fontSize: '28px', + }, + timer: { + fontFamily: '"JetBrains Mono", "Fira Code", monospace', + fontWeight: 600, + fontSize: '24px', + }, + score: { + fontFamily: '"JetBrains Mono", "Fira Code", monospace', + fontWeight: 700, + fontSize: '16px', + }, + chat: { + fontFamily: 'Pretendard, -apple-system, sans-serif', + fontWeight: 400, + fontSize: '14px', + }, +} + +export const GAME_LAYOUT = { + splitView: { + canvas: '65%', + chat: '35%', + }, + canvas: { + width: 640, + height: 320, + }, + spacing: { + header: 36, + toolbar: 32, + gap: 6, + }, +} + +export const GAME_ANIMATIONS = { + bubble: { + duration: '3s', + easing: 'ease-out', + }, + timerPulse: { + duration: '1s', + easing: 'ease-in-out', + }, + transition: { + duration: '0.3s', + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + }, +} + +// MUI sx props에서 사용할 수 있는 스타일 객체들 +export const gameStyles = { + // 게임 상태 뱃지 + statusBadge: (status) => ({ + px: 1.5, + py: 0.5, + borderRadius: '12px', + fontSize: '0.75rem', + fontWeight: 600, + bgcolor: GAME_COLORS.status[`${status}Bg`] || GAME_COLORS.status.waitingBg, + color: GAME_COLORS.status[status] || GAME_COLORS.status.waiting, + }), + + // 타이머 스타일 + timer: (seconds) => ({ + fontFamily: GAME_TYPOGRAPHY.timer.fontFamily, + fontWeight: GAME_TYPOGRAPHY.timer.fontWeight, + fontSize: GAME_TYPOGRAPHY.timer.fontSize, + color: seconds <= 10 + ? GAME_COLORS.timer.danger + : seconds <= 30 + ? GAME_COLORS.timer.warning + : GAME_COLORS.timer.normal, + animation: seconds <= 10 ? 'pulse 1s ease-in-out infinite' : 'none', + }), + + // 캔버스 컨테이너 + canvasContainer: (isActive, isDanger) => ({ + border: '3px solid', + borderColor: isDanger + ? GAME_COLORS.canvas.borderDanger + : isActive + ? GAME_COLORS.canvas.borderActive + : GAME_COLORS.canvas.border, + borderRadius: '12px', + overflow: 'hidden', + bgcolor: GAME_COLORS.canvas.background, + transition: `border-color ${GAME_ANIMATIONS.transition.duration} ${GAME_ANIMATIONS.transition.easing}`, + }), + + // 정답 메시지 + correctMessage: { + bgcolor: GAME_COLORS.answer.correctBg, + color: GAME_COLORS.answer.correctText, + fontWeight: 700, + borderRadius: '8px', + px: 1.5, + py: 0.75, + }, + + // 순위 아이콘 + rankIcon: (rank) => { + const colors = { + 1: GAME_COLORS.rank.gold, + 2: GAME_COLORS.rank.silver, + 3: GAME_COLORS.rank.bronze, + } + return { + color: colors[rank] || 'text.secondary', + fontWeight: 700, + } + }, +} + +export default { + GAME_COLORS, + GAME_TYPOGRAPHY, + GAME_LAYOUT, + GAME_ANIMATIONS, + gameStyles, +} diff --git a/src/domains/grammar/components/SessionSidebar.jsx b/src/domains/grammar/components/SessionSidebar.jsx index 5282ba6..79dd774 100644 --- a/src/domains/grammar/components/SessionSidebar.jsx +++ b/src/domains/grammar/components/SessionSidebar.jsx @@ -170,8 +170,9 @@ export default function SessionSidebar({ > + {session.lastMessage || `${session.messageCount} ${isKorean ? '메시지' : 'messages'}`} - + {formatDate(session.updatedAt)} diff --git a/src/i18n/translations.js b/src/i18n/translations.js index a7cd430..6ef4b88 100644 --- a/src/i18n/translations.js +++ b/src/i18n/translations.js @@ -375,6 +375,14 @@ export const translations = { ALL_BADGES: '마스터', }, }, + + // Games + games: { + title: '게임', + description: '재미있는 게임으로 영어 실력을 향상하세요', + catchmindTitle: '캐치마인드', + catchmindDesc: '그림 맞추기 게임', + }, }, en: { @@ -748,6 +756,14 @@ export const translations = { ALL_BADGES: 'Master', }, }, + + // Games + games: { + title: 'Games', + description: 'Improve your English with fun games', + catchmindTitle: 'Catchmind', + catchmindDesc: 'Drawing guessing game', + }, }, } diff --git a/src/layouts/MainLayout/HorizontalNav/index.jsx b/src/layouts/MainLayout/HorizontalNav/index.jsx index b096d2d..442d4fb 100644 --- a/src/layouts/MainLayout/HorizontalNav/index.jsx +++ b/src/layouts/MainLayout/HorizontalNav/index.jsx @@ -15,6 +15,7 @@ import { School as LearnIcon, Settings as SettingsIcon, SmartToy as AiIcon, + SportsEsports as GameIcon, TrendingUp as TrendingIcon, } from '@mui/icons-material' import {useThemeMode} from '../../../contexts/ThemeContext' @@ -119,6 +120,21 @@ const HorizontalNav = () => { }, ], }, + { + id: 'games', + label: t('games.title'), + icon: GameIcon, + color: '#8b5cf6', + children: [ + { + id: 'catchmind', + label: t('games.catchmindTitle'), + icon: GameIcon, + path: '/games/catchmind', + desc: t('games.catchmindDesc') + }, + ], + }, { id: 'dashboard', label: t('nav.dashboard'), diff --git a/src/layouts/MainLayout/Sidebar/index.jsx b/src/layouts/MainLayout/Sidebar/index.jsx index 7a944c1..a522653 100644 --- a/src/layouts/MainLayout/Sidebar/index.jsx +++ b/src/layouts/MainLayout/Sidebar/index.jsx @@ -34,6 +34,7 @@ import { School as LearnIcon, Settings as SettingsIcon, SmartToy as AiIcon, + SportsEsports as GameIcon, TrendingUp as TrendingIcon, } from '@mui/icons-material' import {useThemeMode} from '../../../contexts/ThemeContext' @@ -52,7 +53,7 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => { const [expandedMenus, setExpandedMenus] = useState(() => { const saved = localStorage.getItem('expandedMenus') - return saved ? JSON.parse(saved) : {speaking: true, writing: true, vocab: true} + return saved ? JSON.parse(saved) : {speaking: true, writing: true, vocab: true, games: true} }) useEffect(() => { @@ -146,6 +147,22 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => { }, ], }, + { + id: 'games', + label: t('games.title'), + icon: GameIcon, + color: '#8b5cf6', + bgColor: '#f3e8ff', + children: [ + { + id: 'catchmind', + label: t('games.catchmindTitle'), + icon: GameIcon, + path: '/games/catchmind', + description: t('games.catchmindDesc'), + }, + ], + }, ], }, {