Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -269,6 +272,13 @@ function Dashboard() {
path: '/games/catchmind',
description: t('games.catchmindDesc')
},
{
id: 'wordchain',
title: '끝말잇기',
icon: GameIcon,
path: '/games/wordchain',
description: '영어 끝말잇기'
},
],
},
]
Expand Down Expand Up @@ -1176,6 +1186,9 @@ function App() {
<Route path="/games/catchmind" element={<CatchmindLobbyPage />} />
<Route path="/games/catchmind/:roomId/waiting" element={<CatchmindWaitingPage />} />
<Route path="/games/catchmind/:roomId/play" element={<CatchmindPlayPage />} />
<Route path="/games/wordchain" element={<WordchainLobbyPage />} />
<Route path="/games/wordchain/:roomId/waiting" element={<WordchainWaitingPage />} />
<Route path="/games/wordchain/:roomId/play" element={<WordchainPlayPage />} />
<Route path="/news" element={<NewsListPage />} />
<Route path="/news/:articleId" element={<NewsDetailPage />} />
<Route path="/news/:articleId/quiz" element={<NewsQuizPage />} />
Expand Down
25 changes: 25 additions & 0 deletions src/domains/freetalk/services/chatWebSocketService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
158 changes: 158 additions & 0 deletions src/domains/games/components/wordchain/GameEndModal.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog
open={open}
maxWidth="sm"
fullWidth
PaperProps={{
sx: { borderRadius: '20px' },
}}
>
<DialogTitle
sx={{
bgcolor: GAME_COLORS.primary,
color: 'white',
textAlign: 'center',
py: 3,
}}
>
<TrophyIcon sx={{ fontSize: 48, mb: 1 }} />
<Typography variant="h5" fontWeight={700}>
게임 종료!
</Typography>
</DialogTitle>

<DialogContent sx={{ py: 3 }}>
{winner && (
<Box
sx={{
textAlign: 'center',
mb: 3,
p: 3,
borderRadius: '16px',
bgcolor: '#FEF3C7',
}}
>
<Typography variant="h6" fontWeight={700} color="#92400E" gutterBottom>
우승자
</Typography>
<Typography variant="h4" fontWeight={800} color="#F59E0B">
{winner.nickname || winner.userId}
{winner.userId === currentUserId && ' (나)'}
</Typography>
</Box>
)}

<Typography variant="h6" fontWeight={700} textAlign="center" gutterBottom>
최종 순위
</Typography>

<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mt: 2 }}>
{sortedPlayers.map((player, index) => (
<Box
key={player.userId}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 2,
bgcolor: player.isAlive
? index === 0
? '#FEF3C7'
: index === 1
? '#F3F4F6'
: index === 2
? '#FED7AA'
: 'transparent'
: '#FEE2E2',
borderRadius: '12px',
border: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography
sx={{
fontSize: '1.5rem',
fontWeight: 700,
}}
>
{player.isAlive ? (
index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `${index + 1}위`
) : '❌'}
</Typography>
<Typography variant="body1" fontWeight={600}>
{player.nickname || player.userId}
{player.userId === currentUserId && ' (나)'}
</Typography>
</Box>
<Typography
sx={{
...GAME_TYPOGRAPHY.score,
fontSize: '1rem',
color: player.isAlive ? GAME_COLORS.primary : 'text.secondary',
}}
>
{player.wordsSubmitted || 0}단어
</Typography>
</Box>
))}
</Box>
</DialogContent>

<DialogActions sx={{ p: 2, gap: 1 }}>
<Button
startIcon={<ExitIcon />}
onClick={onExit}
sx={{ borderRadius: '10px', textTransform: 'none' }}
>
나가기
</Button>
<Button
variant="contained"
startIcon={<ReplayIcon />}
onClick={onRestart}
sx={{
bgcolor: GAME_COLORS.primary,
'&:hover': { bgcolor: GAME_COLORS.primaryLight },
borderRadius: '10px',
textTransform: 'none',
fontWeight: 600,
}}
>
다시하기
</Button>
</DialogActions>
</Dialog>
)
}

export default GameEndModal
89 changes: 89 additions & 0 deletions src/domains/games/components/wordchain/PlayerList.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box>
<Typography variant="subtitle2" fontWeight={700} color="text.secondary" gutterBottom>
플레이어
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{players.map((player) => {
const isCurrentTurn = player.userId === currentTurnUserId
const isMe = player.userId === currentUserId
const isAlive = player.isAlive !== false

return (
<Box
key={player.userId}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
p: 1.5,
borderRadius: '12px',
bgcolor: isCurrentTurn
? GAME_COLORS.primaryBg
: isMe
? isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'
: 'transparent',
border: '2px solid',
borderColor: isCurrentTurn ? GAME_COLORS.primary : 'transparent',
opacity: isAlive ? 1 : 0.5,
transition: 'all 0.2s ease',
}}
>
<Avatar
sx={{
width: 36,
height: 36,
bgcolor: isCurrentTurn ? GAME_COLORS.primary : '#9CA3AF',
fontSize: '0.875rem',
}}
>
{player.nickname?.[0] || player.userId?.[0] || '?'}
</Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
fontWeight={isCurrentTurn ? 700 : 500}
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{player.nickname || player.userId}
{isMe && ' (나)'}
</Typography>
{isCurrentTurn && (
<Typography variant="caption" color={GAME_COLORS.primary} fontWeight={600}>
턴 진행 중
</Typography>
)}
</Box>
{!isAlive && (
<Typography variant="caption" color="error" fontWeight={600}>
탈락
</Typography>
)}
{isAlive && !isCurrentTurn && player.hasAnswered && (
<CheckIcon sx={{ fontSize: 20, color: GAME_COLORS.status.waiting }} />
)}
</Box>
)
})}
</Box>
</Box>
)
}

export default PlayerList
49 changes: 49 additions & 0 deletions src/domains/games/components/wordchain/UsedWordsList.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Box, Typography, Chip } from '@mui/material'
import { GAME_COLORS } from '../../theme/gameTheme'

/**
* UsedWordsList - 사용된 단어 목록
*/
const UsedWordsList = ({ words }) => {
return (
<Box>
<Typography variant="subtitle2" fontWeight={700} color="text.secondary" gutterBottom>
사용된 단어 ({words.length})
</Typography>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1,
maxHeight: 200,
overflowY: 'auto',
p: 1,
bgcolor: 'rgba(0,0,0,0.02)',
borderRadius: '12px',
}}
>
{words.length === 0 ? (
<Typography variant="caption" color="text.secondary">
아직 사용된 단어가 없습니다
</Typography>
) : (
words.map((wordData, index) => (
<Chip
key={index}
label={wordData.word || wordData}
size="small"
sx={{
bgcolor: GAME_COLORS.primaryBg,
color: GAME_COLORS.primary,
fontWeight: 600,
fontSize: '0.75rem',
}}
/>
))
)}
</Box>
</Box>
)
}

export default UsedWordsList
Loading