├── .agt ├── agt.ps1 └── agt.sh ├── .github ├── CODEOWNERS ├── pull_request_template.md └── workflows │ ├── auto-close-issue.yml │ ├── be.yml │ ├── fe.yml │ └── ncp_push.yml.bak ├── .nks ├── be-ingress.yaml ├── clovapatra-fe.yaml ├── clovapatra-game.yaml ├── clovapatra-signaling.yaml ├── clovapatra-voice.yaml ├── fe-ingress.yaml ├── fe_Dockerfile ├── game_Dockerfile ├── nginx.conf ├── signaling_Dockerfile └── voice_Dockerfile ├── ReadMe.md ├── be ├── gameServer │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── eslint.config.mjs │ ├── nest-cli.json │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── app.module.ts │ │ ├── common │ │ │ ├── constant.ts │ │ │ └── filters │ │ │ │ └── ws-exceptions.filter.ts │ │ ├── config │ │ │ ├── app.config.ts │ │ │ └── redis.config.ts │ │ ├── main.ts │ │ ├── modules │ │ │ ├── games │ │ │ │ ├── dto │ │ │ │ │ ├── game-data.dto.ts │ │ │ │ │ ├── turn-data.dto.ts │ │ │ │ │ ├── voice-processing-result.dto.ts │ │ │ │ │ └── voice-result-from-server.dto.ts │ │ │ │ ├── games-utils.ts │ │ │ │ ├── games.gateway.ts │ │ │ │ ├── games.module.ts │ │ │ │ ├── games.websocket.emit.controller.ts │ │ │ │ └── games.websocket.on.controller.ts │ │ │ ├── players │ │ │ │ └── dto │ │ │ │ │ └── player-data.dto.ts │ │ │ ├── rooms │ │ │ │ ├── dto │ │ │ │ │ ├── create-room.dto.ts │ │ │ │ │ ├── join-data.dto.ts │ │ │ │ │ ├── paginated-room.dto.ts │ │ │ │ │ └── room-data.dto.ts │ │ │ │ ├── room-utils.ts │ │ │ │ ├── rooms.controller.ts │ │ │ │ ├── rooms.gateway.ts │ │ │ │ ├── rooms.module.ts │ │ │ │ ├── rooms.validation.pipe.ts │ │ │ │ ├── rooms.websocket.emit.controller.ts │ │ │ │ └── rooms.websocket.on.controller.ts │ │ │ └── voice-servers │ │ │ │ ├── voice-servers.gateway.ts │ │ │ │ └── voice-servers.module.ts │ │ └── redis │ │ │ ├── redis.module.ts │ │ │ └── redis.service.ts │ ├── test │ │ └── jest-e2e.json │ ├── tsconfig.build.json │ └── tsconfig.json ├── signalingServer │ ├── .env.sample │ ├── .gitignore │ ├── package-lock.json │ ├── package.json │ └── src │ │ ├── config │ │ └── app.config.js │ │ ├── main.js │ │ └── services │ │ ├── room.service.js │ │ └── socket.service.js └── voiceProcessingServer │ ├── .env.sample │ ├── .gitignore │ ├── package-lock.json │ ├── package.json │ └── src │ ├── main.js │ └── services │ ├── audio-processing.service.js │ ├── audio.service.js │ ├── game-server.service.js │ ├── pitch-detection.service.js │ ├── socket.service.js │ └── speech-recognition.service.js ├── fe ├── .env.sample ├── .gitignore ├── .prettierrc ├── README.md ├── components.json ├── eslint.config.cjs ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── src │ ├── App.css │ ├── App.tsx │ ├── __test__ │ │ └── setup.ts │ ├── assets │ │ └── lottie │ │ │ └── podium.json │ ├── components │ │ ├── common │ │ │ ├── CustomAlertDialog.tsx │ │ │ ├── MikeButton.tsx │ │ │ └── SearchBar.tsx │ │ ├── game │ │ │ └── PitchVisualizer.tsx │ │ └── ui │ │ │ ├── alert-dialog.tsx │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── dialog.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ └── slider.tsx │ ├── config │ │ └── env.ts │ ├── constants │ │ ├── audio.ts │ │ ├── errors.ts │ │ ├── pitch.ts │ │ └── rules.ts │ ├── hooks │ │ ├── useAudioManager.ts │ │ ├── useAudioPermission.ts │ │ ├── useBackExit.ts │ │ ├── useDebounce.ts │ │ ├── useDialogForm.ts │ │ ├── useFormValidation.ts │ │ ├── usePitchDetection.ts │ │ ├── usePreventRefresh.ts │ │ ├── useReconnect.ts │ │ └── useRoomsSSE.ts │ ├── index.css │ ├── lib │ │ └── utils.ts │ ├── main.tsx │ ├── pages │ │ ├── GamePage │ │ │ ├── GameDialog │ │ │ │ ├── ExitDialog.tsx │ │ │ │ └── KickDialog.tsx │ │ │ ├── GameScreen │ │ │ │ ├── EndScreen.tsx │ │ │ │ ├── GameResult.tsx │ │ │ │ ├── GameScreen.tsx │ │ │ │ ├── Lyric.tsx │ │ │ │ ├── PlayScreen.tsx │ │ │ │ └── ReadyScreen.tsx │ │ │ ├── PlayerList │ │ │ │ ├── Player.tsx │ │ │ │ ├── PlayerList.tsx │ │ │ │ └── VolumeBar.tsx │ │ │ └── index.tsx │ │ ├── LandingPage │ │ │ └── index.tsx │ │ ├── NotFoundPage │ │ │ └── index.tsx │ │ └── RoomListPage │ │ │ ├── RoomDialog │ │ │ ├── CreateDialog.tsx │ │ │ └── JoinDialog.tsx │ │ │ ├── RoomHeader │ │ │ └── RoomHeader.tsx │ │ │ ├── RoomList │ │ │ ├── GameRoom.tsx │ │ │ ├── Pagination.tsx │ │ │ └── RoomList.tsx │ │ │ └── index.tsx │ ├── services │ │ ├── SocketService.ts │ │ ├── gameSocket.ts │ │ ├── signalingSocket.ts │ │ └── voiceSocket.ts │ ├── stores │ │ ├── queries │ │ │ ├── getCurrentRoomQuery.ts │ │ │ ├── getRoomsQuery.ts │ │ │ └── searchRoomsQuery.ts │ │ └── zustand │ │ │ ├── useGameStore.ts │ │ │ ├── usePeerStore.ts │ │ │ ├── usePitchStore.ts │ │ │ └── useRoomStore.ts │ ├── types │ │ ├── audioTypes.ts │ │ ├── roomTypes.ts │ │ ├── socketTypes.ts │ │ └── webrtcTypes.ts │ ├── utils │ │ ├── pitchDetection.ts │ │ ├── playerUtils.ts │ │ └── validator.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── package-lock.json └── package.json /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @dev-taewon-kim @student079 @studioOwol -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## #️⃣ 이슈 번호 2 | 3 |
4 | 5 | ## 📝 작업 6 | 7 | 8 | 9 |
10 | 11 | ## 📒 작업 내용 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/auto-close-issue.yml: -------------------------------------------------------------------------------- 1 | name: Auto Close Issue on PR Merge 2 | 3 | on: 4 | pull_request_target: 5 | types: [closed] 6 | branches: 7 | - 'dev-fe' 8 | - 'dev-be' 9 | 10 | permissions: 11 | contents: read 12 | issues: write 13 | pull-requests: write 14 | 15 | jobs: 16 | close-issue: 17 | runs-on: ubuntu-latest 18 | if: github.event.pull_request.merged == true 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | with: 23 | ref: ${{ github.event.pull_request.head.sha }} 24 | 25 | - name: Close linked issue 26 | env: 27 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | PR_NUMBER: ${{ github.event.pull_request.number }} 29 | BRANCH_NAME: ${{ github.event.pull_request.head.ref }} 30 | TARGET_BRANCH: ${{ github.event.pull_request.base.ref }} 31 | run: | 32 | # feature-{fe|be}-#123 형식에서 이슈 번호 추출 33 | if [[ $BRANCH_NAME =~ feature-(fe|be)-#([0-9]+)$ ]]; then 34 | ISSUE_NUMBER="${BASH_REMATCH[2]}" 35 | echo "Found issue number: $ISSUE_NUMBER" 36 | echo "Closing issue #$ISSUE_NUMBER" 37 | gh issue close "$ISSUE_NUMBER" --comment "Automatically closed by PR #$PR_NUMBER merge to $TARGET_BRANCH" 38 | else 39 | echo "Branch name '$BRANCH_NAME' does not match expected pattern" 40 | exit 0 41 | fi -------------------------------------------------------------------------------- /.github/workflows/be.yml: -------------------------------------------------------------------------------- 1 | name: Multiple Server Deployment 2 | 3 | on: 4 | push: 5 | branches: ["dev-be"] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Import environment variables 18 | run: | 19 | echo "${{ secrets.ENV_GAME }}" > ./be/gameServer/.env 20 | echo "${{ secrets.ENV_SIGNALING }}" > ./be/signalingServer/.env 21 | echo "${{ secrets.ENV_VOICE }}" > ./be/voiceProcessingServer/.env 22 | shell: bash 23 | 24 | - name: Setup SSH 25 | uses: webfactory/ssh-agent@v0.9.0 26 | with: 27 | ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} 28 | 29 | - name: Add remote server to known hosts 30 | run: | 31 | mkdir -p ~/.ssh 32 | ssh-keyscan ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts 33 | 34 | - name: Transfer files via SCP 35 | run: | 36 | cd be 37 | # Game Server 38 | cd gameServer 39 | zip -r game-server.zip src package.json tsconfig.json tsconfig.build.json nest-cli.json .env 40 | scp -P ${{ secrets.SERVER_PORT }} ./game-server.zip ${{ secrets.SSH_USER }}@${{ secrets.SERVER_IP }}:/home/${{ secrets.SSH_USER }}/deploy/servers/ 41 | cd .. 42 | 43 | # Signaling Server 44 | cd signalingServer 45 | zip -r signaling-server.zip src package.json .env 46 | scp -P ${{ secrets.SERVER_PORT }} ./signaling-server.zip ${{ secrets.SSH_USER }}@${{ secrets.SERVER_IP }}:/home/${{ secrets.SSH_USER }}/deploy/servers/ 47 | cd .. 48 | 49 | # Voice Processing Server 50 | cd voiceProcessingServer 51 | zip -r voice-server.zip src package.json .env 52 | scp -P ${{ secrets.SERVER_PORT }} ./voice-server.zip ${{ secrets.SSH_USER }}@${{ secrets.SERVER_IP }}:/home/${{ secrets.SSH_USER }}/deploy/servers/ 53 | 54 | - name: Execute remote commands 55 | uses: appleboy/ssh-action@master 56 | with: 57 | host: ${{ secrets.SERVER_IP }} 58 | username: ${{ secrets.SSH_USER }} 59 | key: ${{ secrets.SSH_PRIVATE_KEY }} 60 | port: ${{ secrets.SERVER_PORT }} 61 | script: | 62 | export PATH=$PATH:/home/${{ secrets.SSH_USER }}/.nvm/versions/node/v21.7.3/bin 63 | cd ~/deploy/servers/ 64 | 65 | # Game Server 66 | if pm2 list | grep -q "game-server"; then 67 | pm2 delete game-server 68 | fi 69 | rm -rf ./game-server 70 | unzip -o game-server.zip -d ./game-server 71 | rm game-server.zip 72 | cd game-server 73 | npm install 74 | npm run build 75 | pm2 start dist/main.js --name game-server 76 | cd .. 77 | 78 | # Signaling Server 79 | if pm2 list | grep -q "signaling-server"; then 80 | pm2 delete signaling-server 81 | fi 82 | rm -rf ./signaling-server 83 | unzip -o signaling-server.zip -d ./signaling-server 84 | rm signaling-server.zip 85 | cd signaling-server 86 | npm install 87 | pm2 start src/main.js --name signaling-server 88 | cd .. 89 | 90 | # Voice Processing Server 91 | if pm2 list | grep -q "voice-server"; then 92 | pm2 delete voice-server 93 | fi 94 | rm -rf ./voice-server 95 | unzip -o voice-server.zip -d ./voice-server 96 | rm voice-server.zip 97 | cd voice-server 98 | npm install 99 | pm2 start src/main.js --name voice-server 100 | cd .. 101 | 102 | pm2 save -------------------------------------------------------------------------------- /.github/workflows/fe.yml: -------------------------------------------------------------------------------- 1 | name: React.js Deployment 2 | 3 | on: 4 | push: 5 | branches: ["dev-fe"] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Cache 18 | uses: actions/cache@v4 19 | with: 20 | path: ~/.npm 21 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 22 | restore-keys: | 23 | ${{ runner.os }}-node- 24 | 25 | - name: Set up Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 21.7.3 29 | 30 | - name: Install dependencies 31 | run: | 32 | cd ./fe 33 | echo "${{ secrets.FE_ENV }}" > .env 34 | npm ci 35 | 36 | - name: Build 37 | run: | 38 | cd ./fe 39 | npm run build 40 | 41 | - name: Setup SSH 42 | uses: webfactory/ssh-agent@v0.9.0 43 | with: 44 | ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} 45 | 46 | - name: Add remote server to known hosts 47 | run: | 48 | mkdir -p ~/.ssh 49 | ssh-keyscan ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts 50 | 51 | - name: Execute remote commands 52 | uses: appleboy/ssh-action@master 53 | with: 54 | host: ${{ secrets.SERVER_IP }} 55 | username: ${{ secrets.SSH_USER }} 56 | key: ${{ secrets.SSH_PRIVATE_KEY }} 57 | port: ${{ secrets.SERVER_PORT }} 58 | script: | 59 | sudo rm -rf /var/www/clovapatra.com/html 60 | sudo mkdir -p /var/www/clovapatra.com/html 61 | sudo chown -R ${{ secrets.SSH_USER }}:${{ secrets.SSH_USER }} /var/www/clovapatra.com/html 62 | 63 | - name: Transfer built files via rsync 64 | run: rsync -avz -e 'ssh -p ${{ secrets.SERVER_PORT }}' ./fe/dist/ ${{ secrets.SSH_USER }}@${{ secrets.SERVER_IP }}:/var/www/clovapatra.com/html 65 | -------------------------------------------------------------------------------- /.github/workflows/ncp_push.yml.bak: -------------------------------------------------------------------------------- 1 | name: Build and Deploy to NCP 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | REGISTRY: clovapatra-container-registry.kr.ncr.ntruss.com 10 | FE_IMAGE: clovapatra-container-registry.kr.ncr.ntruss.com/clovapatra-fe 11 | GAME_IMAGE: clovapatra-container-registry.kr.ncr.ntruss.com/clovapatra-game 12 | SIGNALING_IMAGE: clovapatra-container-registry.kr.ncr.ntruss.com/clovapatra-signaling 13 | VOICE_IMAGE: clovapatra-container-registry.kr.ncr.ntruss.com/clovapatra-voice 14 | 15 | jobs: 16 | build-and-push: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout Code 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | 24 | # Copy Dockerfiles to root directory 25 | - name: Copy Dockerfiles to root 26 | run: | 27 | echo "${{ secrets.ENV_FE_NKS }}" > ./fe/.env 28 | echo "${{ secrets.ENV_GAME }}" > ./be/gameServer/.env 29 | echo "${{ secrets.ENV_SIGNALING }}" > ./be/signalingServer/.env 30 | echo "${{ secrets.ENV_VOICE }}" > ./be/voiceProcessingServer/.env 31 | cp .nks/fe_Dockerfile ./Dockerfile.fe 32 | cp .nks/game_Dockerfile ./Dockerfile.game 33 | cp .nks/signaling_Dockerfile ./Dockerfile.signaling 34 | cp .nks/voice_Dockerfile ./Dockerfile.voice 35 | 36 | - name: Set up Docker Buildx 37 | uses: docker/setup-buildx-action@v3 38 | 39 | - name: Login to NCP Container Registry 40 | uses: docker/login-action@v3 41 | with: 42 | registry: ${{ env.REGISTRY }} 43 | username: ${{ secrets.NCP_ACCESS_KEY }} 44 | password: ${{ secrets.NCP_SECRET_KEY }} 45 | 46 | # Build and push Frontend 47 | - name: Build and push frontend image 48 | uses: docker/build-push-action@v5 49 | with: 50 | context: . 51 | file: Dockerfile.fe 52 | push: true 53 | tags: ${{ env.FE_IMAGE }}:latest 54 | cache-from: type=gha 55 | cache-to: type=gha,mode=max 56 | 57 | # Build and push Game Server 58 | - name: Build and push game server image 59 | uses: docker/build-push-action@v5 60 | with: 61 | context: . 62 | file: Dockerfile.game 63 | push: true 64 | tags: ${{ env.GAME_IMAGE }}:latest 65 | cache-from: type=gha 66 | cache-to: type=gha,mode=max 67 | 68 | # Build and push Signaling Server 69 | - name: Build and push signaling server image 70 | uses: docker/build-push-action@v5 71 | with: 72 | context: . 73 | file: Dockerfile.signaling 74 | push: true 75 | tags: ${{ env.SIGNALING_IMAGE }}:latest 76 | cache-from: type=gha 77 | cache-to: type=gha,mode=max 78 | 79 | # Build and push Voice Processing Server 80 | - name: Build and push voice processing server image 81 | uses: docker/build-push-action@v5 82 | with: 83 | context: . 84 | file: Dockerfile.voice 85 | push: true 86 | tags: ${{ env.VOICE_IMAGE }}:latest 87 | cache-from: type=gha 88 | cache-to: type=gha,mode=max 89 | 90 | # Push to NCP Source Commit 91 | - name: Set up Git Config 92 | run: | 93 | git config --global user.name "GitHub Actions" 94 | git config --global user.email "actions@github.com" 95 | 96 | - name: Add NCP Remote 97 | run: | 98 | git remote add ncp https://${{ secrets.NCP_USERNAME }}:${{ secrets.NCP_PASSWORD_URL_ENCODED }}@${{ secrets.NCP_REPO_URL }} 99 | 100 | - name: Push to NCP Main Branch 101 | run: | 102 | git push ncp main --force 103 | -------------------------------------------------------------------------------- /.nks/be-ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: Ingress 3 | metadata: 4 | name: be-ingress 5 | annotations: 6 | nginx.ingress.kubernetes.io/rewrite-target: / 7 | nginx.ingress.kubernetes.io/ssl-redirect: "false" 8 | spec: 9 | ingressClassName: nginx 10 | rules: 11 | - host: "nks-game.clovapatra.com" 12 | http: 13 | paths: 14 | - path: / 15 | pathType: Prefix 16 | backend: 17 | service: 18 | name: clovapatra-game-service 19 | port: 20 | number: 8000 21 | - host: "nks-signaling.clovapatra.com" 22 | http: 23 | paths: 24 | - path: / 25 | pathType: Prefix 26 | backend: 27 | service: 28 | name: clovapatra-signaling-service 29 | port: 30 | number: 8001 31 | - host: "nks-voice-processing.clovapatra.com" 32 | http: 33 | paths: 34 | - path: / 35 | pathType: Prefix 36 | backend: 37 | service: 38 | name: clovapatra-voice-service 39 | port: 40 | number: 8002 41 | -------------------------------------------------------------------------------- /.nks/clovapatra-fe.yaml: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | name: clovapatra-fe 7 | labels: 8 | app: clovapatra-fe 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app: clovapatra-fe 14 | template: 15 | metadata: 16 | labels: 17 | app: clovapatra-fe 18 | spec: 19 | containers: 20 | - name: clovapatra-fe 21 | image: clovapatra-container-registry.kr.ncr.ntruss.com/clovapatra-fe:latest 22 | ports: 23 | - containerPort: 80 24 | command: ["nginx"] 25 | args: ["-g", "daemon off;"] 26 | nodeSelector: 27 | ncloud.com/nks-nodepool: fe-nginx 28 | imagePullSecrets: 29 | - name: regcred 30 | 31 | --- 32 | # Service 33 | 34 | apiVersion: v1 35 | kind: Service 36 | metadata: 37 | name: clovapatra-fe-service 38 | labels: 39 | app: clovapatra-fe 40 | spec: 41 | type: ClusterIP 42 | selector: 43 | app: clovapatra-fe 44 | ports: 45 | - protocol: TCP 46 | port: 80 47 | targetPort: 80 48 | -------------------------------------------------------------------------------- /.nks/clovapatra-game.yaml: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | name: clovapatra-game 7 | labels: 8 | app: clovapatra-game 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app: clovapatra-game 14 | template: 15 | metadata: 16 | labels: 17 | app: clovapatra-game 18 | spec: 19 | containers: 20 | - name: clovapatra-game 21 | image: clovapatra-container-registry.kr.ncr.ntruss.com/clovapatra-game:latest 22 | ports: 23 | - containerPort: 8000 24 | env: 25 | - name: APP_PORT 26 | value: "8000" 27 | - name: REDIS_HOST 28 | value: "redisc-2vvlm0.vpc-cdb.ntruss.com" 29 | - name: REDIS_PORT 30 | value: "6379" 31 | - name: REDIS_PASSWORD 32 | value: "" 33 | command: ["node"] 34 | args: ["dist/main.js"] 35 | nodeSelector: 36 | ncloud.com/nks-nodepool: game 37 | imagePullSecrets: 38 | - name: regcred 39 | 40 | --- 41 | # Service 42 | 43 | apiVersion: v1 44 | kind: Service 45 | metadata: 46 | name: clovapatra-game-service 47 | labels: 48 | app: clovapatra-game 49 | spec: 50 | type: ClusterIP 51 | selector: 52 | app: clovapatra-game 53 | ports: 54 | - protocol: TCP 55 | port: 8000 56 | targetPort: 8000 57 | -------------------------------------------------------------------------------- /.nks/clovapatra-signaling.yaml: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | name: clovapatra-signaling 7 | labels: 8 | app: clovapatra-signaling 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app: clovapatra-signaling 14 | template: 15 | metadata: 16 | labels: 17 | app: clovapatra-signaling 18 | spec: 19 | containers: 20 | - name: clovapatra-signaling 21 | image: clovapatra-container-registry.kr.ncr.ntruss.com/clovapatra-signaling:latest 22 | ports: 23 | - containerPort: 8001 24 | env: 25 | - name: APP_PORT 26 | value: "8001" 27 | - name: REDIS_HOST 28 | value: "redisc-2vvlm0.vpc-cdb.ntruss.com" 29 | - name: REDIS_PORT 30 | value: "6379" 31 | - name: REDIS_PASSWORD 32 | value: "" 33 | command: ["node"] 34 | args: ["src/main.js"] 35 | nodeSelector: 36 | ncloud.com/nks-nodepool: signaling 37 | imagePullSecrets: 38 | - name: regcred 39 | 40 | --- 41 | # Service 42 | 43 | apiVersion: v1 44 | kind: Service 45 | metadata: 46 | name: clovapatra-signaling-service 47 | labels: 48 | app: clovapatra-signaling 49 | spec: 50 | type: ClusterIP 51 | selector: 52 | app: clovapatra-signaling 53 | ports: 54 | - protocol: TCP 55 | port: 8001 56 | targetPort: 8001 57 | -------------------------------------------------------------------------------- /.nks/clovapatra-voice.yaml: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | name: clovapatra-voice 7 | labels: 8 | app: clovapatra-voice 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app: clovapatra-voice 14 | template: 15 | metadata: 16 | labels: 17 | app: clovapatra-voice 18 | spec: 19 | containers: 20 | - name: clovapatra-voice 21 | image: clovapatra-container-registry.kr.ncr.ntruss.com/clovapatra-voice:latest 22 | ports: 23 | - containerPort: 8002 24 | env: 25 | - name: PORT 26 | value: "8002" 27 | - name: CLOVA_API_KEY 28 | valueFrom: 29 | secretKeyRef: 30 | name: clova-api-secret 31 | key: CLOVA_API_KEY 32 | - name: CLOVA_API_URL 33 | value: "https://clovaspeech-gw.ncloud.com/recog/v1/stt" 34 | - name: GAME_SERVER_URL 35 | value: "wss://nks-game.clovapatra.com/rooms" 36 | - name: REDIS_HOST 37 | value: "redisc-2vvlm0.vpc-cdb.ntruss.com" 38 | - name: REDIS_PORT 39 | value: "6379" 40 | - name: REDIS_PASSWORD 41 | value: "" 42 | - name: REDIS_ROOM_KEY_EXPIRE_TIME 43 | value: "60000" 44 | command: ["node"] 45 | args: ["src/main.js"] 46 | nodeSelector: 47 | ncloud.com/nks-nodepool: voice 48 | imagePullSecrets: 49 | - name: regcred 50 | --- 51 | # Service 52 | 53 | apiVersion: v1 54 | kind: Service 55 | metadata: 56 | name: clovapatra-voice-service 57 | labels: 58 | app: clovapatra-voice 59 | spec: 60 | type: ClusterIP 61 | selector: 62 | app: clovapatra-voice 63 | ports: 64 | - protocol: TCP 65 | port: 8002 66 | targetPort: 8002 67 | -------------------------------------------------------------------------------- /.nks/fe-ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: Ingress 3 | metadata: 4 | name: fe-ingress 5 | annotations: 6 | nginx.ingress.kubernetes.io/rewrite-target: / 7 | nginx.ingress.kubernetes.io/ssl-redirect: "false" 8 | nginx.ingress.kubernetes.io/try-files: "$uri /index.html" 9 | spec: 10 | ingressClassName: nginx 11 | rules: 12 | - host: "nks-fe.clovapatra.com" 13 | http: 14 | paths: 15 | - path: / 16 | pathType: Prefix 17 | backend: 18 | service: 19 | name: clovapatra-fe-service 20 | port: 21 | number: 80 22 | -------------------------------------------------------------------------------- /.nks/fe_Dockerfile: -------------------------------------------------------------------------------- 1 | # Build Stage 2 | FROM node:21.7.3-alpine AS build 3 | 4 | WORKDIR /app 5 | 6 | # Copy package.json and package-lock.json 7 | COPY fe/package*.json ./ 8 | 9 | # Install dependencies 10 | RUN npm ci 11 | 12 | # Copy the rest of the application code 13 | COPY fe/ ./ 14 | 15 | # Build the React app 16 | RUN npm run build 17 | 18 | # Nginx Stage 19 | FROM nginx:latest 20 | 21 | # Remove default Nginx static assets 22 | RUN rm -rf /usr/share/nginx/html/* 23 | 24 | # Copy built assets from the build stage 25 | COPY --from=build /app/dist /usr/share/nginx/html 26 | 27 | # Copy custom Nginx configuration 28 | COPY .nks/nginx.conf /etc/nginx/conf.d/default.conf 29 | 30 | # Expose port 80 31 | EXPOSE 80 32 | 33 | # Start Nginx 34 | CMD ["nginx", "-g", "daemon off;"] 35 | -------------------------------------------------------------------------------- /.nks/game_Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:21.7.3-alpine 2 | 3 | WORKDIR /app 4 | 5 | # Copy package.json and package-lock.json 6 | COPY be/gameServer/package*.json ./ 7 | 8 | # Install dependencies 9 | RUN npm ci 10 | 11 | # Copy application code 12 | COPY be/gameServer/ ./ 13 | 14 | # Build the application 15 | RUN npm run build 16 | 17 | # Expose port (specified via environment variable) 18 | EXPOSE ${APP_PORT} 19 | 20 | # Start the application 21 | CMD ["node", "dist/main.js"] 22 | -------------------------------------------------------------------------------- /.nks/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name _; 4 | 5 | root /usr/share/nginx/html; 6 | index index.html; 7 | 8 | # 모든 요청을 index.html로 리디렉션하여 클라이언트 측 라우팅을 지원 9 | location / { 10 | try_files $uri /index.html; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.nks/signaling_Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:21.7.3-alpine 2 | 3 | WORKDIR /app 4 | 5 | # Copy package.json and package-lock.json 6 | COPY be/signalingServer/package*.json ./ 7 | 8 | # Install dependencies 9 | RUN npm ci 10 | 11 | # Copy application code 12 | COPY be/signalingServer/ ./ 13 | 14 | # Expose port 15 | EXPOSE ${APP_PORT} 16 | 17 | # Start the application 18 | CMD ["node", "src/main.js"] 19 | -------------------------------------------------------------------------------- /.nks/voice_Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:21.7.3-alpine 2 | 3 | # Install ffmpeg 4 | RUN apk add --no-cache ffmpeg 5 | 6 | WORKDIR /app 7 | 8 | # Copy package.json and package-lock.json 9 | COPY be/voiceProcessingServer/package*.json ./ 10 | 11 | # Install dependencies 12 | RUN npm ci 13 | 14 | # Copy application code 15 | COPY be/voiceProcessingServer/ ./ 16 | 17 | # Expose port 18 | EXPOSE ${PORT} 19 | 20 | # Start the application 21 | CMD ["node", "src/main.js"] 22 | -------------------------------------------------------------------------------- /ReadMe.md: -------------------------------------------------------------------------------- 1 | # 안녕! 클로바파트라 🗣️ 2 | 3 | ![image](https://github.com/user-attachments/assets/8001fa14-a691-4693-bd02-9aebd811f548) 4 | 5 | [![Hits Badge](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fboostcampwm-2024%2Fweb19-boostproject&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://hits.seeyoufarm.com) 6 | 7 | 서비스 바로가기(https://clovapatra.com) 8 | 9 | **안녕! 클로바파트라**는 음성 기반 실시간 웹 게임 프로젝트입니다. 이 서비스는 참가자들이 다양한 음성 도전을 통해 실시간으로 소통하며 재미를 느낄 수 있도록 설계되었습니다. 10 | 11 | **"안녕! 클로바파트라"와 함께 독창적인 음성 기반 게임의 세계로 빠져보세요!** 🎤 12 | 13 | --- 14 | 15 | ## 📄 목차 16 | 17 | - [📜 프로젝트 개요](#-프로젝트-개요) 18 | - [🚀 주요 기능](#-주요-기능) 19 | - [⚙️ 기술 스택](#️-기술-스택) 20 | - [🏛️ 시스템 아키텍처](#️-시스템-아키텍처) 21 | - [📂 프로젝트 구조](#-프로젝트-구조) 22 | - [🔗 관련 문서](#-관련-문서) 23 | 24 | --- 25 | 26 | ## 📜 프로젝트 개요 27 | 28 | **안녕! 클로바파트라**는 참가자들이 음성을 기반으로 도전을 수행하며, 게임을 즐기는 실시간 웹 게임입니다. 29 | 30 | - 사용자들은 자신의 목소리로 게임을 플레이하며, 발음, 음정 등의 도전을 통해 경쟁합니다. 31 | - WebRTC와 음성 분석 기술을 활용하여 음성 데이터를 실시간으로 처리하며 음정을 분석하고, Clova Speech Recognition API를 통해 발음의 정확도를 측정합니다. 32 | 33 | --- 34 | 35 | ## 🚀 주요 기능 36 | 37 | ### 1. 멀티플레이 지원 38 | 39 | - **방 개설 및 관리**: 사용자는 게임 방을 생성하거나 방에 입장하여 실시간으로 소통할 수 있습니다. 40 | - **실시간 음성 통화**: WebRTC 기반의 실시간 통화 기능을 제공합니다. 41 | - **참가자 관리**: 방 참여, 준비 상태 토글, 강퇴, 음소거, 볼륨 조절 등의 기능을 지원합니다. 42 | 43 | ![멀티플레이 지원](https://github.com/user-attachments/assets/b1c051e7-f581-451d-9cc0-517252db183f) 44 | 45 | ### 2. 게임 모드 46 | 47 | - **클레오파트라 모드**: 이전 참가자보다 높은 음정을 내는 것을 목표로 하는 도전 게임. 48 | 49 | ![클레오파트라 모드](https://github.com/user-attachments/assets/a5b864a8-8836-42ad-83a0-efe5b51dc761) 50 | 51 | - **발음 도전 모드**: 주어진 지문을 정확히 발음하여 90점 이상의 점수를 받는 것을 목표로하는 도전 게임. 52 | 53 | ![발음 도전 모드](https://github.com/user-attachments/assets/d6349380-ad28-4c41-ad79-a947870d816a) 54 | 55 | ### 3. 음성 분석 56 | 57 | - **음정, 볼륨 분석**: 실시간으로 음성 데이터를 분석하여 음정의 높낮이, 음량의 크기를 시각화 및 비교. 58 | - **발음 점수화**: Clova Speech Recognition API를 활용해 발음 정확도를 측정. 59 | 60 | --- 61 | 62 | ## ⚙️ 기술 스택 63 | 64 | ![기술 스택](https://github.com/user-attachments/assets/5bce398c-9bd9-448d-bf50-4a6b15caadf9) 65 | 66 | 67 | ### 프론트엔드 68 | 69 | - **React**: 사용자 인터페이스 개발 70 | - **Zustand**: 클라이언트 전역 상태 관리 71 | - **TanStack Query**: 서버 상태 관리 72 | - **shadcn/ui**: Radix UI + TailwindCSS 컴포넌트 기반 스타일링 73 | - **WebRTC**: 실시간 통신 및 음성 데이터 전송 74 | - **Socket.IO**: 실시간 양방향 통신 75 | 76 | ### 백엔드 77 | 78 | - **NestJS**: 서버 및 게임 로직 관리 79 | - **Express**: 음성 처리 및 시그널링 서버 80 | - **Redis**: Pub/Sub 및 게임 방, 게임 관리 81 | - **Socket.IO**: 실시간 양방향 통신 82 | 83 | ### 기타 84 | 85 | - **Naver Cloud Platform**: 네이버 클라우드 플랫폼을 활용한 인프라 및 서비스 배포 86 | - **Kubernetes**: Container 기반 배포 87 | - **Naver Clova Speech Recognition API**: 음성 데이터 분석 88 | - **NGINX**: 로드 밸런싱 및 요청 라우팅 89 | - **AGT**: 우리가 제작한 GitHub 이슈 기반 프로젝트 관리 툴 90 | 91 | --- 92 | 93 | ## 🏛️ 시스템 아키텍처 94 | 95 | 96 | ![서비스 흐름도](https://github.com/user-attachments/assets/3a8f6a67-82bd-4c33-ae17-5c316a9a06ae) 97 | 98 | ![배포 파이프라인](https://github.com/user-attachments/assets/eda6f750-0cba-49ef-82ca-f1b9ca67382c) 99 | 100 | ![네트워크 구성도](https://github.com/user-attachments/assets/0f96bdc9-e761-43b0-91fd-c491b980649d) 101 | 102 | ### 주요 컴포넌트 103 | 104 | 1. **클라이언트 그룹**: 105 | - WebRTC 기반의 P2P MESH 연결로 음성 데이터 전송. 106 | - React로 구성된 사용자 인터페이스. 107 | - Zustand를 통한 전역 상태 관리. 108 | 2. **게임 서버**: 109 | - Socket.IO와 NestJS를 통해 실시간 게임 로직을 관리. 110 | 3. **시그널링 서버**: 111 | - WebRTC 연결 초기화를 위한 시그널링 처리. 112 | 4. **음성 처리 서버**: 113 | - Express 기반 음성 데이터 분석 처리. 114 | - 병목 현상 방지를 위한 다중화 115 | 5. **저장소**: 116 | - Redis: Pub/Sub 및 게임 상태 캐싱. 117 | 118 | --- 119 | 120 | ## 📂 프로젝트 구조 121 | 122 | ```plaintext 123 | web19-boostproject/ 124 | ├── .agt/ # AGT (GitHub 이슈 기반 커스텀 프로젝트 관리 도구) 관련 설정 및 데이터 폴더 125 | ├── .github/ # GitHub 관련 워크플로우 및 설정 파일 저장소 (CI/CD 파이프라인 등) 126 | ├── .nks/ # NKS (Ncloud Kubernetes Service 관련 설정) 폴더 127 | ├── fe/ # Frontend(React 기반 클라이언트) 128 | │ ├── src/ # 소스 코드 폴더 129 | │ │ ├── components/ # UI 컴포넌트 폴더 (버튼, 입력창 등 재사용 가능한 컴포넌트) 130 | │ │ ├── hooks/ # Custom Hooks 폴더 (상태 관리 및 로직 재사용) 131 | │ │ ├── pages/ # 페이지 단위 컴포넌트 폴더 (라우터와 매핑된 화면들) 132 | │ │ └── utils/ # 공통 유틸리티 함수 폴더 (데이터 변환, API 요청 등) 133 | └── be/ # Backend 134 | ├── gameServer/ # 게임 로직과 상태 관리를 담당하는 NestJS 서버 135 | ├── signalingServer/ # WebRTC 시그널링을 처리하는 Express 서버 136 | └── voiceProcessingServer/ # 음성 데이터를 분석하는 Express 서버 137 | ``` 138 | 139 | --- 140 | 141 | ## 🔗 관련 문서 142 | 143 | 더 자세한 정보는 프로젝트 [**위키 페이지**](https://github.com/boostcampwm-2024/web19-boostproject/wiki)에서 확인할 수 있습니다. 144 | 145 | - 프로젝트 상세 설명 146 | - 주요 기술 및 아키텍처 분석 147 | - 우리만의 GitHub 이슈 기반 프로젝트 관리 툴 148 | - 회의록, 회고록 149 | 150 | 궁금하신 점이 있다면 언제든지 문의해주세요! 😊 151 | -------------------------------------------------------------------------------- /be/gameServer/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | -------------------------------------------------------------------------------- /be/gameServer/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /be/gameServer/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | 6 | /** @type {import('eslint').Linter.Config[]} */ 7 | export default [ 8 | {files: ["**/*.{js,mjs,cjs,ts}"]}, 9 | {languageOptions: { globals: globals.browser }}, 10 | pluginJs.configs.recommended, 11 | ...tseslint.configs.recommended, 12 | ]; -------------------------------------------------------------------------------- /be/gameServer/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /be/gameServer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gameServer", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/common": "^10.4.8", 24 | "@nestjs/config": "^3.3.0", 25 | "@nestjs/core": "^10.4.8", 26 | "@nestjs/platform-express": "^10.4.8", 27 | "@nestjs/platform-socket.io": "^10.4.8", 28 | "@nestjs/swagger": "^8.0.7", 29 | "@nestjs/websockets": "^10.4.8", 30 | "class-transformer": "^0.5.1", 31 | "class-validator": "^0.14.1", 32 | "ioredis": "^5.4.1", 33 | "reflect-metadata": "^0.2.2", 34 | "rxjs": "^7.8.1", 35 | "save-dev": "^0.0.1-security", 36 | "socket.io": "^4.8.1", 37 | "socket.io-client": "^4.8.1", 38 | "uuid": "^11.0.3" 39 | }, 40 | "devDependencies": { 41 | "@eslint/js": "^9.15.0", 42 | "@nestjs/cli": "^10.4.8", 43 | "@nestjs/schematics": "^10.2.3", 44 | "@nestjs/testing": "^10.4.8", 45 | "@types/express": "^5.0.0", 46 | "@types/jest": "^29.5.14", 47 | "@types/node": "^22.9.3", 48 | "@types/supertest": "^6.0.2", 49 | "@typescript-eslint/eslint-plugin": "^8.15.0", 50 | "@typescript-eslint/parser": "^8.15.0", 51 | "eslint": "^9.15.0", 52 | "eslint-config-prettier": "^9.1.0", 53 | "eslint-plugin-prettier": "^5.2.1", 54 | "globals": "^15.12.0", 55 | "jest": "^29.7.0", 56 | "prettier": "^3.3.3", 57 | "source-map-support": "^0.5.21", 58 | "supertest": "^7.0.0", 59 | "ts-jest": "^29.2.5", 60 | "ts-loader": "^9.5.1", 61 | "ts-node": "^10.9.2", 62 | "tsconfig-paths": "^4.2.0", 63 | "typescript": "^5.6.0", 64 | "typescript-eslint": "^8.15.0" 65 | }, 66 | "jest": { 67 | "moduleFileExtensions": [ 68 | "js", 69 | "json", 70 | "ts" 71 | ], 72 | "rootDir": "src", 73 | "testRegex": ".*\\.spec\\.ts$", 74 | "transform": { 75 | "^.+\\.(t|j)s$": "ts-jest" 76 | }, 77 | "collectCoverageFrom": [ 78 | "**/*.(t|j)s" 79 | ], 80 | "coverageDirectory": "../coverage", 81 | "testEnvironment": "node" 82 | }, 83 | "overrides": { 84 | "glob": "^9.0.0" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /be/gameServer/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import appConfig from './config/app.config'; 4 | import { RoomsModule } from './modules/rooms/rooms.module'; 5 | import { GamesModule } from './modules/games/games.module'; 6 | import { VoiceServersModule } from './modules/voice-servers/voice-servers.module'; 7 | 8 | @Module({ 9 | imports: [ 10 | ConfigModule.forRoot({ 11 | isGlobal: true, 12 | load: [appConfig], 13 | }), 14 | RoomsModule, 15 | GamesModule, 16 | VoiceServersModule, 17 | ], 18 | }) 19 | export class AppModule {} 20 | -------------------------------------------------------------------------------- /be/gameServer/src/common/constant.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorMessages { 2 | ROOM_NOT_FOUND = 'RoomNotFound', 3 | GAME_NOT_FOUND = 'GameNotFound', 4 | ROOM_FULL = 'RoomFull', 5 | NICKNAME_TAKEN = 'NicknameTaken', 6 | PLAYER_NOT_FOUND = 'PlayerNotFound', 7 | ONLY_HOST_CAN_START = 'HostOnlyStart', 8 | INTERNAL_ERROR = 'InternalError', 9 | ALL_PLAYERS_MUST_BE_READY = 'AllPlayersMustBeReady', 10 | NOT_ENOUGH_PLAYERS = 'NotEnoughPlayers', 11 | VALIDATION_FAILED = 'ValidationFailed', 12 | GAME_ALREADY_IN_PROGRESS = 'GameAlreadyInProgress', 13 | } 14 | 15 | export enum RedisKeys { 16 | ROOMS_LIST = 'roomsList', 17 | ROOMS_UPDATE_CHANNEL = 'roomUpdate', 18 | ROOM_NAME_TO_ID_HASH = 'roomNamesToIds', 19 | ROOM_NAMES_SORTED_KEY = 'roomNames', 20 | } 21 | 22 | export enum RoomsConstant { 23 | ROOMS_MAX_PLAYERS = 4, 24 | ROOMS_LIMIT = 9, 25 | } 26 | -------------------------------------------------------------------------------- /be/gameServer/src/common/filters/ws-exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { Catch, ArgumentsHost } from '@nestjs/common'; 2 | import { WsException } from '@nestjs/websockets'; 3 | import { Logger } from '@nestjs/common'; 4 | 5 | @Catch(WsException) 6 | export class WsExceptionsFilter { 7 | private readonly logger = new Logger(WsExceptionsFilter.name); 8 | 9 | catch(exception: WsException, host: ArgumentsHost) { 10 | const client = host.switchToWs().getClient(); 11 | const errorResponse = exception.getError(); 12 | 13 | client.emit('error', errorResponse); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /be/gameServer/src/config/app.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('app', () => ({ 4 | port: parseInt(process.env.APP_PORT, 10) || 8000, 5 | })); 6 | -------------------------------------------------------------------------------- /be/gameServer/src/config/redis.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('redis', () => ({ 4 | host: process.env.REDIS_HOST || 'localhost', 5 | port: parseInt(process.env.REDIS_PORT, 10) || 6379, 6 | password: process.env.REDIS_PASSWORD || '', 7 | })); 8 | -------------------------------------------------------------------------------- /be/gameServer/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 3 | import { AppModule } from './app.module'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { IoAdapter } from '@nestjs/platform-socket.io'; 6 | 7 | // Custom IoAdapter with CORS options 8 | class CustomIoAdapter extends IoAdapter { 9 | createIOServer(port: number, options?) { 10 | const server = super.createIOServer(port, { 11 | ...options, 12 | cors: { 13 | origin: '*', 14 | methods: '*', 15 | credentials: true, 16 | transports: ['websocket', 'polling'], 17 | }, 18 | allowEIO3: true, 19 | }); 20 | return server; 21 | } 22 | } 23 | 24 | async function bootstrap() { 25 | const app = await NestFactory.create(AppModule); 26 | 27 | // HTTP CORS 설정 28 | app.enableCors({ 29 | origin: '*', // 모든 도메인 허용 30 | methods: '*', 31 | allowedHeaders: '*', 32 | exposedHeaders: '*', 33 | credentials: true, 34 | maxAge: 86400, // 24시간 35 | }); 36 | 37 | // WebSocket CORS 설정 38 | app.useWebSocketAdapter(new CustomIoAdapter(app)); 39 | 40 | app.setGlobalPrefix('api'); 41 | 42 | const config = new DocumentBuilder() 43 | .setTitle('GameServer API Document') 44 | .setDescription('GameServer API description') 45 | .setVersion('1.0') 46 | .build(); 47 | const documentFactory = () => SwaggerModule.createDocument(app, config); 48 | SwaggerModule.setup('api/docs', app, documentFactory); 49 | 50 | const configService = app.get(ConfigService); 51 | 52 | await app.listen(configService.get('app.port')); 53 | 54 | process.on('SIGTERM', async () => { 55 | console.log('SIGTERM received 앱 종료시작'); 56 | await app.close(); 57 | }); 58 | 59 | process.on('SIGINT', async () => { 60 | console.log('SIGINT received 앱 종료시작'); 61 | await app.close(); 62 | }); 63 | 64 | process.on('SIGUSR2', async () => { 65 | console.log('SIGUSR2 received 앱 종료시작 (nodemon에서 재시작)'); 66 | await app.close(); 67 | }); // 68 | } 69 | bootstrap(); 70 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/games/dto/game-data.dto.ts: -------------------------------------------------------------------------------- 1 | import { PlayerDataDto } from '../../players/dto/player-data.dto'; 2 | 3 | export class GameDataDto { 4 | gameId: string; 5 | players: PlayerDataDto[]; 6 | alivePlayers: string[]; 7 | currentTurn: number; 8 | currentPlayer: string; 9 | previousPitch: number; 10 | previousPlayers: string[]; 11 | rank: string[]; 12 | } 13 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/games/dto/turn-data.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export enum GameMode { 4 | PRONUNCIATION = 'PRONUNCIATION', 5 | CLEOPATRA = 'CLEOPATRA', 6 | RANDOM = 'RANDOM', 7 | } 8 | 9 | export class TurnDataDto { 10 | @ApiProperty({ 11 | example: '6f42377f-42ea-42cc-ac1a-b5d2b99d4ced', 12 | type: String, 13 | description: '게임 방 ID', 14 | }) 15 | roomId: string; 16 | 17 | @ApiProperty({ 18 | example: 'player1', 19 | type: String, 20 | description: '해당 단계를 수행하는 플레이어 닉네임', 21 | }) 22 | playerNickname: string; 23 | 24 | @ApiProperty({ 25 | example: GameMode.PRONUNCIATION, 26 | type: String, 27 | description: '해당 단계 게임 모드', 28 | }) 29 | gameMode: GameMode; 30 | 31 | @ApiProperty({ 32 | example: 7, 33 | type: Number, 34 | description: '해당 단계 게임 모드를 수행할 때의 제한시간 (sec)', 35 | }) 36 | timeLimit: number; 37 | 38 | @ApiProperty({ 39 | example: 40 | '도토리가 문을 도로록, 드르륵, 두루룩 열었는가? 드로록, 두루륵, 두르룩 열었는가.', 41 | type: String, 42 | description: '가사', 43 | }) 44 | lyrics: string; 45 | } 46 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/games/dto/voice-processing-result.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class VoiceProcessingResultDto { 4 | @ApiProperty({ 5 | example: 'player1', 6 | type: String, 7 | description: '플레이어 닉네임', 8 | }) 9 | playerNickname: string; 10 | 11 | @ApiProperty({ 12 | example: 'PASS', 13 | type: String, 14 | description: '결과', 15 | }) 16 | result: string; 17 | 18 | @ApiProperty({ 19 | example: '3옥도#', 20 | type: String, 21 | description: '음계', 22 | required: false, 23 | }) 24 | note?: string; 25 | 26 | @ApiProperty({ 27 | example: 99, 28 | type: Number, 29 | description: '발음 게임 점수', 30 | }) 31 | procounceScore?: number; 32 | } 33 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/games/dto/voice-result-from-server.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class VoiceResultFromServerDto { 4 | @ApiProperty({ 5 | example: '6f42377f-42ea-42cc-ac1a-b5d2b99d4ced', 6 | type: String, 7 | description: '게임 방 ID', 8 | }) 9 | roomId: string; 10 | 11 | @ApiProperty({ 12 | example: 'player1', 13 | type: String, 14 | description: '해당 단계를 수행하는 플레이어 닉네임', 15 | }) 16 | playerNickname: string; 17 | 18 | @ApiProperty({ 19 | example: 92, 20 | type: Number, 21 | description: '발음게임 점수', 22 | required: false, 23 | }) 24 | pronounceScore?: number; 25 | 26 | @ApiProperty({ 27 | example: 'A#3', 28 | type: String, 29 | description: '음정게임 평균 음', 30 | required: false, 31 | }) 32 | averageNote?: string; 33 | } 34 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/games/games-utils.ts: -------------------------------------------------------------------------------- 1 | import { GameMode, TurnDataDto } from './dto/turn-data.dto'; 2 | import { RoomDataDto } from '../rooms/dto/room-data.dto'; 3 | import { GameDataDto } from './dto/game-data.dto'; 4 | 5 | const SAMPLE_DATA = [ 6 | { 7 | timeLimit: 7, 8 | lyrics: '간장공장 공장장은 강 공장장이고 된장공장 공장장은 공 공장장이다.', 9 | }, 10 | { 11 | timeLimit: 7, 12 | lyrics: 13 | '내가 그린 기린 그림은 긴 기린 그림이고 네가 그린 기린 그림은 안 긴 기린 그림이다.', 14 | }, 15 | { 16 | timeLimit: 9, 17 | lyrics: 18 | '저기 계신 콩국수 국수 장수는 새 콩국수 국수 장수이고, 여기 계신 콩국수 국수 장수는 헌 콩국수 국수 장수다.', 19 | }, 20 | { 21 | timeLimit: 5, 22 | lyrics: '서울특별시 특허허가과 허가과장 허과장.', 23 | }, 24 | { 25 | timeLimit: 6, 26 | lyrics: '중앙청 창살은 쌍창살이고 시청의 창살은 외창살이다.', 27 | }, 28 | ]; 29 | 30 | export function createTurnData( 31 | roomId: string, 32 | gameData: GameDataDto, 33 | roomData: RoomDataDto, 34 | ): TurnDataDto { 35 | let gameMode = roomData.gameMode; 36 | 37 | // 랜덤 모드인 경우 비율에 따라 게임 모드 결정 38 | if (gameMode === GameMode.RANDOM) { 39 | const ratio = roomData.randomModeRatio || 50; 40 | const randomValue = Math.random() * 100; 41 | gameMode = 42 | randomValue < ratio ? GameMode.CLEOPATRA : GameMode.PRONUNCIATION; 43 | } 44 | 45 | if (gameMode === GameMode.CLEOPATRA) { 46 | return { 47 | roomId: roomId, 48 | playerNickname: gameData.currentPlayer, 49 | gameMode, 50 | timeLimit: 7, 51 | lyrics: '안녕! 클레오파트라! 세상에서 제일가는 포테이토 칩!', 52 | }; 53 | } 54 | 55 | const randomSentence = 56 | SAMPLE_DATA[Math.floor(Math.random() * SAMPLE_DATA.length)]; 57 | 58 | return { 59 | roomId: roomId, 60 | playerNickname: gameData.currentPlayer, 61 | gameMode, 62 | timeLimit: randomSentence.timeLimit, 63 | lyrics: randomSentence.lyrics, 64 | }; 65 | } 66 | 67 | export function selectCurrentPlayer( 68 | alivePlayers: string[], 69 | previousPlayers: string[], 70 | ): string { 71 | let candidates = alivePlayers; 72 | 73 | if (candidates.length === 0) { 74 | return null; 75 | } 76 | 77 | if ( 78 | previousPlayers.length >= 2 && 79 | previousPlayers[0] === previousPlayers[1] 80 | ) { 81 | candidates = alivePlayers.filter((player) => player !== previousPlayers[0]); 82 | } 83 | const randomIndex = Math.floor(Math.random() * candidates.length); 84 | return candidates[randomIndex]; 85 | } 86 | 87 | export function checkPlayersReady(roomData: RoomDataDto): boolean { 88 | return roomData.players 89 | .filter((player) => player.playerNickname !== roomData.hostNickname) 90 | .every((player) => player.isReady); 91 | } 92 | 93 | export function removePlayerFromGame( 94 | gameData: GameDataDto, 95 | playerNickname: string, 96 | ): void { 97 | if (gameData.alivePlayers.includes(playerNickname)) { 98 | gameData.alivePlayers = gameData.alivePlayers.filter( 99 | (player: string) => player !== playerNickname, 100 | ); 101 | 102 | if (!gameData.rank.includes(playerNickname)) { 103 | gameData.rank.unshift(playerNickname); 104 | } 105 | } 106 | } 107 | 108 | export function noteToNumber(note: string): number { 109 | const matches = note.match(/([A-G]#?)(\d+)/); 110 | if (!matches) return null; 111 | 112 | const [, noteName, octave] = matches; 113 | const noteBase = { 114 | C: 0, 115 | 'C#': 1, 116 | D: 2, 117 | 'D#': 3, 118 | E: 4, 119 | F: 5, 120 | 'F#': 6, 121 | G: 7, 122 | 'G#': 8, 123 | A: 9, 124 | 'A#': 10, 125 | B: 11, 126 | }[noteName]; 127 | 128 | return noteBase + (parseInt(octave) + 1) * 12; 129 | } 130 | 131 | export function numberToNote(number: number): string { 132 | const koreanNoteNames = { 133 | C: '도', 134 | 'C#': '도#', 135 | D: '레', 136 | 'D#': '레#', 137 | E: '미', 138 | F: '파', 139 | 'F#': '파#', 140 | G: '솔', 141 | 'G#': '솔#', 142 | A: '라', 143 | 'A#': '라#', 144 | B: '시', 145 | }; 146 | 147 | const noteNames = Object.keys(koreanNoteNames); 148 | const noteBase = number % 12; 149 | const octave = Math.floor(number / 12) - 1; 150 | 151 | const noteName = noteNames[noteBase]; 152 | const koreanNote = koreanNoteNames[noteName]; 153 | 154 | return `${octave}옥${koreanNote}`; 155 | } 156 | 157 | export function updatePreviousPlayers( 158 | gameData: GameDataDto, 159 | playerNickname: string, 160 | ): void { 161 | if (gameData.previousPlayers.length >= 2) { 162 | gameData.previousPlayers.shift(); 163 | } 164 | gameData.previousPlayers.push(playerNickname); 165 | } 166 | 167 | export function transformScore(originalScore: number) { 168 | return Math.floor(Math.min(90 + ((originalScore - 40) / 60) * 10, 100)); 169 | } 170 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/games/games.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { RedisModule } from '../../redis/redis.module'; 3 | import { GamesWebSocketEmitController } from './games.websocket.emit.controller'; 4 | import { GamesWebSocketOnController } from './games.websocket.on.controller'; 5 | import { GamesGateway } from './games.gateway'; 6 | 7 | @Module({ 8 | imports: [RedisModule], 9 | providers: [GamesGateway], 10 | controllers: [GamesWebSocketEmitController, GamesWebSocketOnController], 11 | }) 12 | export class GamesModule {} 13 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/games/games.websocket.emit.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post } from '@nestjs/common'; 2 | import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; 3 | import { TurnDataDto } from './dto/turn-data.dto'; 4 | import { VoiceProcessingResultDto } from './dto/voice-processing-result.dto'; 5 | 6 | @ApiTags('Rooms (WebSocket: 서버에서 발행하는 이벤트)') 7 | @Controller('rooms') 8 | export class GamesWebSocketEmitController { 9 | @Post('turnChanged') 10 | @ApiOperation({ 11 | summary: '현재 단계 수행할 정보', 12 | description: 'turnData를 전달해 현재 단계 수행에 대한 정보를 전달합니다.', 13 | }) 14 | @ApiResponse({ 15 | description: '현재 단계 정보', 16 | type: TurnDataDto, 17 | }) 18 | turnChanged() { 19 | return; 20 | } 21 | 22 | @Post('voiceProcessingResult') 23 | @ApiOperation({ 24 | summary: '채점 결과', 25 | description: 26 | '해당 단계를 수행한 playerNickname과 result(SUCCESS, FAILURE)을 전달합니다.', 27 | }) 28 | @ApiResponse({ 29 | description: '채점 결과', 30 | type: VoiceProcessingResultDto, 31 | examples: { 32 | example1: { 33 | summary: '클레오파트라게임 채점 결과', 34 | value: { 35 | result: 'PASS', 36 | playerNickname: '호스트', 37 | note: '3옥도#', 38 | }, 39 | }, 40 | example2: { 41 | summary: '발음게임 채점 결과', 42 | value: { 43 | result: 'PASS', 44 | playerNickname: '플레이어', 45 | pronounceScore: 99, 46 | }, 47 | }, 48 | }, 49 | }) 50 | voiceProcessingResult() { 51 | return; 52 | } 53 | 54 | @Post('endGame') 55 | @ApiOperation({ 56 | summary: '게임 종료', 57 | description: '게임 종료를 알리고, 최종 순위 rank 배열을 전달합니다.', 58 | }) 59 | @ApiResponse({ 60 | description: 'rank', 61 | type: [String], 62 | example: ['player1', 'player3', 'player4', 'player2'], 63 | }) 64 | endGame() { 65 | return; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/games/games.websocket.on.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post } from '@nestjs/common'; 2 | import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger'; 3 | import { VoiceResultFromServerDto } from './dto/voice-result-from-server.dto'; 4 | 5 | @ApiTags('Rooms (WebSocket: 서버에서 수신하는 이벤트)') 6 | @Controller('rooms') 7 | export class GamesWebSocketOnController { 8 | @Post('startGame') 9 | @ApiOperation({ 10 | summary: '게임시작', 11 | description: 12 | 'wss://clovapatra.com/rooms 에서 "startGame" 이벤트를 emit해 사용합니다. 성공적으로 게임이 시작되면 "turnChanged" 이벤트를 발행해 게임 진행에 필요한 정보를 전달합니다.', 13 | }) 14 | startGame() { 15 | // This method does not execute any logic. It's for Swagger documentation only. 16 | return; 17 | } 18 | 19 | @Post('next') 20 | @ApiOperation({ 21 | summary: '다음 턴 데이터 받기', 22 | description: 23 | 'wss://clovapatra.com/rooms 에서 "next" 이벤트를 emit해 사용합니다. turnData를 client, voice-processing-server 에 turnChanged 이벤트로 전달합니다.', 24 | }) 25 | next() { 26 | return; 27 | } 28 | 29 | @Post('voiceResult') 30 | @ApiOperation({ 31 | summary: '음성 처리서버에서 처리한 결과 받기', 32 | description: 33 | 'wss://clovapatra.com/rooms 에서 "voiceResult" 이벤트를 emit해 사용합니다.', 34 | }) 35 | @ApiBody({ type: VoiceResultFromServerDto }) 36 | voiceResult() { 37 | // This method does not execute any logic. It's for Swagger documentation only. 38 | return; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/players/dto/player-data.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class PlayerDataDto { 4 | @ApiProperty({ 5 | example: 'playerNickname123', 6 | description: '플레이어의 닉네임', 7 | }) 8 | playerNickname: string; 9 | 10 | @ApiProperty({ 11 | example: true, 12 | description: '플레이어의 준비 상태 (true: 준비 완료, false: 대기 중)', 13 | }) 14 | isReady: boolean; 15 | 16 | @ApiProperty({ 17 | example: true, 18 | description: '플레이어의 음소거 상태 (true: 음소거, false: 정상)', 19 | }) 20 | isMuted: boolean; 21 | 22 | @ApiProperty({ 23 | example: true, 24 | description: '플레이어의 게임 진행 상태 (true: 탈락, false: 생존)', 25 | }) 26 | isDead: boolean; 27 | 28 | @ApiProperty({ 29 | example: true, 30 | description: '플레이어의 탈주 상태 (true: 탈주)', 31 | }) 32 | isLeft: boolean; 33 | } 34 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/rooms/dto/create-room.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { 3 | IsString, 4 | IsNotEmpty, 5 | Length, 6 | Matches, 7 | IsEnum, 8 | IsInt, 9 | IsNumber, 10 | Min, 11 | Max, 12 | ValidateIf, 13 | } from 'class-validator'; 14 | import { GameMode } from '../../games/dto/turn-data.dto'; 15 | 16 | export class CreateRoomDto { 17 | @IsString({ message: 'roomName은 문자열이어야 합니다.' }) 18 | @IsNotEmpty({ message: 'roomName은 필수 입력 항목입니다.' }) 19 | @Length(2, 12, { message: 'roomName은 2자에서 12자 사이여야 합니다.' }) 20 | @ApiProperty({ example: '게임방123', description: 'Room name' }) 21 | roomName: string; 22 | 23 | @IsString({ message: 'hostNickname은 문자열이어야 합니다.' }) 24 | @IsNotEmpty({ message: 'hostNickname은 필수 입력 항목입니다.' }) 25 | @Length(2, 8, { message: 'hostNickname은 2자에서 8자 사이여야 합니다.' }) 26 | @Matches(/^[a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ ]+$/, { 27 | message: 'hostNickname은 한글, 알파벳, 숫자, 공백만 허용됩니다.', 28 | }) 29 | @ApiProperty({ 30 | example: 'Nickname', 31 | description: 'hostNickname', 32 | }) 33 | hostNickname: string; 34 | 35 | @IsInt() 36 | @Min(2) 37 | @Max(10) 38 | @ApiProperty({ 39 | example: 4, 40 | description: '최대 플레이어 수 (2-10명)', 41 | }) 42 | maxPlayers: number; 43 | 44 | @IsEnum(GameMode) 45 | @ApiProperty({ 46 | enum: GameMode, 47 | example: GameMode.RANDOM, 48 | description: '게임 모드', 49 | }) 50 | gameMode: GameMode; 51 | 52 | @ValidateIf((o) => o.gameMode === GameMode.RANDOM) 53 | @IsNumber() 54 | @Min(1) 55 | @Max(99) 56 | @ApiProperty({ 57 | example: 50, 58 | description: '랜덤 모드에서 클레오파트라 모드의 비율 (1-99%)', 59 | required: false, 60 | }) 61 | randomModeRatio?: number; 62 | } 63 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/rooms/dto/join-data.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, IsNotEmpty, Length, Matches } from 'class-validator'; 3 | 4 | export class JoinRoomDto { 5 | @IsString({ message: 'roomId는 문자열이어야 합니다.' }) 6 | @IsNotEmpty({ message: 'roomId는 필수 입력 항목입니다.' }) 7 | @ApiProperty({ 8 | example: '6f42377f-42ea-42cc-ac1a-b5d2b99d4ced', 9 | description: '게임 방 ID', 10 | }) 11 | roomId: string; 12 | 13 | @IsString({ message: 'playerNickname은 문자열이어야 합니다.' }) 14 | @IsNotEmpty({ message: 'playerNickname은 필수 입력 항목입니다.' }) 15 | @Length(2, 8, { message: 'playerNickname은 2자에서 8자 사이여야 합니다.' }) 16 | @Matches(/^[a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ ]+$/, { 17 | message: 'playerNickname은 한글, 알파벳, 숫자, 공백만 허용됩니다.', 18 | }) 19 | @ApiProperty({ 20 | example: 'playerNickname123', 21 | description: '입장할 사용자의 닉네임', 22 | }) 23 | playerNickname: string; 24 | } 25 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/rooms/dto/paginated-room.dto.ts: -------------------------------------------------------------------------------- 1 | import { RoomDataDto } from './room-data.dto'; 2 | 3 | export class PaginatedRoomDto { 4 | rooms: RoomDataDto[]; 5 | pagination: { 6 | currentPage: number; 7 | totalPages: number; 8 | totalItems: number; 9 | hasNextPage: boolean; 10 | hasPreviousPage: boolean; 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/rooms/dto/room-data.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { PlayerDataDto } from '../../players/dto/player-data.dto'; 3 | import { GameMode } from '../../games/dto/turn-data.dto'; 4 | 5 | export class RoomDataDto { 6 | @ApiProperty({ 7 | example: '6f42377f-42ea-42cc-ac1a-b5d2b99d4ced', 8 | description: '게임 방 ID', 9 | }) 10 | roomId: string; 11 | 12 | @ApiProperty({ 13 | example: '게임방123', 14 | description: '게임 방 이름', 15 | }) 16 | roomName: string; 17 | 18 | @ApiProperty({ 19 | example: 'hostNickname123', 20 | description: '방을 생성한 사용자의 닉네임', 21 | }) 22 | hostNickname: string; 23 | 24 | @ApiProperty({ 25 | type: [PlayerDataDto], 26 | example: [ 27 | { 28 | playerNickname: 'hostNic123', 29 | isReady: true, 30 | isMuted: false, 31 | }, 32 | { 33 | playerNickname: 'player1', 34 | isReady: false, 35 | isMuted: true, 36 | }, 37 | ], 38 | description: '현재 방에 참여한 플레이어 목록과 준비 상태', 39 | }) 40 | players: PlayerDataDto[]; 41 | 42 | @ApiProperty({ 43 | example: 'waiting', 44 | description: '현재 방의 상태 (예: 대기 중, 게임 중)', 45 | }) 46 | status: string; 47 | 48 | @ApiProperty({ 49 | example: 4, 50 | description: '최대 플레이어 수', 51 | }) 52 | maxPlayers: number; 53 | 54 | @ApiProperty({ 55 | enum: GameMode, 56 | example: GameMode.RANDOM, 57 | description: '게임 모드', 58 | }) 59 | gameMode: GameMode; 60 | 61 | @ApiProperty({ 62 | example: 50, 63 | description: '랜덤 모드에서 클레오파트라 모드의 비율', 64 | required: false, 65 | }) 66 | randomModeRatio?: number; 67 | } 68 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/rooms/room-utils.ts: -------------------------------------------------------------------------------- 1 | import { RoomDataDto } from './dto/room-data.dto'; 2 | import { PlayerDataDto } from '../players/dto/player-data.dto'; 3 | 4 | export const convertRoomDataToHash = ( 5 | roomData: RoomDataDto, 6 | ): Record => { 7 | const { 8 | roomId, 9 | roomName, 10 | hostNickname, 11 | players, 12 | status, 13 | maxPlayers, 14 | gameMode, 15 | randomModeRatio, 16 | } = roomData; 17 | 18 | return { 19 | roomId, 20 | roomName, 21 | hostNickname, 22 | players: JSON.stringify(players), 23 | status, 24 | maxPlayers: String(maxPlayers), // maxPlayers를 문자열로 변환하여 저장 25 | gameMode, 26 | ...(randomModeRatio !== undefined && { 27 | randomModeRatio: String(randomModeRatio), 28 | }), 29 | }; 30 | }; 31 | 32 | export const isRoomFull = (roomData: RoomDataDto): boolean => { 33 | return roomData.players.length >= roomData.maxPlayers; 34 | }; 35 | 36 | export const isNicknameTaken = ( 37 | roomData: RoomDataDto, 38 | playerNickname: string, 39 | ): boolean => { 40 | return roomData.players.some( 41 | (player: PlayerDataDto) => player.playerNickname === playerNickname, 42 | ); 43 | }; 44 | 45 | export const removePlayerFromRoom = ( 46 | roomData: RoomDataDto, 47 | nickname: string, 48 | ): void => { 49 | roomData.players = roomData.players.filter( 50 | (player: PlayerDataDto) => player.playerNickname !== nickname, 51 | ); 52 | }; 53 | 54 | export const changeRoomHost = (roomData: RoomDataDto): void => { 55 | roomData.hostNickname = roomData.players[0].playerNickname; 56 | }; 57 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/rooms/rooms.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { RedisModule } from '../../redis/redis.module'; 3 | import { RoomsGateway } from './rooms.gateway'; 4 | import { RoomsWebSocketOnController } from './rooms.websocket.on.controller'; 5 | import { RoomsWebSocketEmitController } from './rooms.websocket.emit.controller'; 6 | import { RoomController } from './rooms.controller'; 7 | 8 | @Module({ 9 | imports: [RedisModule], 10 | providers: [RoomsGateway], 11 | controllers: [ 12 | RoomsWebSocketOnController, 13 | RoomsWebSocketEmitController, 14 | RoomController, 15 | ], 16 | }) 17 | export class RoomsModule {} 18 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/rooms/rooms.validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { ValidationPipe } from '@nestjs/common'; 3 | import { WsException } from '@nestjs/websockets'; 4 | import { ErrorMessages } from '../../common/constant'; 5 | 6 | @Injectable() 7 | export class RoomsValidationPipe extends ValidationPipe { 8 | private readonly logger = new Logger(RoomsValidationPipe.name); 9 | 10 | constructor() { 11 | super({ 12 | exceptionFactory: (errors) => { 13 | if (errors.length > 0) { 14 | this.logger.warn(`Validation failed: ${errors}`); 15 | 16 | throw new WsException(ErrorMessages.VALIDATION_FAILED); 17 | } 18 | }, 19 | transform: true, // 요청 데이터를 DTO로 변환 20 | whitelist: true, // DTO에 정의된 필드만 받도록 제한 21 | forbidNonWhitelisted: true, // 정의되지 않은 필드가 오면 에러 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/rooms/rooms.websocket.emit.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post } from '@nestjs/common'; 2 | import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; 3 | import { RoomDataDto } from './dto/room-data.dto'; 4 | import { PlayerDataDto } from '../players/dto/player-data.dto'; 5 | 6 | @ApiTags('Rooms (WebSocket: 서버에서 발행하는 이벤트)') 7 | @Controller('rooms') 8 | export class RoomsWebSocketEmitController { 9 | @Post('roomCreated') 10 | @ApiOperation({ 11 | summary: '게임 방 생성 완료', 12 | description: '성공적으로 게임방이 생성되었을 때 RoomData를 전달합니다.', 13 | }) 14 | @ApiResponse({ 15 | description: 'Room created successfully', 16 | type: RoomDataDto, 17 | }) 18 | createRoom() { 19 | return; 20 | } 21 | 22 | @Post('updateUsers') 23 | @ApiOperation({ 24 | summary: '게임방 유저 업데이트', 25 | description: 26 | '방의 사용자들에게 "updateUsers" 이벤트를 통해 갱신된 사용자 목록을 제공합니다.', 27 | }) 28 | @ApiResponse({ 29 | description: '플레이어 데이터 객체 배열', 30 | type: [PlayerDataDto], 31 | }) 32 | updateUsers() { 33 | // This method does not execute any logic. It's for Swagger documentation only. 34 | return; 35 | } 36 | 37 | @Post('kicked') 38 | @ApiOperation({ 39 | summary: '강퇴 이벤트 발생', 40 | description: '방의 사용자들에게 다른 사용자의 강퇴 이벤트를 알립니다.', 41 | }) 42 | @ApiResponse({ 43 | description: '해당 강퇴 사용자 닉네임', 44 | type: String, 45 | }) 46 | kicked() { 47 | // This method does not execute any logic. It's for Swagger documentation only. 48 | return; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/rooms/rooms.websocket.on.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Body } from '@nestjs/common'; 2 | import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger'; 3 | import { CreateRoomDto } from './dto/create-room.dto'; 4 | import { JoinRoomDto } from './dto/join-data.dto'; 5 | 6 | @ApiTags('Rooms (WebSocket: 서버에서 수신하는 이벤트)') 7 | @Controller('rooms') 8 | export class RoomsWebSocketOnController { 9 | @Post('createRoom') 10 | @ApiOperation({ 11 | summary: '게임 방 생성', 12 | description: 13 | 'wss://clovapatra.com/rooms 에서 "createRoom" 이벤트를 emit해 사용합니다. 성공적으로 게임방이 생성되면 "roomCreated" 이벤트를 발행해 RoomData를 전달합니다.', 14 | }) 15 | @ApiBody({ type: CreateRoomDto }) 16 | createRoom(@Body() createRoomDto: CreateRoomDto) { 17 | // This method does not execute any logic. It's for Swagger documentation only. 18 | return createRoomDto; 19 | } 20 | 21 | @Post('joinRoom') 22 | @ApiOperation({ 23 | summary: '게임 방 입장', 24 | description: 25 | 'wss://clovapatra.com/rooms 에서 "joinRoom" 이벤트를 emit해 사용합니다. 성공적으로 입장하면 입장한 방의 사용자들에게 "updateUsers" 이벤트를 통해 갱신된 사용자 목록을 제공합니다. 이미 게임중인 경우 들어가지 못하며 GameAlreadyInProgress 에러 메시지를 가진 에러를 전송합니다.', 26 | }) 27 | @ApiBody({ type: JoinRoomDto }) 28 | joinRoom(@Body() joinRoomDto: JoinRoomDto) { 29 | return joinRoomDto; 30 | } 31 | 32 | @Post('disconnect') 33 | @ApiOperation({ 34 | summary: '게임 방 나가기, 소켓 연결 해제', 35 | description: 36 | 'wss://clovapatra.com/rooms 에서 "disconnect" 이벤트를 emit해 사용합니다.', 37 | }) 38 | disconnect(): void {} 39 | 40 | @Post('setReady') 41 | @ApiOperation({ 42 | summary: '플레이어 준비 완료', 43 | description: 44 | 'wss://clovapatra.com/rooms 에서 "setReady" 이벤트를 emit해 사용합니다. 이미 준비 완료라면 준비대기로 변하고, 준비 대기라면 준비 완료 상태로 바뀝니다. 성공적으로 처리되면 모든 클라이언트에게 "updateUsers" 이벤트를 발행합니다.', 45 | }) 46 | ready() { 47 | return; 48 | } 49 | 50 | @Post('setMute') 51 | @ApiOperation({ 52 | summary: '플레이어 음소거', 53 | description: 54 | 'wss://clovapatra.com/rooms 에서 "setMute" 이벤트를 emit해 사용합니다. 음소거 상태를 토글합니다. 성공적으로 처리되면 모든 클라이언트에게 "updateUsers" 이벤트를 발행합니다.', 55 | }) 56 | mute() { 57 | return; 58 | } 59 | 60 | @Post('kickPlayer') 61 | @ApiOperation({ 62 | summary: '플레이어 강퇴', 63 | description: 64 | 'wss://clovapatra.com/rooms 에서 "kickPlayer" 이벤트를 emit해 사용합니다. 성공적으로 강퇴되면 해당 방의 클라이언트에게 "kicked", "updateUsers" 이벤트를 발행합니다.', 65 | }) 66 | @ApiBody({ type: String, description: '강퇴할 사용자 playerNickname' }) 67 | kickPlayer(@Body() playerNickname: string) { 68 | return playerNickname; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/voice-servers/voice-servers.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WebSocketGateway, 3 | WebSocketServer, 4 | SubscribeMessage, 5 | OnGatewayDisconnect, 6 | ConnectedSocket, 7 | } from '@nestjs/websockets'; 8 | import { Server, Socket } from 'socket.io'; 9 | import { Logger, UseFilters } from '@nestjs/common'; 10 | import { WsExceptionsFilter } from '../../common/filters/ws-exceptions.filter'; 11 | 12 | const VOICE_SERVERS = 'voice-servers'; 13 | 14 | @WebSocketGateway({ 15 | namespace: '/rooms', 16 | cors: { 17 | origin: '*', 18 | methods: ['GET', 'POST'], 19 | credentials: true, 20 | }, 21 | }) 22 | @UseFilters(WsExceptionsFilter) 23 | export class VoiceServersGateway implements OnGatewayDisconnect { 24 | private readonly logger = new Logger(VoiceServersGateway.name); 25 | 26 | @WebSocketServer() 27 | server: Server; 28 | 29 | @SubscribeMessage('registerVoiceServer') 30 | async handleregisterVoiceServer(@ConnectedSocket() client: Socket) { 31 | try { 32 | client.join(VOICE_SERVERS); 33 | this.logger.log(`Voice server registered: ${client.id}`); 34 | } catch (error) { 35 | this.logger.error(`Failed to register voice server: ${error.message}`); 36 | client.emit('error', 'Failed to register voice server'); 37 | } 38 | } 39 | 40 | @SubscribeMessage('disconnect') 41 | async handleDisconnect(@ConnectedSocket() client: Socket) { 42 | this.logger.log(`Voice server disconnected: ${client.id}`); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /be/gameServer/src/modules/voice-servers/voice-servers.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { VoiceServersGateway } from './voice-servers.gateway'; 3 | 4 | @Module({ 5 | imports: [], 6 | providers: [VoiceServersGateway], 7 | controllers: [], 8 | }) 9 | export class VoiceServersModule {} 10 | -------------------------------------------------------------------------------- /be/gameServer/src/redis/redis.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Global } from '@nestjs/common'; 2 | import { RedisService } from './redis.service'; 3 | import { Redis } from 'ioredis'; 4 | import { ConfigModule, ConfigService } from '@nestjs/config'; 5 | import redisConfig from '../config/redis.config'; 6 | 7 | @Global() 8 | @Module({ 9 | imports: [ConfigModule.forRoot({ load: [redisConfig] })], 10 | providers: [ 11 | { 12 | provide: 'REDIS_CLIENT', 13 | useFactory: (configService: ConfigService) => { 14 | return new Redis({ 15 | host: configService.get('redis.host'), 16 | port: configService.get('redis.port'), 17 | password: configService.get('redis.password'), 18 | }); 19 | }, 20 | inject: [ConfigService], 21 | }, 22 | RedisService, 23 | ], 24 | exports: ['REDIS_CLIENT', RedisService], 25 | }) 26 | export class RedisModule {} 27 | -------------------------------------------------------------------------------- /be/gameServer/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /be/gameServer/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /be/gameServer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /be/signalingServer/.env.sample: -------------------------------------------------------------------------------- 1 | APP_PORT=8001 2 | REDIS_HOST=localhost 3 | REDIS_PORT=6379 4 | REDIS_PASSWORD= -------------------------------------------------------------------------------- /be/signalingServer/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | -------------------------------------------------------------------------------- /be/signalingServer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "signalingServer", 3 | "version": "1.0.0", 4 | "description": "WebRTC Signaling Server for P2P Mesh Voice Chat", 5 | "main": "src/main.js", 6 | "scripts": { 7 | "start": "node src/main.js", 8 | "dev": "nodemon src/main.js" 9 | }, 10 | "dependencies": { 11 | "cors": "^2.8.5", 12 | "dotenv": "^16.0.3", 13 | "express": "^4.18.2", 14 | "ioredis": "^5.4.1", 15 | "socket.io": "^4.7.1" 16 | }, 17 | "devDependencies": { 18 | "nodemon": "^2.0.22" 19 | } 20 | } -------------------------------------------------------------------------------- /be/signalingServer/src/config/app.config.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | 3 | module.exports = { 4 | port: process.env.APP_PORT || 8001, 5 | cors: { 6 | origin: "*", 7 | methods: "*", 8 | allowedHeaders: ["*"], 9 | credentials: true, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /be/signalingServer/src/main.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const { createServer } = require("http"); 3 | const { Server } = require("socket.io"); 4 | const config = require("./config/app.config"); 5 | const SocketService = require("./services/socket.service"); 6 | 7 | const app = express(); 8 | app.get("/health-check", (req, res) => res.status(200).send("Healthy")); 9 | 10 | const httpServer = createServer(app); 11 | const io = new Server(httpServer, { 12 | cors: config.cors, 13 | }); 14 | 15 | const socketService = new SocketService(io); 16 | 17 | io.on("connection", (socket) => { 18 | socketService.handleConnection(socket); 19 | }); 20 | 21 | httpServer.listen(config.port, () => { 22 | console.log(`Signaling server is running on port ${config.port}`); 23 | }); 24 | -------------------------------------------------------------------------------- /be/signalingServer/src/services/room.service.js: -------------------------------------------------------------------------------- 1 | class RoomService { 2 | constructor() { 3 | // 방 정보를 저장하는 Map 4 | // Map 5 | this.rooms = new Map(); 6 | } 7 | 8 | /** 9 | * 방 정보 구조 10 | * { 11 | * users: Map 12 | * // 모든 사용자가 방 정보를 받았는지 확인하는 카운터 13 | * receivedInfoCount: number, 14 | * } 15 | * 16 | * UserInfo 구조 17 | * { 18 | * socketId: string, 19 | * sdp: RTCSessionDescription, 20 | * candidates: RTCIceCandidate[], 21 | * deviceId: string, // 오디오 장치 ID 22 | * playerNickname: string, // 닉네임 23 | * timestamp: number // 마지막 업데이트 시간 24 | * } 25 | */ 26 | 27 | /** 28 | * 방에 사용자 추가 29 | * @param {string} roomId - 방 ID 30 | * @param {string} socketId - 소켓 ID 31 | * @param {Object} userInfo - 사용자 정보 (SDP, ICE candidates 등) 32 | */ 33 | addUser(roomId, socketId, userInfo) { 34 | console.log(`[RoomService] 사용자 ${socketId}가 방 ${roomId}에 참가 시도`); 35 | 36 | if (!this.rooms.has(roomId)) { 37 | console.log(`[RoomService] 새로운 방 ${roomId} 생성`); 38 | this.rooms.set(roomId, { 39 | users: new Map(), 40 | receivedInfoCount: 0, 41 | }); 42 | } 43 | 44 | const room = this.rooms.get(roomId); 45 | room.users.set(socketId, { 46 | ...userInfo, 47 | timestamp: Date.now(), 48 | }); 49 | 50 | console.log(`[RoomService] 방 ${roomId}의 현재 사용자 수: ${room.users.size}`); 51 | return Array.from(room.users.values()); 52 | } 53 | 54 | /** 55 | * 방에서 사용자 제거 56 | * @param {string} socketId - 소켓 ID 57 | */ 58 | removeUser(socketId) { 59 | console.log(`[RoomService] 사용자 ${socketId} 제거 시도`); 60 | 61 | for (const [roomId, room] of this.rooms) { 62 | if (room.users.has(socketId)) { 63 | room.users.delete(socketId); 64 | console.log(`[RoomService] 사용자 ${socketId}가 방 ${roomId}에서 제거됨`); 65 | 66 | if (room.users.size === 0) { 67 | this.rooms.delete(roomId); 68 | console.log(`[RoomService] 빈 방 ${roomId} 삭제`); 69 | } 70 | return roomId; 71 | } 72 | } 73 | return null; 74 | } 75 | 76 | /** 77 | * 사용자 정보 업데이트 (SDP, ICE candidate 등) 78 | * @param {string} roomId - 방 ID 79 | * @param {string} socketId - 소켓 ID 80 | * @param {Object} updates - 업데이트할 정보 81 | */ 82 | updateUser(roomId, socketId, updates) { 83 | console.log(`[RoomService] 사용자 ${socketId} 정보 업데이트`); 84 | 85 | const room = this.rooms.get(roomId); 86 | if (!room) return false; 87 | 88 | const userInfo = room.users.get(socketId); 89 | if (!userInfo) return false; 90 | 91 | room.users.set(socketId, { 92 | ...userInfo, 93 | ...updates, 94 | timestamp: Date.now(), 95 | }); 96 | 97 | console.log(`[RoomService] 사용자 ${socketId} 정보 업데이트 완료`); 98 | return true; 99 | } 100 | 101 | /** 102 | * 방 정보 수신 확인 103 | * @param {string} roomId - 방 ID 104 | * @returns {boolean} - 모든 사용자가 정보를 받았는지 여부 105 | */ 106 | confirmReceived(roomId) { 107 | const room = this.rooms.get(roomId); 108 | if (!room) return false; 109 | 110 | room.receivedInfoCount++; 111 | console.log(`[RoomService] 방 ${roomId}의 정보 수신 확인: ${room.receivedInfoCount}/${room.users.size}`); 112 | 113 | if (room.receivedInfoCount >= room.users.size) { 114 | room.receivedInfoCount = 0; 115 | return true; 116 | } 117 | return false; 118 | } 119 | 120 | /** 121 | * P2P 연결 계획 생성 122 | * @param {string} roomId - 방 ID 123 | * @returns {Array<{from: string, to: string}>} - 연결 계획 124 | */ 125 | createConnectionPlan(roomId) { 126 | const room = this.rooms.get(roomId); 127 | if (!room) return []; 128 | 129 | const users = Array.from(room.users.keys()); 130 | const connections = []; 131 | 132 | // 모든 사용자를 서로 연결 133 | for (let i = 0; i < users.length; i++) { 134 | for (let j = i + 1; j < users.length; j++) { 135 | connections.push({ 136 | from: users[i], 137 | to: users[j], 138 | }); 139 | } 140 | } 141 | 142 | console.log(`[RoomService] 방 ${roomId}의 연결 계획 생성:`, connections); 143 | return connections; 144 | } 145 | 146 | /** 147 | * 방의 모든 사용자 정보 반환 148 | * @param {string} roomId - 방 ID 149 | */ 150 | getRoomInfo(roomId) { 151 | const room = this.rooms.get(roomId); 152 | if (!room) return null; 153 | 154 | return { 155 | users: Array.from(room.users.entries()).map(([socketId, info]) => ({ 156 | socketId, 157 | ...info, 158 | })), 159 | userMappings: Array.from(room.users.entries()).reduce((mappings, [socketId, info]) => { 160 | mappings[info.playerNickname] = socketId; 161 | return mappings; 162 | }, {}), 163 | }; 164 | } 165 | } 166 | 167 | module.exports = RoomService; 168 | -------------------------------------------------------------------------------- /be/signalingServer/src/services/socket.service.js: -------------------------------------------------------------------------------- 1 | const RoomService = require("./room.service"); 2 | 3 | class SocketService { 4 | constructor(io) { 5 | this.io = io; 6 | this.roomService = new RoomService(); 7 | } 8 | 9 | /** 10 | * 새로운 소켓 연결 처리 11 | * @param {Socket} socket - Socket.io 소켓 객체 12 | */ 13 | handleConnection(socket) { 14 | console.log(`[SocketService] 새로운 사용자 연결: ${socket.id}`); 15 | 16 | // 방 참가 요청 처리 17 | socket.on("join_room", (data) => { 18 | const { roomId, sdp, candidates, deviceId, playerNickname } = data; 19 | console.log(`[SocketService] 사용자 ${socket.id}(닉네임 ${playerNickname})가 방 ${roomId} 참가 요청`); 20 | 21 | // 방에 사용자 추가 22 | socket.join(roomId); 23 | this.roomService.addUser(roomId, socket.id, { 24 | sdp, 25 | candidates, 26 | deviceId, 27 | playerNickname, 28 | }); 29 | 30 | // 방의 모든 사용자에게 업데이트된 정보 전송 31 | this.broadcastRoomUpdate(roomId); 32 | }); 33 | 34 | // 방 정보 수신 확인 35 | socket.on("room_info_received", (roomId) => { 36 | console.log(`[SocketService] 사용자 ${socket.id}가 방 ${roomId} 정보 수신 확인`); 37 | 38 | if (this.roomService.confirmReceived(roomId)) { 39 | // 모든 사용자가 정보를 받았으면 연결 계획 전송 40 | const plan = this.roomService.createConnectionPlan(roomId); 41 | this.io.to(roomId).emit("start_connections", plan); 42 | } 43 | }); 44 | 45 | // WebRTC 시그널링 처리 46 | socket.on("webrtc_offer", (data) => { 47 | console.log(`[SocketService] WebRTC Offer: ${socket.id} -> ${data.toId}`); 48 | this.io.to(data.toId).emit("webrtc_offer", { 49 | sdp: data.sdp, 50 | fromId: socket.id, 51 | }); 52 | }); 53 | 54 | socket.on("webrtc_answer", (data) => { 55 | console.log(`[SocketService] WebRTC Answer: ${socket.id} -> ${data.toId}`); 56 | this.io.to(data.toId).emit("webrtc_answer", { 57 | sdp: data.sdp, 58 | fromId: socket.id, 59 | }); 60 | }); 61 | 62 | socket.on("webrtc_ice_candidate", (data) => { 63 | console.log(`[SocketService] ICE Candidate: ${socket.id} -> ${data.toId}`); 64 | this.io.to(data.toId).emit("webrtc_ice_candidate", { 65 | candidate: data.candidate, 66 | fromId: socket.id, 67 | }); 68 | }); 69 | 70 | // 연결 해제 처리 71 | socket.on("disconnect", () => { 72 | console.log(`[SocketService] 사용자 연결 해제: ${socket.id}`); 73 | const roomId = this.roomService.removeUser(socket.id); 74 | 75 | if (roomId) { 76 | this.io.to(roomId).emit("user_disconnected", socket.id); 77 | this.broadcastRoomUpdate(roomId); 78 | } 79 | }); 80 | } 81 | 82 | /** 83 | * 방의 모든 사용자에게 업데이트된 정보 전송 84 | * @param {string} roomId - 방 ID 85 | */ 86 | broadcastRoomUpdate(roomId) { 87 | const roomInfo = this.roomService.getRoomInfo(roomId); 88 | if (roomInfo) { 89 | console.log(`[SocketService] 방 ${roomId} 정보 브로드캐스트`); 90 | this.io.to(roomId).emit("room_info", roomInfo); 91 | } 92 | } 93 | } 94 | 95 | module.exports = SocketService; 96 | -------------------------------------------------------------------------------- /be/voiceProcessingServer/.env.sample: -------------------------------------------------------------------------------- 1 | PORT=8002 2 | CLOVA_API_KEY=1234 3 | CLOVA_API_URL=https://clovaspeech-gw.ncloud.com/recog/v1/stt 4 | GAME_SERVER_URL=http://localhost:8000 5 | REDIS_HOST=localhost 6 | REDIS_PORT=6379 7 | REDIS_PASSWORD= 8 | REDIS_ROOM_KEY_EXPIRE_TIME=60 -------------------------------------------------------------------------------- /be/voiceProcessingServer/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | -------------------------------------------------------------------------------- /be/voiceProcessingServer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "voiceprocessingserver", 3 | "version": "1.0.0", 4 | "main": "server.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "start": "node server.js" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "description": "", 13 | "dependencies": { 14 | "@socket.io/redis-adapter": "^8.3.0", 15 | "axios": "^1.7.7", 16 | "cluster": "^0.7.7", 17 | "dotenv": "^16.4.5", 18 | "express": "^4.21.1", 19 | "ioredis": "^5.4.1", 20 | "socket.io": "^4.8.1", 21 | "socket.io-client": "^4.8.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /be/voiceProcessingServer/src/main.js: -------------------------------------------------------------------------------- 1 | const cluster = require("cluster"); 2 | const numCPUs = require("os").cpus().length; 3 | 4 | if (cluster.isMaster) { 5 | console.log(`Master ${process.pid} is running`); 6 | 7 | // Fork workers 8 | for (let i = 0; i < numCPUs; i++) { 9 | cluster.fork(); 10 | } 11 | 12 | cluster.on("exit", (worker, code, signal) => { 13 | console.log(`Worker ${worker.process.pid} died`); 14 | cluster.fork(); 15 | }); 16 | } else { 17 | require("dotenv").config(); 18 | const express = require("express"); 19 | const http = require("http"); 20 | const Redis = require("ioredis"); 21 | const { createAdapter } = require("@socket.io/redis-adapter"); 22 | const { Server } = require("socket.io"); 23 | 24 | // Environment variables 25 | const PORT = process.env.PORT || 8002; 26 | const CLOVA_API_KEY = process.env.CLOVA_API_KEY; 27 | const CLOVA_API_URL = process.env.CLOVA_API_URL; 28 | const GAME_SERVER_URL = process.env.GAME_SERVER_URL; 29 | const REDIS_HOST = process.env.REDIS_HOST; 30 | const REDIS_PORT = process.env.REDIS_PORT; 31 | const REDIS_PASSWORD = process.env.REDIS_PASSWORD; 32 | const REDIS_ROOM_KEY_EXPIRE_TIME = process.env.REDIS_ROOM_KEY_EXPIRE_TIME; 33 | 34 | const app = express(); 35 | app.get("/health-check", (req, res) => res.status(200).send("Healthy")); 36 | const server = http.createServer(app); 37 | 38 | // Redis pub/sub clients for Socket.IO adapter 39 | const pubClient = new Redis({ 40 | host: REDIS_HOST, 41 | port: REDIS_PORT, 42 | password: REDIS_PASSWORD, 43 | }); 44 | 45 | const subClient = pubClient.duplicate(); 46 | 47 | // Redis client for data storage 48 | const redis = new Redis({ 49 | host: REDIS_HOST, 50 | port: REDIS_PORT, 51 | password: REDIS_PASSWORD, 52 | }); 53 | 54 | // Create Socket.IO server with Redis adapter 55 | const io = new Server(server, { 56 | cors: { origin: "*" }, 57 | transports: ["websocket"], 58 | upgrade: false, 59 | }); 60 | 61 | io.adapter(createAdapter(pubClient, subClient)); 62 | 63 | // Services initialization 64 | const AudioService = require("./services/audio.service"); 65 | const SpeechRecognitionService = require("./services/speech-recognition.service"); 66 | const PitchDetectionService = require("./services/pitch-detection.service"); 67 | const AudioProcessingService = require("./services/audio-processing.service"); 68 | const SocketService = require("./services/socket.service"); 69 | 70 | const audioService = new AudioService(); 71 | const speechRecognitionService = new SpeechRecognitionService(CLOVA_API_KEY, CLOVA_API_URL); 72 | const pitchDetectionService = new PitchDetectionService(); 73 | const audioProcessingService = new AudioProcessingService(audioService, speechRecognitionService, pitchDetectionService, redis); 74 | 75 | // Initialize Socket Service with shared Redis pub/sub clients 76 | const socketService = new SocketService(io, audioProcessingService, redis); 77 | socketService.initialize(); 78 | 79 | // Game Server Connection (Primary Worker Only) 80 | if (cluster.worker.id === 1) { 81 | const GameServerService = require("./services/game-server.service"); 82 | const gameServerService = new GameServerService(GAME_SERVER_URL, redis, REDIS_ROOM_KEY_EXPIRE_TIME); 83 | gameServerService.initialize(); 84 | 85 | // Subscribe to voice results in Primary Worker 86 | const resultSubscriber = redis.duplicate(); 87 | resultSubscriber.subscribe("voiceResult", (err, count) => { 88 | if (err) { 89 | console.error("Failed to subscribe to voiceResult:", err); 90 | return; 91 | } 92 | console.log(`Primary worker subscribed to voiceResult channel`); 93 | }); 94 | 95 | resultSubscriber.on("message", (channel, message) => { 96 | if (channel === "voiceResult") { 97 | try { 98 | const result = JSON.parse(message); 99 | console.log("Primary worker received voice result:", result); 100 | gameServerService.sendVoiceResult(result); 101 | } catch (error) { 102 | console.error("Error processing voice result:", error); 103 | } 104 | } 105 | }); 106 | 107 | console.log(`Primary worker ${process.pid} connected to game server`); 108 | } 109 | 110 | server.listen(PORT, () => { 111 | console.log(`Worker ${process.pid} started - Voice processing server running on port ${PORT}`); 112 | }); 113 | 114 | process.on("uncaughtException", (error) => { 115 | console.error(`Worker ${process.pid} Uncaught Exception:`, error); 116 | }); 117 | 118 | process.on("unhandledRejection", (reason, promise) => { 119 | console.error(`Worker ${process.pid} Unhandled Rejection at:`, promise, "reason:", reason); 120 | }); 121 | } 122 | -------------------------------------------------------------------------------- /be/voiceProcessingServer/src/services/audio-processing.service.js: -------------------------------------------------------------------------------- 1 | class AudioProcessingService { 2 | constructor(audioService, speechRecognitionService, pitchDetectionService, redis) { 3 | this.audioService = audioService; 4 | this.speechRecognitionService = speechRecognitionService; 5 | this.pitchDetectionService = pitchDetectionService; 6 | this.redis = redis; 7 | } 8 | 9 | async processAudio(audioChunks, session) { 10 | console.log("Starting audio processing..."); 11 | 12 | const audioBuffer = Buffer.concat(audioChunks); 13 | const wavBuffer = await this.audioService.convertToWav(audioBuffer); 14 | 15 | let result = {}; 16 | 17 | try { 18 | if (session.gameMode === "CLEOPATRA") { 19 | const averageNote = await this.pitchDetectionService.detectPitch(wavBuffer); 20 | result = { averageNote }; 21 | } else if (session.gameMode === "PRONUNCIATION") { 22 | const pronounceScore = await this.speechRecognitionService.recognizeSpeech(wavBuffer, session.lyrics); 23 | result = { pronounceScore }; 24 | } 25 | 26 | // Add session information to result 27 | result.roomId = session.roomId; 28 | result.playerNickname = session.playerNickname; 29 | 30 | // Publish result to Redis for Primary Worker to handle 31 | await this.redis.publish("voiceResult", JSON.stringify(result)); 32 | 33 | console.log("Voice processing completed:", result); 34 | } catch (error) { 35 | console.error("Audio processing error:", error); 36 | throw error; 37 | } 38 | } 39 | } 40 | 41 | module.exports = AudioProcessingService; 42 | -------------------------------------------------------------------------------- /be/voiceProcessingServer/src/services/audio.service.js: -------------------------------------------------------------------------------- 1 | const { Readable } = require("stream"); 2 | const { spawn } = require("child_process"); 3 | 4 | class AudioService { 5 | async convertToWav(audioBuffer) { 6 | return new Promise((resolve, reject) => { 7 | const ffmpeg = spawn("ffmpeg", ["-f", "webm", "-i", "pipe:0", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "-f", "wav", "pipe:1"]); 8 | 9 | const chunks = []; 10 | 11 | ffmpeg.stdout.on("data", (chunk) => chunks.push(chunk)); 12 | 13 | ffmpeg.on("error", (error) => { 14 | console.error("FFmpeg process error:", error); 15 | reject(error); 16 | }); 17 | 18 | ffmpeg.on("close", (code) => { 19 | if (code === 0) { 20 | const outputBuffer = Buffer.concat(chunks); 21 | console.log("Audio conversion successful. Output size:", outputBuffer.length); 22 | resolve(outputBuffer); 23 | } else { 24 | reject(new Error(`FFmpeg process exited with code ${code}`)); 25 | } 26 | }); 27 | 28 | const readable = new Readable(); 29 | readable._read = () => {}; 30 | readable.push(audioBuffer); 31 | readable.push(null); 32 | readable.pipe(ffmpeg.stdin); 33 | }); 34 | } 35 | } 36 | 37 | module.exports = AudioService; 38 | -------------------------------------------------------------------------------- /be/voiceProcessingServer/src/services/game-server.service.js: -------------------------------------------------------------------------------- 1 | const io = require("socket.io-client"); 2 | 3 | class GameServerService { 4 | constructor(gameServerUrl, redis, expireTime) { 5 | this.gameServerUrl = gameServerUrl; 6 | this.redis = redis; 7 | this.socket = null; 8 | this.expireTime = expireTime; 9 | } 10 | 11 | initialize() { 12 | this.socket = require("socket.io-client")(this.gameServerUrl, { 13 | transports: ["websocket"], 14 | }); 15 | 16 | this.socket.on("connect", () => { 17 | console.log("Connected to game server"); 18 | // 음성 처리 서버로 등록 19 | this.socket.emit("registerVoiceServer"); 20 | }); 21 | 22 | this.socket.on("turnChanged", async (data) => { 23 | console.log("Received turnChanged event:", data); 24 | 25 | try { 26 | const redisKey = `turn:${data.roomId}:${data.playerNickname}`; 27 | 28 | await this.redis.hset(redisKey, { 29 | gameMode: data.gameMode, 30 | lyrics: data.lyrics || "", 31 | timeLimit: data.timeLimit, 32 | timestamp: Date.now(), 33 | }); 34 | 35 | await this.redis.expire(redisKey, this.expireTime); 36 | 37 | await this.redis.publish( 38 | "turnUpdate", 39 | JSON.stringify({ 40 | type: "turnChanged", 41 | data: { roomId: data.roomId, playerNickname: data.playerNickname }, 42 | }) 43 | ); 44 | 45 | console.log("Turn data saved:", redisKey); 46 | } catch (error) { 47 | console.error("Error in turn changed handler:", error); 48 | } 49 | }); 50 | 51 | this.socket.on("connect_error", (error) => { 52 | console.error("Game server connection error:", error); 53 | }); 54 | 55 | this.socket.on("disconnect", (reason) => { 56 | console.log("Disconnected from game server:", reason); 57 | }); 58 | } 59 | 60 | sendVoiceResult(result) { 61 | if (!this.socket) { 62 | console.error("Game server socket not initialized"); 63 | return; 64 | } 65 | 66 | if (!this.socket.connected) { 67 | console.error("Game server socket not connected"); 68 | return; 69 | } 70 | 71 | try { 72 | this.socket.emit("voiceResult", result); 73 | console.log("Voice result sent to game server:", result); 74 | } catch (error) { 75 | console.error("Error sending voice result:", error); 76 | } 77 | } 78 | } 79 | module.exports = GameServerService; 80 | -------------------------------------------------------------------------------- /be/voiceProcessingServer/src/services/socket.service.js: -------------------------------------------------------------------------------- 1 | class SocketService { 2 | constructor(io, audioProcessingService, redis) { 3 | this.io = io; 4 | this.audioProcessingService = audioProcessingService; 5 | this.redis = redis; 6 | } 7 | 8 | initialize() { 9 | this.io.on("connection", async (socket) => { 10 | console.log("Client connected:", socket.id, "Worker:", process.pid); 11 | 12 | const { roomId, playerNickname } = socket.handshake.query; 13 | let audioChunks = []; 14 | let currentSession = null; 15 | 16 | try { 17 | // 연결 시점에 세션 검증 18 | const turnData = await this.redis.hgetall(`turn:${roomId}:${playerNickname}`); 19 | 20 | if (!turnData || Object.keys(turnData).length === 0) { 21 | console.log("No turn data found:", roomId, playerNickname); 22 | socket.emit("error", { message: "Invalid session" }); 23 | socket.disconnect(true); 24 | return; 25 | } 26 | 27 | currentSession = { 28 | roomId, 29 | playerNickname, 30 | gameMode: turnData.gameMode, 31 | lyrics: turnData.lyrics, 32 | timeLimit: parseInt(turnData.timeLimit), 33 | }; 34 | 35 | console.log("Session validated:", currentSession); 36 | } catch (error) { 37 | console.error("Session validation error:", error); 38 | socket.emit("error", { message: "Session validation failed" }); 39 | socket.disconnect(true); 40 | return; 41 | } 42 | 43 | socket.on("start_recording", () => { 44 | console.log("Recording started for session:", currentSession); 45 | audioChunks = []; 46 | }); 47 | 48 | socket.on("audio_data", (data) => { 49 | if (!currentSession) return; 50 | 51 | const buffer = Buffer.from(data); 52 | audioChunks.push(buffer); 53 | }); 54 | 55 | socket.on("disconnect", async () => { 56 | console.log("Client disconnected:", socket.id, "Worker:", process.pid); 57 | 58 | if (!currentSession || audioChunks.length === 0) { 59 | console.log("No valid session or audio data"); 60 | return; 61 | } 62 | 63 | try { 64 | await this.audioProcessingService.processAudio(audioChunks, currentSession); 65 | await this.redis.del(`turn:${currentSession.roomId}:${currentSession.playerNickname}`); 66 | console.log("Audio processing completed for session:", currentSession); 67 | } catch (error) { 68 | console.error("Processing error:", error); 69 | } finally { 70 | audioChunks = []; 71 | currentSession = null; 72 | } 73 | }); 74 | }); 75 | } 76 | } 77 | 78 | module.exports = SocketService; 79 | -------------------------------------------------------------------------------- /be/voiceProcessingServer/src/services/speech-recognition.service.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | 3 | class SpeechRecognitionService { 4 | constructor(apiKey, apiUrl) { 5 | this.apiKey = apiKey; 6 | this.apiUrl = apiUrl; 7 | } 8 | 9 | async recognizeSpeech(wavBuffer, utterance) { 10 | const params = new URLSearchParams({ 11 | lang: "Kor", 12 | assessment: "true", 13 | utterance: utterance.replace(/[ ,.]/g, ""), 14 | graph: "false", 15 | }).toString(); 16 | 17 | try { 18 | const response = await axios.post(`${this.apiUrl}?${params}`, wavBuffer, { 19 | headers: { 20 | "Content-Type": "application/octet-stream", 21 | "X-CLOVASPEECH-API-KEY": this.apiKey, 22 | Accept: "application/json", 23 | }, 24 | timeout: 10000, 25 | maxContentLength: Infinity, 26 | maxBodyLength: Infinity, 27 | }); 28 | 29 | return response.data.assessment_score; 30 | } catch (error) { 31 | this.handleApiError(error); 32 | } 33 | } 34 | 35 | handleApiError(error) { 36 | console.error("Error details:", error); 37 | 38 | if (axios.isAxiosError(error)) { 39 | if (error.response) { 40 | console.error("API Error Response:", { 41 | status: error.response.status, 42 | data: error.response.data, 43 | }); 44 | throw new Error(`API Error: ${error.response.status} - ${JSON.stringify(error.response.data)}`); 45 | } else if (error.request) { 46 | throw new Error("API request failed: No response received"); 47 | } 48 | } 49 | 50 | throw error; 51 | } 52 | } 53 | 54 | module.exports = SpeechRecognitionService; 55 | -------------------------------------------------------------------------------- /fe/.env.sample: -------------------------------------------------------------------------------- 1 | VITE_GAME_SERVER_URL="wss://game.example.com/rooms" 2 | VITE_SIGNALING_SERVER_URL="wss://signaling.example.com" 3 | VITE_VOICE_SERVER_URL="wss://voice-processing.example.com" 4 | VITE_STUN_SERVER="stun:coturn.example.com:3478" 5 | VITE_TURN_SERVER="turn:coturn.example.com:3478" 6 | VITE_TURN_USERNAME="your_coturn_username" 7 | VITE_TURN_CREDENTIAL="your_coturn_password" 8 | VITE_GAME_SSE_URL="https://game.example.com/api/rooms/stream" 9 | VITE_GAME_REST_BASE_URL="https://game.example.com/" 10 | -------------------------------------------------------------------------------- /fe/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .env 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /fe/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /fe/README.md: -------------------------------------------------------------------------------- 1 | ## 🔍 문제 해결 과정 2 | 3 | ### 새로운 게임방이 추가될 때 순서대로 추가되지 않음 4 | 5 | ### useRoomStore 사용 6 | 7 | - 훅/컴포넌트 내부에서 사용: useRoomStore() 8 | - React 외부의 비동기 작업, 이벤트 핸들러: useRoomStore.getState() 9 | 10 | ### 채점 중에서 게임 페이즈가 넘어가지 않는 문제 11 | 12 | - 음소거 버튼 문제처럼 userUpdates로 상태가 변경되면 채점 중에서 넘어가지 않는다. 13 | - 턴이 바뀌고 해당 차례의 사용자의 음성 정보를 넘겨줄 때 roomId가 필요한데 currentRoom의 roomId를 전달하고 있어서, updateUsers로 currentRoom이 변경되면 이 문제가 일어날 수 있다. 14 | - 그래서 currentRoom에서 가져오지 않고, useParams로 가져와서 전달해 주니까 채점 중에서 넘어가지 않는 문제는 없어진 것 같다. (아직 모름..) 15 | - 그런데 이렇게 처리하니까 게임 중일 때 또 다른 문제가 생겼다. 16 | - 자기 차례일 때 새로고침 시: 재입장 처리 돼서 순위에 2번 반영되고, 게임 준비 & 시작하면 이전 게임의 결과가 나와 버린다. (result가 초기화 되지 않음) 17 | - 다른 사람 차례일 때 새로고침 시: 순위에 2번 반영되지는 않지만, 역시나 게임 준비 & 시작하면 이전 게임의 결과가 나온다. (result가 초기화 되지 않음) 18 | - 새로고침 해서 준비 화면에 있는 상태에서 새로고침을 한 번 더 하면 정상 동작한다. 19 | - 새로고침 할 때 방 나가기 처리를 해야할 것 같은데.. 잘 모르겠다.. ㅜ 일단 채점 중에서 멈춰있지는 않는 것 같다. 20 | 21 | ### ExitDialog 새로고침 후 뒤로가기 하면 취소 버튼에 포커싱 22 | 23 | - KickDialog도 똑같이 shadcn/ui AlertDialog 컴포넌트를 쓰고 있는데, 강퇴 시에는 아무리 새로고침을 해도 취소 버튼에 포커싱되면서 아웃라인이 생기지 않음 24 | - 왜 방 나가기 Dialog만, 그것도 새로고침을 하고 나면 그러는 거지? 25 | - 나가기 버튼으로 나갈 때는 문제 없고 새로고침 후 뒤로가기 하면 그러는 것 같다. 뒤로가기 이벤트가 뭔가 영향을 주는 건가? 아무튼 AlertDialog 컴포넌트 자체에 처리 26 | 27 | ```jsx 28 | 38 | ``` 39 | 40 | ### 새로고침 지옥에서 꺼내줘 41 | 42 | - 개발 시작 단계부터 날 괴롭게 했던 새로고침.. 재입장 처리로 어떻게 넘어갔었는데, 게임 중일 때는 막아야 함 43 | - 키보드 동작은 막을 수 있는데 브라우저 새로고침 버튼 클릭은 막을 수 없음. Alert 띄우는 게 최선인데 이 Alert도 메시지 수정 불가. 44 | - 채점 중에서 안 넘어가는 문제가 해결되지 않았다. 45 | - 계속 테스트해 보는데 음성 데이터 전달 중에 새로고침 하면 채점이 안 되고 결과를 못 받아와서 그런 것 같다. 46 | - `beforeunload` 이벤트의 브라우저 기본 alert보다 먼저 혹은 동시에 CustomAlertDialog을 띄우는 것은 불가능함 47 | - 강퇴처럼 방 목록 페이지에 왔을 때 알림을 띄우기로 함 48 | - 이게 왜 잘 안되는 건지 모르겠다.. 강퇴랑 별다를 게 없는 거 같은데..🤯 나중에 고쳐보는 걸로 49 | - 페이지가 새로고침되면서 상태가 초기화되기 때문에 알림이 표시되지 않음. 강퇴 알림처럼 sessionStorage에 저장하고 가져와야 함 50 | 51 | ### 화요일 데일리스크럼 이슈 공유: 대기 중인 방에 링크로 입장 시 닉네임 설정 전에 마이크 권한 요청 및 오디오 연결되는 문제 52 | 53 | - 방 목록 페이지에서 게임 방 클릭 시에는 제대로 동작하는데, 링크 입장 시에는 음성이 먼저 연결된다는 이슈를 전달받음 54 | - 원인 55 | - 링크 입장 시 GamePage index.tsx의 useReconnect로 소켓 연결, 닉네임 설정, 유효성 검증, 각 서버에 join된다. (방 목록 페이지에서의 입장은 handleJoin) 56 | - 방 목록 페이지에서 게임 방 클릭 시에는 잘 동작한다는 말이 힌트가 되어줬다. 57 | - useReconnect에서 순서가 JoinDialog의 handleJoin의 순서와 달랐기 때문이다. 유효성 검증이 추가되고 gameSocket의 joinRoom도 비동기 함수로 바꾸고 했는데 useReconnect 훅에서 순서를 바꿔준다는 걸 잊어버렸다. 58 | - 해결 59 | - useReconnect에서 각 함수 호출 순서를 handleJoin과 동일하게 맞춰서 해결..! 60 | - gameSocket.joinRoom에서 유효성 검증에 대한 에러를 발생시키고 있기 때문에 순서를 잘 생각해야 한다. 게임 중 새로고침, 게임 중인 방 링크 입장할 때는 입장 불가 알림 처리도 해야 했기 때문에 머리가 터져버리는 줄 알았다. 오죽했으면 손으로 써가면서 체크함,,😇 61 | -------------------------------------------------------------------------------- /fe/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /fe/eslint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react/jsx-runtime', 8 | 'plugin:@typescript-eslint/recommended-type-checked', 9 | 'plugin:@typescript-eslint/stylistic-type-checked', 10 | 'plugin:react-hooks/recommended', 11 | // This disables the formatting rules in ESLint that Prettier is going to be responsible for handling. 12 | // Make sure it's always the last config, so it gets the chance to override other configs. 13 | 'prettier', 14 | ], 15 | ignorePatterns: ['dist', '.eslintrc.cjs'], 16 | parser: '@typescript-eslint/parser', 17 | parserOptions: { 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | project: ['./tsconfig.json', './tsconfig.node.json'], 21 | tsconfigRootDir: __dirname, 22 | }, 23 | plugins: ['react-refresh'], 24 | rules: { 25 | 'react-refresh/only-export-components': [ 26 | 'warn', 27 | { allowConstantExport: true }, 28 | ], 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /fe/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 12 | 13 | Clovapatra 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /fe/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fe", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "test": "vitest" 12 | }, 13 | "format": "prettier --write ./src", 14 | "dependencies": { 15 | "@radix-ui/react-alert-dialog": "^1.1.2", 16 | "@radix-ui/react-dialog": "^1.1.2", 17 | "@radix-ui/react-label": "^2.1.0", 18 | "@radix-ui/react-slider": "^1.2.1", 19 | "@radix-ui/react-slot": "^1.1.0", 20 | "@tanstack/react-query": "^5.59.20", 21 | "axios": "^1.7.7", 22 | "class-variance-authority": "^0.7.0", 23 | "clsx": "^2.1.1", 24 | "framer-motion": "^11.11.17", 25 | "lottie-react": "^2.4.0", 26 | "lucide-react": "^0.454.0", 27 | "react": "^18.3.1", 28 | "react-dom": "^18.3.1", 29 | "react-icons": "^5.3.0", 30 | "react-router-dom": "^6.27.0", 31 | "react-toastify": "^10.0.6", 32 | "socket.io-client": "^4.8.1", 33 | "tailwind-merge": "^2.5.4", 34 | "tailwindcss-animate": "^1.0.7", 35 | "uuid": "^11.0.2", 36 | "zustand": "^5.0.1" 37 | }, 38 | "devDependencies": { 39 | "@eslint/js": "^9.13.0", 40 | "@tanstack/react-query-devtools": "^5.59.20", 41 | "@testing-library/jest-dom": "^6.6.2", 42 | "@testing-library/react": "^16.0.1", 43 | "@types/node": "^22.8.7", 44 | "@types/react": "^18.3.12", 45 | "@types/react-dom": "^18.3.1", 46 | "@types/uuid": "^10.0.0", 47 | "@vitejs/plugin-react": "^4.3.3", 48 | "autoprefixer": "^10.4.20", 49 | "eslint": "^9.13.0", 50 | "eslint-config-prettier": "^9.1.0", 51 | "eslint-plugin-react": "^7.37.2", 52 | "eslint-plugin-react-hooks": "^5.0.0", 53 | "eslint-plugin-react-refresh": "^0.4.14", 54 | "globals": "^15.11.0", 55 | "jsdom": "^25.0.1", 56 | "postcss": "^8.4.47", 57 | "prettier": "3.3.3", 58 | "tailwindcss": "^3.4.14", 59 | "typescript": "~5.6.2", 60 | "typescript-eslint": "^8.11.0", 61 | "vite": "^5.4.10", 62 | "vitest": "^2.1.4" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /fe/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /fe/src/App.css: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fe/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Route, Routes } from 'react-router-dom'; 2 | import './App.css'; 3 | import GamePage from './pages/GamePage'; 4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 5 | import RoomListPage from './pages/RoomListPage'; 6 | import { ToastContainer } from 'react-toastify'; 7 | import 'react-toastify/dist/ReactToastify.css'; 8 | import LandingPage from './pages/LandingPage'; 9 | 10 | const queryClient = new QueryClient(); 11 | 12 | function App() { 13 | return ( 14 | 15 |
16 | 17 | 18 | } /> 19 | } /> 20 | } /> 21 | 22 | 23 | 24 |
25 |
26 | ); 27 | } 28 | 29 | export default App; 30 | -------------------------------------------------------------------------------- /fe/src/__test__/setup.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /fe/src/components/common/CustomAlertDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AlertDialog, 3 | AlertDialogAction, 4 | AlertDialogContent, 5 | AlertDialogDescription, 6 | AlertDialogFooter, 7 | AlertDialogHeader, 8 | AlertDialogTitle, 9 | } from '@/components/ui/alert-dialog'; 10 | 11 | interface CustomAlertDialogProps { 12 | open: boolean; 13 | onOpenChange: (open: boolean) => void; 14 | title: string; 15 | description?: string; 16 | actionText?: string; 17 | handleClick?: () => void; 18 | } 19 | 20 | const CustomAlertDialog = ({ 21 | open, 22 | onOpenChange, 23 | title, 24 | description, 25 | actionText = '확인', 26 | handleClick, 27 | }: CustomAlertDialogProps) => { 28 | return ( 29 | 30 | 31 | 32 | {title} 33 | {description && ( 34 | {description} 35 | )} 36 | 37 | 38 | 42 | {actionText} 43 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default CustomAlertDialog; 51 | -------------------------------------------------------------------------------- /fe/src/components/common/MikeButton.tsx: -------------------------------------------------------------------------------- 1 | import { FaMicrophone, FaMicrophoneSlash } from 'react-icons/fa6'; 2 | import { Button } from '../ui/button'; 3 | 4 | interface MikeButtonProps { 5 | isMuted: boolean; 6 | onToggle: () => void; 7 | } 8 | 9 | const MikeButton = ({ isMuted, onToggle }: MikeButtonProps) => { 10 | return ( 11 | 27 | ); 28 | }; 29 | 30 | export default MikeButton; 31 | -------------------------------------------------------------------------------- /fe/src/components/common/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from '@/components/ui/input'; 2 | import { FiSearch } from 'react-icons/fi'; 3 | import { useState, useEffect } from 'react'; 4 | import { useDebounce } from '@/hooks/useDebounce'; 5 | import useRoomStore from '@/stores/zustand/useRoomStore'; 6 | import { searchRoomsQuery } from '@/stores/queries/searchRoomsQuery'; 7 | import { getRoomsQuery } from '@/stores/queries/getRoomsQuery'; 8 | 9 | const SearchBar = () => { 10 | const [searchTerm, setSearchTerm] = useState(''); 11 | const debouncedSearch = useDebounce(searchTerm, 200); // 200ms 디바운스 12 | const { setRooms, userPage } = useRoomStore(); 13 | const { data: searchResults } = searchRoomsQuery(debouncedSearch); 14 | const { data: roomsData, refetch: refetchAllRooms } = getRoomsQuery(userPage); 15 | 16 | // 검색 결과 또는 전체 방 목록으로 업데이트 17 | useEffect(() => { 18 | if (!debouncedSearch.trim() && roomsData?.rooms) { 19 | refetchAllRooms(); 20 | setRooms(roomsData.rooms); 21 | return; 22 | } 23 | 24 | // 검색 결과가 있으면 방 목록 업데이트 25 | if (searchResults) { 26 | setRooms(searchResults); 27 | } 28 | }, [debouncedSearch, searchResults, roomsData, setRooms, refetchAllRooms]); 29 | 30 | return ( 31 |
32 | 33 | setSearchTerm(e.target.value)} 37 | placeholder="방 제목 검색" 38 | className="font-galmuri pl-8" 39 | /> 40 |
41 | ); 42 | }; 43 | 44 | export default SearchBar; 45 | -------------------------------------------------------------------------------- /fe/src/components/game/PitchVisualizer.tsx: -------------------------------------------------------------------------------- 1 | import { motion, AnimatePresence } from 'framer-motion'; 2 | import useGameStore from '@/stores/zustand/useGameStore'; 3 | import useRoomStore from '@/stores/zustand/useRoomStore'; 4 | import usePitchStore from '@/stores/zustand/usePitchStore'; 5 | import { signalingSocket } from '@/services/signalingSocket'; 6 | import { PITCH_CONSTANTS } from '@/constants/pitch'; 7 | import { usePitchDetection } from '@/hooks/usePitchDetection'; 8 | 9 | interface PitchVisualizerProps { 10 | isGameplayPhase: boolean; 11 | } 12 | 13 | const PitchVisualizer = ({ isGameplayPhase }: PitchVisualizerProps) => { 14 | const { currentPlayer } = useRoomStore(); 15 | const { turnData, rank } = useGameStore(); 16 | const { currentOpacity, currentVolume, resetPitch } = usePitchStore(); 17 | 18 | // 클레오파트라 모드 여부와 스트림 상태를 판단하여 피치 검출 훅 호출 19 | const isCleopatraMode = turnData?.gameMode === 'CLEOPATRA'; 20 | const isActive = isCleopatraMode && rank.length === 0 && isGameplayPhase; 21 | 22 | // 스트림 가져오기 23 | let stream: MediaStream | null = null; 24 | if (isActive) { 25 | if (turnData.playerNickname === currentPlayer) { 26 | stream = signalingSocket.getLocalStream(); 27 | } else { 28 | stream = signalingSocket.getPeerStream(turnData.playerNickname); 29 | } 30 | } 31 | 32 | // 피치 검출 훅 호출 33 | usePitchDetection(isCleopatraMode && isActive, stream); 34 | 35 | // 렌더링 조건 확인 36 | if (!isActive) { 37 | return null; 38 | } 39 | 40 | // 볼륨에 따른 스케일 계산 41 | const scale = 42 | PITCH_CONSTANTS.VISUALIZER_MIN_SCALE + 43 | currentVolume * PITCH_CONSTANTS.VISUALIZER_VOLUME_MULTIPLIER; 44 | 45 | // 스타일 정의 46 | const containerStyle: React.CSSProperties = { 47 | position: 'fixed', 48 | top: 0, 49 | left: 0, 50 | right: 0, 51 | bottom: 0, 52 | display: 'flex', 53 | alignItems: 'center', 54 | justifyContent: 'center', 55 | pointerEvents: 'none', 56 | zIndex: 9999, 57 | // overflow: 'hidden', 58 | }; 59 | 60 | const imageContainerStyle: React.CSSProperties = { 61 | position: 'relative', 62 | width: PITCH_CONSTANTS.CONTAINER_SIZE, 63 | height: PITCH_CONSTANTS.CONTAINER_SIZE, 64 | display: 'flex', 65 | alignItems: 'center', 66 | justifyContent: 'center', 67 | maxWidth: '100vw', 68 | maxHeight: '100vh', 69 | }; 70 | 71 | return ( 72 |
73 | 74 | 103 | Angry Pepe 118 | 119 | 120 |
121 | ); 122 | }; 123 | 124 | export default PitchVisualizer; 125 | -------------------------------------------------------------------------------- /fe/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /fe/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /fe/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLDivElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |
64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /fe/src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as DialogPrimitive from '@radix-ui/react-dialog'; 3 | import { X } from 'lucide-react'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const Dialog = DialogPrimitive.Root; 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger; 10 | 11 | const DialogPortal = DialogPrimitive.Portal; 12 | 13 | const DialogClose = DialogPrimitive.Close; 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )); 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | {/* 46 | 47 | Close 48 | */} 49 | 50 | 51 | )); 52 | DialogContent.displayName = DialogPrimitive.Content.displayName; 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ); 66 | DialogHeader.displayName = 'DialogHeader'; 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ); 80 | DialogFooter.displayName = 'DialogFooter'; 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )); 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )); 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogClose, 114 | DialogTrigger, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | }; 121 | -------------------------------------------------------------------------------- /fe/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /fe/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ) 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )) 22 | Label.displayName = LabelPrimitive.Root.displayName 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /fe/src/components/ui/slider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as SliderPrimitive from '@radix-ui/react-slider'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | const Slider = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 19 | 20 | 21 | 22 | 23 | )); 24 | Slider.displayName = SliderPrimitive.Root.displayName; 25 | 26 | export { Slider }; 27 | -------------------------------------------------------------------------------- /fe/src/config/env.ts: -------------------------------------------------------------------------------- 1 | export const ENV = { 2 | GAME_SERVER_URL: import.meta.env.VITE_GAME_SERVER_URL, 3 | SIGNALING_SERVER_URL: import.meta.env.VITE_SIGNALING_SERVER_URL, 4 | VOICE_SERVER_URL: import.meta.env.VITE_VOICE_SERVER_URL, 5 | STUN_SERVERS: { 6 | iceServers: [ 7 | { 8 | urls: import.meta.env.VITE_STUN_SERVER, 9 | }, 10 | { 11 | urls: import.meta.env.VITE_TURN_SERVER, 12 | username: import.meta.env.VITE_TURN_USERNAME, 13 | credential: import.meta.env.VITE_TURN_CREDENTIAL, 14 | }, 15 | ], 16 | }, 17 | SSE_URL: import.meta.env.VITE_GAME_SSE_URL, 18 | REST_BASE_URL: import.meta.env.VITE_GAME_REST_BASE_URL, 19 | }; 20 | -------------------------------------------------------------------------------- /fe/src/constants/audio.ts: -------------------------------------------------------------------------------- 1 | export const MEDIA_CONSTRAINTS = Object.freeze({ 2 | audio: { 3 | echoCancellation: true, // 에코 제거 4 | noiseSuppression: true, // 노이즈 제거 5 | autoGainControl: true, // 자동 게인 제어 6 | channelCount: 1, // 모노 채널 7 | sampleRate: 16000, // 16kHz 샘플레이트 8 | sampleSize: 16, // 16비트 샘플 크기 9 | }, 10 | video: false, // 비디오 비활성화 11 | }); 12 | -------------------------------------------------------------------------------- /fe/src/constants/errors.ts: -------------------------------------------------------------------------------- 1 | export const ERROR_MESSAGES = Object.freeze({ 2 | emptyNickname: '닉네임을 입력해주세요.', 3 | duplicatedNickname: '이미 사용 중인 닉네임입니다.', 4 | emptyRoomName: '방 제목을 입력해주세요.', 5 | invalidNickname: '닉네임은 한글, 영문, 숫자, 공백만 사용 가능합니다.', 6 | nicknameLength: '닉네임은 2~8자로 입력해주세요.', 7 | roomNameLength: '방 제목은 2~12자로 입력해주세요.', 8 | }); 9 | 10 | export const ERROR_CODES = Object.freeze({ 11 | duplicatedNickname: 'NicknameTaken', 12 | validation: 'ValidationFailed', 13 | noRoom: 'RoomNotFound', 14 | noGame: 'GameNotFound', 15 | noPlayer: 'PlayerNotFound', 16 | serverError: 'InternalError', 17 | fullRoom: 'RoomFull', 18 | notAllReady: 'AllPlayersMustBeReady', 19 | notEnoughPlayers: 'NotEnoughPlayers', 20 | }); 21 | -------------------------------------------------------------------------------- /fe/src/constants/pitch.ts: -------------------------------------------------------------------------------- 1 | export const PITCH_CONSTANTS = { 2 | // 주파수 관련 상수 (Hz 단위) 3 | MIN_FREQ: 100, // 최소 감지 주파수 4 | MAX_FREQ: 1000, // 최대 감지 주파수 5 | MID_FREQ: 550, // 중간 주파수 6 | FREQ_MULTIPLIER: 1.25, // 음계 보정치 배율 7 | 8 | // 불투명도 설정 9 | MIN_OPACITY: 0.0, // 최소 불투명도 10 | MAX_OPACITY: 1.0, // 최대 불투명도 11 | INITIAL_OPACITY: 0.0, // 초기 불투명도 12 | 13 | // 로깅 및 볼륨 관련 14 | LOG_INTERVAL: 1000, // 로그 출력 간격 (ms) 15 | MIN_VOLUME_THRESHOLD: 0.35, // 최소 인식 볼륨 임계값 16 | 17 | // 시각화 관련 상수 18 | VISUALIZER_MIN_SCALE: 0.5, // 최소 스케일 19 | VISUALIZER_MAX_SCALE: 1.25, // 최대 스케일 20 | VISUALIZER_VOLUME_MULTIPLIER: 1.0, // 볼륨에 따른 스케일 배율 21 | 22 | // 애니메이션 관련 상수 23 | ANIMATION_SPRING_CONFIG: { 24 | type: 'spring', 25 | stiffness: 700, 26 | damping: 30, 27 | mass: 1, 28 | } as const, 29 | 30 | // 트랜지션 타이밍 31 | OPACITY_TRANSITION_DURATION: 0.5, // 불투명도 변화 지속 시간 32 | SCALE_TRANSITION_DURATION: 2.5, // 크기 변화 지속 시간 33 | 34 | // 컨테이너 크기 (반응형) 35 | CONTAINER_SIZE: '80vw', // 페페 화면 너비 36 | } as const; 37 | -------------------------------------------------------------------------------- /fe/src/constants/rules.ts: -------------------------------------------------------------------------------- 1 | export const RULES = Object.freeze({ 2 | maxPage: 9, 3 | maxPlayer: 4, 4 | pageLimit: 9, 5 | }); 6 | -------------------------------------------------------------------------------- /fe/src/hooks/useAudioManager.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | export const useAudioManager = () => { 4 | const setAudioStream = useCallback( 5 | ( 6 | peerId: string, 7 | stream: MediaStream, 8 | playerInfo: { 9 | currentPlayer: string; 10 | isCurrent: boolean; 11 | } 12 | ) => { 13 | const existingAudio = document.getElementById( 14 | `audio-${peerId}` 15 | ) as HTMLAudioElement; 16 | 17 | if (existingAudio) { 18 | existingAudio.remove(); 19 | } 20 | 21 | const audioElement = new Audio(); 22 | audioElement.id = `audio-${peerId}`; 23 | audioElement.srcObject = stream; 24 | audioElement.autoplay = true; 25 | audioElement.volume = 0.5; // 초기 볼륨 26 | 27 | document.body.appendChild(audioElement); 28 | }, 29 | [] 30 | ); 31 | 32 | const setVolume = useCallback((peerId: string, volume: number) => { 33 | const audioElement = document.getElementById( 34 | `audio-${peerId}` 35 | ) as HTMLAudioElement; 36 | 37 | if (audioElement) { 38 | audioElement.volume = volume; 39 | } else { 40 | console.log('audioElement를 찾을 수 없음:', peerId); 41 | } 42 | }, []); 43 | 44 | const removeAudio = useCallback((peerId: string) => { 45 | const audioElement = document.getElementById( 46 | `audio-${peerId}` 47 | ) as HTMLAudioElement; 48 | if (audioElement) { 49 | audioElement.srcObject = null; 50 | audioElement.remove(); 51 | } 52 | }, []); 53 | 54 | const cleanup = useCallback(() => { 55 | const audioElements = document.querySelectorAll('audio'); 56 | audioElements.forEach((audio: HTMLAudioElement) => { 57 | audio.srcObject = null; 58 | audio.remove(); 59 | }); 60 | }, []); 61 | 62 | return { 63 | setAudioStream, 64 | setVolume, 65 | removeAudio, 66 | cleanup, 67 | }; 68 | }; 69 | -------------------------------------------------------------------------------- /fe/src/hooks/useAudioPermission.ts: -------------------------------------------------------------------------------- 1 | import { MEDIA_CONSTRAINTS } from '@/constants/audio'; 2 | import { useState } from 'react'; 3 | 4 | export const useAudioPermission = () => { 5 | const [isLoading, setIsLoading] = useState(false); 6 | 7 | const requestPermission = async () => { 8 | setIsLoading(true); 9 | try { 10 | const stream = 11 | await navigator.mediaDevices.getUserMedia(MEDIA_CONSTRAINTS); 12 | return stream; 13 | } catch (error) { 14 | if (error instanceof Error) { 15 | if (error.name === 'NotAllowedError') { 16 | throw new Error('마이크 권한이 필요합니다.'); 17 | } 18 | throw new Error('마이크 연결에 실패했습니다.'); 19 | } 20 | throw error; 21 | } finally { 22 | setIsLoading(false); 23 | } 24 | }; 25 | 26 | return { requestPermission, isLoading }; 27 | }; 28 | -------------------------------------------------------------------------------- /fe/src/hooks/useBackExit.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export const useBackExit = ({ setShowExitDialog }) => { 4 | const popStateListenerRef = useRef<(() => void) | null>(null); 5 | 6 | // 컴포넌트 마운트 시 한 번만 실행 7 | useEffect(() => { 8 | // 이전에 등록된 리스너가 있다면 제거 9 | if (popStateListenerRef.current) { 10 | window.removeEventListener('popstate', popStateListenerRef.current); 11 | } 12 | 13 | // 새로운 popstate 이벤트 리스너 생성 14 | const handlePopState = () => { 15 | setShowExitDialog(true); 16 | window.history.pushState(null, '', window.location.pathname); 17 | }; 18 | 19 | // 현재 리스너를 ref에 저장 (cleanup을 위해) 20 | popStateListenerRef.current = handlePopState; 21 | 22 | // 초기 history 상태 설정 및 이벤트 리스너 등록 23 | window.history.pushState(null, '', window.location.pathname); 24 | window.addEventListener('popstate', handlePopState); 25 | 26 | // cleanup 함수 27 | return () => { 28 | if (popStateListenerRef.current) { 29 | window.removeEventListener('popstate', popStateListenerRef.current); 30 | popStateListenerRef.current = null; 31 | } 32 | }; 33 | }, []); // 빈 의존성 배열로 마운트 시에만 실행 34 | }; 35 | -------------------------------------------------------------------------------- /fe/src/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export const useDebounce = (value: T, delay: number): T => { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const timer = setTimeout(() => { 8 | setDebouncedValue(value); 9 | }, delay); 10 | 11 | return () => { 12 | clearTimeout(timer); 13 | }; 14 | }, [value, delay]); 15 | 16 | return debouncedValue; 17 | }; 18 | -------------------------------------------------------------------------------- /fe/src/hooks/useDialogForm.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, KeyboardEvent } from 'react'; 2 | 3 | interface UseDialogFormProps { 4 | inputs: { 5 | id: string; 6 | value: string; 7 | onChange: (value: string) => void; 8 | }[]; 9 | onSubmit: () => void; 10 | isSubmitDisabled: boolean; 11 | } 12 | 13 | export const useDialogForm = ({ 14 | inputs, 15 | onSubmit, 16 | isSubmitDisabled, 17 | open, 18 | }: UseDialogFormProps & { open: boolean }) => { 19 | const inputRefs = useRef<(HTMLInputElement | null)[]>([]); 20 | 21 | useEffect(() => { 22 | // 첫 번째 입력 필드에 포커스 23 | if (open && inputRefs.current[0]) { 24 | inputRefs.current[0].focus(); 25 | } 26 | }, [open]); 27 | 28 | const handleKeyDown = ( 29 | event: KeyboardEvent, 30 | index: number 31 | ) => { 32 | if (event.key === 'Enter') { 33 | event.preventDefault(); 34 | 35 | // 마지막 입력 필드가 아닌 경우 다음 필드로 이동 36 | if (index < inputs.length - 1) { 37 | inputRefs.current[index + 1]?.focus(); 38 | return; 39 | } 40 | 41 | // 마지막 입력 필드이고 제출이 가능한 경우 제출 42 | if (!isSubmitDisabled) { 43 | onSubmit(); 44 | } 45 | } 46 | }; 47 | 48 | return { 49 | inputRefs, 50 | handleKeyDown, 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /fe/src/hooks/useFormValidation.ts: -------------------------------------------------------------------------------- 1 | import { validateNickname, validateRoomName } from '@/utils/validator'; 2 | import { useEffect, useState } from 'react'; 3 | import { useDebounce } from './useDebounce'; 4 | 5 | export const useFormValidation = () => { 6 | const [errors, setErrors] = useState({ 7 | nickname: '', 8 | roomName: '', 9 | }); 10 | 11 | // 각 필드의 사용자 상호작용 여부를 저장하는 상태 12 | // touched가 true인 필드만 유효성 검사 실행 13 | const [touched, setTouched] = useState({ 14 | nickname: false, 15 | roomName: false, 16 | }); 17 | 18 | // 실제 입력값을 저장하는 상태 19 | const [inputs, setInputs] = useState({ 20 | nickname: '', 21 | roomName: '', 22 | }); 23 | 24 | // 입력값을 디바운스 처리할 state 25 | const debouncedInputs = useDebounce(inputs, 200); 26 | 27 | // 디바운스된 입력값이 변경될 때마다 유효성 검증 28 | useEffect(() => { 29 | const newErrors = { 30 | // 닉네임 필드가 터치되었을 때만 유효성 검사 31 | nickname: touched.nickname 32 | ? debouncedInputs.nickname === '' 33 | ? '닉네임을 입력해주세요.' 34 | : validateNickname(debouncedInputs.nickname) 35 | : '', 36 | // 방 제목 필드가 터치되었을 때만 유효성 검사 37 | roomName: touched.roomName 38 | ? debouncedInputs.roomName === '' 39 | ? '방 제목을 입력해주세요.' 40 | : validateRoomName(debouncedInputs.roomName) 41 | : '', 42 | }; 43 | setErrors(newErrors); 44 | }, [debouncedInputs, touched]); 45 | 46 | // 입력값 업데이트 함수 47 | const updateInput = (field: 'nickname' | 'roomName', value: string) => { 48 | setInputs((prev) => ({ 49 | ...prev, 50 | [field]: value, 51 | })); 52 | 53 | // 입력이 발생한 필드를 터치 상태로 변경 54 | setTouched((prev) => ({ 55 | ...prev, 56 | [field]: true, 57 | })); 58 | }; 59 | 60 | // 최종 제출 시 유효성 검증 61 | const validateForm = (nickname: string, roomName?: string) => { 62 | // 제출 시에는 모든 필드를 터치 상태로 설정 63 | setTouched({ 64 | nickname: true, 65 | roomName: true, 66 | }); 67 | 68 | const newErrors = { 69 | nickname: validateNickname(nickname), 70 | roomName: roomName ? validateRoomName(roomName) : '', 71 | }; 72 | 73 | setErrors(newErrors); 74 | return !Object.values(newErrors).some((error) => error !== ''); 75 | }; 76 | 77 | // 폼 상태 초기화 (다이얼로그 닫을 때 사용) 78 | const resetForm = () => { 79 | setTouched({ 80 | nickname: false, 81 | roomName: false, 82 | }); 83 | setErrors({ 84 | nickname: '', 85 | roomName: '', 86 | }); 87 | setInputs({ 88 | nickname: '', 89 | roomName: '', 90 | }); 91 | }; 92 | 93 | return { errors, validateForm, updateInput, setErrors, resetForm }; 94 | }; 95 | -------------------------------------------------------------------------------- /fe/src/hooks/usePitchDetection.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import usePitchStore from '@/stores/zustand/usePitchStore'; 3 | import useGameStore from '@/stores/zustand/useGameStore'; 4 | import useRoomStore from '@/stores/zustand/useRoomStore'; 5 | import { signalingSocket } from '@/services/signalingSocket'; 6 | import { PitchDetector } from '@/utils/pitchDetection'; 7 | 8 | export function usePitchDetection( 9 | isCleopatraMode: boolean, 10 | stream: MediaStream | null 11 | ) { 12 | const { updatePitch } = usePitchStore(); 13 | const { turnData } = useGameStore(); 14 | const { currentPlayer } = useRoomStore(); 15 | const [isAnalyzing, setIsAnalyzing] = useState(false); 16 | const pitchDetectorRef = useRef(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 | --------------------------------------------------------------------------------