diff --git a/FE/package.json b/FE/package.json index 933c281..8f7db18 100644 --- a/FE/package.json +++ b/FE/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" @@ -40,4 +40,4 @@ "typescript-eslint": "^8.11.0", "vite": "^5.4.10" } -} +} \ No newline at end of file diff --git a/FE/src/App.tsx b/FE/src/App.tsx index 7f91482..cb337f4 100644 --- a/FE/src/App.tsx +++ b/FE/src/App.tsx @@ -9,7 +9,7 @@ function App() { } /> } /> - } /> + } /> not found} /> diff --git a/FE/src/api/socket.ts b/FE/src/api/socket.ts index 38c33cb..5d8fd01 100644 --- a/FE/src/api/socket.ts +++ b/FE/src/api/socket.ts @@ -1,78 +1,66 @@ import { io, Socket } from 'socket.io-client'; import SocketEvents from '../constants/socketEvents'; +import { SocketDataMap } from './socketEventTypes'; -// type SocketEvent = (typeof SocketEvents)[keyof typeof SocketEvents]; - -type ChatMessage = { - userId: string; - message: string; -}; - -type CreateRoomPayload = { - title: string; - maxPlayerCount: number; - gameMode: string; - isPublic: boolean; -}; - -// 이벤트의 데이터 타입을 정의 -// type SocketDataMap = { -// [SocketEvents.CHAT_MESSAGE]: ChatMessage; -// [SocketEvents.CREATE_ROOM]: CreateRoomPayload; -// // 다른 이벤트의 데이터 타입을 추가 -// }; +type SocketEvent = keyof SocketDataMap; class SocketService { private socket: Socket; private url: string; + private handlers: (() => void)[]; constructor(url: string) { this.socket = io(); this.url = url; + this.handlers = []; } - connect() { + async connect() { + if (this.isActive()) return; this.socket = io(this.url); - return new Promise((resolve, reject) => { - this.socket.on('connect', () => resolve(null)); + await new Promise((resolve, reject) => { + this.socket.on('connect', () => resolve()); this.socket.on('error', () => reject()); }); + this.handlers.forEach((h) => h()); + return; + } + + disconnect() { + this.socket.disconnect(); } isActive() { - return this.socket && this.socket.active; + return this.socket && this.socket.connected; } - // 이벤트 수신 메서드 - // on(event: T, callback: (data: SocketDataMap[T]) => void) { - // this.socket.on(event, (data: SocketDataMap[T]) => { - // callback(data); - // }); - // } + on(event: T, callback: (data: SocketDataMap[T]['response']) => void) { + const handler = () => this.socket.on(event as string, callback); + this.handlers.push(handler); + if (this.isActive()) handler(); + } - // 메시지 전송 메서드 - sendChatMessage(message: ChatMessage) { - this.socket.emit(SocketEvents.CHAT_MESSAGE, message); + emit(event: T, data: SocketDataMap[T]['request']) { + this.socket.emit(event, data); } - // 방 생성 메서드 - async createRoom(payload: CreateRoomPayload) { - await this.connect(); - this.socket.emit(SocketEvents.CREATE_ROOM, payload); + sendChatMessage(message: SocketDataMap[typeof SocketEvents.CHAT_MESSAGE]['request']) { + this.emit(SocketEvents.CHAT_MESSAGE, message); } - // 연결 종료 메서드 - disconnect() { - this.socket.disconnect(); + async createRoom(payload: SocketDataMap['createRoom']['request']) { + await this.connect(); + this.socket.emit(SocketEvents.CREATE_ROOM, payload); } - joinRoom(gameId: string, playerName: string) { - this.socket.send(SocketEvents.JOIN_ROOM, { gameId, playerName }); + async joinRoom(gameId: string, playerName: string) { + if (!this.isActive()) await this.connect(); + this.socket.emit(SocketEvents.JOIN_ROOM, { gameId, playerName }); } chatMessage(gameId: string, message: string) { - this.socket.send(SocketEvents.CHAT_MESSAGE, { gameId, message }); + this.socket.emit(SocketEvents.CHAT_MESSAGE, { gameId, message }); } } -export const socketService = new SocketService('http://quizground.duckdns.org:3000/game'); +export const socketService = new SocketService('http://' + window.location.hostname + ':3000/game'); diff --git a/FE/src/api/socketEventTypes.ts b/FE/src/api/socketEventTypes.ts new file mode 100644 index 0000000..14c4d87 --- /dev/null +++ b/FE/src/api/socketEventTypes.ts @@ -0,0 +1,165 @@ +// 채팅 메시지 전달 타입 +type ChatMessageRequest = { + gameId: string; // PIN + message: string; +}; + +type ChatMessageResponse = { + playerId: string; // socketId + playerName: string; + message: string; + timestamp: Date; +}; + +// 플레이어 위치 업데이트 타입 +type UpdatePositionRequest = { + newPosition: [number, number]; +}; + +type UpdatePositionResponse = { + playerId: string; // socketId + playerPosition: [number, number]; +}; + +// 게임방 생성 타입 +type CreateRoomRequest = { + title: string; + gameMode: 'RANKING' | 'SURVIVAL'; + maxPlayerCount: number; + isPublic: boolean; +}; + +type CreateRoomResponse = { + gameId: string; // PIN +}; + +// 게임방 옵션 수정 타입 +type UpdateRoomOptionRequest = { + gameId: string; + title: string; + gameMode: 'RANKING' | 'SURVIVAL'; + maxPlayerCount: number; + isPublic: boolean; +}; + +type UpdateRoomOptionResponse = { + title: string; + gameMode: 'RANKING' | 'SURVIVAL'; + maxPlayerCount: number; + isPublic: boolean; +}; + +// 게임방 퀴즈셋 수정 타입 +type UpdateRoomQuizsetRequest = { + quizsetId: number; + quizCount: number; +}; + +type UpdateRoomQuizsetResponse = { + quizsetId: number; + quizCount: number; +}; + +// 게임방 입장 타입 +type JoinRoomRequest = { + gameId: string; + playerName: string; +}; + +type JoinRoomResponse = { + players: Array<{ + playerId: string; // socketId + playerName: string; + playerPosition: [number, number]; + }>; +}; + +// 게임 시작 타입 +type StartGameRequest = { + gameId: string; +}; + +type StartGameResponse = Record; // 빈 객체 + +// 게임 정지 타입 +type StopGameRequest = { + gameId: string; +}; + +type StopGameResponse = { + status: string; +}; + +// 퀴즈 시간 종료 타입 +type EndQuizTimeEvent = { + gameId: string; +}; + +// 퀴즈 시작 타입 +type StartQuizTimeEvent = { + quiz: string; + options: string[]; + quizEndTime: Date; +}; + +// 게임 점수 업데이트 타입 +type UpdateScoreEvent = { + scores: Map; // Map +}; + +// 게임방 퇴장 타입 +type ExitRoomEvent = { + playerId: string; +}; + +// 전체 소켓 이벤트 타입 맵 +export type SocketDataMap = { + chatMessage: { + request: ChatMessageRequest; + response: ChatMessageResponse; + }; + updatePosition: { + request: UpdatePositionRequest; + response: UpdatePositionResponse; + }; + createRoom: { + request: CreateRoomRequest; + response: CreateRoomResponse; + }; + updateRoomOption: { + request: UpdateRoomOptionRequest; + response: UpdateRoomOptionResponse; + }; + updateRoomQuizset: { + request: UpdateRoomQuizsetRequest; + response: UpdateRoomQuizsetResponse; + }; + joinRoom: { + request: JoinRoomRequest; + response: JoinRoomResponse; + }; + startGame: { + request: StartGameRequest; + response: StartGameResponse; + }; + stopGame: { + request: StopGameRequest; + response: StopGameResponse; + }; + endQuizTime: { + request: null; + response: EndQuizTimeEvent; + }; + startQuizTime: { + request: null; + response: StartQuizTimeEvent; + }; + updateScore: { + request: null; + response: UpdateScoreEvent; + }; + exitRoom: { + request: null; + response: ExitRoomEvent; + }; +}; diff --git a/FE/src/components/Chat.tsx b/FE/src/components/Chat.tsx index 80e46ec..ad20328 100644 --- a/FE/src/components/Chat.tsx +++ b/FE/src/components/Chat.tsx @@ -1,25 +1,13 @@ import { socketService } from '@/api/socket'; -import { useEffect, useState } from 'react'; - -const sampleChat = Array(100) - .fill(null) - .map((_, i) => ({ name: 'user' + i, message: 'messagemessagemessagemessagemessagemessage' })); +import { useChatStore } from '@/store/useChatStore'; +import { useRoomStore } from '@/store/useRoomStore'; +import { useState } from 'react'; const Chat = () => { - const [messages, setMessages] = useState>([]); + const gameId = useRoomStore((state) => state.gameId); + const messages = useChatStore((state) => state.messages); const [inputValue, setInputValue] = useState(''); - useEffect(() => { - setMessages(sampleChat); //TODO 나중에 고쳐야 함 - // 서버에서 메시지를 받을 때 - // socket.on('chat message', (message) => { - // setMessages((prevMessages) => [...prevMessages, message]); - // }); - // return () => { - // socket.off('chat message'); - // }; - }, []); - const handleInputChange = (e: React.ChangeEvent) => { setInputValue(e.target.value); }; @@ -27,18 +15,19 @@ const Chat = () => { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (inputValue.trim()) { - socketService.chatMessage('1234', inputValue); + if (inputValue.trim() && gameId) { + socketService.chatMessage(gameId, inputValue); setInputValue(''); } }; + return (
메시지
{messages.map((e, i) => (
- {e.name} + {e.playerName} {e.message}
))} diff --git a/FE/src/components/GameHeader.tsx b/FE/src/components/GameHeader.tsx index 1382324..69762f7 100644 --- a/FE/src/components/GameHeader.tsx +++ b/FE/src/components/GameHeader.tsx @@ -1,11 +1,12 @@ import { ClipboardCopy } from './ClipboardCopy'; import Card from '@mui/material/Card'; import { QuizPreview } from './QuizView'; +import { useParams } from 'react-router-dom'; export const GameHeader = () => { - // 임시값 - const pinNum = '123456'; - const linkURL = 'naver.com'; + const { gameId } = useParams<{ gameId: string }>(); + const pinNum = String(gameId); + const linkURL = window.location.hostname + `/game/${gameId}`; return (
@@ -13,7 +14,7 @@ export const GameHeader = () => {
- 퀴즈이름22 + 퀴즈이름
diff --git a/FE/src/components/ParticipantDisplay.tsx b/FE/src/components/ParticipantDisplay.tsx index 60ea9cf..75cf5f4 100644 --- a/FE/src/components/ParticipantDisplay.tsx +++ b/FE/src/components/ParticipantDisplay.tsx @@ -1,15 +1,18 @@ -const samplePalyer = Array(100) - .fill(null) - .map((_, i) => ({ id: i, name: 'user' + i })); +import { usePlayerStore } from '@/store/usePlayerStore'; const ParticipantDisplay = () => { + const players = usePlayerStore((state) => state.players); + console.log(players); return (
참가자
- {samplePalyer.map((e, i) => ( -
-
{i + 1 + '. ' + e.name}
+ {players.map((player, i) => ( +
+
{i + 1 + '. ' + player.playerName}
))} diff --git a/FE/src/components/Player.tsx b/FE/src/components/Player.tsx new file mode 100644 index 0000000..313d406 --- /dev/null +++ b/FE/src/components/Player.tsx @@ -0,0 +1,19 @@ +type Props = { + name: string; + position: [number, number]; +}; + +export const Player = ({ name, position }: Props) => { + const [xPos, yPos] = position; + // const randomX = xPos + Math.floor(Math.random() * 100) + 1; // 1~100 범위의 랜덤값을 xPos에 추가 + // const randomY = yPos + Math.floor(Math.random() * 100) + 1; + + const top = xPos * 100 + '%'; + const left = yPos * 100 + '%'; + console.log(top, left); + return ( +
+ {'😀' + name} +
+ ); +}; diff --git a/FE/src/components/QuizOptionBoard.tsx b/FE/src/components/QuizOptionBoard.tsx index 8db97cf..1ac3e43 100644 --- a/FE/src/components/QuizOptionBoard.tsx +++ b/FE/src/components/QuizOptionBoard.tsx @@ -1,3 +1,5 @@ +import { usePlayerStore } from '@/store/usePlayerStore'; +import { Player } from './Player'; type Params = { options: string[]; }; @@ -16,17 +18,25 @@ const optionColors = [ ]; export const QuizOptionBoard = ({ options }: Params) => { + const players = usePlayerStore((state) => state.players); return ( -
- {options.map((option, i) => ( -
- {i + 1 + '. ' + option} -
- ))} +
+
+ {players.map((player) => ( + + ))} +
+
+ {options.map((option, i) => ( +
+ {i + 1 + '. ' + option} +
+ ))} +
); }; diff --git a/FE/src/pages/GamePage.tsx b/FE/src/pages/GamePage.tsx index cc544df..c22115d 100644 --- a/FE/src/pages/GamePage.tsx +++ b/FE/src/pages/GamePage.tsx @@ -2,21 +2,29 @@ import Chat from '@/components/Chat'; import ParticipantDisplay from '@/components/ParticipantDisplay'; import { QuizOptionBoard } from '@/components/QuizOptionBoard'; import { Modal } from '../components/Modal'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { GameHeader } from '@/components/GameHeader'; import { HeaderBar } from '@/components/HeaderBar'; -import { useParams } from 'react-router-dom'; import { socketService } from '@/api/socket'; +import { useParams } from 'react-router-dom'; +import { useRoomStore } from '@/store/useRoomStore'; export const GamePage = () => { + const { gameId } = useParams<{ gameId: string }>(); + const updateRoom = useRoomStore((state) => state.updateRoom); const [playerName, setPlayerName] = useState(''); const [isModalOpen, setIsModalOpen] = useState(true); - const pin = useParams(); + + updateRoom({ gameId }); + + useEffect(() => { + if (gameId && playerName) { + socketService.joinRoom(gameId, playerName); + } + }, [gameId, playerName]); const handleNameSubmit = (name: string) => { setPlayerName(name); - // 닉네임 설정 소켓 요청 - socketService.joinRoom(String(pin), name); setIsModalOpen(false); // 이름이 설정되면 모달 닫기 }; @@ -29,7 +37,7 @@ export const GamePage = () => {
- +
@@ -37,7 +45,7 @@ export const GamePage = () => {
- +
{ + const gameId = useRoomStore((state) => state.gameId); const [title, setTitle] = useState(''); const [maxPlayerCount, setMaxPlayerCount] = useState(RoomConfig.DEFAULT_PLAYERS); const [gameMode, setGameMode] = useState<'SURVIVAL' | 'RANKING'>('RANKING'); const [roomPublic, setRoomPublic] = useState(true); + const navigate = useNavigate(); + + useEffect(() => { + if (gameId) navigate(`/game/${gameId}`); + }, [gameId, navigate]); const handleModeChange = (e: React.ChangeEvent) => { const value = e.target.value === 'RANKING' ? 'RANKING' : 'SURVIVAL'; @@ -31,7 +38,6 @@ export const GameSetupPage = () => { gameMode, isPublic: roomPublic }; - socketService.createRoom(roomData); }; diff --git a/FE/src/store/useChatStore.ts b/FE/src/store/useChatStore.ts new file mode 100644 index 0000000..01cc62f --- /dev/null +++ b/FE/src/store/useChatStore.ts @@ -0,0 +1,18 @@ +import { socketService } from '@/api/socket'; +import { create } from 'zustand'; + +type ChatStore = { + messages: { playerName: string; message: string }[]; + addMessage: (playerName: string, message: string) => void; +}; + +export const useChatStore = create((set) => ({ + messages: [], + addMessage: (playerName: string, message: string) => { + set((state) => ({ messages: [...state.messages, { playerName, message }] })); + } +})); + +socketService.on('chatMessage', (data) => { + useChatStore.getState().addMessage(data.playerName, data.message); +}); diff --git a/FE/src/store/usePlayerStore.ts b/FE/src/store/usePlayerStore.ts new file mode 100644 index 0000000..9ba55fb --- /dev/null +++ b/FE/src/store/usePlayerStore.ts @@ -0,0 +1,51 @@ +import { socketService } from '@/api/socket'; +import { create } from 'zustand'; + +type Player = { + playerId: string; // socketId + playerName: string; + playerPosition: [number, number]; // [x, y] 좌표 +}; + +type PlayerStore = { + players: Player[]; + addPlayers: (players: Player[]) => void; + updatePlayerPosition: (playerId: string, newPosition: [number, number]) => void; // 위치 업데이트 + removePlayer: (playerId: string) => void; +}; + +export const usePlayerStore = create((set) => ({ + players: [], + + addPlayers: (players) => { + set((state) => ({ + players: [...state.players, ...players] + })); + }, + + updatePlayerPosition: (playerId, newPosition) => { + set((state) => ({ + players: state.players.map((player) => + player.playerId === playerId ? { ...player, playerPosition: newPosition } : player + ) + })); + }, + + removePlayer: (playerId) => { + set((state) => ({ + players: state.players.filter((player) => player.playerId !== playerId) + })); + } +})); + +socketService.on('joinRoom', (data) => { + usePlayerStore.getState().addPlayers(data.players); +}); + +socketService.on('updatePosition', (data) => { + usePlayerStore.getState().updatePlayerPosition(data.playerId, data.playerPosition); +}); + +socketService.on('exitRoom', (data) => { + usePlayerStore.getState().removePlayer(data.playerId); +}); diff --git a/FE/src/store/useRoomStore.ts b/FE/src/store/useRoomStore.ts new file mode 100644 index 0000000..0316e8b --- /dev/null +++ b/FE/src/store/useRoomStore.ts @@ -0,0 +1,38 @@ +import { socketService } from '@/api/socket'; +import { create } from 'zustand'; + +type RoomOption = { + title?: string; + gameMode?: 'RANKING' | 'SURVIVAL'; + maxPlayerCount?: number; + isPublic?: boolean; + gameId?: string; +}; + +type RoomStore = { + title: string; + gameMode: 'RANKING' | 'SURVIVAL'; + maxPlayerCount: number; + isPublic: boolean; + gameId: string; + updateRoom: (roomOption: RoomOption) => void; +}; + +export const useRoomStore = create((set) => ({ + title: '', + gameMode: 'SURVIVAL', + maxPlayerCount: 50, + isPublic: true, + gameId: '', + updateRoom: (roomOption: RoomOption) => { + set(() => roomOption); + } +})); + +socketService.on('createRoom', (data) => { + useRoomStore.getState().updateRoom({ gameId: data.gameId }); +}); + +socketService.on('updateRoomOption', (data) => { + useRoomStore.getState().updateRoom(data); +});