(null);
17 |
18 | // 클린업 함수 정의
19 | const cleanup = () => {
20 | if (pitchDetectorRef.current) {
21 | pitchDetectorRef.current.cleanup();
22 | pitchDetectorRef.current = null;
23 | }
24 | updatePitch(0, 0); // 피치와 볼륨을 모두 0으로 초기화
25 | setIsAnalyzing(false);
26 | };
27 |
28 | useEffect(() => {
29 | // 클레오파트라 모드가 아니거나, 턴 데이터가 없으면 pitch 분석 중지
30 | if (!isCleopatraMode || !turnData) {
31 | cleanup();
32 | return;
33 | }
34 |
35 | // 이미 분석 중이면 중복 실행 방지
36 | if (isAnalyzing) return;
37 |
38 | // 스트림이 없으면 대기 (스트림이 늦게 도착할 수 있음)
39 | if (!stream) {
40 | // 스트림이 도착할 때까지 대기
41 | const intervalId = setInterval(() => {
42 | const newStream = signalingSocket.getPeerStream(
43 | turnData.playerNickname
44 | );
45 | if (newStream) {
46 | clearInterval(intervalId);
47 | startPitchDetection(newStream);
48 | }
49 | }, 500);
50 |
51 | // 컴포넌트 언마운트 시 인터벌 클리어
52 | return () => clearInterval(intervalId);
53 | } else {
54 | // 스트림이 있으면 바로 pitch 분석 시작
55 | startPitchDetection(stream);
56 | }
57 |
58 | // 컴포넌트 언마운트 시 클린업
59 | return cleanup;
60 | }, [isCleopatraMode, stream, turnData, currentPlayer]);
61 |
62 | const startPitchDetection = (stream: MediaStream) => {
63 | // 기존 분석 중지 및 초기화
64 | cleanup();
65 | setIsAnalyzing(true);
66 |
67 | // 현재 플레이어가 턴을 진행 중인지 확인
68 | const isCurrentPlayerTurn = turnData.playerNickname === currentPlayer;
69 |
70 | // 피치 검출기 생성 및 설정
71 | const pitchDetector = new PitchDetector();
72 | pitchDetector.setup(
73 | stream,
74 | (pitch, volume) => {
75 | updatePitch(pitch, volume);
76 | },
77 | {
78 | currentPlayer: turnData.playerNickname,
79 | isCurrent: isCurrentPlayerTurn,
80 | },
81 | true // 게임이 활성화된 상태로 설정
82 | );
83 |
84 | pitchDetectorRef.current = pitchDetector;
85 | };
86 |
87 | return null; // 이 훅은 컴포넌트에 UI를 렌더링하지 않으므로 null 반환
88 | }
89 |
--------------------------------------------------------------------------------
/fe/src/hooks/usePreventRefresh.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { toast } from 'react-toastify';
3 |
4 | export const usePreventRefresh = (isPlaying: boolean) => {
5 | useEffect(() => {
6 | if (!isPlaying) return;
7 |
8 | const preventKeyboardRefresh = (e: KeyboardEvent) => {
9 | if (
10 | e.key === 'F5' ||
11 | (e.ctrlKey && e.key === 'r') ||
12 | (e.metaKey && e.key === 'r')
13 | ) {
14 | e.preventDefault();
15 |
16 | toast.error('게임 중에는 새로고침 할 수 없습니다!', {
17 | position: 'top-left',
18 | autoClose: 1000,
19 | style: {
20 | fontFamily: 'Galmuri11, monospace',
21 | width: '25rem',
22 | minWidth: '25rem',
23 | },
24 | });
25 | }
26 | };
27 |
28 | document.addEventListener('keydown', preventKeyboardRefresh);
29 |
30 | return () => {
31 | document.removeEventListener('keydown', preventKeyboardRefresh);
32 | };
33 | }, [isPlaying]);
34 | };
35 |
--------------------------------------------------------------------------------
/fe/src/hooks/useReconnect.ts:
--------------------------------------------------------------------------------
1 | import { gameSocket } from '@/services/gameSocket';
2 | import { signalingSocket } from '@/services/signalingSocket';
3 | import { getCurrentRoomQuery } from '@/stores/queries/getCurrentRoomQuery';
4 | import useRoomStore from '@/stores/zustand/useRoomStore';
5 | import { useEffect } from 'react';
6 | import { useParams } from 'react-router-dom';
7 | import { useAudioPermission } from './useAudioPermission';
8 | import { useAudioManager } from './useAudioManager';
9 |
10 | export const useReconnect = ({ currentRoom }) => {
11 | const { roomId } = useParams();
12 | const { setCurrentRoom } = useRoomStore();
13 | const nickname = sessionStorage.getItem('user_nickname');
14 | const { data: room } = getCurrentRoomQuery(roomId);
15 | const { requestPermission } = useAudioPermission();
16 | const audioManager = useAudioManager();
17 |
18 | useEffect(() => {
19 | const handleReconnect = async () => {
20 | try {
21 | if (room && !currentRoom) {
22 | // 현재 방 설정
23 | setCurrentRoom(room);
24 |
25 | // 게임 소켓 연결
26 | if (!gameSocket.socket?.connected) {
27 | gameSocket.connect();
28 | await gameSocket.joinRoom(roomId, nickname);
29 | }
30 |
31 | // 마이크 권한 요청 및 스트림 설정
32 | const stream = await requestPermission();
33 |
34 | if (!signalingSocket.socket?.connected) {
35 | console.log('Connecting signalingSocket...');
36 | signalingSocket.connect();
37 | await signalingSocket.setupLocalStream(stream);
38 | }
39 |
40 | // audioManager 설정 (소켓 연결 후)
41 | if (!signalingSocket.hasAudioManager()) {
42 | signalingSocket.setAudioManager(audioManager);
43 | }
44 |
45 | // 시그널링 방 참가
46 | await signalingSocket.joinRoom(room, nickname);
47 | }
48 | } catch (error) {
49 | console.error('Reconnection failed:', error);
50 | // 실패 시 audioManager 제거
51 | signalingSocket.setAudioManager(null);
52 |
53 | if (error === 'GameAlreadyInProgress') {
54 | sessionStorage.setItem('gameInProgressError', 'true');
55 | window.location.href = '/rooms';
56 | }
57 | }
58 | };
59 |
60 | handleReconnect();
61 |
62 | return () => {
63 | signalingSocket.setAudioManager(null);
64 | };
65 | }, [room, currentRoom, audioManager, requestPermission, roomId, nickname]);
66 | };
67 |
--------------------------------------------------------------------------------
/fe/src/hooks/useRoomsSSE.ts:
--------------------------------------------------------------------------------
1 | import useRoomStore from '@/stores/zustand/useRoomStore';
2 | import { useEffect } from 'react';
3 | import { ENV } from '@/config/env';
4 | import { getRoomsQuery } from '@/stores/queries/getRoomsQuery';
5 |
6 | let eventSource: EventSource | null = null;
7 |
8 | export const useRoomsSSE = () => {
9 | const { setRooms, setPagination, setUserPage } = useRoomStore();
10 | const userPage = useRoomStore((state) => state.userPage);
11 | const { data } = getRoomsQuery(userPage);
12 |
13 | const connectSSE = (userPage: number) => {
14 | eventSource = new EventSource(`${ENV.SSE_URL}?page=${userPage}`);
15 |
16 | eventSource.onmessage = (event) => {
17 | try {
18 | const sseData = JSON.parse(event.data);
19 | setRooms(sseData.rooms);
20 | setPagination(sseData.pagination);
21 |
22 | if (!sseData.rooms.length && userPage > 0) {
23 | setUserPage(sseData.pagination.currentPage - 1);
24 | return;
25 | }
26 |
27 | setUserPage(sseData.pagination.currentPage);
28 | } catch (error) {
29 | console.error('Failed to parse rooms data:', error);
30 | }
31 | };
32 |
33 | eventSource.onerror = (error) => {
34 | console.error('SSE Error:', error);
35 | eventSource.close();
36 | };
37 | };
38 |
39 | useEffect(() => {
40 | if (data) {
41 | setRooms(data.rooms);
42 | setPagination(data.pagination);
43 | connectSSE(userPage);
44 | }
45 |
46 | return () => {
47 | if (eventSource) {
48 | eventSource.close();
49 | eventSource = null;
50 | }
51 | };
52 | }, [data?.pagination, data?.rooms, userPage]);
53 | };
54 |
--------------------------------------------------------------------------------
/fe/src/index.css:
--------------------------------------------------------------------------------
1 | @import url('https://cdn.jsdelivr.net/npm/galmuri@latest/dist/galmuri.css');
2 |
3 | @tailwind base;
4 | @tailwind components;
5 | @tailwind utilities;
6 |
7 | @layer base {
8 | @font-face {
9 | font-family: 'DOSGothic';
10 | src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_eight@1.0/DOSGothic.woff')
11 | format('woff');
12 | font-weight: normal;
13 | font-style: normal;
14 | }
15 |
16 | :root {
17 | --background: 0 0% 100%;
18 | --foreground: 222.2 84% 4.9%;
19 | --card: 0 0% 100%;
20 | --card-foreground: 222.2 84% 4.9%;
21 | --popover: 0 0% 100%;
22 | --popover-foreground: 222.2 84% 4.9%;
23 | --primary: 222.2 47.4% 11.2%;
24 | --primary-foreground: 210 40% 98%;
25 | --secondary: 210 40% 96.1%;
26 | --secondary-foreground: 222.2 47.4% 11.2%;
27 | --muted: 210 40% 96.1%;
28 | --muted-foreground: 215.4 16.3% 46.9%;
29 | --accent: 210 40% 96.1%;
30 | --accent-foreground: 222.2 47.4% 11.2%;
31 | --destructive: 0 84.2% 60.2%;
32 | --destructive-foreground: 210 40% 98%;
33 | --border: 214.3 31.8% 91.4%;
34 | --input: 214.3 31.8% 91.4%;
35 | --ring: 222.2 84% 4.9%;
36 | --chart-1: 12 76% 61%;
37 | --chart-2: 173 58% 39%;
38 | --chart-3: 197 37% 24%;
39 | --chart-4: 43 74% 66%;
40 | --chart-5: 27 87% 67%;
41 | --radius: 0.5rem;
42 | }
43 | .dark {
44 | --background: 222.2 84% 4.9%;
45 | --foreground: 210 40% 98%;
46 | --card: 222.2 84% 4.9%;
47 | --card-foreground: 210 40% 98%;
48 | --popover: 222.2 84% 4.9%;
49 | --popover-foreground: 210 40% 98%;
50 | --primary: 210 40% 98%;
51 | --primary-foreground: 222.2 47.4% 11.2%;
52 | --secondary: 217.2 32.6% 17.5%;
53 | --secondary-foreground: 210 40% 98%;
54 | --muted: 217.2 32.6% 17.5%;
55 | --muted-foreground: 215 20.2% 65.1%;
56 | --accent: 217.2 32.6% 17.5%;
57 | --accent-foreground: 210 40% 98%;
58 | --destructive: 0 62.8% 30.6%;
59 | --destructive-foreground: 210 40% 98%;
60 | --border: 217.2 32.6% 17.5%;
61 | --input: 217.2 32.6% 17.5%;
62 | --ring: 212.7 26.8% 83.9%;
63 | --chart-1: 220 70% 50%;
64 | --chart-2: 160 60% 45%;
65 | --chart-3: 30 80% 55%;
66 | --chart-4: 280 65% 60%;
67 | --chart-5: 340 75% 55%;
68 | }
69 | }
70 | @layer base {
71 | * {
72 | @apply border-border;
73 | }
74 | body {
75 | @apply bg-background text-foreground relative;
76 | }
77 | }
78 |
79 | :root {
80 | margin: 0;
81 | padding: 0;
82 |
83 | color-scheme: light dark;
84 |
85 | font-synthesis: none;
86 | text-rendering: optimizeLegibility;
87 | }
88 |
89 | * {
90 | box-sizing: border-box;
91 | }
92 |
93 | html,
94 | body,
95 | #root {
96 | height: 100%;
97 | }
98 |
99 | body {
100 | margin: 0;
101 | }
102 |
103 | .app {
104 | @apply flex flex-col mx-auto justify-center max-w-[1074px] lg:px-0 p-8;
105 | }
106 |
107 | .game-wrapper {
108 | @apply relative w-full min-h-screen;
109 | }
110 |
111 | .game-wrapper::before {
112 | @apply content-[''] fixed inset-0 w-full h-full bg-main-desert bg-cover bg-center opacity-75 -z-10;
113 | }
114 |
--------------------------------------------------------------------------------
/fe/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/fe/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client';
2 | import './index.css';
3 | import App from './App.tsx';
4 |
5 | createRoot(document.getElementById('root')!).render();
6 |
--------------------------------------------------------------------------------
/fe/src/pages/GamePage/GameDialog/ExitDialog.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AlertDialog,
3 | AlertDialogAction,
4 | AlertDialogCancel,
5 | AlertDialogContent,
6 | AlertDialogDescription,
7 | AlertDialogFooter,
8 | AlertDialogHeader,
9 | AlertDialogTitle,
10 | } from '@/components/ui/alert-dialog';
11 | import { gameSocket } from '@/services/gameSocket';
12 | import { signalingSocket } from '@/services/signalingSocket';
13 | import useGameStore from '@/stores/zustand/useGameStore';
14 | import useRoomStore from '@/stores/zustand/useRoomStore';
15 | import { RoomDialogProps } from '@/types/roomTypes';
16 | import { useNavigate } from 'react-router-dom';
17 |
18 | const ExitDialog = ({ open, onOpenChange }: RoomDialogProps) => {
19 | const { setCurrentRoom } = useRoomStore();
20 | const resetGame = useGameStore((state) => state.resetGame);
21 | const navigate = useNavigate();
22 |
23 | const handleExit = () => {
24 | gameSocket.disconnect();
25 | signalingSocket.disconnect();
26 |
27 | setCurrentRoom(null);
28 | resetGame();
29 | navigate('/rooms');
30 | };
31 |
32 | return (
33 |
34 |
35 |
36 | 방 나가기
37 |
38 | 정말로 방을 나가시겠습니까?
39 |
40 |
41 |
42 | 취소
43 | 확인
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default ExitDialog;
51 |
--------------------------------------------------------------------------------
/fe/src/pages/GamePage/GameDialog/KickDialog.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AlertDialog,
3 | AlertDialogAction,
4 | AlertDialogCancel,
5 | AlertDialogContent,
6 | AlertDialogDescription,
7 | AlertDialogFooter,
8 | AlertDialogHeader,
9 | AlertDialogTitle,
10 | } from '@/components/ui/alert-dialog';
11 | import { RoomDialogProps } from '@/types/roomTypes';
12 | import { gameSocket } from '@/services/gameSocket';
13 | import { cn } from '@/lib/utils';
14 |
15 | interface KickDialogProps extends RoomDialogProps {
16 | playerNickname: string;
17 | }
18 |
19 | const KickDialog = ({
20 | open,
21 | onOpenChange,
22 | playerNickname,
23 | }: KickDialogProps) => {
24 | const handleKick = () => {
25 | gameSocket.kickPlayer(playerNickname);
26 | onOpenChange(false);
27 | };
28 |
29 | return (
30 |
31 |
32 |
33 | 강제 퇴장 확인
34 |
35 | {playerNickname}님을 정말로 강제 퇴장 하시겠습니까?
36 |
37 |
38 |
39 | 취소
40 |
44 | 확인
45 |
46 |
47 |
48 |
49 | );
50 | };
51 |
52 | export default KickDialog;
53 |
--------------------------------------------------------------------------------
/fe/src/pages/GamePage/GameScreen/EndScreen.tsx:
--------------------------------------------------------------------------------
1 | import Lottie from 'lottie-react';
2 | import podiumAnimation from '@/assets/lottie/podium.json';
3 | import useGameStore from '@/stores/zustand/useGameStore';
4 | import { motion } from 'framer-motion';
5 | import { Button } from '@/components/ui/button';
6 | import { useParams } from 'react-router-dom';
7 | import { getCurrentRoomQuery } from '@/stores/queries/getCurrentRoomQuery';
8 | import useRoomStore from '@/stores/zustand/useRoomStore';
9 |
10 | const EndScreen = () => {
11 | const rank = useGameStore((state) => state.rank);
12 | const resetGame = useGameStore((state) => state.resetGame);
13 | const { roomId } = useParams();
14 | const { refetch } = getCurrentRoomQuery(roomId);
15 | const { setCurrentRoom } = useRoomStore();
16 |
17 | const handleGameEnd = async () => {
18 | try {
19 | resetGame();
20 | // room 정보 다시 가져오기
21 | const { data } = await refetch();
22 | // 새로운 room 정보로 상태 업데이트
23 | if (data) {
24 | setCurrentRoom(data);
25 | }
26 | } catch (error) {
27 | console.error('Failed to refresh room data:', error);
28 | }
29 | };
30 |
31 | const positions = {
32 | 0: { top: '18%', left: '49.7%' },
33 | 1: { top: '54%', left: '34.5%' },
34 | 2: { top: '68%', left: '60.5%' },
35 | };
36 |
37 | const getDelay = (index: number) => {
38 | switch (index) {
39 | case 2:
40 | return 0.7;
41 | case 1:
42 | return 1.4;
43 | case 0:
44 | return 2.1;
45 | default:
46 | return 0;
47 | }
48 | };
49 |
50 | return (
51 |
52 |
53 |
58 |
59 |
60 | {rank.slice(0, 3).map((playerName, index) => (
61 |
77 |
87 |
88 | {playerName}
89 |
90 |
91 |
92 | ))}
93 |
94 |
95 |
101 | 최종 순위
102 |
103 | {rank.map((playerName, index) => (
104 |
108 | {index + 1}위
109 | {playerName}
110 |
111 | ))}
112 |
113 |
114 |
115 |
121 |
133 |
134 |
135 |
136 | );
137 | };
138 |
139 | export default EndScreen;
140 |
--------------------------------------------------------------------------------
/fe/src/pages/GamePage/GameScreen/GameResult.tsx:
--------------------------------------------------------------------------------
1 | import useGameStore from '@/stores/zustand/useGameStore';
2 | import { motion } from 'framer-motion';
3 |
4 | const GameResult = () => {
5 | const { resultData, turnData } = useGameStore();
6 |
7 | if (!resultData) return null;
8 |
9 | const getResultText = () => {
10 | const resultText = resultData.result === 'PASS' ? 'PASS!' : 'FAIL!';
11 |
12 | if (turnData?.gameMode === 'CLEOPATRA') {
13 | return `${resultData.note} ${resultText}`;
14 | }
15 | return `${resultData.pronounceScore}점 ${resultText}`;
16 | };
17 |
18 | return (
19 |
26 |
27 |
28 | {resultData.playerNickname}
29 |
30 |
46 | {getResultText()}
47 |
48 |
49 |
50 | );
51 | };
52 |
53 | export default GameResult;
54 |
--------------------------------------------------------------------------------
/fe/src/pages/GamePage/GameScreen/GameScreen.tsx:
--------------------------------------------------------------------------------
1 | import useGameStore from '@/stores/zustand/useGameStore';
2 | import useRoomStore from '@/stores/zustand/useRoomStore';
3 | import { useEffect } from 'react';
4 | import ReadyScreen from './ReadyScreen';
5 | import PlayScreen from './PlayScreen';
6 |
7 | const GameScreen = () => {
8 | const { currentPlayer, setCurrentPlayer } = useRoomStore();
9 | const { turnData } = useGameStore();
10 |
11 | useEffect(() => {
12 | if (!currentPlayer) {
13 | const nickname = sessionStorage.getItem('user_nickname');
14 | if (nickname) {
15 | setCurrentPlayer(nickname);
16 | }
17 | }
18 | }, [currentPlayer]);
19 |
20 | return turnData ? : ;
21 | };
22 |
23 | export default GameScreen;
24 |
--------------------------------------------------------------------------------
/fe/src/pages/GamePage/GameScreen/Lyric.tsx:
--------------------------------------------------------------------------------
1 | import { motion, AnimatePresence } from 'framer-motion';
2 |
3 | interface LyricProps {
4 | text: string;
5 | timing: number;
6 | isActive: boolean;
7 | playerIndex?: number;
8 | }
9 |
10 | const Lyric = ({ text, timing, isActive, playerIndex = 0 }: LyricProps) => {
11 | return (
12 |
13 | {isActive && (
14 |
15 |
28 | {text}
29 |
30 |
31 | )}
32 |
33 | );
34 | };
35 |
36 | export default Lyric;
37 |
--------------------------------------------------------------------------------
/fe/src/pages/GamePage/GameScreen/ReadyScreen.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useMemo } from 'react';
2 | import { Button } from '@/components/ui/button';
3 | import { Gamepad2, CheckCircle2 } from 'lucide-react';
4 | import useRoomStore from '@/stores/zustand/useRoomStore';
5 | import { gameSocket } from '@/services/gameSocket';
6 |
7 | const ReadyScreen = () => {
8 | const [isReady, setIsReady] = useState(false);
9 | const [isGameStarted, setIsGameStarted] = useState(false);
10 | const { currentRoom, currentPlayer } = useRoomStore();
11 |
12 | if (!currentRoom) return null;
13 |
14 | const isHost = currentPlayer === currentRoom.hostNickname;
15 |
16 | const canStartGame = useMemo(() => {
17 | if (!currentRoom) return false;
18 | if (currentRoom.players.length <= 1) return false;
19 |
20 | return currentRoom.players.every((player) => {
21 | const isPlayerHost = player.playerNickname === currentRoom.hostNickname;
22 | return isPlayerHost || player.isReady;
23 | });
24 | }, [currentRoom]);
25 |
26 | const toggleReady = () => {
27 | const newReadyState = !isReady;
28 | setIsReady(newReadyState);
29 |
30 | if (!isHost) {
31 | gameSocket.setReady();
32 | }
33 | };
34 |
35 | const handleGameStart = () => {
36 | if (!isHost || isGameStarted) return;
37 |
38 | try {
39 | console.log('Starting game...');
40 | gameSocket.startGame();
41 | setIsGameStarted((prev) => !prev);
42 | console.log('Game socket event emitted');
43 | } catch (error) {
44 | console.error('Game start error:', error);
45 | }
46 | };
47 |
48 | return (
49 |
50 | {isHost ? (
51 |
60 | ) : (
61 |
69 | )}
70 |
71 | {!canStartGame ? (
72 |
73 | 모든 플레이어가 준비를 완료해야 게임을 시작할 수 있습니다.
74 |
75 | ) : (
76 |
77 | 모든 플레이어가 준비 완료되었습니다. 게임을 시작해 주세요!
78 |
79 | )}
80 |
81 | );
82 | };
83 |
84 | export default ReadyScreen;
85 |
--------------------------------------------------------------------------------
/fe/src/pages/GamePage/PlayerList/Player.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent } from '@/components/ui/card';
2 | import { FaCrown, FaMicrophoneSlash, FaRegFaceSmile } from 'react-icons/fa6';
3 | import VolumeBar from './VolumeBar';
4 | import { PlayerProps } from '@/types/roomTypes';
5 | import { isHost } from '@/utils/playerUtils';
6 | import useRoomStore from '@/stores/zustand/useRoomStore';
7 | import { Button } from '@/components/ui/button';
8 | import { useEffect, useState } from 'react';
9 | import { signalingSocket } from '@/services/signalingSocket';
10 | import KickDialog from '../GameDialog/KickDialog';
11 | import { gameSocket } from '@/services/gameSocket';
12 | import MikeButton from '@/components/common/MikeButton';
13 | import useGameStore from '@/stores/zustand/useGameStore';
14 |
15 | const Player = ({ playerNickname, isReady, isDead, isLeft }: PlayerProps) => {
16 | const { currentRoom, currentPlayer } = useRoomStore();
17 | // 본인이 방장인지
18 | const isCurrentPlayerHost = currentPlayer === currentRoom?.hostNickname;
19 | // 방장인지 참가자인지
20 | const isPlayerHost = isHost(playerNickname);
21 | // playerNickname이 본인인지
22 | const isCurrentPlayer = currentPlayer === playerNickname;
23 | // 본인의 음소거 상태 (마이크 버튼 토글)
24 | const [isCurrentPlayerMuted, setIsCurrentPlayerMuted] = useState(false);
25 | // 음소거한 사용자 다른 사용자에게 표시하기 위한 상태
26 | const [isMuted, setIsMuted] = useState(false);
27 | const [showKickDialog, setShowKickDialog] = useState(false);
28 | const muteStatus = useGameStore((state) => state.muteStatus);
29 |
30 | useEffect(() => {
31 | setIsMuted(muteStatus[playerNickname]);
32 | }, [muteStatus]);
33 |
34 | const handleKick = () => {
35 | setShowKickDialog(true);
36 | };
37 |
38 | const toggleMute = () => {
39 | if (!isCurrentPlayer) return;
40 |
41 | const newMutedState = !isCurrentPlayerMuted;
42 | const stream = signalingSocket.getLocalStream();
43 |
44 | if (stream) {
45 | const audioTrack = stream.getAudioTracks()[0];
46 | if (audioTrack) {
47 | audioTrack.enabled = !newMutedState;
48 | }
49 | }
50 |
51 | setIsCurrentPlayerMuted(newMutedState);
52 | gameSocket.setMute();
53 | };
54 |
55 | return (
56 |
57 |
58 |
59 | {isPlayerHost ? (
60 |
61 | ) : (
62 |
63 | )}
64 | {playerNickname}
65 |
66 |
67 |
68 | {isLeft ? (
69 |

74 | ) : isDead ? (
75 |

80 | ) : (
81 | ''
82 | )}
83 |
84 |
85 |
86 | {isCurrentPlayer ? (
87 |
88 | ) : isMuted ? (
89 |
90 | ) : (
91 |
92 | )}
93 | {isCurrentPlayerHost && !isPlayerHost && (
94 |
102 | )}
103 |
104 |
105 |
106 |
111 |
112 | );
113 | };
114 |
115 | export default Player;
116 |
--------------------------------------------------------------------------------
/fe/src/pages/GamePage/PlayerList/PlayerList.tsx:
--------------------------------------------------------------------------------
1 | import { RULES } from '@/constants/rules';
2 | import Player from './Player';
3 | import { PlayerProps } from '@/types/roomTypes';
4 |
5 | interface PlayerListProps {
6 | players: PlayerProps[];
7 | }
8 |
9 | const PlayerList = ({ players }: PlayerListProps) => {
10 | const emptySlots = RULES.maxPlayer - players.length;
11 |
12 | return (
13 |
14 | {players.map((player) => (
15 |
16 | ))}
17 | {Array.from({ length: emptySlots }).map((_, index) => (
18 |
22 | ))}
23 |
24 | );
25 | };
26 |
27 | export default PlayerList;
28 |
--------------------------------------------------------------------------------
/fe/src/pages/GamePage/PlayerList/VolumeBar.tsx:
--------------------------------------------------------------------------------
1 | import { Slider } from '@/components/ui/slider';
2 | import { HiSpeakerWave, HiSpeakerXMark } from 'react-icons/hi2';
3 | import { useState } from 'react';
4 | import usePeerStore from '@/stores/zustand/usePeerStore';
5 | import { useAudioManager } from '@/hooks/useAudioManager';
6 |
7 | interface VolumeBarProps {
8 | playerNickname: string;
9 | }
10 |
11 | const VolumeBar = ({ playerNickname }: VolumeBarProps) => {
12 | const [volumeLevel, setVolumeLevel] = useState(50);
13 | const userMappings = usePeerStore((state) => state.userMappings);
14 | const { setVolume } = useAudioManager();
15 |
16 | const peerId = userMappings[playerNickname];
17 |
18 | const handleVolumeChange = (value: number[]) => {
19 | const newVolume = value[0];
20 |
21 | setVolumeLevel(newVolume);
22 |
23 | if (peerId) {
24 | setVolume(peerId, newVolume / 100);
25 | }
26 | };
27 |
28 | const toggleMute = () => {
29 | if (volumeLevel > 0) {
30 | setVolumeLevel(0);
31 | if (peerId) {
32 | setVolume(peerId, 0);
33 | }
34 | } else {
35 | setVolumeLevel(50);
36 | if (peerId) {
37 | setVolume(peerId, volumeLevel / 100);
38 | }
39 | }
40 | };
41 |
42 | return (
43 |
44 |
54 |
61 |
62 | );
63 | };
64 |
65 | export default VolumeBar;
66 |
--------------------------------------------------------------------------------
/fe/src/pages/GamePage/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import useRoomStore from '@/stores/zustand/useRoomStore';
3 | import PlayerList from './PlayerList/PlayerList';
4 | import { Button } from '@/components/ui/button';
5 | import ExitDialog from './GameDialog/ExitDialog';
6 | import { useReconnect } from '@/hooks/useReconnect';
7 | import { useBackExit } from '@/hooks/useBackExit';
8 | import { NotFound } from '@/pages/NotFoundPage';
9 | import GameScreen from './GameScreen/GameScreen';
10 | import { useAudioManager } from '@/hooks/useAudioManager';
11 | import { signalingSocket } from '@/services/signalingSocket';
12 | import { toast } from 'react-toastify';
13 | import { useParams } from 'react-router-dom';
14 | import { getCurrentRoomQuery } from '@/stores/queries/getCurrentRoomQuery';
15 | import JoinDialog from '../RoomListPage/RoomDialog/JoinDialog';
16 |
17 | const GamePage = () => {
18 | const [showJoinDialog, setShowJoinDialog] = useState(false);
19 | const [showExitDialog, setShowExitDialog] = useState(false);
20 | const { kickedPlayer, setKickedPlayer } = useRoomStore();
21 | const currentRoom = useRoomStore((state) => state.currentRoom);
22 | const audioManager = useAudioManager();
23 | const { roomId } = useParams();
24 | const { data: room } = getCurrentRoomQuery(roomId);
25 | const nickname = sessionStorage.getItem('user_nickname');
26 |
27 | useReconnect({ currentRoom });
28 | useBackExit({ setShowExitDialog });
29 |
30 | useEffect(() => {
31 | if (room && !currentRoom) {
32 | if (!nickname) {
33 | setShowJoinDialog(true);
34 | }
35 | }
36 | }, [room, currentRoom, nickname]);
37 |
38 | // 오디오 매니저 설정
39 | useEffect(() => {
40 | signalingSocket.setAudioManager(audioManager);
41 |
42 | return () => {
43 | signalingSocket.setAudioManager(null);
44 | };
45 | }, [audioManager]);
46 |
47 | // 강퇴 알림 처리 추가
48 | useEffect(() => {
49 | if (kickedPlayer) {
50 | toast.error(`${kickedPlayer}님이 강퇴되었습니다.`, {
51 | position: 'top-right',
52 | autoClose: 1000,
53 | style: {
54 | fontFamily: 'Galmuri11, monospace',
55 | },
56 | });
57 |
58 | setKickedPlayer(null);
59 | }
60 | }, [kickedPlayer, setKickedPlayer, toast]);
61 |
62 | const handleClickExit = () => {
63 | setShowExitDialog(true);
64 | };
65 |
66 | const handleCopyLink = () => {
67 | // 현재 URL을 구성
68 | const currentURL = `${window.location.origin}/game/${roomId}`;
69 |
70 | // 클립보드에 복사
71 | navigator.clipboard
72 | .writeText(currentURL)
73 | .then(() => {
74 | toast.success('링크가 클립보드에 복사되었습니다!', {
75 | position: 'top-right',
76 | autoClose: 1000,
77 | style: {
78 | width: '25rem',
79 | fontFamily: 'Galmuri11, monospace',
80 | },
81 | });
82 | })
83 | .catch((err) => {
84 | console.error('링크 복사 실패:', err);
85 | toast.error('링크 복사에 실패했습니다.', {
86 | position: 'top-right',
87 | autoClose: 1000,
88 | style: {
89 | fontFamily: 'Galmuri11, monospace',
90 | },
91 | });
92 | });
93 | };
94 |
95 | if (!currentRoom) {
96 | return ;
97 | }
98 |
99 | if (showJoinDialog) {
100 | return (
101 |
106 | );
107 | }
108 |
109 | return (
110 |
111 |
112 |
113 |
114 |
({
116 | playerNickname: player.playerNickname,
117 | isReady: player.isReady,
118 | isDead: player.isDead,
119 | isLeft: player.isLeft,
120 | }))}
121 | />
122 |
123 |
124 |
125 |
131 |
134 |
135 |
136 |
137 |
138 |
139 | );
140 | };
141 |
142 | export default GamePage;
143 |
--------------------------------------------------------------------------------
/fe/src/pages/NotFoundPage/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import { motion } from 'framer-motion';
3 | import { useEffect } from 'react';
4 |
5 | export const NotFound = () => {
6 | useEffect(() => {
7 | // NotFound 페이지에서만 스크롤 숨기기
8 | document.body.style.overflow = 'hidden';
9 | document.documentElement.style.overflow = 'hidden';
10 |
11 | // 컴포넌트 언마운트 시 스크롤 복원
12 | return () => {
13 | document.body.style.overflow = 'auto';
14 | document.documentElement.style.overflow = 'auto';
15 | };
16 | }, []);
17 |
18 | return (
19 |
20 |
21 |
22 |
32 |
33 | 4 0 4
34 |
35 | PAGE NOT FOUND
36 | {' '}
37 | {/* 간격 증가 */}
38 |
39 |
40 |
41 |
42 | 앗! 방을 찾을 수 없습니다.
43 |
44 |
45 | 방이 삭제되었거나 존재하지 않는 방입니다.
46 |
47 |
54 |
55 |
56 | );
57 | };
58 |
--------------------------------------------------------------------------------
/fe/src/pages/RoomListPage/RoomHeader/RoomHeader.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import { useState } from 'react';
3 | import CreateDialog from '../RoomDialog/CreateDialog';
4 |
5 | const RoomHeader = () => {
6 | const [isDialogOpen, setIsDialogOpen] = useState(false);
7 |
8 | const handleDialogOpen = () => {
9 | setIsDialogOpen(true);
10 | };
11 |
12 | return (
13 | <>
14 |
15 | 방 목록
16 |
19 |
20 |
21 |
22 | >
23 | );
24 | };
25 |
26 | export default RoomHeader;
27 |
--------------------------------------------------------------------------------
/fe/src/pages/RoomListPage/RoomList/GameRoom.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardHeader, CardTitle } from '@/components/ui/card';
2 | import { Room } from '@/types/roomTypes';
3 | import { FaCircle, FaCrown, FaUsers } from 'react-icons/fa6';
4 |
5 | interface GameRoomProps {
6 | room: Room;
7 | onJoinRoom: (roomId: string) => void;
8 | }
9 |
10 | const GameRoom = ({ room, onJoinRoom }: GameRoomProps) => {
11 | const isGameStarted = (status: string) => {
12 | return status === 'progress';
13 | };
14 | const isRoomFull = room.players.length >= 4;
15 |
16 | const handleRoomClick = () => {
17 | if (!isGameStarted(room.status) && !isRoomFull) {
18 | onJoinRoom(room.roomId);
19 | }
20 | };
21 |
22 | return (
23 |
31 |
32 |
33 | {room.roomName}
34 |
35 |
36 |
37 |
38 | 방장: {room.hostNickname}
39 |
40 |
41 |
44 |
45 | 상태:{' '}
46 |
51 | {isGameStarted(room.status) ? '게임 중' : '대기 중'}
52 |
53 |
54 |
55 |
56 |
57 | 인원 수: {room.players.length} / 4
58 |
59 |
60 |
61 |
62 | );
63 | };
64 |
65 | export default GameRoom;
66 |
--------------------------------------------------------------------------------
/fe/src/pages/RoomListPage/RoomList/Pagination.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import { getRoomsQuery } from '@/stores/queries/getRoomsQuery';
3 | import useRoomStore from '@/stores/zustand/useRoomStore';
4 | import { useEffect } from 'react';
5 | import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6';
6 |
7 | const Pagination = () => {
8 | const { pagination, setUserPage } = useRoomStore();
9 | const userPage = useRoomStore((state) => state.userPage);
10 | const { totalPages } = pagination;
11 | const { refetch } = getRoomsQuery(userPage);
12 |
13 | useEffect(() => {
14 | refetch();
15 | }, [userPage, refetch]);
16 |
17 | const handlePageChange = async (newPage: number) => {
18 | setUserPage(newPage);
19 | };
20 |
21 | return (
22 |
23 |
31 |
32 | {Array.from({ length: totalPages }, (_, i) => (
33 |
42 | ))}
43 |
44 |
52 |
53 | );
54 | };
55 |
56 | export default Pagination;
57 |
--------------------------------------------------------------------------------
/fe/src/pages/RoomListPage/RoomList/RoomList.tsx:
--------------------------------------------------------------------------------
1 | import GameRoom from './GameRoom';
2 | import Pagination from './Pagination';
3 | import { RULES } from '@/constants/rules';
4 | import { useEffect, useState } from 'react';
5 | import JoinDialog from '../RoomDialog/JoinDialog';
6 | import useRoomStore from '@/stores/zustand/useRoomStore';
7 |
8 | const RoomList = () => {
9 | const rooms = useRoomStore((state) => state.rooms);
10 | const pagination = useRoomStore((state) => state.pagination);
11 | const [isJoinDialogOpen, setIsJoinDialogOpen] = useState(false);
12 | const [selectedRoomId, setSelectedRoomId] = useState(null);
13 | const [showPagination, setShowPagination] = useState(false);
14 |
15 | useEffect(() => {
16 | if (pagination?.totalPages > 1) {
17 | setShowPagination(true);
18 | }
19 |
20 | if (pagination?.totalPages === 1) {
21 | setShowPagination(false);
22 | }
23 | }, [pagination]);
24 |
25 | const onJoinRoom = (roomId: string) => {
26 | setSelectedRoomId(roomId);
27 | setIsJoinDialogOpen(true);
28 | };
29 |
30 | return (
31 |
32 |
33 | {rooms.map((room) => (
34 |
35 | ))}
36 | {rooms.length > 0 &&
37 | rooms.length < RULES.maxPage &&
38 | Array.from({ length: RULES.maxPage - rooms.length }).map((_, i) => (
39 |
40 | ))}
41 |
42 |
43 | {showPagination &&
}
44 |
45 | {selectedRoomId && (
46 |
51 | )}
52 |
53 | );
54 | };
55 |
56 | export default RoomList;
57 |
--------------------------------------------------------------------------------
/fe/src/pages/RoomListPage/index.tsx:
--------------------------------------------------------------------------------
1 | import SearchBar from '@/components/common/SearchBar';
2 | import RoomHeader from './RoomHeader/RoomHeader';
3 | import RoomList from './RoomList/RoomList';
4 | import { useEffect, useState } from 'react';
5 | import CustomAlertDialog from '@/components/common/CustomAlertDialog';
6 | import useRoomStore from '@/stores/zustand/useRoomStore';
7 | import { useRoomsSSE } from '@/hooks/useRoomsSSE';
8 |
9 | const RoomListPage = () => {
10 | const [showAlert, setShowAlert] = useState(false);
11 | const [alertMessage, setAlertMessage] = useState('');
12 | const { rooms } = useRoomStore();
13 | const isEmpty = rooms.length === 0;
14 |
15 | useRoomsSSE();
16 |
17 | useEffect(() => {
18 | const kickedRoomName = sessionStorage.getItem('kickedRoomName');
19 |
20 | // 강퇴 처리
21 | if (kickedRoomName) {
22 | setAlertMessage(`${kickedRoomName}방에서 강퇴되었습니다.`);
23 | setShowAlert(true);
24 | sessionStorage.removeItem('kickedRoomName');
25 | }
26 |
27 | // 게임 중 입장 에러
28 | const gameInProgressError = sessionStorage.getItem('gameInProgressError');
29 |
30 | if (gameInProgressError) {
31 | setAlertMessage('게임이 진행 중인 방에는 입장할 수 없습니다.');
32 | setShowAlert(true);
33 | sessionStorage.removeItem('gameInProgressError');
34 | }
35 | }, []);
36 |
37 | return (
38 |
39 |
40 |
41 |
42 | {isEmpty ? (
43 |
44 |

49 |
50 | ) : (
51 |
52 | )}
53 |
54 |
55 |
61 |
62 | );
63 | };
64 |
65 | export default RoomListPage;
66 |
--------------------------------------------------------------------------------
/fe/src/services/SocketService.ts:
--------------------------------------------------------------------------------
1 | import { Socket } from 'socket.io-client';
2 |
3 | export class SocketService {
4 | #socket: Socket | undefined;
5 |
6 | constructor() {
7 | this.#socket = undefined;
8 | }
9 |
10 | get socket(): Socket | undefined {
11 | return this.#socket;
12 | }
13 |
14 | setSocket(socket: Socket | undefined | null) {
15 | this.#socket = socket ? socket : undefined;
16 | }
17 |
18 | isConnected() {
19 | return !this.#socket?.connected;
20 | }
21 |
22 | disconnect() {
23 | if (!this.#socket?.connected) return;
24 | this.#socket.disconnect();
25 | this.#socket = undefined;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/fe/src/services/gameSocket.ts:
--------------------------------------------------------------------------------
1 | import { io, Socket } from 'socket.io-client';
2 | import {
3 | ClientToServerEvents,
4 | GameResultProps,
5 | MuteStatus,
6 | ServerToClientEvents,
7 | TurnData,
8 | } from '@/types/socketTypes';
9 | import { PlayerProps, Room } from '@/types/roomTypes';
10 | import { SocketService } from './SocketService';
11 | import useRoomStore from '@/stores/zustand/useRoomStore';
12 | import { ENV } from '@/config/env';
13 | import useGameStore from '@/stores/zustand/useGameStore';
14 |
15 | class GameSocket extends SocketService {
16 | constructor() {
17 | super();
18 | }
19 |
20 | connect() {
21 | if (this.socket?.connected) return;
22 |
23 | const socket = io(ENV.GAME_SERVER_URL, {
24 | transports: ['websocket'],
25 | withCredentials: false,
26 | }) as Socket;
27 |
28 | this.setSocket(socket);
29 | this.setupEventListeners();
30 | }
31 |
32 | private setupEventListeners() {
33 | if (!this.socket) return;
34 |
35 | // 소켓 모니터링
36 | this.socket.on('connect', () => {
37 | console.log('Game socket connected');
38 | });
39 |
40 | this.socket.on('connect_error', (error) => {
41 | console.error('Game socket connection error:', error);
42 | });
43 |
44 | this.socket.on('error', (error) => {
45 | console.error('Socket error:', error);
46 | });
47 |
48 | this.socket.on('roomCreated', (room: Room) => {
49 | const store = useRoomStore.getState();
50 | store.setRooms([...store.rooms, room]);
51 | store.setCurrentRoom(room);
52 | });
53 |
54 | this.socket.on('updateUsers', (players: PlayerProps[]) => {
55 | const { currentRoom, setCurrentRoom } = useRoomStore.getState();
56 |
57 | if (currentRoom) {
58 | setCurrentRoom({
59 | ...currentRoom,
60 | players,
61 | hostNickname: players[0].playerNickname,
62 | });
63 | }
64 | });
65 |
66 | this.socket.on('kicked', (playerNickname: string) => {
67 | const {
68 | currentRoom,
69 | currentPlayer,
70 | setCurrentRoom,
71 | setCurrentPlayer,
72 | setKickedPlayer,
73 | } = useRoomStore.getState();
74 |
75 | if (!currentRoom) return;
76 |
77 | if (currentPlayer === playerNickname) {
78 | sessionStorage.setItem('kickedRoomName', currentRoom.roomName);
79 |
80 | setCurrentRoom(null);
81 | setCurrentPlayer(null);
82 | window.location.href = '/rooms';
83 | return;
84 | }
85 |
86 | setKickedPlayer(playerNickname);
87 | });
88 |
89 | this.socket.on('muteStatusChanged', (muteStatus: MuteStatus) => {
90 | const { setMuteStatus } = useGameStore.getState();
91 | setMuteStatus(muteStatus);
92 | });
93 |
94 | this.socket.on('turnChanged', (turnData: TurnData) => {
95 | const { setTurnData } = useGameStore.getState();
96 | setTurnData(turnData);
97 | });
98 |
99 | this.socket.on('voiceProcessingResult', (result: GameResultProps) => {
100 | const { setGameResult } = useGameStore.getState();
101 | setGameResult(result);
102 | });
103 |
104 | this.socket.on('endGame', (rank: string[]) => {
105 | const { setRank } = useGameStore.getState();
106 | setRank(rank);
107 | });
108 | }
109 |
110 | createRoom(roomName: string, hostNickname: string) {
111 | this.socket?.emit('createRoom', { roomName, hostNickname });
112 | }
113 |
114 | joinRoom(roomId: string, playerNickname: string) {
115 | return new Promise((resolve, reject) => {
116 | // error 한번만 체크하는 이벤트 리스너
117 | this.socket?.once('error', (error) => {
118 | reject(error);
119 | });
120 |
121 | // updateUsers가 오면 성공으로 처리
122 | this.socket?.once('updateUsers', () => {
123 | resolve(true);
124 | });
125 |
126 | this.socket?.emit('joinRoom', { roomId, playerNickname });
127 | });
128 | }
129 |
130 | kickPlayer(playerNickname: string) {
131 | this.socket?.emit('kickPlayer', playerNickname);
132 | }
133 |
134 | setReady() {
135 | this.socket?.emit('setReady');
136 | }
137 |
138 | setMute() {
139 | this.socket?.emit('setMute');
140 | }
141 |
142 | startGame() {
143 | this.socket?.emit('startGame');
144 | }
145 |
146 | next() {
147 | this.socket?.emit('next');
148 | }
149 | }
150 |
151 | export const gameSocket = new GameSocket();
152 |
--------------------------------------------------------------------------------
/fe/src/services/voiceSocket.ts:
--------------------------------------------------------------------------------
1 | import { Socket, io } from 'socket.io-client';
2 | import { VoiceSocketEvents } from '@/types/socketTypes';
3 | import { SocketService } from './SocketService';
4 | import { ENV } from '@/config/env';
5 |
6 | class VoiceSocket extends SocketService {
7 | private mediaRecorder: MediaRecorder | null;
8 | private onErrorCallback: ((error: string) => void) | null;
9 | private onRecordingStateChange: ((isRecording: boolean) => void) | null;
10 | private readonly VOICE_SERVER_URL = ENV.VOICE_SERVER_URL;
11 |
12 | constructor() {
13 | super();
14 | this.mediaRecorder = null;
15 | this.onErrorCallback = null;
16 | this.onRecordingStateChange = null;
17 | }
18 |
19 | initialize(
20 | onError: (error: string) => void,
21 | onStateChange: (isRecording: boolean) => void
22 | ) {
23 | this.onErrorCallback = onError;
24 | this.onRecordingStateChange = onStateChange;
25 | }
26 |
27 | private async connect(roomId: string, playerNickname: string): Promise {
28 | return new Promise((resolve, reject) => {
29 | if (this.socket?.connected) {
30 | this.socket.disconnect();
31 | this.setSocket(null);
32 | }
33 |
34 | const socket = io(this.VOICE_SERVER_URL, {
35 | transports: ['websocket'],
36 | query: { roomId, playerNickname },
37 | }) as Socket;
38 |
39 | this.setSocket(socket);
40 |
41 | this.socket.on('connect', () => {
42 | console.log('Voice server connected');
43 | this.socket.emit('start_recording');
44 | resolve();
45 | });
46 |
47 | this.socket.on('error', (error) => {
48 | console.error('Voice server error:', error);
49 | this.handleError(error);
50 | reject(error);
51 | });
52 |
53 | this.socket.on('connect_error', (error) => {
54 | console.error('Voice server connection error:', error);
55 | reject(error);
56 | });
57 | });
58 | }
59 |
60 | async startRecording(
61 | localStream: MediaStream,
62 | roomId: string,
63 | playerNickname: string
64 | ) {
65 | try {
66 | if (!localStream) {
67 | throw new Error('마이크가 연결되어 있지 않습니다.');
68 | }
69 |
70 | await this.connect(roomId, playerNickname);
71 |
72 | const audioTrack = localStream.getAudioTracks()[0];
73 | const mediaStream = new MediaStream([audioTrack]);
74 |
75 | this.mediaRecorder = new MediaRecorder(mediaStream, {
76 | mimeType: 'audio/webm;codecs=opus',
77 | bitsPerSecond: 128000,
78 | audioBitsPerSecond: 96000,
79 | videoBitsPerSecond: 0,
80 | });
81 |
82 | this.mediaRecorder.ondataavailable = async (event: BlobEvent) => {
83 | if (event.data.size > 0) {
84 | try {
85 | const buffer = await event.data.arrayBuffer();
86 | if (this.socket?.connected) {
87 | // console.log('Sending audio chunk:', buffer.byteLength, 'bytes');
88 | this.socket.emit('audio_data', buffer);
89 | }
90 | } catch (error) {
91 | console.error('Error processing audio chunk:', error);
92 | this.handleError(error as Error);
93 | }
94 | }
95 | };
96 |
97 | this.mediaRecorder.start(100);
98 | console.log('Recording started');
99 |
100 | if (this.onRecordingStateChange) {
101 | this.onRecordingStateChange(true);
102 | }
103 | } catch (error) {
104 | console.error('Error in startRecording:', error);
105 | this.handleError(error as Error);
106 | }
107 | }
108 |
109 | private handleError(error: Error) {
110 | if (this.onErrorCallback) {
111 | this.onErrorCallback(
112 | error.message || '음성 처리 중 오류가 발생했습니다.'
113 | );
114 | }
115 | this.cleanupRecording();
116 | }
117 |
118 | private cleanupRecording() {
119 | console.log('Cleaning up recording');
120 |
121 | if (this.mediaRecorder?.state !== 'inactive') {
122 | this.mediaRecorder?.stop();
123 | }
124 | this.mediaRecorder = null;
125 |
126 | if (this.socket) {
127 | if (this.socket?.connected) {
128 | this.socket.disconnect();
129 | }
130 |
131 | this.setSocket(null);
132 | }
133 |
134 | if (this.onRecordingStateChange) {
135 | this.onRecordingStateChange(false);
136 | }
137 | }
138 |
139 | isRecording() {
140 | return this.mediaRecorder && this.mediaRecorder.state === 'recording';
141 | }
142 |
143 | override disconnect() {
144 | this.cleanupRecording();
145 | super.disconnect();
146 | }
147 | }
148 |
149 | export const voiceSocket = new VoiceSocket();
150 |
--------------------------------------------------------------------------------
/fe/src/stores/queries/getCurrentRoomQuery.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import axios from 'axios';
3 | import { Room } from '@/types/roomTypes';
4 | import { ENV } from '@/config/env';
5 |
6 | const gameAPI = axios.create({
7 | baseURL: ENV.REST_BASE_URL,
8 | timeout: 5000,
9 | withCredentials: false,
10 | });
11 |
12 | export const getCurrentRoomQuery = (roomId: string) => {
13 | return useQuery({
14 | queryKey: ['rooms', roomId],
15 | queryFn: async () => {
16 | const response = await gameAPI.get(`/api/rooms/${roomId}`);
17 | return response.data;
18 | },
19 | });
20 | };
21 |
--------------------------------------------------------------------------------
/fe/src/stores/queries/getRoomsQuery.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import axios from 'axios';
3 | import { PaginatedResponse, Room } from '@/types/roomTypes';
4 | import { ENV } from '@/config/env';
5 |
6 | const gameAPI = axios.create({
7 | baseURL: ENV.REST_BASE_URL,
8 | timeout: 5000,
9 | withCredentials: false,
10 | });
11 |
12 | export const getRoomsQuery = (currentPage: number) => {
13 | return useQuery({
14 | queryKey: ['rooms'],
15 | queryFn: async () => {
16 | const { data } = await gameAPI.get>(
17 | `/api/rooms?page=${currentPage}`
18 | );
19 |
20 | return data;
21 | },
22 | });
23 | };
24 |
--------------------------------------------------------------------------------
/fe/src/stores/queries/searchRoomsQuery.ts:
--------------------------------------------------------------------------------
1 | import { ENV } from '@/config/env';
2 | import { Room } from '@/types/roomTypes';
3 | import { useQuery } from '@tanstack/react-query';
4 | import axios from 'axios';
5 |
6 | const gameAPI = axios.create({
7 | baseURL: ENV.REST_BASE_URL,
8 | timeout: 5000,
9 | withCredentials: false,
10 | });
11 |
12 | export const searchRoomsQuery = (searchTerm: string) => {
13 | return useQuery({
14 | queryKey: ['rooms', 'search', searchTerm],
15 | queryFn: async () => {
16 | if (!searchTerm.trim()) return [];
17 | const response = await gameAPI.get(
18 | `/api/rooms/search?roomName=${encodeURIComponent(searchTerm)}`
19 | );
20 | return response.data;
21 | },
22 | enabled: !!searchTerm.trim(), // 검색어가 있을 때만 쿼리 실행
23 | });
24 | };
25 |
--------------------------------------------------------------------------------
/fe/src/stores/zustand/useGameStore.ts:
--------------------------------------------------------------------------------
1 | import { GameResultProps, MuteStatus, TurnData } from '@/types/socketTypes';
2 | import { create } from 'zustand';
3 | import { devtools } from 'zustand/middleware';
4 |
5 | interface GameStore {
6 | turnData: TurnData | null;
7 | resultData: GameResultProps;
8 | muteStatus: MuteStatus;
9 | rank: string[];
10 | gameInProgressError: boolean;
11 | }
12 |
13 | interface GameActions {
14 | setTurnData: (turnData: TurnData) => void;
15 | setGameResult: (resultData: GameResultProps) => void;
16 | setMuteStatus: (muteStatus: MuteStatus) => void;
17 | setRank: (rank: string[]) => void;
18 | setGameInProgressError: (value: boolean) => void;
19 | resetGame: () => void;
20 | }
21 |
22 | const initialState: GameStore = {
23 | turnData: null,
24 | resultData: null,
25 | muteStatus: {},
26 | rank: [],
27 | gameInProgressError: false,
28 | };
29 |
30 | const useGameStore = create()(
31 | devtools((set) => ({
32 | ...initialState,
33 |
34 | setTurnData: (turnData) =>
35 | set(() => ({
36 | turnData,
37 | })),
38 |
39 | setGameResult: (resultData) =>
40 | set(() => ({
41 | resultData,
42 | })),
43 |
44 | setRank: (rank) => set(() => ({ rank })),
45 |
46 | setGameInProgressError: (gameInProgressError) =>
47 | set({ gameInProgressError }),
48 |
49 | resetGame: () =>
50 | set({
51 | // 초기화 로직
52 | turnData: null,
53 | resultData: null,
54 | rank: [],
55 | }),
56 |
57 | setMuteStatus: (muteStatus) => set(() => ({ muteStatus })),
58 | }))
59 | );
60 |
61 | export default useGameStore;
62 |
--------------------------------------------------------------------------------
/fe/src/stores/zustand/usePeerStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { devtools } from 'zustand/middleware';
3 |
4 | interface PeerStore {
5 | userMappings: Record;
6 | }
7 |
8 | interface PeerActions {
9 | setUserMappings: (mappings: Record) => void;
10 | }
11 |
12 | const initialState: PeerStore = {
13 | userMappings: {},
14 | };
15 |
16 | const usePeerStore = create()(
17 | devtools((set) => ({
18 | ...initialState,
19 |
20 | setUserMappings: (mappings) =>
21 | set(() => ({
22 | userMappings: { ...mappings },
23 | })),
24 | }))
25 | );
26 |
27 | export default usePeerStore;
28 |
--------------------------------------------------------------------------------
/fe/src/stores/zustand/usePitchStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { devtools } from 'zustand/middleware';
3 | import { PITCH_CONSTANTS } from '@/constants/pitch';
4 |
5 | interface PitchStore {
6 | currentPitch: number; // 현재 주파수
7 | currentOpacity: number; // 현재 불투명도
8 | currentVolume: number; // 현재 목소리 크기 (0.0 ~ 1.0)
9 | lastUpdateTime: number; // 마지막 업데이트 시간
10 | updatePitch: (pitch: number, volume: number) => void; // 주파수와 볼륨 업데이트 함수
11 | resetPitch: () => void; // 상태 초기화 함수
12 | }
13 |
14 | // 음계에 따른 불투명도를 계산하는 함수
15 | const calculateOpacity = (pitch: number): number => {
16 | // 음성이 없거나 매우 낮은 경우 최소값으로 설정
17 | if (!pitch || pitch < PITCH_CONSTANTS.MIN_FREQ) {
18 | return PITCH_CONSTANTS.MIN_OPACITY;
19 | }
20 |
21 | // 주파수 값을 0~1 범위로 정규화
22 | const normalizedPitch = Math.min(
23 | Math.max(
24 | (pitch - PITCH_CONSTANTS.MIN_FREQ) /
25 | (PITCH_CONSTANTS.MAX_FREQ - PITCH_CONSTANTS.MIN_FREQ),
26 | 0
27 | ),
28 | 1
29 | );
30 |
31 | // 정규화된 값을 불투명도 범위로 매핑 (비선형적으로)
32 | const opacity =
33 | PITCH_CONSTANTS.MIN_OPACITY +
34 | Math.pow(normalizedPitch, 0.3) * // 더 쉽게 불투명도 증가
35 | (PITCH_CONSTANTS.MAX_OPACITY - PITCH_CONSTANTS.MIN_OPACITY);
36 |
37 | return opacity;
38 | };
39 |
40 | const usePitchStore = create()(
41 | devtools((set) => ({
42 | currentPitch: 0,
43 | currentOpacity: PITCH_CONSTANTS.MIN_OPACITY,
44 | currentVolume: 0,
45 | lastUpdateTime: Date.now(),
46 |
47 | /**
48 | * 주파수와 볼륨을 업데이트하는 함수
49 | * @param pitch 현재 주파수
50 | * @param volume 현재 볼륨
51 | */
52 | updatePitch: (pitch, volume) =>
53 | set(() => {
54 | // 새로운 불투명도 계산
55 | const newOpacity = calculateOpacity(pitch);
56 |
57 | // 볼륨값 정규화 (0.0 ~ 1.0 범위로 조정)
58 | const normalizedVolume = Math.min(Math.max(volume, 0), 1);
59 |
60 | return {
61 | currentPitch: pitch,
62 | currentOpacity: newOpacity,
63 | currentVolume: normalizedVolume,
64 | lastUpdateTime: Date.now(),
65 | };
66 | }),
67 |
68 | /**
69 | * 피치 상태를 초기화하는 함수
70 | */
71 | resetPitch: () =>
72 | set({
73 | currentPitch: 0,
74 | currentOpacity: PITCH_CONSTANTS.MIN_OPACITY,
75 | currentVolume: 0,
76 | lastUpdateTime: Date.now(),
77 | }),
78 | }))
79 | );
80 |
81 | export default usePitchStore;
82 |
--------------------------------------------------------------------------------
/fe/src/stores/zustand/useRoomStore.ts:
--------------------------------------------------------------------------------
1 | import { PaginationData, Room } from '@/types/roomTypes';
2 | import { create } from 'zustand';
3 | import { devtools } from 'zustand/middleware';
4 |
5 | interface RoomStore {
6 | rooms: Room[];
7 | currentRoom: Room | null;
8 | currentPlayer: string | null;
9 | kickedPlayer: string | null;
10 | pagination: PaginationData | null;
11 | userPage: number;
12 | }
13 |
14 | interface RoomActions {
15 | setRooms: (rooms: Room[]) => void;
16 | setCurrentRoom: (room: Room) => void;
17 | setCurrentPlayer: (nickname: string) => void;
18 | setKickedPlayer: (nickname: string) => void;
19 | setPagination: (pagination: PaginationData) => void;
20 | setUserPage: (userPage: number) => void;
21 | }
22 |
23 | const initialState: RoomStore = {
24 | rooms: [],
25 | currentRoom: null,
26 | currentPlayer: '',
27 | kickedPlayer: '',
28 | pagination: null,
29 | userPage: 0,
30 | };
31 |
32 | const useRoomStore = create()(
33 | devtools((set) => ({
34 | ...initialState,
35 |
36 | setRooms: (rooms) =>
37 | set(() => ({
38 | rooms,
39 | })),
40 |
41 | setCurrentRoom: (room) =>
42 | set(() => ({
43 | currentRoom: room,
44 | })),
45 |
46 | setCurrentPlayer: (nickname) =>
47 | set(() => ({
48 | currentPlayer: nickname,
49 | })),
50 |
51 | setKickedPlayer: (nickname) =>
52 | set(() => ({
53 | kickedPlayer: nickname,
54 | })),
55 |
56 | setPagination: (pagination) =>
57 | set(() => ({
58 | pagination,
59 | })),
60 |
61 | setUserPage: (userPage) => {
62 | set(() => ({
63 | userPage,
64 | }));
65 | },
66 | }))
67 | );
68 |
69 | export default useRoomStore;
70 |
--------------------------------------------------------------------------------
/fe/src/types/audioTypes.ts:
--------------------------------------------------------------------------------
1 | // 음계 데이터 관련 타입
2 | export interface PitchData {
3 | pitch: number; // 현재 음계 값
4 | timestamp: number; // 음계가 측정된 시간
5 | }
6 |
7 | // 음계 상태 관리를 위한 타입
8 | export interface PitchState {
9 | currentPitch: number; // 현재 음계
10 | maxPitch: number; // 최대 음계
11 | minPitch: number; // 최소 음계
12 | lastUpdateTime: number; // 마지막 업데이트 시간
13 | }
14 |
15 | // 오디오 분석기 설정 타입
16 | export interface AudioAnalyzerConfig {
17 | fftSize: number; // FFT 크기
18 | smoothingTimeConstant: number; // 스무딩 상수
19 | minDecibels: number; // 최소 데시벨
20 | maxDecibels: number; // 최대 데시벨
21 | }
22 |
23 | // 음계 추출기 인터페이스
24 | export interface PitchDetector {
25 | analyzePitch: (audioData: Float32Array) => number;
26 | }
27 |
--------------------------------------------------------------------------------
/fe/src/types/roomTypes.ts:
--------------------------------------------------------------------------------
1 | export interface PlayerProps {
2 | playerNickname: string;
3 | isReady: boolean;
4 | isDead: boolean;
5 | isLeft: boolean;
6 | }
7 |
8 | export interface Room {
9 | roomId: string;
10 | roomName: string;
11 | hostNickname: string;
12 | players: PlayerProps[];
13 | status: 'waiting' | 'playing';
14 | }
15 |
16 | export interface RoomDialogProps {
17 | open: boolean;
18 | onOpenChange: (open: boolean) => void;
19 | }
20 |
21 | export interface PaginationData {
22 | currentPage: number;
23 | totalPages: number;
24 | totalItems: number;
25 | hasNextPage: boolean;
26 | hasPreviousPage: boolean;
27 | }
28 |
29 | export interface PaginatedResponse {
30 | rooms: T[];
31 | pagination: PaginationData;
32 | }
33 |
--------------------------------------------------------------------------------
/fe/src/types/socketTypes.ts:
--------------------------------------------------------------------------------
1 | import { Room } from './roomTypes';
2 |
3 | // 게임 서버 이벤트 타입
4 | export interface ServerToClientEvents {
5 | roomCreated: (room: Room) => void;
6 | updateUsers: (players: string[]) => void;
7 | error: (error: { code: string; message: string }) => void;
8 | kicked: (playerNickname: string) => void;
9 | turnChanged: (turnData: TurnData) => void;
10 | voiceProcessingResult: (result: GameResultProps) => void;
11 | muteStatusChanged: (MuteStatus: MuteStatus) => void;
12 | endGame: (rank: string[]) => void;
13 | }
14 |
15 | export interface ClientToServerEvents {
16 | createRoom: (data: { roomName: string; hostNickname: string }) => void;
17 | joinRoom: (data: { roomId: string; playerNickname: string }) => void;
18 | kickPlayer: (playerNickname: string) => void;
19 | setReady: () => void;
20 | setMute: () => void;
21 | startGame: () => void;
22 | next: () => void;
23 | }
24 |
25 | // 서버에서 받아오는 데이터 타입
26 | export interface TurnData {
27 | roomId: string;
28 | playerNickname: string;
29 | gameMode: string;
30 | timeLimit: number;
31 | lyrics: string;
32 | }
33 |
34 | export interface GameResultProps {
35 | playerNickname: string;
36 | result: string;
37 | note?: string;
38 | pronounceScore?: number;
39 | }
40 |
41 | export type MuteStatus = {
42 | [playerNickname: string]: boolean;
43 | };
44 |
45 | // 음성 처리 서버 이벤트 타입
46 | export interface VoiceSocketEvents {
47 | audio_data: (buffer: ArrayBuffer) => void;
48 | start_recording: () => void;
49 | error: (error: Error) => void;
50 | }
51 |
--------------------------------------------------------------------------------
/fe/src/types/webrtcTypes.ts:
--------------------------------------------------------------------------------
1 | // 시그널링 서버 이벤트 타입
2 | export interface SignalingData {
3 | fromId: string;
4 | toId: string;
5 | sdp?: RTCSessionDescription;
6 | candidate?: RTCIceCandidate;
7 | }
8 |
9 | export interface ConnectionPlan {
10 | from: string;
11 | to: string;
12 | }
13 |
14 | export interface SignalingEvents {
15 | start_connections: (connections: ConnectionPlan[]) => void;
16 | start_call: (data: { fromId: string }) => void;
17 | webrtc_offer: (data: {
18 | fromId: string;
19 | sdp: RTCSessionDescriptionInit;
20 | }) => void;
21 | webrtc_answer: (data: {
22 | fromId: string;
23 | sdp: RTCSessionDescriptionInit;
24 | }) => void;
25 | webrtc_ice_candidate: (data: {
26 | fromId: string;
27 | candidate: RTCIceCandidateInit;
28 | }) => void;
29 | user_disconnected: (userId: string) => void;
30 | }
31 |
--------------------------------------------------------------------------------
/fe/src/utils/playerUtils.ts:
--------------------------------------------------------------------------------
1 | import useRoomStore from '@/stores/zustand/useRoomStore';
2 |
3 | export const isHost = (playerNickname: string) => {
4 | const { currentRoom } = useRoomStore();
5 |
6 | return playerNickname === currentRoom.hostNickname;
7 | };
8 |
--------------------------------------------------------------------------------
/fe/src/utils/validator.ts:
--------------------------------------------------------------------------------
1 | import { ERROR_MESSAGES } from '@/constants/errors';
2 |
3 | export const validateNickname = (nickname: string): string => {
4 | const trimmed = nickname.trim();
5 | const regex = /^[a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ ]+$/;
6 | let error = '';
7 |
8 | switch (true) {
9 | case !trimmed:
10 | error = ERROR_MESSAGES.emptyNickname;
11 | break;
12 | case !regex.test(trimmed):
13 | error = ERROR_MESSAGES.invalidNickname;
14 | break;
15 | case trimmed.length < 2 || trimmed.length > 8:
16 | error = ERROR_MESSAGES.nicknameLength;
17 | break;
18 | }
19 |
20 | return error;
21 | };
22 |
23 | export const validateRoomName = (roomName: string): string => {
24 | const trimmed = roomName.trim();
25 | let error = '';
26 |
27 | switch (true) {
28 | case !trimmed:
29 | error = ERROR_MESSAGES.emptyRoomName;
30 | break;
31 | case trimmed.length < 2 || trimmed.length > 12:
32 | error = ERROR_MESSAGES.roomNameLength;
33 | break;
34 | }
35 |
36 | return error;
37 | };
38 |
--------------------------------------------------------------------------------
/fe/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | declare module '*.png' {
4 | const value: string;
5 | export default value;
6 | }
7 |
--------------------------------------------------------------------------------
/fe/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | darkMode: ['class'],
4 | content: ['./index.html', './src/**/*.{ts,tsx,js,jsx}'],
5 | theme: {
6 | extend: {
7 | backgroundImage: {
8 | 'main-desert': 'url(https://i.imgur.com/RMCkgEF.png)',
9 | },
10 |
11 | fontFamily: {
12 | galmuri: ['Galmuri11', 'monospace'],
13 | galmuri9: ['Galmuri9', 'monospace'],
14 | galmuri14: ['Galmuri14', 'monospace'],
15 | pretendard: ['Pretendard', 'sans-serif'],
16 | },
17 |
18 | fontSize: {
19 | 'galmuri-20': ['20px'],
20 | },
21 |
22 | borderRadius: {
23 | lg: 'var(--radius)',
24 | md: 'calc(var(--radius) - 2px)',
25 | sm: 'calc(var(--radius) - 4px)',
26 | },
27 |
28 | colors: {
29 | brand: {
30 | main: '#74D7C1',
31 | navy: '#02032F',
32 | gold: '#FFB400',
33 | mint: '#13E7EF',
34 | },
35 |
36 | background: 'hsl(var(--background))',
37 | foreground: 'hsl(var(--foreground))',
38 | card: {
39 | DEFAULT: 'hsl(var(--card))',
40 | foreground: 'hsl(var(--card-foreground))',
41 | },
42 | popover: {
43 | DEFAULT: 'hsl(var(--popover))',
44 | foreground: 'hsl(var(--popover-foreground))',
45 | },
46 | primary: {
47 | DEFAULT: 'hsl(var(--primary))',
48 | foreground: 'hsl(var(--primary-foreground))',
49 | },
50 | secondary: {
51 | DEFAULT: 'hsl(var(--secondary))',
52 | foreground: 'hsl(var(--secondary-foreground))',
53 | },
54 | muted: {
55 | DEFAULT: 'hsl(var(--muted))',
56 | foreground: 'hsl(var(--muted-foreground))',
57 | },
58 | accent: {
59 | DEFAULT: 'hsl(var(--accent))',
60 | foreground: 'hsl(var(--accent-foreground))',
61 | },
62 | destructive: {
63 | DEFAULT: 'hsl(var(--destructive))',
64 | foreground: 'hsl(var(--destructive-foreground))',
65 | },
66 | border: 'hsl(var(--border))',
67 | input: 'hsl(var(--input))',
68 | ring: 'hsl(var(--ring))',
69 | chart: {
70 | 1: 'hsl(var(--chart-1))',
71 | 2: 'hsl(var(--chart-2))',
72 | 3: 'hsl(var(--chart-3))',
73 | 4: 'hsl(var(--chart-4))',
74 | 5: 'hsl(var(--chart-5))',
75 | },
76 | },
77 | },
78 | },
79 | plugins: [require('tailwindcss-animate')],
80 | };
81 |
--------------------------------------------------------------------------------
/fe/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "Bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": false,
20 | "noUnusedLocals": false,
21 | "noUnusedParameters": false,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true,
24 |
25 | "baseUrl": ".",
26 | "paths": {
27 | "@/*": ["./src/*"]
28 | }
29 | },
30 | "include": ["src"]
31 | }
32 |
--------------------------------------------------------------------------------
/fe/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ],
7 | "compilerOptions": {
8 | "baseUrl": ".",
9 | "paths": {
10 | "@/*": ["./src/*"]
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/fe/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "Bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/fe/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import { defineConfig } from 'vite';
3 | import react from '@vitejs/plugin-react';
4 | import path from 'path';
5 |
6 | // https://vitejs.dev/config/
7 | export default defineConfig({
8 | plugins: [react()],
9 | test: {
10 | globals: true,
11 | environment: 'jsdom',
12 | setupFiles: ['src/__tests__/setup.ts'],
13 | },
14 | resolve: {
15 | alias: {
16 | '@': path.resolve(__dirname, './src'),
17 | },
18 | },
19 | });
20 |
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web19-Clovapatra",
3 | "lockfileVersion": 3,
4 | "requires": true,
5 | "packages": {}
6 | }
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------