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 (
+
+ )
+}
+
+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={대기중만}
+ />
+
+
+
+
+
+
+
+
+ {/* 방 만들기 버튼 */}
+ }
+ onClick={() => setCreateModalOpen(true)}
+ sx={{
+ bgcolor: GAME_COLORS.primary,
+ '&:hover': { bgcolor: GAME_COLORS.primaryLight },
+ borderRadius: '12px',
+ textTransform: 'none',
+ fontWeight: 600,
+ px: 3,
+ }}
+ >
+ 방 만들기
+
+
+
+ {/* 에러 */}
+ {error && (
+ setError(null)} sx={{ mb: 3, borderRadius: '12px' }}>
+ {error}
+
+ )}
+
+ {/* 방 목록 */}
+ {loading ? (
+
+
+
+ ) : rooms.length === 0 ? (
+
+
+
+
+
+ 대기 중인 방이 없습니다
+
+
+ 새로운 게임방을 만들어보세요!
+
+ }
+ onClick={() => setCreateModalOpen(true)}
+ sx={{
+ bgcolor: GAME_COLORS.primary,
+ '&:hover': { bgcolor: GAME_COLORS.primaryLight },
+ borderRadius: '12px',
+ textTransform: 'none',
+ fontWeight: 600,
+ }}
+ >
+ 방 만들기
+
+
+ ) : (
+
+ {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 ? (
+ }
+ onClick={handleStartGame}
+ disabled={!canStart || starting}
+ sx={{
+ bgcolor: canStart ? GAME_COLORS.status.waiting : '#9CA3AF',
+ '&:hover': { bgcolor: canStart ? '#059669' : '#9CA3AF' },
+ borderRadius: '12px',
+ textTransform: 'none',
+ fontWeight: 700,
+ px: 4,
+ py: 1.5,
+ }}
+ >
+ {starting ? '시작 중...' : canStart ? '게임 시작' : '2명 이상 필요'}
+
+ ) : (
+
+
+ 방장이 게임을 시작할 때까지 기다려주세요
+
+
+
+ )}
+
+
+
+ )
+}
+
+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: '영어 끝말잇기',
+ },
],
},
],