Skip to content

Commit 4d19a7e

Browse files
committed
feat: 게임 문제 렌더링, 정답자 모달, 게임 결과 모달 구현
1 parent dd04bb4 commit 4d19a7e

File tree

11 files changed

+342
-203
lines changed

11 files changed

+342
-203
lines changed

src/hooks/useStompClient.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export default function useStompClient(roomId, onMessage) {
2727
stompClient = new Client({
2828
brokerURL: 'wss://brainrace.duckdns.org:7080/ws/game-room',
2929
reconnectDelay: 5000,
30-
debug: (msg) => console.log('[STOMP]', msg),
30+
// debug: (msg) => console.log('[STOMP]', msg),
3131
onConnect: () => {
3232
console.log('✅ STOMP 연결됨');
3333
isConnected = true;
@@ -54,20 +54,30 @@ export default function useStompClient(roomId, onMessage) {
5454
useEffect(() => {
5555
if (!stompClient || !isConnected || !roomId) return;
5656

57-
console.log(`📥 구독 시작: /sub/room/${roomId}`);
57+
console.log(`📥 구독 시작: /sub/room/${roomId}, /user/queue`);
5858

59-
const subscription = stompClient.subscribe(`/sub/room/${roomId}`, (message) => {
59+
const roomSubscription = stompClient.subscribe(`/sub/room/${roomId}`, (message) => {
6060
try {
6161
const payload = JSON.parse(message.body);
6262
onMessageRef.current?.(payload);
6363
} catch (err) {
64-
console.error('❌ 메시지 파싱 실패:', err);
64+
console.error('❌ room 메시지 파싱 실패:', err);
65+
}
66+
});
67+
68+
const privateSubscription = stompClient.subscribe(`/user/queue`, (message) => {
69+
try {
70+
const payload = JSON.parse(message.body);
71+
onMessageRef.current?.(payload);
72+
} catch (err) {
73+
console.error('❌ private 메시지 파싱 실패:', err);
6574
}
6675
});
6776

6877
return () => {
69-
subscription.unsubscribe();
70-
console.log(`📤 구독 해제: /sub/room/${roomId}`);
78+
roomSubscription.unsubscribe();
79+
privateSubscription.unsubscribe();
80+
console.log(`📤 구독 해제: /sub/room/${roomId}, /user/queue`);
7181
};
7282
}, [roomId, ready]);
7383

src/layout/game/GameLayout.js

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,13 @@ const GameLayout = () => {
128128
}, [sendMessage]);
129129

130130
return (
131-
<div className="d-flex flex-column vh-100">
132-
<div className="d-flex flex-grow-1">
133-
<Sidebar />
134-
<main className="flex-grow-1">
135-
<Outlet />
136-
</main>
137-
</div>
131+
<div className="flex h-screen">
132+
<Sidebar />
133+
<main className="flex-1 overflow-y-auto">
134+
<Outlet />
135+
</main>
138136
</div>
137+
139138
);
140139
}
141140

src/layout/game/Sidebar.js

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,18 @@ import LiveLeaderboard from "./components/LiveLeaderboard"
33
import ParticipantsList from "./components/ParticipantsList"
44
import ChatSection from "./components/ChatSection"
55

6-
const leaderboardData = [
7-
{ rank: 1, name: "Lewis Hamilton", score: 12, color: "text-red-600" },
8-
{ rank: 2, name: "Max Verstappen", score: 11, color: "text-blue-600" },
9-
{ rank: 3, name: "Charles Leclerc", score: 10, color: "text-red-500" },
10-
{ rank: 4, name: "Lando Norris", score: 9, color: "text-orange-500" },
11-
{ rank: 5, name: "George Russell", score: 8, color: "text-cyan-500" },
12-
{ rank: 6, name: "Fernando Alonso", score: 7, color: "text-green-600" },
13-
{ rank: 7, name: "Oscar Piastri", score: 6, color: "text-purple-600" },
14-
{ rank: 8, name: "Carlos Sainz", score: 5, color: "text-pink-600" },
15-
];
16-
176
function Sidebar() {
187
const location = useLocation();
198
const isGamePlay = location.pathname.includes("/play");
209

2110
return (
22-
<aside className="w-96 bg-white border-r border-gray-200 flex flex-col">
23-
{isGamePlay ? <LiveLeaderboard entries={leaderboardData} /> : <ParticipantsList />}
24-
<ChatSection />
11+
<aside className="w-96 h-full min-h-0 flex flex-col border-r border-gray-200 bg-white">
12+
<div>
13+
{isGamePlay ? <LiveLeaderboard /> : <ParticipantsList />}
14+
</div>
15+
<div className="flex-1 min-h-0">
16+
<ChatSection />
17+
</div>
2518
</aside>
2619
)
2720
}

src/layout/game/components/ChatSection.js

Lines changed: 58 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client"
22

3-
import {useEffect, useState} from "react"
3+
import {useEffect, useRef, useState} from "react"
44
import { MessageCircle, Send, Car, Bell } from "lucide-react"
55
import {useRecoilState, useRecoilValue} from "recoil";
66
import {
@@ -19,6 +19,14 @@ function ChatSection() {
1919
const [newNotice, setNewNotice] = useRecoilState(systemNoticeAtom); // 단일 채팅 수신
2020
const playerList = useRecoilValue(playerListAtom);
2121
const loginUser = useRecoilValue(loginUserAtom);
22+
const scrollRef = useRef(null);
23+
24+
useEffect(() => {
25+
// 메시지 추가 시 마지막 요소로 스크롤
26+
if (scrollRef.current) {
27+
scrollRef.current.scrollIntoView({ behavior: 'smooth' });
28+
}
29+
}, [messages]); // messages가 바뀔 때마다 실행
2230

2331
// 닉네임 → color 매핑 함수
2432
const getColorByNickname = (nickname) => {
@@ -54,49 +62,57 @@ function ChatSection() {
5462
}
5563

5664
return (
57-
<div className="flex-1 border-t border-gray-200 flex flex-col">
58-
<div className="p-4 bg-gray-50 border-b border-gray-200">
59-
<h3 className="text-base font-semibold text-gray-800 flex items-center">
60-
<MessageCircle className="w-5 h-5 mr-2 text-blue-600" />
61-
실시간 채팅
62-
</h3>
63-
</div>
64-
<div className="flex-1 p-4 overflow-y-auto space-y-2">
65-
{messages.map((msg) => (
66-
msg.type === 'chat' ?
67-
<div key={msg.id} className="flex items-start space-x-3">
68-
<Car className={`w-5 h-5 mt-0.5 ${msg.color ? msg.color : "text-gray-500"}`} />
69-
<div>
70-
<div className="text-sm! font-medium text-gray-600">{msg.nickname}</div>
71-
<div className="text-sm! text-gray-800">{msg.message}</div>
72-
</div>
73-
</div>
74-
: <div key={msg.id} className="flex items-start space-x-3">
75-
<Bell className={`w-5 h-5 mt-0.5 "text-gray-500"`} />
76-
<div>
77-
<div className="text-sm! text-gray-800">{msg.noticeMessage}</div>
65+
<div className="flex flex-col h-full border-t border-gray-200">
66+
{/* 제목 */}
67+
<div className="p-4 bg-gray-50 border-b border-gray-200">
68+
<h3 className="text-base font-semibold text-gray-800 flex items-center">
69+
<MessageCircle className="w-5 h-5 mr-2 text-blue-600" />
70+
실시간 채팅
71+
</h3>
72+
</div>
73+
74+
{/* 채팅 목록 (스크롤) */}
75+
<div className="flex-1 min-h-0 p-4 overflow-y-auto space-y-2">
76+
{messages.map((msg) => (
77+
msg.type === "chat" ? (
78+
<div key={msg.id} className="flex items-start space-x-3">
79+
<Car className={`w-5 h-5 mt-0.5 ${msg.color ?? "text-gray-500"}`} />
80+
<div>
81+
<div className="text-sm font-medium text-gray-600">{msg.nickname}</div>
82+
<div className="text-sm text-gray-800">{msg.message}</div>
83+
</div>
84+
</div>
85+
) : (
86+
<div key={msg.id} className="flex items-start space-x-3">
87+
<Bell className="w-5 h-5 mt-0.5 text-gray-500" />
88+
<div>
89+
<div className="text-sm text-gray-800">{msg.noticeMessage}</div>
90+
</div>
7891
</div>
79-
</div>
80-
))}
81-
</div>
82-
<div className="p-3 border-t border-gray-200">
83-
<div className="flex space-x-2">
84-
<input
85-
type="text"
86-
placeholder="메시지를 입력하세요..."
87-
value={chatMessage}
88-
onChange={(e) => setChatMessage(e.target.value)}
89-
onKeyPress={(e) => e.key === "Enter" && handleSendMessage()}
90-
className="flex-1 px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-red-500"
91-
/>
92-
<button
93-
onClick={handleSendMessage}
94-
className="px-3 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
95-
>
96-
<Send className="w-4 h-4" />
97-
</button>
92+
)
93+
))}
94+
<div ref={scrollRef} />
95+
</div>
96+
97+
{/* 입력창 */}
98+
<div className="p-3 border-t border-gray-200 bg-white">
99+
<div className="flex space-x-2">
100+
<input
101+
type="text"
102+
placeholder="메시지를 입력하세요..."
103+
value={chatMessage}
104+
onChange={(e) => setChatMessage(e.target.value)}
105+
onKeyPress={(e) => e.key === "Enter" && handleSendMessage()}
106+
className="flex-1 px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-red-500"
107+
/>
108+
<button
109+
onClick={handleSendMessage}
110+
className="px-3 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
111+
>
112+
<Send className="w-4 h-4" />
113+
</button>
114+
</div>
98115
</div>
99-
</div>
100116
</div>
101117
)
102118
}

src/layout/game/components/GameSettings.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ function GameSettings({ roomId, gameSetting, allReady }) {
3434
};
3535

3636
const handleRoundChange = (e) => {
37-
console.log('handleQuestionCountChange: ', e.target.value)
3837
const roundMessage = {
3938
"message" : {
4039
"round" : e.target.value

src/layout/game/components/LiveLeaderboard.js

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
1-
import { Trophy, Crown, Medal } from "lucide-react"
1+
import {Trophy} from "lucide-react"
2+
import {useRecoilValue} from "recoil";
3+
import {rankUpdateAtom} from "../../../state/atoms";
4+
import {useEffect, useState} from "react";
5+
6+
function LiveLeaderboard() {
7+
const ranks = useRecoilValue(rankUpdateAtom);
8+
const [sortedRanks, setSortedRanks] = useState([]);
9+
10+
useEffect(() => {
11+
if (ranks) {
12+
setSortedRanks(
13+
[...ranks] // 원본 복사
14+
.sort((a, b) => b.correctCount - a.correctCount)
15+
.map((item, index) => ({
16+
...item,
17+
rank: index + 1
18+
}))
19+
);
20+
}
21+
}, [ranks])
222

3-
function LiveLeaderboard({ entries }) {
423
const getRankBg = (rank) => {
524
switch (rank) {
625
case 1:
@@ -43,20 +62,20 @@ function LiveLeaderboard({ entries }) {
4362
<div className="p-4">
4463
<h3 className="text-base font-semibold text-gray-800 mb-4 flex items-center">
4564
<Trophy className="w-5 h-5 mr-2 text-yellow-600" />
46-
실시간 랭킹 ({entries.length})
65+
실시간 랭킹 ({sortedRanks.length})
4766
</h3>
4867
<div className="space-y-0">
49-
{entries.map((entry) => (
50-
<div key={entry.rank} className={`flex items-center space-x-3 p-1 rounded-md ${getRankBg(entry.rank)}`}>
68+
{sortedRanks.map((rank) => (
69+
<div key={rank.rank} className={`flex items-center space-x-3 p-1 rounded-md ${getRankBg(rank.rank)}`}>
5170
<div
52-
className={`w-7 h-7 ${getRankColor(entry.rank)} text-white rounded-full flex items-center justify-center text-sm font-bold`}
71+
className={`w-7 h-7 ${getRankColor(rank.rank)} text-white rounded-full flex items-center justify-center text-sm font-bold`}
5372
>
54-
{entry.rank}
73+
{rank.rank}
5574
</div>
5675
<div className="flex-1 min-w-0">
57-
<div className="text-sm font-medium text-gray-900 truncate">{entry.name}</div>
76+
<div className="text-sm font-medium text-gray-900 truncate">{rank.nickname}</div>
5877
</div>
59-
<div className={`text-sm font-medium ${getScoreColor(entry.rank)}`}>정답 {entry.score}</div>
78+
<div className={`text-sm font-medium ${getScoreColor(rank.rank)}`}>정답 {rank.correctCount}</div>
6079
</div>
6180
))}
6281
</div>
Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
function QuizQuestion({ question }) {
2-
return (
3-
<div className="bg-white rounded-2xl shadow-lg p-8 mb-8 flex-1 flex items-center justify-center">
4-
<div className="text-center">
5-
<h2 className="text-4xl font-bold text-gray-900">{question}</h2>
1+
2+
function QuizQuestion({ questionContent }) {
3+
return (
4+
<div className="bg-white rounded-2xl shadow-lg p-8 mb-8 flex-1 flex items-center justify-center">
5+
<div className="text-center">
6+
<h2 className="text-4xl font-bold text-gray-900">{questionContent ?? '곧 퀴즈가 시작됩니다!'}</h2>
7+
</div>
68
</div>
7-
</div>
8-
)
9+
)
910
}
1011

1112
export default QuizQuestion

src/layout/game/components/QuizResultsModal.js

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
"use client"
2-
3-
import { useState, useEffect } from "react"
4-
import { Medal, Crown, X } from "lucide-react"
1+
import {useEffect, useState} from "react"
2+
import {Clock} from "lucide-react";
3+
import QuizTimer from "./QuizTimer";
54

65
function QuizResultsModal({ isVisible, results, onClose }) {
7-
const [showModal, setShowModal] = useState(false)
6+
const [showModal, setShowModal] = useState(false);
87

98
useEffect(() => {
109
if (isVisible) {
@@ -62,13 +61,29 @@ function QuizResultsModal({ isVisible, results, onClose }) {
6261
}`}
6362
onClick={(e) => e.stopPropagation()}
6463
>
64+
{/* 헤더 */}
6565
<div className="flex justify-between items-start mb-6">
6666
<h2 className="text-2xl font-bold text-gray-900">🏁 퀴즈 최종 결과</h2>
67-
<button onClick={handleClose} className="text-gray-400 hover:text-gray-600 transition-colors p-1">
68-
<X className="w-6 h-6" />
69-
</button>
67+
<QuizTimer duration={10} onTimeUp={onClose} size={"small"}/>
7068
</div>
71-
69+
{/* 대기실 이동 안내 */}
70+
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
71+
<div className="flex items-center justify-between">
72+
<div className="flex items-center gap-3">
73+
<Clock className="w-5 h-5 text-blue-600" />
74+
<div>
75+
<p className="text-sm font-semibold text-blue-800">잠시 후 대기실로 이동합니다</p>
76+
<p className="text-xs text-blue-600 mt-1">타이머가 끝나면 자동으로 대기실로 돌아갑니다</p>
77+
</div>
78+
</div>
79+
{/*<div className="flex items-center gap-2 text-blue-600">*/}
80+
{/* <Users className="w-4 h-4" />*/}
81+
{/* <ArrowRight className="w-4 h-4" />*/}
82+
{/* <span className="text-sm font-medium">대기실</span>*/}
83+
{/*</div>*/}
84+
</div>
85+
</div>
86+
{/* 결과테이블 */}
7287
<div className="bg-white rounded-xl border border-gray-200">
7388
<div className="overflow-x-auto">
7489
<table className="w-full">
@@ -92,9 +107,9 @@ function QuizResultsModal({ isVisible, results, onClose }) {
92107
</div>
93108
</div>
94109
</td>
95-
<td className="px-6 py-4 text-sm font-medium text-gray-900">{result.name}</td>
110+
<td className="px-6 py-4 text-sm font-medium text-gray-900">{result.nickname}</td>
96111
<td className="px-6 py-4 text-center text-sm text-green-600 font-semibold">
97-
{result.correctAnswers} / {result.totalQuestions}
112+
{result.totalCorrectCount}
98113
</td>
99114
<td className="px-6 py-4 text-right text-sm font-bold text-gray-900">{result.score}</td>
100115
</tr>
@@ -103,15 +118,6 @@ function QuizResultsModal({ isVisible, results, onClose }) {
103118
</table>
104119
</div>
105120
</div>
106-
107-
<div className="mt-6 flex justify-center">
108-
<button
109-
onClick={handleClose}
110-
className="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
111-
>
112-
닫기
113-
</button>
114-
</div>
115121
</div>
116122
</div>
117123
)

0 commit comments

Comments
 (0)