- [x] 입장 코드 입력하고 접속 가능 한 거 보여주기
- [ ] 퀴즈존 진행 과정
- [x] 퀴즈존 접속 채팅 가능
- [x] 방장이 퀴즈존 시작 버튼 클릭하면 시작하는 모습
- [x] 퀴즈 문제 풀이 과정(대기 - 풀이 - 결과 보여주기)
- [x] 퀴즈 결과 페이지 확인 모습 보여주기
- [x] 30초 후에 소켓 연결 해제 확인
- [ ] 퀴즈존 생성 과정
- [x] 퀴즈셋 선택 후 생성하기
- [x] 퀴즈셋 직접 생성
- [ ] 300명 접속 가능 테스트
import { useReducer, useRef } from 'react';
import useWebSocket from '@/hook/useWebSocket.tsx';
import {
ChatMessage,
JoinQuizZoneResponse,
NextQuizResponse,
Player,
QuizZone,
QuizZoneResultState,
SomeoneSubmitResponse,
SubmitResponse,
} from '@/types/quizZone.types.ts';
import atob from '@/utils/atob';
export type QuizZoneAction =
| { type: 'init'; payload: QuizZone }
| { type: 'join'; payload: JoinQuizZoneResponse }
| { type: 'someone_join'; payload: Player }
| { type: 'someone_leave'; payload: string }
| { type: 'start'; payload: undefined }
| { type: 'submit'; payload: SubmitResponse }
| { type: 'someone_submit'; payload: SomeoneSubmitResponse }
| { type: 'nextQuiz'; payload: NextQuizResponse }
| { type: 'playQuiz'; payload: undefined }
| { type: 'quizTimeout'; payload: undefined }
| { type: 'finish'; payload: undefined }
| { type: 'summary'; payload: QuizZoneResultState }
| { type: 'chat'; payload: ChatMessage }
| { type: 'leave'; payload: undefined };
export type chatAction = {
type: 'chat';
payload: ChatMessage;
};
type Reducer<S, A> = (state: S, action: A) => S;
const quizZoneReducer: Reducer<QuizZone, QuizZoneAction> = (state, action) => {
const { type, payload } = action;
switch (type) {
case 'init':
return {
...state,
stage: payload.stage,
title: payload.title,
description: payload.description,
quizCount: payload.quizCount,
hostId: payload.hostId,
currentPlayer: payload.currentPlayer,
chatMessages: payload.chatMessages,
currentQuiz:
payload.currentQuiz !== undefined
? {
...payload.currentQuiz,
question: atob(payload.currentQuiz?.question ?? ''),
}
: undefined,
maxPlayers: payload.maxPlayers,
players: [],
};
case 'join':
return { ...state, players: payload.Players, offset: payload.offset };
case 'someone_join':
const isPlayerExist = state.players?.some((player) => player.id === payload.id);
if (isPlayerExist) {
return state; // 이미 존재하는 플레이어라면 상태 변경 없음
}
return { ...state, players: [...(state.players ?? []), payload] };
case 'someone_leave':
return {
...state,
players: state.players?.filter((player) => player.id !== payload) ?? [],
};
case 'start':
return {
...state,
stage: 'LOBBY',
};
case 'submit':
return {
...state,
state: 'IN_PROGRESS',
currentPlayer: {
...state.currentPlayer,
state: 'SUBMIT',
},
chatMessages: payload.chatMessages,
currentQuizResult: {
fastestPlayers: payload.fastestPlayerIds
.map((id) => state.players?.find((p) => p.id === id))
.filter((p) => !!p),
submittedCount: payload.submittedCount,
totalPlayerCount: payload.totalPlayerCount,
},
};
case 'someone_submit':
const { clientId, submittedCount } = payload;
const player = state.players?.find((p) => p.id === clientId);
const fastestPlayers = state.currentQuizResult?.fastestPlayers ?? [];
return {
...state,
currentQuizResult: {
...state.currentQuizResult!,
fastestPlayers: [...fastestPlayers, player].slice(0, 3).filter((p) => !!p),
submittedCount,
},
};
case 'nextQuiz':
const { nextQuiz } = payload;
return {
...state,
stage: 'IN_PROGRESS',
currentPlayer: {
...state.currentPlayer,
state: 'WAIT',
},
currentQuiz: {
...state.currentQuiz,
question: atob(nextQuiz.question),
currentIndex: nextQuiz.currentIndex,
playTime: nextQuiz.playTime,
startTime: nextQuiz.startTime,
deadlineTime: nextQuiz.deadlineTime,
quizType: 'SHORT',
},
currentQuizResult: {
...state.currentQuizResult,
...payload.currentQuizResult,
},
};
case 'playQuiz':
return {
...state,
stage: 'IN_PROGRESS',
currentPlayer: {
...state.currentPlayer,
state: 'PLAY',
},
};
case 'quizTimeout':
return {
...state,
state: 'IN_PROGRESS',
currentPlayer: {
...state.currentPlayer,
state: 'WAIT',
},
};
case 'finish':
return {
...state,
stage: 'RESULT',
isLastQuiz: true,
};
case 'summary':
return {
...state,
stage: 'RESULT',
score: payload.score,
submits: payload.submits,
quizzes: payload.quizzes,
ranks: payload.ranks,
endSocketTime: payload.endSocketTime,
};
case 'chat':
return {
...state,
chatMessages: [...(state.chatMessages || []), payload],
};
case 'leave':
return {
isQuizZoneEnd: true,
...state,
};
default:
return state;
}
};
export const chatMessagesReducer: Reducer<ChatMessage[], chatAction> = (chatMessages, action) => {
const { type, payload } = action;
switch (type) {
case 'chat':
return [...chatMessages, payload];
default:
return chatMessages;
}
};
/**
* @description 다중 사용자 퀴즈 게임 환경에서 퀴즈존 상태와 상호작용을 관리하는 커스텀 훅입니다.
*
* @example
* ```tsx
* const QuizComponent = () => {
* const {
* quizZoneState,
* initQuizZoneData,
* submitQuiz,
* startQuiz,
* playQuiz
* } = useQuizZone();
*
* // 퀴즈 초기화
* useEffect(() => {
* initQuizZoneData(initialData);
* }, []);
*
* // 답안 제출
* const handleSubmit = (answer: string) => {
* submitQuiz(answer);
* };
* ```
*
* @returns {Object} 퀴즈존 상태와 제어 함수들을 포함하는 객체
* @returns {QuizZone} .quizZoneState - 현재 퀴즈존의 상태
* @returns {Function} .initQuizZoneData - 초기 데이터로 퀴즈존을 초기화하는 함수
* @returns {Function} .submitQuiz - 현재 퀴즈에 대한 답안을 제출하는 함수
* @returns {Function} .startQuiz - 퀴즈 세션을 시작하는 함수
* @returns {Function} .playQuiz - 퀴즈 상태를 플레이 모드로 변경하는 함수
*/
const useQuizZone = (quizZoneId: string, handleReconnect?: () => void) => {
const initialQuizZoneState: QuizZone = {
stage: 'LOBBY',
currentPlayer: {
id: '',
nickname: '',
},
title: '',
description: '',
hostId: '',
quizCount: 0,
players: [],
score: 0,
submits: [],
quizzes: [],
chatMessages: [],
maxPlayers: 0,
offset: 0,
};
const joinRequestTimeRef = useRef(0);
const [quizZoneState, dispatch] = useReducer(quizZoneReducer, initialQuizZoneState);
const messageHandler = (event: MessageEvent) => {
const { event: QuizZoneEvent, data } = JSON.parse(event.data);
if (QuizZoneEvent === 'join') {
// console.log(data);
const receiveTime = new Date().getTime();
console.log(receiveTime);
const serverTime = data.serverTime;
console.log('serverTime', serverTime);
// offset = 서버시간 - (요청시간 + 응답시간)/2
const offset = serverTime - (joinRequestTimeRef.current + receiveTime) / 2;
console.log('offset', offset);
const newData: JoinQuizZoneResponse = { Players: data.data, offset };
dispatch({
type: QuizZoneEvent,
payload: newData,
});
return;
}
dispatch({
type: QuizZoneEvent,
payload: data,
});
};
const getServerTime = () => {
return new Date().getTime() + quizZoneState.offset;
};
const handleFinish = () => {
dispatch({ type: 'leave', payload: undefined });
};
const wsUrl = `${import.meta.env.VITE_WS_URL}/play`;
const { beginConnection, sendMessage, closeConnection } = useWebSocket({
wsUrl,
messageHandler,
handleFinish,
handleReconnect,
});
//initialize QuizZOne
const initQuizZoneData = async (quizZone: QuizZone) => {
dispatch({ type: 'init', payload: quizZone });
beginConnection();
joinQuizZone({ quizZoneId });
};
//퀴즈 시작 함수
const startQuiz = () => {
const message = JSON.stringify({ event: 'start' });
sendMessage(message);
};
//퀴즈존 나가기 함수
const exitQuiz = () => {
const message = JSON.stringify({ event: 'leave' });
sendMessage(message);
};
// 퀴즈 제출 함수
const submitQuiz = (answer: string) => {
const message = JSON.stringify({
event: 'submit',
data: {
answer,
index: quizZoneState.currentQuiz?.currentIndex,
submittedAt: getServerTime(),
},
});
sendMessage(message);
};
const playQuiz = () => {
dispatch({ type: 'playQuiz', payload: undefined });
};
const joinQuizZone = ({ quizZoneId }: any) => {
const requestTime = new Date().getTime();
joinRequestTimeRef.current = requestTime;
const message = JSON.stringify({ event: 'join', data: { quizZoneId } });
sendMessage(message);
};
const sendChat = (chatMessage: any) => {
sendMessage(JSON.stringify({ event: 'chat', data: chatMessage }));
};
return {
quizZoneState,
initQuizZoneData,
submitQuiz,
startQuiz,
playQuiz,
closeConnection,
exitQuiz,
joinQuizZone,
sendChat,
getServerTime,
};
};
export default useQuizZone;
//play.gateway.ts
@SubscribeMessage('join')
async join(
@ConnectedSocket() client: WebSocketWithSession,
@MessageBody() quizJoinDto: QuizJoinDto,
) {
const sessionId = client.session.id;
const { quizZoneId } = quizJoinDto;
const { currentPlayer, players } = await this.playService.joinQuizZone(
quizZoneId,
sessionId,
);
const { id, nickname } = currentPlayer;
const playerIds = players.map((player) => player.id);
const data = players.map(({ id, nickname }) => ({
id,
nickname,
}));
if (this.clients.has(sessionId) && this.clients.get(sessionId).quizZoneId === quizZoneId) {
this.clients.set(sessionId, { quizZoneId, socket: client });
return {
event: 'join',
data: { data, serverTime: Date.now() },
};
}
this.clients.set(sessionId, { quizZoneId, socket: client });
this.broadcast(playerIds, 'someone_join', { id, nickname });
return {
event: 'join',
data: { data, serverTime: Date.now() },
};
}