diff --git a/src/App.jsx b/src/App.jsx index e8a4800..ab94f58 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -32,6 +32,9 @@ 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 WordchainLobbyPage from './domains/games/pages/WordchainLobbyPage' +import WordchainWaitingPage from './domains/games/pages/WordchainWaitingPage' +import WordchainPlayPage from './domains/games/pages/WordchainPlayPage' import { NewsListPage, NewsDetailPage, NewsQuizPage, NewsWordsPage, NewsStatsPage } from './domains/news' import { dailyService, statsService } from './domains/vocab/services/vocabService' import { getNewsStats, getDashboardStats } from './domains/news/services/newsService' @@ -269,6 +272,13 @@ function Dashboard() { path: '/games/catchmind', description: t('games.catchmindDesc') }, + { + id: 'wordchain', + title: '끝말잇기', + icon: GameIcon, + path: '/games/wordchain', + description: '영어 끝말잇기' + }, ], }, ] @@ -1176,6 +1186,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/domains/freetalk/services/chatWebSocketService.js b/src/domains/freetalk/services/chatWebSocketService.js index c670394..f8fbe75 100644 --- a/src/domains/freetalk/services/chatWebSocketService.js +++ b/src/domains/freetalk/services/chatWebSocketService.js @@ -213,6 +213,31 @@ class ChatWebSocketConnection { // 추측 메시지 - 일반 메시지로 처리 this.callbacks.onMessage?.(data) break + case 'wordchain_start': + case 'WORDCHAIN_START': + console.log('[ChatWebSocket] Wordchain start received:', data) + this.callbacks.onWordchainStart?.(data) + break + case 'wordchain_correct': + case 'WORDCHAIN_CORRECT': + console.log('[ChatWebSocket] Wordchain correct received:', data) + this.callbacks.onWordchainCorrect?.(data) + break + case 'wordchain_wrong': + case 'WORDCHAIN_WRONG': + console.log('[ChatWebSocket] Wordchain wrong received:', data) + this.callbacks.onWordchainWrong?.(data) + break + case 'wordchain_timeout': + case 'WORDCHAIN_TIMEOUT': + console.log('[ChatWebSocket] Wordchain timeout received:', data) + this.callbacks.onWordchainTimeout?.(data) + break + case 'wordchain_end': + case 'WORDCHAIN_END': + console.log('[ChatWebSocket] Wordchain end received:', data) + this.callbacks.onWordchainEnd?.(data) + break default: console.log('[ChatWebSocket] Unknown message type:', type || messageType, data) this.callbacks.onMessage?.(data) diff --git a/src/domains/games/components/wordchain/GameEndModal.jsx b/src/domains/games/components/wordchain/GameEndModal.jsx new file mode 100644 index 0000000..abcb3eb --- /dev/null +++ b/src/domains/games/components/wordchain/GameEndModal.jsx @@ -0,0 +1,158 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, +} from '@mui/material' +import { + EmojiEvents as TrophyIcon, + ExitToApp as ExitIcon, + Replay as ReplayIcon, +} from '@mui/icons-material' +import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme' + +/** + * GameEndModal - 게임 종료 모달 + */ +const GameEndModal = ({ open, winner, finalPlayers, onRestart, onExit, currentUserId }) => { + const sortedPlayers = [...(finalPlayers || [])] + .sort((a, b) => { + // 생존자가 우선 + if (a.isAlive && !b.isAlive) return -1 + if (!a.isAlive && b.isAlive) return 1 + // 제출한 단어 수로 정렬 + return (b.wordsSubmitted || 0) - (a.wordsSubmitted || 0) + }) + + return ( + + + + + 게임 종료! + + + + + {winner && ( + + + 우승자 + + + {winner.nickname || winner.userId} + {winner.userId === currentUserId && ' (나)'} + + + )} + + + 최종 순위 + + + + {sortedPlayers.map((player, index) => ( + + + + {player.isAlive ? ( + index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `${index + 1}위` + ) : '❌'} + + + {player.nickname || player.userId} + {player.userId === currentUserId && ' (나)'} + + + + {player.wordsSubmitted || 0}단어 + + + ))} + + + + + + + + + ) +} + +export default GameEndModal diff --git a/src/domains/games/components/wordchain/PlayerList.jsx b/src/domains/games/components/wordchain/PlayerList.jsx new file mode 100644 index 0000000..2e31812 --- /dev/null +++ b/src/domains/games/components/wordchain/PlayerList.jsx @@ -0,0 +1,89 @@ +import { Box, Typography, Avatar } from '@mui/material' +import { CheckCircle as CheckIcon } from '@mui/icons-material' +import { GAME_COLORS } from '../../theme/gameTheme' +import { useThemeMode } from '../../../../contexts/ThemeContext' + +/** + * PlayerList - 플레이어 목록 (턴 & 생존 상태) + */ +const PlayerList = ({ players, currentTurnUserId, currentUserId }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + return ( + + + 플레이어 + + + {players.map((player) => { + const isCurrentTurn = player.userId === currentTurnUserId + const isMe = player.userId === currentUserId + const isAlive = player.isAlive !== false + + return ( + + + {player.nickname?.[0] || player.userId?.[0] || '?'} + + + + {player.nickname || player.userId} + {isMe && ' (나)'} + + {isCurrentTurn && ( + + 턴 진행 중 + + )} + + {!isAlive && ( + + 탈락 + + )} + {isAlive && !isCurrentTurn && player.hasAnswered && ( + + )} + + ) + })} + + + ) +} + +export default PlayerList diff --git a/src/domains/games/components/wordchain/UsedWordsList.jsx b/src/domains/games/components/wordchain/UsedWordsList.jsx new file mode 100644 index 0000000..c366ec4 --- /dev/null +++ b/src/domains/games/components/wordchain/UsedWordsList.jsx @@ -0,0 +1,49 @@ +import { Box, Typography, Chip } from '@mui/material' +import { GAME_COLORS } from '../../theme/gameTheme' + +/** + * UsedWordsList - 사용된 단어 목록 + */ +const UsedWordsList = ({ words }) => { + return ( + + + 사용된 단어 ({words.length}) + + + {words.length === 0 ? ( + + 아직 사용된 단어가 없습니다 + + ) : ( + words.map((wordData, index) => ( + + )) + )} + + + ) +} + +export default UsedWordsList diff --git a/src/domains/games/components/wordchain/WordDisplay.jsx b/src/domains/games/components/wordchain/WordDisplay.jsx new file mode 100644 index 0000000..38a310e --- /dev/null +++ b/src/domains/games/components/wordchain/WordDisplay.jsx @@ -0,0 +1,73 @@ +import { Box, Typography } from '@mui/material' +import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme' +import { useThemeMode } from '../../../../contexts/ThemeContext' + +/** + * WordDisplay - 현재 단어 & 다음 글자 표시 + */ +const WordDisplay = ({ currentWord, nextLetter, isMyTurn }) => { + const { mode } = useThemeMode() + const isDark = mode === 'dark' + + return ( + + {currentWord ? ( + <> + + 현재 단어 + + + {currentWord} + + + + 다음 시작 글자: + + + {nextLetter} + + + + ) : ( + + 첫 단어를 기다리는 중... + + )} + + ) +} + +export default WordDisplay diff --git a/src/domains/games/components/wordchain/WordInput.jsx b/src/domains/games/components/wordchain/WordInput.jsx new file mode 100644 index 0000000..c6d9587 --- /dev/null +++ b/src/domains/games/components/wordchain/WordInput.jsx @@ -0,0 +1,96 @@ +import { useState } from 'react' +import { Box, TextField, Button, Typography } from '@mui/material' +import { Send as SendIcon } from '@mui/icons-material' +import { GAME_COLORS } from '../../theme/gameTheme' + +/** + * WordInput - 단어 입력 필드 + */ +const WordInput = ({ onSubmit, disabled, nextLetter, isMyTurn }) => { + const [word, setWord] = useState('') + const [error, setError] = useState('') + + const handleSubmit = (e) => { + e.preventDefault() + + const trimmedWord = word.trim().toLowerCase() + + // 유효성 검사 + if (!trimmedWord) { + setError('단어를 입력하세요') + return + } + + if (nextLetter && trimmedWord[0] !== nextLetter.toLowerCase()) { + setError(`단어는 '${nextLetter}'로 시작해야 합니다`) + return + } + + // 알파벳만 허용 + if (!/^[a-z]+$/.test(trimmedWord)) { + setError('영어 알파벳만 입력하세요') + return + } + + setError('') + onSubmit(trimmedWord) + setWord('') + } + + const handleChange = (e) => { + setWord(e.target.value) + setError('') + } + + return ( + + {!isMyTurn && ( + + 다른 플레이어의 턴입니다 + + )} + + + + + + ) +} + +export default WordInput diff --git a/src/domains/games/components/wordchain/WordchainTimer.jsx b/src/domains/games/components/wordchain/WordchainTimer.jsx new file mode 100644 index 0000000..aac71cf --- /dev/null +++ b/src/domains/games/components/wordchain/WordchainTimer.jsx @@ -0,0 +1,59 @@ +import { Box, CircularProgress, Typography } from '@mui/material' +import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme' + +/** + * WordchainTimer - 원형 타이머 + */ +const WordchainTimer = ({ timeLeft, timeLimit }) => { + const percentage = (timeLeft / timeLimit) * 100 + const isDanger = timeLeft <= 5 + + return ( + + + + + {timeLeft} + + + + ) +} + +export default WordchainTimer diff --git a/src/domains/games/pages/WordchainLobbyPage.jsx b/src/domains/games/pages/WordchainLobbyPage.jsx new file mode 100644 index 0000000..a4b8542 --- /dev/null +++ b/src/domains/games/pages/WordchainLobbyPage.jsx @@ -0,0 +1,312 @@ +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, + Link as LinkIcon, + 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 WordchainLobbyPage = () => { + 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, + }) + + // 방 목록 조회 - gameType을 WORDCHAIN으로 필터링 + 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) + // WORDCHAIN 타입 필터링 + const wordchainRooms = (response.data.rooms || []).filter( + room => room.gameType === 'WORDCHAIN' + ) + setRooms(wordchainRooms) + } catch (err) { + console.error('Failed to fetch rooms:', err) + setError('방 목록을 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + }, [filters]) + + useEffect(() => { + fetchRooms() + }, [fetchRooms]) + + // 방 생성 + const handleCreateRoom = async (data) => { + try { + setCreating(true) + // gameType을 WORDCHAIN으로 설정 + const response = await gameService.createRoom({ + ...data, + gameType: 'WORDCHAIN', + }) + setCreateModalOpen(false) + // 생성된 방의 대기실로 이동 + navigate(`/games/wordchain/${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/wordchain/${room.roomId}/waiting`) + } catch (err) { + console.error('Failed to join room:', err) + setError(err.message || '방 참가에 실패했습니다') + } + } + + // 관전 + const handleSpectateRoom = (room) => { + navigate(`/games/wordchain/${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} + gameType="WORDCHAIN" + /> + + ) +} + +export default WordchainLobbyPage diff --git a/src/domains/games/pages/WordchainPlayPage.jsx b/src/domains/games/pages/WordchainPlayPage.jsx new file mode 100644 index 0000000..383d656 --- /dev/null +++ b/src/domains/games/pages/WordchainPlayPage.jsx @@ -0,0 +1,400 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Container, + Grid, + IconButton, + Typography, +} from '@mui/material' +import { + ExitToApp as ExitIcon, + Stop as StopIcon, +} from '@mui/icons-material' +import WordDisplay from '../components/wordchain/WordDisplay' +import WordchainTimer from '../components/wordchain/WordchainTimer' +import PlayerList from '../components/wordchain/PlayerList' +import UsedWordsList from '../components/wordchain/UsedWordsList' +import WordInput from '../components/wordchain/WordInput' +import GameEndModal from '../components/wordchain/GameEndModal' +import { gameService } from '../services/gameService' +import wordchainService from '../services/wordchainService' +import { GAME_COLORS } from '../theme/gameTheme' +import { useAuth } from '../../../contexts/AuthContext' +import { useThemeMode } from '../../../contexts/ThemeContext' +import { useChatWebSocket } from '../../freetalk/hooks/useChatWebSocket' + +const WordchainPlayPage = () => { + const { roomId } = useParams() + const navigate = useNavigate() + const { user } = useAuth() + const { mode } = useThemeMode() + const isDark = mode === 'dark' + const currentUserId = user?.userId || user?.username || user?.sub + + // WebSocket 연결 + const { + isConnected, + gameState: wsGameState, + error: wsError, + connect, + disconnect, + } = useChatWebSocket(roomId, currentUserId) + + // 로컬 게임 상태 + const [gameState, setGameState] = useState({ + status: 'PLAYING', + currentWord: null, + nextLetter: null, + currentTurnUserId: null, + turnStartTime: Date.now(), + turnTimeLimit: 15, + players: [], + usedWords: [], + winner: null, + }) + + const [room, setRoom] = useState(null) + const [loading, setLoading] = useState(true) + const [timeLeft, setTimeLeft] = useState(15) + const [showEndModal, setShowEndModal] = useState(false) + const [submitting, setSubmitting] = useState(false) + + const isMyTurn = gameState.currentTurnUserId === currentUserId + + // WebSocket gameState 업데이트 반영 + useEffect(() => { + if (wsGameState) { + console.log('[WordchainPlayPage] Received wsGameState:', wsGameState) + + setGameState(prev => ({ + ...prev, + status: wsGameState.status ?? prev.status, + currentWord: wsGameState.currentWord ?? prev.currentWord, + nextLetter: wsGameState.nextLetter ?? prev.nextLetter, + currentTurnUserId: wsGameState.currentTurnUserId ?? prev.currentTurnUserId, + turnStartTime: wsGameState.turnStartTime ?? prev.turnStartTime, + turnTimeLimit: wsGameState.turnTimeLimit ?? prev.turnTimeLimit ?? 15, + players: wsGameState.players ?? prev.players, + usedWords: wsGameState.usedWords ?? prev.usedWords, + winner: wsGameState.winner ?? prev.winner, + })) + + // 턴이 변경되면 타이머 리셋 + if (wsGameState.currentTurnUserId && wsGameState.currentTurnUserId !== prev.currentTurnUserId) { + setTimeLeft(wsGameState.turnTimeLimit ?? 15) + } + + // 게임 종료 처리 + if (wsGameState.status === 'FINISHED' && !showEndModal) { + setShowEndModal(true) + } + } + }, [wsGameState, showEndModal]) + + // 초기 로드 및 WebSocket 연결 + useEffect(() => { + const initGame = async () => { + try { + setLoading(true) + + // 방 정보 조회 + const roomResponse = await gameService.getRoom(roomId) + setRoom(roomResponse.data) + + // 게임 상태 조회 + let gameData + try { + const statusResponse = await wordchainService.getStatus(roomId) + gameData = statusResponse.data || statusResponse + } catch { + // 게임 상태가 없으면 시작 + const gameResponse = await wordchainService.start(roomId) + gameData = gameResponse.data || gameResponse + } + + setGameState({ + status: 'PLAYING', + currentWord: gameData.currentWord || null, + nextLetter: gameData.nextLetter || null, + currentTurnUserId: gameData.currentTurnUserId, + turnStartTime: gameData.turnStartTime || Date.now(), + turnTimeLimit: gameData.turnTimeLimit || 15, + players: gameData.players || roomResponse.data.participants || [], + usedWords: gameData.usedWords || [], + winner: null, + }) + + setTimeLeft(gameData.turnTimeLimit || 15) + + // WebSocket 연결 + console.log('[WordchainPlayPage] Connecting WebSocket...') + await connect() + console.log('[WordchainPlayPage] WebSocket connected') + } catch (err) { + console.error('Failed to init game:', err) + } finally { + setLoading(false) + } + } + + initGame() + + return () => { + console.log('[WordchainPlayPage] Disconnecting WebSocket...') + disconnect() + } + }, [roomId, currentUserId, connect, disconnect]) + + // 타이머 + useEffect(() => { + if (gameState.status !== 'PLAYING') return + + const interval = setInterval(() => { + setTimeLeft(prev => { + if (prev <= 1) { + // 시간 초과 + if (isMyTurn && isConnected) { + console.log('[WordchainPlayPage] Timer expired, sending timeout') + wordchainService.timeout(roomId).catch(err => { + console.error('Failed to send timeout:', err) + }) + } + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(interval) + }, [gameState.status, isMyTurn, isConnected, roomId]) + + // 단어 제출 + const handleSubmitWord = async (word) => { + if (!isMyTurn || submitting) return + + try { + setSubmitting(true) + await wordchainService.submit(roomId, word) + // 서버에서 WebSocket으로 결과 브로드캐스트 + } catch (err) { + console.error('Failed to submit word:', err) + alert(err.response?.data?.message || err.message || '단어 제출에 실패했습니다') + } finally { + setSubmitting(false) + } + } + + // 게임 종료 + const handleStopGame = async () => { + try { + disconnect() + await wordchainService.stop(roomId) + sessionStorage.removeItem(`roomToken_${roomId}`) + navigate('/games/wordchain') + } catch (err) { + console.error('Failed to stop game:', err) + } + } + + // 재시작 + const handleRestart = async () => { + try { + disconnect() + await gameService.restartGame(roomId) + setShowEndModal(false) + navigate(`/games/wordchain/${roomId}/waiting`) + } catch (err) { + console.error('Failed to restart:', err) + setShowEndModal(false) + navigate(`/games/wordchain/${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/wordchain') + } + + if (loading) { + return ( + + + + ) + } + + return ( + + {/* 헤더 */} + + + + + + {room?.name || '끝말잇기'} + + + + + + + + + + + + {/* WebSocket 에러 */} + {wsError && ( + + + {wsError} + + + )} + + {/* 메인 컨텐츠 */} + + + {/* 좌측: 게임 영역 */} + + {/* 타이머 & 현재 턴 */} + + + + 현재 턴 + + + {gameState.players.find(p => p.userId === gameState.currentTurnUserId)?.nickname || + gameState.currentTurnUserId || + '대기 중'} + + {isMyTurn && ( + + )} + + + + + {/* 현재 단어 & 다음 글자 */} + + + + + {/* 단어 입력 */} + + + + + + {/* 우측: 플레이어 & 사용된 단어 */} + + {/* 플레이어 목록 */} + + + + + {/* 사용된 단어 */} + + + + + + + + {/* 게임 종료 모달 */} + + + ) +} + +export default WordchainPlayPage diff --git a/src/domains/games/pages/WordchainWaitingPage.jsx b/src/domains/games/pages/WordchainWaitingPage.jsx new file mode 100644 index 0000000..e33642c --- /dev/null +++ b/src/domains/games/pages/WordchainWaitingPage.jsx @@ -0,0 +1,393 @@ +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 { useThemeMode } from '../../../contexts/ThemeContext' +import { useChatWebSocket } from '../../freetalk/hooks/useChatWebSocket' + +const WordchainWaitingPage = () => { + const { roomId } = useParams() + const navigate = useNavigate() + const { user } = useAuth() + const { mode } = useThemeMode() + const isDark = mode === 'dark' + 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 [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') + const existingIds = new Set(systemMessages.map(m => m.id)) + const uniqueWsMessages = wsMessages.filter(m => !existingIds.has(m.id)) + const allMessages = [...systemMessages, ...uniqueWsMessages] + 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('[WordchainWaitingPage] Game started via WebSocket, navigating to play page') + navigate(`/games/wordchain/${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/wordchain/${roomId}/play`) + } + } catch (err) { + console.error('Failed to fetch room:', err) + setError('방 정보를 불러오는데 실패했습니다') + } finally { + if (showLoading) { + setLoading(false) + } + } + }, [roomId, navigate]) + + // 초기 로딩 및 WebSocket 연결 + useEffect(() => { + const init = async () => { + await fetchRoom(true) + console.log('[WordchainWaitingPage] Connecting WebSocket...') + try { + await connect() + console.log('[WordchainWaitingPage] WebSocket connected') + } catch (err) { + console.error('[WordchainWaitingPage] WebSocket connection failed:', err) + } + } + init() + + return () => { + console.log('[WordchainWaitingPage] 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) + navigate(`/games/wordchain/${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) + sessionStorage.removeItem(`roomToken_${roomId}`) + navigate('/games/wordchain') + } catch (err) { + console.error('Failed to leave room:', err) + } + } + + // 채팅 메시지 전송 + const handleSendMessage = (content) => { + if (isConnected) { + console.log('[WordchainWaitingPage] Sending message via WebSocket:', content) + wsSendMessage(content, 'TEXT') + } else { + 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?.turnTimeLimit || 15}초 + + + + + 난이도 + + + {room.level || 'INTERMEDIATE'} + + + + + {isHost ? ( + + ) : ( + + + 방장이 게임을 시작할 때까지 기다려주세요 + + + + )} + + + + ) +} + +export default WordchainWaitingPage diff --git a/src/domains/games/services/wordchainService.js b/src/domains/games/services/wordchainService.js new file mode 100644 index 0000000..b63a441 --- /dev/null +++ b/src/domains/games/services/wordchainService.js @@ -0,0 +1,58 @@ +/** + * Word Chain Service - 백엔드 API 연동 + * 영어 끝말잇기 게임 관련 REST API 호출 + */ +import chatApi from '../../../api/chatApi' + +/** + * 끝말잇기 게임 진행 관련 API + */ +export const wordchainService = { + /** + * 게임 시작 + * @param {string} roomId + */ + start: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/start`, {}) + return response.data + }, + + /** + * 단어 제출 + * @param {string} roomId + * @param {string} word + */ + submit: async (roomId, word) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/submit`, { word }) + return response.data + }, + + /** + * 타임아웃 처리 + * @param {string} roomId + */ + timeout: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/timeout`, {}) + return response.data + }, + + /** + * 게임 중단 + * @param {string} roomId + */ + stop: async (roomId) => { + const response = await chatApi.post(`/chat/rooms/${roomId}/wordchain/stop`, {}) + return response.data + }, + + /** + * 게임 상태 조회 + * @param {string} roomId + */ + getStatus: async (roomId) => { + const response = await chatApi.get(`/chat/rooms/${roomId}/wordchain/status`) + return response.data + }, +} + +export default wordchainService diff --git a/src/layouts/MainLayout/Sidebar/index.jsx b/src/layouts/MainLayout/Sidebar/index.jsx index fa9dbc5..abc6754 100644 --- a/src/layouts/MainLayout/Sidebar/index.jsx +++ b/src/layouts/MainLayout/Sidebar/index.jsx @@ -169,6 +169,13 @@ const Sidebar = ({open, collapsed, onToggleCollapse, onClose}) => { path: '/games/catchmind', description: t('games.catchmindDesc'), }, + { + id: 'wordchain', + label: '끝말잇기', + icon: GameIcon, + path: '/games/wordchain', + description: '영어 끝말잇기', + }, ], }, ],