diff --git a/FE/src/api/mocks/SocketMock.ts b/FE/src/api/mocks/SocketMock.ts index 32c7f20..ff9a372 100644 --- a/FE/src/api/mocks/SocketMock.ts +++ b/FE/src/api/mocks/SocketMock.ts @@ -6,9 +6,14 @@ type SocketEvent = keyof SocketDataMap; export class SocketMock { private listenerSet: Record void)[]> = {}; private onAnyListenerList: ((event: string, ...args: unknown[]) => void)[] = []; - constructor(url: string) { - console.log(`Mock WebSocket 연결: ${url}`); + constructor() { + console.log(`Mock WebSocket 연결`); } + + /** + * socket.io 인터페이스 + * id, connected, on, onAny, emit, disconnect + */ id = 'memememememe'; connected = true; on(event: string, listener: (...args: unknown[]) => void) { @@ -45,7 +50,13 @@ export class SocketMock { this.connected = false; } - //여기서 서버 이벤트를 실행시킨다. + /** + * 유틸 함수 + * emitServer: 서버에서 이벤트를 발생 시킨다 + * delay: n초 지연시킨다 + * log: 채팅창에 로그를 띄운다 + * random: 시드 기반 랜덤 함수. 항상 동일한 결과를 보장 + */ emitServer(event: T, data: SocketDataMap[T]['response']) { if (this.listenerSet[event]) { this.listenerSet[event].forEach((e) => e(data)); @@ -65,20 +76,33 @@ export class SocketMock { timestamp: 0 }); } - //시드 기반 랜덤 함수 - SEED = 7777; + private SEED = 7777; random() { this.SEED = (this.SEED * 16807) % 2147483647; return (this.SEED - 1) / 2147483646; } - isCurrentJoin = false; - currentPlayerName = ''; - players: { - playerId: string; - playerName: string; - playerPosition: [number, number]; - }[] = []; + /** + * 서버 비즈니스 로직 + * players: 플레이어 맵(키: 플레이어 아이디, 값: 플레이어) + * scores: 플레이어 점수 정보 + * quiz: 현재 진행중인 퀴즈 + * handleChat() + * handleJoin() + * handlePosition() + * handleOption() + * handleQuiz() + */ + players: Record< + string, + { + playerId: string; + playerName: string; + playerPosition: [number, number]; + } + > = {}; + + scores: Record = {}; quiz: { quiz: string; @@ -87,55 +111,33 @@ export class SocketMock { choiceList: { content: string; order: number }[]; } | null = null; - // 아래는 서버 비즈니스 로직 private async handleChat(data: SocketDataMap['chatMessage']['request']) { await this.delay(0.1); - this.emitServer('chatMessage', { - playerId: this.id, - playerName: this.currentPlayerName, - message: data.message, - timestamp: 0 - }); + this.chatMessage(this.id, data.message); } private async handleJoin(data: SocketDataMap[typeof SocketEvents.JOIN_ROOM]['request']) { - if (this.isCurrentJoin) return; - this.isCurrentJoin = true; + if (this.getPlayer(this.id)) return; await this.delay(0.1); - this.currentPlayerName = data.playerName; - const currentPlayer: (typeof this.players)[number] = { + const currentPlayer: (typeof this.players)[string] = { playerId: this.id, playerName: data.playerName, playerPosition: [0.5, 0.5] }; - this.emitServer(SocketEvents.JOIN_ROOM, { - players: this.players - }); - this.emitServer(SocketEvents.JOIN_ROOM, { - players: [currentPlayer] - }); - this.players.push(currentPlayer); + this.emitServer(SocketEvents.JOIN_ROOM, { players: this.getPlayerList() }); + this.emitServer(SocketEvents.JOIN_ROOM, { players: [currentPlayer] }); + this.addPlayers([currentPlayer]); } private async handlePosition( data: SocketDataMap[typeof SocketEvents.UPDATE_POSITION]['request'] ) { await this.delay(0.1); - const targetPlayer = this.players.find((p) => p.playerId === this.id); - if (targetPlayer) targetPlayer.playerPosition = data.newPosition; - this.emitServer(SocketEvents.UPDATE_POSITION, { - playerId: this.id, - playerPosition: data.newPosition - }); + this.updatePlayerPosition(this.id, data.newPosition); } private async handleOption( data: SocketDataMap[typeof SocketEvents.UPDATE_ROOM_OPTION]['request'] ) { await this.delay(0.1); - this.emitServer(SocketEvents.UPDATE_ROOM_OPTION, { - title: data.title, - gameMode: data.gameMode, - maxPlayerCount: data.maxPlayerCount, - isPublic: data.isPublic - }); + this.emitServer(SocketEvents.UPDATE_ROOM_OPTION, data); } private async handleQuiz( data: SocketDataMap[typeof SocketEvents.UPDATE_ROOM_QUIZSET]['request'] @@ -144,7 +146,26 @@ export class SocketMock { this.emitServer(SocketEvents.UPDATE_ROOM_QUIZSET, data); } - //퀴즈 관련 비즈니스 로직 + /** + * 비즈니스 로직 관련 유틸 함수 + * getPlayer() + * getPlayers() + * addPlayers() + * setQuiz() + * calculate() + * progressQuiz() + * updatePlayerPosition() + * chatMessage + */ + getPlayer(id: string) { + return this.players[id]; + } + getPlayerList() { + return Object.values(this.players); + } + addPlayers(players: Array) { + players.forEach((p) => (this.players[p.playerId] = p)); + } setQuiz(quiz: string, quizSecond: number, choiceList: string[]) { const COUNT_DOWN_TIME = 3000; this.quiz = { @@ -156,23 +177,44 @@ export class SocketMock { this.emitServer('startQuizTime', this.quiz); } calculateScore(answer: number) { - const players = this.players.map((p) => { + const players = this.getPlayerList().map((p) => { const [y, x] = p.playerPosition; const option = - Math.round(x) + Math.floor(y * Math.ceil((this.quiz?.choiceList.length || 0) / 2)) * 2 + 1; + Math.round(x) + Math.floor(y * Math.ceil((this.quiz?.choiceList.length || 0) / 2)) * 2; + this.scores[p.playerId] = (this.scores[p.playerId] | 0) + (option === answer ? 22 : 0); return { playerId: p.playerId, isAnswer: option === answer, - score: option === answer ? 22 : 0 + score: this.scores[p.playerId] }; }); const payload = { answer, players }; this.emitServer('endQuizTime', payload); } - async progressQuiz(quiz: string, quizSecond: number, choiceList: string[], answer: number) { + async progressQuiz(quiz: string, quizSecond: number, choiceList: string[], answerIndex: number) { this.setQuiz(quiz, quizSecond, choiceList); + this.log('퀴즈 전송 완료.'); await this.delay(3 + quizSecond); - this.calculateScore(answer); + this.calculateScore(answerIndex); + this.log('퀴즈가 종료 되었습니다.'); + } + + updatePlayerPosition(playerId: string, newPosition: [number, number]) { + this.getPlayer(playerId).playerPosition = newPosition; + this.emitServer('updatePosition', { + playerId: this.getPlayer(playerId).playerId, + playerPosition: newPosition + }); + } + + chatMessage(playerId: string, message: string) { + const player = this.getPlayer(playerId); + this.emitServer('chatMessage', { + playerId: player.playerId, + playerName: player.playerName, + message, + timestamp: 0 + }); } } diff --git a/FE/src/api/mocks/socketMocks/SocketMockChat.ts b/FE/src/api/mocks/socketMocks/SocketMockChat.ts index 4afd896..eb02a74 100644 --- a/FE/src/api/mocks/socketMocks/SocketMockChat.ts +++ b/FE/src/api/mocks/socketMocks/SocketMockChat.ts @@ -2,27 +2,27 @@ import { SocketMock } from '../SocketMock'; export default class SocketMockChat extends SocketMock { constructor() { - super(''); - this.players = Array(10) - .fill(null) - .map((_, i) => ({ - playerId: String(i + 1), - playerName: 'player' + i, - playerPosition: [i / 10, i / 10] - })); - this.test1(); + super(); + this.addPlayers( + Array(10) + .fill(null) + .map((_, i) => ({ + playerId: String(i + 1), + playerName: 'player' + i, + playerPosition: [i / 10, i / 10] + })) + ); + this.testChat(); } // 10명의 유저가 채팅을 보냄 - async test1() { - for (let i = 0; i < 150; i++) { - await this.delay(0.1); - const index = i % this.players.length; - this.emitServer('chatMessage', { - playerId: this.players[index].playerId, - playerName: this.players[index].playerName, - message: String(i), - timestamp: 0 - }); + async testChat() { + const playerCount = this.getPlayerList().length; + const testTime = 5; + for (let j = 0; j < testTime; j++) { + for (const player of this.getPlayerList()) { + await this.delay(1 / playerCount); + this.chatMessage(player.playerId, 'message' + player.playerId); + } } } } diff --git a/FE/src/api/mocks/socketMocks/SocketMockLoadTest.ts b/FE/src/api/mocks/socketMocks/SocketMockLoadTest.ts index 799e6c1..4690930 100644 --- a/FE/src/api/mocks/socketMocks/SocketMockLoadTest.ts +++ b/FE/src/api/mocks/socketMocks/SocketMockLoadTest.ts @@ -2,46 +2,38 @@ import { SocketMock } from '../SocketMock'; export default class SocketMockLoadTest extends SocketMock { constructor() { - super(''); - this.players = Array(100) - .fill(null) - .map((_, i) => ({ - playerId: String(i + 1), - playerName: 'player' + i, - playerPosition: [this.random(), this.random()] - })); + super(); + this.addPlayers( + Array(100) + .fill(null) + .map((_, i) => ({ + playerId: String(i + 1), + playerName: 'player' + i, + playerPosition: [i / 10, i / 10] + })) + ); this.testChat(); this.testMove(); } - // 10명의 유저가 채팅을 보냄 + async testChat() { - const playerCount = this.players.length; + const playerCount = this.getPlayerList().length; const testTime = 10; for (let j = 0; j < testTime; j++) { - //10초동안 - for (let i = 0; i < playerCount; i++) { + for (const player of this.getPlayerList()) { await this.delay(1 / playerCount); - this.emitServer('chatMessage', { - playerId: this.players[i].playerId, - playerName: this.players[i].playerName, - message: 'message' + i, - timestamp: 0 - }); + this.chatMessage(player.playerId, 'message' + player.playerId); } } } async testMove() { - const playerCount = this.players.length; + const playerCount = this.getPlayerList().length; const testTime = 10; for (let j = 0; j < testTime; j++) { - //10초동안 - for (let i = 0; i < playerCount; i++) { + for (const player of this.getPlayerList()) { await this.delay(1 / playerCount); - this.emitServer('updatePosition', { - playerId: this.players[i].playerId, - playerPosition: [this.random(), this.random()] - }); + this.updatePlayerPosition(player.playerId, [this.random(), this.random()]); } } } diff --git a/FE/src/api/mocks/socketMocks/SocketMockLoadTestWithQuiz.ts b/FE/src/api/mocks/socketMocks/SocketMockLoadTestWithQuiz.ts index 4dbf274..29b6eaf 100644 --- a/FE/src/api/mocks/socketMocks/SocketMockLoadTestWithQuiz.ts +++ b/FE/src/api/mocks/socketMocks/SocketMockLoadTestWithQuiz.ts @@ -2,63 +2,59 @@ import { SocketMock } from '../SocketMock'; export default class SocketMockLoadTestWithQuiz extends SocketMock { constructor() { - super(''); - this.players = Array(100) - .fill(null) - .map((_, i) => ({ - playerId: String(i + 1), - playerName: 'player' + i, - playerPosition: [this.random(), this.random()] - })); - this.testQuiz(); + super(); + this.addPlayers( + Array(100) + .fill(null) + .map((_, i) => ({ + playerId: String(i + 1), + playerName: 'player' + i, + playerPosition: [i / 10, i / 10] + })) + ); this.testChat(); this.testMove(); + this.testQuiz(); } - async testQuiz() { - //2초후 게임 시작 - await this.delay(2); - this.log('게임이 시작되었습니다.'); - this.emitServer('startGame', {}); - //퀴즈 전송 - await this.progressQuiz('1+1=?', 5, ['1', '2', '3'], 1); - this.log('첫번째 퀴즈가 종료되었습니다.'); - await this.delay(3); - this.log('두번째 퀴즈가 시작되었습니다.'); - await this.progressQuiz('2+2=?', 5, ['1', '2', '4'], 2); - this.log('테스트가 종료되었습니다.'); - } - - // 10명의 유저가 채팅을 보냄 async testChat() { - const playerCount = this.players.length; + const playerCount = this.getPlayerList().length; const testTime = 10; for (let j = 0; j < testTime; j++) { - //10초동안 - for (let i = 0; i < playerCount; i++) { + for (const player of this.getPlayerList()) { await this.delay(1 / playerCount); - this.emitServer('chatMessage', { - playerId: this.players[i].playerId, - playerName: this.players[i].playerName, - message: 'message' + i, - timestamp: 0 - }); + this.chatMessage(player.playerId, 'message' + player.playerId); } } } async testMove() { - const playerCount = this.players.length; + const playerCount = this.getPlayerList().length; const testTime = 10; for (let j = 0; j < testTime; j++) { - //10초동안 - for (let i = 0; i < playerCount; i++) { + for (const player of this.getPlayerList()) { await this.delay(1 / playerCount); - this.emitServer('updatePosition', { - playerId: this.players[i].playerId, - playerPosition: [this.random(), this.random()] - }); + this.updatePlayerPosition(player.playerId, [this.random(), this.random()]); } } } + + async testQuiz() { + //2초후 게임 시작 + await this.delay(2); + this.log('게임이 시작되었습니다.'); + this.emitServer('startGame', {}); + //퀴즈 전송 + await this.progressQuiz('1+1=?', 5, ['1', '2', '3'], 1); + this.log('첫번째 퀴즈가 종료되었습니다.'); + await this.delay(3); + this.log('두번째 퀴즈가 시작되었습니다.'); + await this.progressQuiz('2+2=?', 5, ['1', '2', '4'], 2); + this.log('테스트가 종료되었습니다.'); + + // 퀴즈 종료 + await this.delay(5); + this.log('게임이 종료되었습니다.'); + this.emitServer('endGame', { hostId: this.id }); + } } diff --git a/FE/src/api/mocks/socketMocks/SocketMockNextQuiz.ts b/FE/src/api/mocks/socketMocks/SocketMockNextQuiz.ts index 36042b7..c34fadf 100644 --- a/FE/src/api/mocks/socketMocks/SocketMockNextQuiz.ts +++ b/FE/src/api/mocks/socketMocks/SocketMockNextQuiz.ts @@ -2,14 +2,16 @@ import { SocketMock } from '../SocketMock'; export default class SocketMockNextQuiz extends SocketMock { constructor() { - super(''); - this.players = Array(10) - .fill(null) - .map((_, i) => ({ - playerId: String(i + 1), - playerName: 'player' + i, - playerPosition: [i / 10, i / 10] - })); + super(); + this.addPlayers( + Array(10) + .fill(null) + .map((_, i) => ({ + playerId: String(i + 1), + playerName: 'player' + i, + playerPosition: [i / 10, i / 10] + })) + ); this.test(); } @@ -21,7 +23,7 @@ export default class SocketMockNextQuiz extends SocketMock { //퀴즈 전송 await this.progressQuiz('1+1=?', 3, ['1', '2', '3'], 1); this.log('첫번째 퀴즈가 종료되었습니다.'); - await this.delay(3); + await this.delay(4); this.log('두번째 퀴즈가 시작되었습니다.'); await this.progressQuiz('2+2=?', 3, ['1', '2', '4'], 2); this.log('테스트가 종료되었습니다.'); diff --git a/FE/src/api/mocks/socketMocks/SocketMockStartEnd.ts b/FE/src/api/mocks/socketMocks/SocketMockStartEnd.ts index 7b896f0..0f68a01 100644 --- a/FE/src/api/mocks/socketMocks/SocketMockStartEnd.ts +++ b/FE/src/api/mocks/socketMocks/SocketMockStartEnd.ts @@ -2,14 +2,16 @@ import { SocketMock } from '../SocketMock'; export default class SocketMockStartEnd extends SocketMock { constructor() { - super(''); - this.players = Array(10) - .fill(null) - .map((_, i) => ({ - playerId: String(i + 1), - playerName: 'player' + i, - playerPosition: [i / 10, i / 10] - })); + super(); + this.addPlayers( + Array(10) + .fill(null) + .map((_, i) => ({ + playerId: String(i + 1), + playerName: 'player' + i, + playerPosition: [i / 10, i / 10] + })) + ); this.test(); } @@ -18,15 +20,13 @@ export default class SocketMockStartEnd extends SocketMock { await this.delay(2); this.log('게임이 시작되었습니다.'); this.emitServer('startGame', {}); - //퀴즈 전송 + //퀴즈 진행 await this.delay(2); - this.setQuiz('1+0+0은?', 5, ['1', '2', '3', '4']); - this.log('퀴즈 전송 완료.'); + await this.progressQuiz('1+0+0은?', 5, ['1', '2', '3', '4'], 0); // 퀴즈 종료 - await this.delay(8); - this.calculateScore(0); - this.log('퀴즈 가 종료 되었습니다.'); - this.emitServer('endGame', { hostId: '123123' }); + await this.delay(5); + this.log('게임이 종료되었습니다.'); + this.emitServer('endGame', { hostId: this.id }); } } diff --git a/FE/src/api/mocks/socketMocks/SocketMockStartGame.ts b/FE/src/api/mocks/socketMocks/SocketMockStartGame.ts index 2c02836..fb20e69 100644 --- a/FE/src/api/mocks/socketMocks/SocketMockStartGame.ts +++ b/FE/src/api/mocks/socketMocks/SocketMockStartGame.ts @@ -2,14 +2,16 @@ import { SocketMock } from '../SocketMock'; export default class SocketMockStartGame extends SocketMock { constructor() { - super(''); - this.players = Array(10) - .fill(null) - .map((_, i) => ({ - playerId: String(i + 1), - playerName: 'player' + i, - playerPosition: [i / 10, i / 10] - })); + super(); + this.addPlayers( + Array(10) + .fill(null) + .map((_, i) => ({ + playerId: String(i + 1), + playerName: 'player' + i, + playerPosition: [i / 10, i / 10] + })) + ); this.test(); } @@ -18,14 +20,9 @@ export default class SocketMockStartGame extends SocketMock { await this.delay(2); this.log('게임이 시작되었습니다.'); this.emitServer('startGame', {}); - //퀴즈 전송 - await this.delay(2); - this.setQuiz('1+0+0은?', 5, ['1', '2', '3', '4']); - this.log('퀴즈 전송 완료.'); - // 퀴즈 종료 - await this.delay(8); - this.calculateScore(1); - this.log('퀴즈 가 종료 되었습니다.'); + //퀴즈 진행 + await this.delay(2); + this.progressQuiz('1+0+0은?', 5, ['1', '2', '3', '4'], 0); } } diff --git a/FE/src/components/ParticipantDisplay.tsx b/FE/src/components/ParticipantDisplay.tsx index 112fc96..48dc9f0 100644 --- a/FE/src/components/ParticipantDisplay.tsx +++ b/FE/src/components/ParticipantDisplay.tsx @@ -13,6 +13,8 @@ const ParticipantDisplay: React.FC = ({ gameState }) => const maxPlayerCount = useRoomStore((state) => state.maxPlayerCount); const currentPlayerId = usePlayerStore((state) => state.currentPlayerId); const isHost = usePlayerStore((state) => state.isHost); + const gameMode = useRoomStore((state) => state.gameMode); + // 대기 모드일 때 참가자 목록 표시 const renderWaitingMode = () => (
@@ -31,7 +33,7 @@ const ParticipantDisplay: React.FC = ({ gameState }) => ); // 진행 모드일 때 랭킹 현황 표시 - const renderProgressMode = () => ( + const renderProgressRankingMode = () => (
{players .sort((a, b) => b.playerScore - a.playerScore) // 점수 내림차순 @@ -62,12 +64,40 @@ const ParticipantDisplay: React.FC = ({ gameState }) =>
); + // 진행 모드일 때 생존자 표시 + const renderProgressSurvivalMode = () => ( +
+ {players + .filter((player) => player.isAlive) + .map((player, i) => ( + +
+ {i + 1 + '. ' + player.playerName} +
+
+ ))} +
+ ); + return (
- {gameState === GameState.WAIT ? `참가자 [${playerCount}/${maxPlayerCount}]` : `랭킹 현황`} + {gameState === GameState.WAIT + ? `참가자 [${playerCount}/${maxPlayerCount}]` + : gameMode === 'SURVIVAL' + ? '생존자' + : `랭킹 현황`}
- {gameState === GameState.WAIT ? renderWaitingMode() : renderProgressMode()} + {gameState === GameState.WAIT + ? renderWaitingMode() + : gameMode === 'SURVIVAL' + ? renderProgressSurvivalMode() + : renderProgressRankingMode()}
); }; diff --git a/FE/src/components/Player.tsx b/FE/src/components/Player.tsx index f997bab..ef0b2a7 100644 --- a/FE/src/components/Player.tsx +++ b/FE/src/components/Player.tsx @@ -12,9 +12,10 @@ type Props = { position: [number, number]; isCurrent: boolean; isAnswer: boolean; + isAlive: boolean; }; -export const Player = ({ name, position, isCurrent, isAnswer }: Props) => { +export const Player = ({ name, position, isCurrent, isAnswer, isAlive }: Props) => { const [showEffect, setShowEffect] = useState(false); const [effectData, setEffectData] = useState(AnswerEffect); const quizState = useQuizeStore((state) => state.quizState); @@ -73,7 +74,11 @@ export const Player = ({ name, position, isCurrent, isAnswer }: Props) => { return (
e.preventDefault()} >
diff --git a/FE/src/components/QuizHeader.tsx b/FE/src/components/QuizHeader.tsx index 0b89119..f1290fa 100644 --- a/FE/src/components/QuizHeader.tsx +++ b/FE/src/components/QuizHeader.tsx @@ -4,7 +4,7 @@ import AnswerModal from './AnswerModal'; import QuizState from '@/constants/quizState'; import Lottie from 'lottie-react'; import quizLoading from '../assets/lottie/quiz_loading.json'; -import useServerDate from '@/hooks/useServerDate'; +import { getServerTimestamp } from '@/utils/serverTime'; export const QuizHeader = () => { const currentQuiz = useQuizeStore((state) => state.currentQuiz); @@ -13,13 +13,12 @@ export const QuizHeader = () => { const [isAnswerVisible, setIsAnswerVisible] = useState(false); const [limitTime, setLimitTime] = useState(0); const answer = useQuizeStore((state) => state.currentAnswer); - const serverNow = useServerDate(); useEffect(() => { if (currentQuiz) { - setSeconds((currentQuiz.endTime - serverNow()) / 1000); + setSeconds((currentQuiz.endTime - getServerTimestamp()) / 1000); setLimitTime((currentQuiz.endTime - currentQuiz.startTime) / 1000); } - }, [currentQuiz, serverNow]); + }, [currentQuiz]); useEffect(() => { setIsAnswerVisible(quizState === QuizState.END); @@ -28,9 +27,9 @@ export const QuizHeader = () => { useEffect(() => { requestAnimationFrame(() => { if (seconds <= 0 || !currentQuiz) return; - setSeconds((currentQuiz.endTime - serverNow()) / 1000); + setSeconds((currentQuiz.endTime - getServerTimestamp()) / 1000); }); - }, [currentQuiz, seconds, serverNow]); + }, [currentQuiz, seconds]); if (!currentQuiz) return ( @@ -40,17 +39,17 @@ export const QuizHeader = () => {
); - if (currentQuiz.startTime > serverNow()) + if (currentQuiz.startTime > getServerTimestamp()) return (
- {Math.ceil((currentQuiz.startTime - serverNow()) / 1000)} + {Math.ceil((currentQuiz.startTime - getServerTimestamp()) / 1000)}
); return (
0.2 ? 'black' : 'red' }} > {seconds <= 0 ? '종료' : seconds.toFixed(2)} diff --git a/FE/src/components/QuizOptionBoard.tsx b/FE/src/components/QuizOptionBoard.tsx index 47b60e5..3405f42 100644 --- a/FE/src/components/QuizOptionBoard.tsx +++ b/FE/src/components/QuizOptionBoard.tsx @@ -4,7 +4,7 @@ import { socketService } from '@/api/socket'; import { useRoomStore } from '@/store/useRoomStore'; import { useEffect, useRef, useState } from 'react'; import { useQuizeStore } from '@/store/useQuizStore'; -import useServerDate from '@/hooks/useServerDate'; +import { getServerTimestamp } from '@/utils/serverTime'; const optionColors = [ '#FF9AA2', // pastel red @@ -29,7 +29,6 @@ export const QuizOptionBoard = () => { const quizAnswer = useQuizeStore((state) => state.currentAnswer); const [selectedOption, setSelectedOption] = useState(currentQuiz?.choiceList.length); const [choiceListVisible, setChoiceListVisible] = useState(false); - const serverNow = useServerDate(); const handleClick: React.MouseEventHandler = (e) => { const { pageX, pageY } = e; @@ -66,13 +65,13 @@ export const QuizOptionBoard = () => { // 퀴즈 시작 시간에 선택지 렌더링 useEffect(() => { const interval = setInterval(() => { - if (!choiceListVisible && currentQuiz && currentQuiz.startTime <= serverNow()) + if (!choiceListVisible && currentQuiz && currentQuiz.startTime <= getServerTimestamp()) setChoiceListVisible(true); - else if (choiceListVisible && currentQuiz && currentQuiz.startTime > serverNow()) + else if (choiceListVisible && currentQuiz && currentQuiz.startTime > getServerTimestamp()) setChoiceListVisible(false); }, 100); return () => clearInterval(interval); - }, [choiceListVisible, currentQuiz, serverNow]); + }, [choiceListVisible, currentQuiz]); return (
{ >
{boardRect - ? players.map((player) => { - return ( - - ); - }) + ? players + .filter((player) => player.isAlive || player.playerId === currentPlayerId) + .map((player) => { + return ( + + ); + }) : null}
diff --git a/FE/src/components/QuizSetSearchList.tsx b/FE/src/components/QuizSetSearchList.tsx index d543250..2d954d7 100644 --- a/FE/src/components/QuizSetSearchList.tsx +++ b/FE/src/components/QuizSetSearchList.tsx @@ -2,21 +2,21 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import { useCallback, useEffect, useRef, useState } from 'react'; import { QuizPreview } from './QuizPreview'; -type Quiz = { - id: string; - quiz: string; - limitTime: number; - choiceList: { - content: string; - order: number; - }[]; -}; +// type Quiz = { +// id: string; +// quiz: string; +// limitTime: number; +// choiceList: { +// content: string; +// order: number; +// }[]; +// }; type QuizSet = { id: string; title: string; category: string; - quizList: Quiz[]; + quizCount: number; }; type Params = { diff --git a/FE/src/components/QuizSettingModal.tsx b/FE/src/components/QuizSettingModal.tsx index 8d50077..1c1a93b 100644 --- a/FE/src/components/QuizSettingModal.tsx +++ b/FE/src/components/QuizSettingModal.tsx @@ -3,21 +3,21 @@ import { QuizPreview } from './QuizPreview'; import { socketService } from '@/api/socket'; import QuizSetSearchList from './QuizSetSearchList'; -type Quiz = { - id: string; - quiz: string; - limitTime: number; - choiceList: { - content: string; - order: number; - }[]; -}; +// type Quiz = { +// id: string; +// quiz: string; +// limitTime: number; +// choiceList: { +// content: string; +// order: number; +// }[]; +// }; type QuizSet = { id: string; title: string; category: string; - quizList: Quiz[]; + quizCount: number; }; type Props = { @@ -51,7 +51,7 @@ export const QuizSettingModal = ({ isOpen, onClose }: Props) => { const handleSelectQuizSet = (quizSet: QuizSet) => { setSelectedQuizSet(quizSet); - setQuizCount(quizSet.quizList.length); + setQuizCount(quizSet.quizCount); }; return ( @@ -93,7 +93,7 @@ export const QuizSettingModal = ({ isOpen, onClose }: Props) => { setQuizCount(Number(e.target.value))} /> diff --git a/FE/src/components/ResultModal.tsx b/FE/src/components/ResultModal.tsx index 6035082..9019f5e 100644 --- a/FE/src/components/ResultModal.tsx +++ b/FE/src/components/ResultModal.tsx @@ -1,6 +1,7 @@ import Lottie from 'lottie-react'; import starBg from '../assets/lottie/star_bg.json'; import { usePlayerStore } from '@/store/usePlayerStore'; +import { useRoomStore } from '@/store/useRoomStore'; type GameResultModalProps = { isOpen: boolean; @@ -13,6 +14,7 @@ export const ResultModal: React.FC = ({ onClose, currentPlayerName }) => { + const gameMode = useRoomStore((state) => state.gameMode); const players = usePlayerStore((state) => state.players); const sortedPlayers = [...players].sort((a, b) => b.playerScore - a.playerScore); @@ -38,10 +40,18 @@ export const ResultModal: React.FC = ({ className={`flex justify-between px-4 py-2 border-b border-gray-100 ${currentPlayerName === player.playerName ? `bg-cyan-100` : null} last:border-none`} > - {index + 1}등{' '} + {gameMode === 'RANKING' ? ( + {index + 1}등 + ) : player.isAlive ? ( + 생존 + ) : ( + 탈락 + )}{' '} {player.playerName} - {player.playerScore}점 + {gameMode === 'RANKING' && ( + {player.playerScore}점 + )}
))}
diff --git a/FE/src/hooks/useServerDate.ts b/FE/src/hooks/useServerDate.ts deleted file mode 100644 index b2def38..0000000 --- a/FE/src/hooks/useServerDate.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; - -const API_PATH = '/api/time'; - -const useServerDate = () => { - const [offset, setOffset] = useState(0); - useEffect(() => { - const startClientTime = Date.now(); - fetch(API_PATH) - .then((res) => res.json()) - .then((res) => { - const endClientTime = Date.now(); - const clientTime = (startClientTime + endClientTime) / 2; - setOffset(clientTime - res.serverTime); - }); - }, []); - const now = useCallback(() => Date.now() - offset, [offset]); - return now; -}; - -export default useServerDate; diff --git a/FE/src/mocks/data.ts b/FE/src/mocks/data.ts index 0cfad19..233851a 100644 --- a/FE/src/mocks/data.ts +++ b/FE/src/mocks/data.ts @@ -4,13 +4,14 @@ export const QuizSetList = Array(100) id: i, title: 'title ' + i, category: 'category ' + i, - quizList: Array(i).fill({ - id: '0', - quiz: '', - limitTime: 1000, - choiceList: { - content: 'content', - order: 1 - } - }) + quizCount: i + // quizList: Array(i).fill({ + // id: '0', + // quiz: '', + // limitTime: 1000, + // choiceList: { + // content: 'content', + // order: 1 + // } + // }) })); diff --git a/FE/src/pages/GameLobbyPage.tsx b/FE/src/pages/GameLobbyPage.tsx index 7001655..0d08a14 100644 --- a/FE/src/pages/GameLobbyPage.tsx +++ b/FE/src/pages/GameLobbyPage.tsx @@ -1,6 +1,5 @@ import { HeaderBar } from '@/components/HeaderBar'; import { LobbyList } from '@/components/LobbyList'; -import { useEffect, useState } from 'react'; // api 코드 분리 폴더 구조 논의 // const fetchLobbyRooms = async () => { diff --git a/FE/src/pages/GamePage.tsx b/FE/src/pages/GamePage.tsx index 12f99da..9762bf0 100644 --- a/FE/src/pages/GamePage.tsx +++ b/FE/src/pages/GamePage.tsx @@ -20,6 +20,7 @@ export const GamePage = () => { const currentPlayerName = usePlayerStore((state) => state.currentPlayerName); const setCurrentPlayerName = usePlayerStore((state) => state.setCurrentPlayerName); const setGameState = useRoomStore((state) => state.setGameState); + const resetScore = usePlayerStore((state) => state.resetScore); const [isModalOpen, setIsModalOpen] = useState(true); const [isResultOpen, setIsResultOpen] = useState(false); @@ -37,7 +38,6 @@ export const GamePage = () => { if (gameState === GameState.END) setIsResultOpen(true); }, [gameState]); - // setCurrentPlayerName('test123'); const handleNameSubmit = (name: string) => { setCurrentPlayerName(name); setIsModalOpen(false); // 이름이 설정되면 모달 닫기 @@ -45,6 +45,7 @@ export const GamePage = () => { const handleEndGame = () => { setGameState(GameState.WAIT); + resetScore(); setIsResultOpen(false); }; diff --git a/FE/src/store/usePlayerStore.ts b/FE/src/store/usePlayerStore.ts index acaa5ee..8f51b88 100644 --- a/FE/src/store/usePlayerStore.ts +++ b/FE/src/store/usePlayerStore.ts @@ -1,12 +1,14 @@ import { socketService } from '@/api/socket'; import { create } from 'zustand'; +import { useRoomStore } from './useRoomStore'; type Player = { playerId: string; // socketId playerName: string; playerPosition: [number, number]; // [x, y] 좌표 playerScore: number; - isAnswer?: boolean; + isAnswer: boolean; + isAlive: boolean; }; type PlayerStore = { @@ -23,6 +25,7 @@ type PlayerStore = { setCurrentPlayerName: (currentPlayerName: string) => void; setIsHost: (isHost: boolean) => void; setPlayers: (players: Player[]) => void; + resetScore: () => void; }; export const usePlayerStore = create((set) => ({ @@ -73,24 +76,33 @@ export const usePlayerStore = create((set) => ({ setCurrentPlayerId: (currentPlayerId) => { set(() => ({ currentPlayerId })); }, + setCurrentPlayerName: (currentPlayerName) => { set(() => ({ currentPlayerName })); }, setIsHost: (isHost) => { set(() => ({ isHost })); + }, + + resetScore: () => { + set((state) => ({ + players: state.players.map((p) => ({ ...p, playerScore: 0, isAlive: true, isAnswer: true })) + })); } })); socketService.on('joinRoom', (data) => { const { addPlayers, setCurrentPlayerId } = usePlayerStore.getState(); - const playersWithScore = data.players.map((player) => ({ + const newPlayers = data.players.map((player) => ({ ...player, - playerScore: 0 // 점수 없으면 0으로 설정 + playerScore: 0, + isAlive: true, + isAnswer: true })); - addPlayers(playersWithScore); + addPlayers(newPlayers); const socketId = socketService.getSocketId(); - if (playersWithScore.length > 0 && playersWithScore[0].playerId === socketId) { + if (newPlayers.length > 0 && newPlayers[0].playerId === socketId) { setCurrentPlayerId(socketId); } }); @@ -101,15 +113,41 @@ socketService.on('updatePosition', (data) => { socketService.on('endQuizTime', (data) => { const { players, setPlayers } = usePlayerStore.getState(); + const { gameMode } = useRoomStore.getState(); + setPlayers( - data.players.map((p) => ({ - playerId: String(p.playerId), - playerName: players.find((e) => e.playerId === p.playerId)?.playerName || '', - playerPosition: players.find((e) => e.playerId === p.playerId)?.playerPosition || [0, 0], - playerScore: p.score, - isAnswer: p.isAnswer - })) + data.players.map((p) => { + const _p = players.find((e) => e.playerId === p.playerId); + return { + playerId: String(p.playerId), + playerName: _p?.playerName || '', + playerPosition: _p?.playerPosition || [0, 0], + playerScore: p.score, + isAnswer: p.isAnswer, + isAlive: _p?.isAlive || false + }; + }) ); + + // 서바이벌 모드일 경우 3초 뒤에 탈락한 플레이어를 보이지 않게 한다. + if (gameMode === 'SURVIVAL') { + setTimeout(() => { + const { players, setPlayers } = usePlayerStore.getState(); + + setPlayers( + players.map((p) => { + return { + ...p, + isAlive: p.isAlive && p?.isAnswer + }; + }) + ); + }, 3000); + } +}); + +socketService.on('endGame', (data) => { + usePlayerStore.getState().setIsHost(data.hostId === socketService.getSocketId()); }); socketService.on('exitRoom', (data) => { diff --git a/FE/src/store/useQuizStore.ts b/FE/src/store/useQuizStore.ts index 87ca39b..137e3e9 100644 --- a/FE/src/store/useQuizStore.ts +++ b/FE/src/store/useQuizStore.ts @@ -40,6 +40,7 @@ type QuizStore = { setCurrentQuiz: (quiz: CurrentQuiz) => void; setCurrentAnswer: (answer: number) => void; addQuizSet: (quizSet: QuizSet) => void; + resetQuiz: () => void; }; export const useQuizeStore = create((set) => ({ @@ -58,7 +59,8 @@ export const useQuizeStore = create((set) => ({ // 진행 중인 퀴즈 설정 setCurrentQuiz: (quiz) => set({ currentQuiz: quiz }), setCurrentAnswer: (answer) => set({ currentAnswer: answer }), - setQuizState: (state) => set({ quizState: state }) + setQuizState: (state) => set({ quizState: state }), + resetQuiz: () => set({ quizSets: [], currentQuiz: null }) })); // 진행 중인 퀴즈 설정 @@ -70,3 +72,7 @@ socketService.on('endQuizTime', (data) => { useQuizeStore.getState().setQuizState(QuizState.END); useQuizeStore.getState().setCurrentAnswer(Number(data.answer)); }); + +socketService.on('endGame', () => { + useQuizeStore.getState().resetQuiz(); +}); diff --git a/FE/src/utils/serverTime.ts b/FE/src/utils/serverTime.ts new file mode 100644 index 0000000..96e07e4 --- /dev/null +++ b/FE/src/utils/serverTime.ts @@ -0,0 +1,28 @@ +let offset = 0; +let offsetTotal = 0; +let offsetCount = 0; +let lastSyncTimestamp = 0; +const SYNC_DELAY = 10000; +const API_PATH = '/api/time'; + +const syncServerTimestamp = () => { + if (lastSyncTimestamp + SYNC_DELAY > Date.now()) return; + lastSyncTimestamp = Date.now(); + const startClientTime = Date.now(); + fetch(API_PATH) + .then((res) => res.json()) + .then((res) => { + const endClientTime = Date.now(); + const clientTime = (startClientTime + endClientTime) / 2; + offsetTotal += clientTime - res.serverTime; + offsetCount++; + offset = Math.floor(offsetTotal / offsetCount); + }); +}; + +syncServerTimestamp(); + +export const getServerTimestamp = () => { + syncServerTimestamp(); + return Date.now() - offset; +};