├── .github ├── ISSUE_TEMPLATE │ └── feature-template.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── apply-issue-template.yml │ ├── client-ci-cd.yml │ ├── docker-compose-deploy.yml │ ├── server-ci-cd.yml │ ├── storybook-ci-cd.yml │ ├── storybook-preview-ci-cd.yml │ └── typedoc-ci-cd.yml ├── .gitignore ├── Dockerfile.nginx ├── Dockerfile.server ├── README.md ├── client ├── .env.example ├── .prettierrc ├── .storybook │ ├── main.ts │ └── preview.ts ├── README.md ├── eslint.config.js ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ └── site.webmanifest ├── src │ ├── App.css │ ├── App.tsx │ ├── api │ │ ├── api.config.ts │ │ └── gameApi.ts │ ├── assets │ │ ├── arrow.svg │ │ ├── big-timer.gif │ │ ├── bucket-icon.svg │ │ ├── crown-first.png │ │ ├── help-icon.svg │ │ ├── left.svg │ │ ├── lottie │ │ │ ├── game-win.lottie │ │ │ ├── help │ │ │ │ ├── fifth.json │ │ │ │ ├── first.json │ │ │ │ ├── fourth.json │ │ │ │ ├── second.json │ │ │ │ └── third.json │ │ │ ├── loading.lottie │ │ │ ├── round-loss.lottie │ │ │ └── round-win.lottie │ │ ├── pen-icon.svg │ │ ├── podium.gif │ │ ├── profile-placeholder.png │ │ ├── redo-icon.svg │ │ ├── right.svg │ │ ├── small-timer.gif │ │ ├── small-timer.png │ │ ├── sound-logo.svg │ │ └── sounds │ │ │ ├── entry-sound-effect.mp3 │ │ │ ├── game-loss.mp3 │ │ │ └── game-win.mp3 │ ├── components │ │ ├── bgm-button │ │ │ └── BackgroundMusicButton.tsx │ │ ├── canvas │ │ │ ├── CanvasToolbar.tsx │ │ │ ├── CanvasUI.tsx │ │ │ ├── DrawingArea.tsx │ │ │ ├── GameCanvas.tsx │ │ │ ├── InkGauge.tsx │ │ │ └── MainCanvas.tsx │ │ ├── chat │ │ │ ├── ChatBubbleUI.tsx │ │ │ ├── ChatButtle.stories.tsx │ │ │ ├── ChatContatiner.tsx │ │ │ ├── ChatInput.tsx │ │ │ └── ChatList.tsx │ │ ├── lobby │ │ │ ├── InviteButton.tsx │ │ │ └── StartButton.tsx │ │ ├── modal │ │ │ ├── HelpRollingModal.tsx │ │ │ ├── NavigationModal.tsx │ │ │ ├── RoleModal.tsx │ │ │ └── RoundEndModal.tsx │ │ ├── player │ │ │ └── PlayerCardList.tsx │ │ ├── quiz │ │ │ └── QuizStage.tsx │ │ ├── result │ │ │ └── PodiumPlayers.tsx │ │ ├── setting │ │ │ ├── Setting.tsx │ │ │ ├── SettingContent.tsx │ │ │ └── SettingItem.tsx │ │ ├── toast │ │ │ └── ToastContainer.tsx │ │ └── ui │ │ │ ├── BackgroundCanvas.tsx │ │ │ ├── Button.stories.tsx │ │ │ ├── Button.tsx │ │ │ ├── Dropdown.stories.tsx │ │ │ ├── Dropdown.tsx │ │ │ ├── HelpContainer.tsx │ │ │ ├── Input.stories.tsx │ │ │ ├── Input.tsx │ │ │ ├── Logo.stories.tsx │ │ │ ├── Logo.tsx │ │ │ ├── Modal.stories.tsx │ │ │ ├── Modal.tsx │ │ │ ├── PixelTransitionContainer.tsx │ │ │ ├── QuizTitle.stories.tsx │ │ │ ├── QuizTitle.tsx │ │ │ ├── Toast.tsx │ │ │ └── player-card │ │ │ ├── PlayerCard.stories.tsx │ │ │ ├── PlayerCard.tsx │ │ │ ├── PlayerInfo.tsx │ │ │ ├── PlayerProfile.tsx │ │ │ └── PlayerStatus.tsx │ ├── constants │ │ ├── backgroundConstants.ts │ │ ├── canvasConstants.ts │ │ ├── cdn.ts │ │ ├── gameConstant.ts │ │ ├── shortcutKeys.ts │ │ └── socket-error-messages.ts │ ├── handlers │ │ ├── canvas │ │ │ └── cursorInOutHandler.ts │ │ └── socket │ │ │ ├── chatSocket.handler.ts │ │ │ ├── drawingSocket.handler.ts │ │ │ └── gameSocket.handler.ts │ ├── hooks │ │ ├── canvas │ │ │ ├── useDrawing.ts │ │ │ ├── useDrawingOperation.ts │ │ │ └── useDrawingState.ts │ │ ├── socket │ │ │ ├── useChatSocket.ts │ │ │ ├── useDrawingSocket.ts │ │ │ └── useGameSocket.ts │ │ ├── useBackgroundMusic.ts │ │ ├── useCoordinateScale.ts │ │ ├── useCreateRoom.ts │ │ ├── useDropdown.ts │ │ ├── useModal.ts │ │ ├── usePageTransition.ts │ │ ├── usePlayerRanking.ts │ │ ├── useScrollToBottom.ts │ │ ├── useShortcuts.ts │ │ ├── useStartButton.tsx │ │ ├── useTimeout.ts │ │ └── useTimer.ts │ ├── index.css │ ├── layouts │ │ ├── BrowserNavigationGuard.tsx │ │ ├── GameHeader.tsx │ │ ├── GameLayout.tsx │ │ └── RootLayout.tsx │ ├── main.tsx │ ├── pages │ │ ├── GameRoomPage.tsx │ │ ├── LobbyPage.tsx │ │ ├── MainPage.tsx │ │ └── ResultPage.tsx │ ├── routes.tsx │ ├── stores │ │ ├── navigationModal.store.ts │ │ ├── socket │ │ │ ├── chatSocket.store.ts │ │ │ ├── gameSocket.store.ts │ │ │ ├── socket.config.ts │ │ │ └── socket.store.ts │ │ ├── timer.store.ts │ │ ├── toast.store.ts │ │ ├── useCanvasStore.ts │ │ └── useStore.ts │ ├── types │ │ ├── canvas.types.ts │ │ ├── chat.types.ts │ │ ├── declarations.d.ts │ │ ├── game.types.ts │ │ └── socket.types.ts │ ├── utils │ │ ├── checkProduction.ts │ │ ├── checkTimerDifference.ts │ │ ├── cn.ts │ │ ├── formatDate.ts │ │ ├── getCanvasContext.ts │ │ ├── getDrawPoint.ts │ │ ├── hexToRGBA.ts │ │ ├── playerIdStorage.ts │ │ ├── soundManager.ts │ │ └── timer.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── core ├── .prettierrc ├── crdt │ ├── LWWMap.ts │ ├── LWWRegister.ts │ ├── index.ts │ └── test │ │ ├── LWWMap.test.ts │ │ ├── LWWRegister.test.ts │ │ ├── drawing-utils.ts │ │ ├── random-drawing.test.ts │ │ ├── test-types.ts │ │ └── test-utils.ts ├── index.ts ├── package.json ├── playwright.config.ts ├── tsconfig.json ├── tsup.config.ts ├── types │ ├── crdt.types.ts │ ├── game.types.ts │ ├── index.ts │ └── socket.types.ts └── vitest.config.ts ├── docker-compose.yml ├── nginx.conf ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── server ├── .env.example ├── .eslintrc.js ├── .prettierrc ├── README.md ├── nest-cli.json ├── package.json ├── pnpm-lock.yaml ├── src │ ├── app.module.ts │ ├── chat │ │ ├── chat.gateway.spec.ts │ │ ├── chat.gateway.ts │ │ ├── chat.module.ts │ │ ├── chat.repository.ts │ │ ├── chat.service.spec.ts │ │ └── chat.service.ts │ ├── common │ │ ├── clova-client.ts │ │ ├── enums │ │ │ ├── game.status.enum.ts │ │ │ ├── game.timer.enum.ts │ │ │ └── socket.error-code.enum.ts │ │ ├── services │ │ │ └── timer.service.ts │ │ └── types │ │ │ └── game.types.ts │ ├── drawing │ │ ├── drawing.gateway.spec.ts │ │ ├── drawing.gateway.ts │ │ ├── drawing.module.ts │ │ ├── drawing.repository.ts │ │ └── drawing.service.ts │ ├── exceptions │ │ └── game.exception.ts │ ├── filters │ │ └── ws-exception.filter.ts │ ├── game │ │ ├── game.controller.spec.ts │ │ ├── game.controller.ts │ │ ├── game.gateway.spec.ts │ │ ├── game.gateway.ts │ │ ├── game.module.ts │ │ ├── game.repository.ts │ │ ├── game.service.spec.ts │ │ └── game.service.ts │ ├── main.ts │ └── redis │ │ ├── redis.module.ts │ │ └── redis.service.ts ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json ├── tsconfig.base.json └── typedoc.json /.github/ISSUE_TEMPLATE/feature-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Template 3 | about: Project's features 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | ## 📂 구현 기능 10 | 11 | 1-2문장으로 요약. 12 | 13 | ## 📝 상세 작업 내용 14 | 15 | - [ ] 16 | - [ ] 17 | 18 | ## 🔆 참고 사항 (선택) 19 | 20 | ## ⏰ 예상 작업 시간 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull Request Template 3 | about: Review of Issue Results 4 | title: '${작업 키워드}: ' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | ## 📂 작업 내용 10 | 11 | closes #이슈번호 12 | 13 | - [x] 작업 내용 14 | 15 | ## 💡 자세한 설명 16 | 17 | (가능한 한 자세히 작성해 주시면 도움이 됩니다.) 18 | 19 | ## 📗 참고 자료 & 구현 결과 (선택) 20 | 21 | ## 📢 리뷰 요구 사항 (선택) 22 | 23 | ## 🚩 후속 작업 (선택) 24 | 25 | ## ✅ 셀프 체크리스트 26 | 27 | - [ ] PR 제목을 형식에 맞게 작성했나요? 28 | - [ ] 브랜치 전략에 맞는 브랜치에 PR을 올리고 있나요? (`main`이 아닙니다.) 29 | - [ ] 이슈는 close 했나요? 30 | - [ ] Reviewers, Labels를 등록했나요? 31 | - [ ] 작업 도중 문서 수정이 필요한 경우 잘 수정했나요? 32 | - [ ] 테스트는 잘 통과했나요? 33 | - [ ] 불필요한 코드는 제거했나요? 34 | -------------------------------------------------------------------------------- /.github/workflows/apply-issue-template.yml: -------------------------------------------------------------------------------- 1 | name: Apply Issue Template 2 | on: 3 | issues: 4 | types: [opened] 5 | jobs: 6 | apply-template: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | 11 | - uses: actions/github-script@v6 12 | with: 13 | github-token: ${{ secrets.GITHUB_TOKEN }} 14 | script: | 15 | const fs = require('fs'); 16 | const issue = context.payload.issue; 17 | const fullTemplate = fs.readFileSync('.github/ISSUE_TEMPLATE/feature-template.md', 'utf8'); 18 | const templateContent = fullTemplate.split('---').slice(2).join('---').trim(); 19 | 20 | await github.rest.issues.update({ 21 | owner: context.repo.owner, 22 | repo: context.repo.repo, 23 | issue_number: issue.number, 24 | body: templateContent 25 | }); 26 | -------------------------------------------------------------------------------- /.github/workflows/client-ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: Client CI/CD 2 | 3 | on: 4 | push: 5 | branches: [develop] 6 | paths: 7 | - 'client/**' 8 | - 'core/**' 9 | - 'nginx.conf' 10 | - '.github/workflows/client-ci-cd.yml' 11 | - 'Dockerfile.nginx' 12 | 13 | jobs: 14 | ci-cd: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: '20' 23 | 24 | - name: Setup pnpm 25 | uses: pnpm/action-setup@v3 26 | with: 27 | version: '9' 28 | 29 | - name: Install Dependencies 30 | run: pnpm install --frozen-lockfile 31 | 32 | - name: Lint Client 33 | working-directory: ./client 34 | run: pnpm lint | true 35 | 36 | - name: Test Client 37 | working-directory: ./client 38 | run: pnpm test | true 39 | 40 | - name: Docker Setup 41 | uses: docker/setup-buildx-action@v3 42 | 43 | - name: Login to Docker Hub 44 | uses: docker/login-action@v3 45 | with: 46 | username: ${{ secrets.DOCKERHUB_USERNAME }} 47 | password: ${{ secrets.DOCKERHUB_TOKEN }} 48 | 49 | - name: Build and Push Docker Image 50 | uses: docker/build-push-action@v5 51 | with: 52 | context: . 53 | file: ./Dockerfile.nginx 54 | push: true 55 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/troublepainter-nginx:latest 56 | build-args: | 57 | VITE_API_URL=${{secrets.VITE_API_URL}} 58 | VITE_SOCKET_URL=${{secrets.VITE_SOCKET_URL}} 59 | 60 | - name: Deploy to Server 61 | uses: appleboy/ssh-action@v1.0.0 62 | with: 63 | host: ${{ secrets.SSH_HOST }} 64 | username: mira 65 | key: ${{ secrets.SSH_PRIVATE_KEY }} 66 | script: | 67 | cd /home/mira/web30-stop-troublepainter 68 | export DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} 69 | docker pull ${{ secrets.DOCKERHUB_USERNAME }}/troublepainter-nginx:latest 70 | docker compose up -d nginx -------------------------------------------------------------------------------- /.github/workflows/docker-compose-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Docker Compose Update 2 | 3 | on: 4 | push: 5 | branches: [develop] 6 | paths: 7 | - 'docker-compose.yml' 8 | - '.github/workflows/docker-compose-deploy.yml' 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Deploy to Server 15 | uses: appleboy/ssh-action@v1.0.0 16 | with: 17 | host: ${{ secrets.SSH_HOST }} 18 | username: mira 19 | key: ${{ secrets.SSH_PRIVATE_KEY }} 20 | script: | 21 | cd /home/mira/web30-stop-troublepainter 22 | export DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} 23 | docker compose pull 24 | docker compose up -d -------------------------------------------------------------------------------- /.github/workflows/server-ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: Server CI/CD 2 | 3 | on: 4 | push: 5 | branches: [develop] 6 | paths: 7 | - 'server/**' 8 | - 'core/**' 9 | - '.github/workflows/server-ci-cd.yml' 10 | - 'Dockerfile.server' 11 | 12 | jobs: 13 | ci-cd: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: '20' 23 | - name: Setup pnpm 24 | uses: pnpm/action-setup@v3 25 | with: 26 | version: '9' 27 | 28 | - name: Install dependencies 29 | run: pnpm install --frozen-lockfile 30 | 31 | - name: Build Core Package 32 | working-directory: ./core 33 | run: pnpm build 34 | 35 | - name: Create .env file 36 | working-directory: ./server 37 | run: | 38 | echo "REDIS_HOST=${{ secrets.REDIS_HOST }}" >> .env 39 | echo "REDIS_PORT=${{ secrets.REDIS_PORT }}" >> .env 40 | echo "CLOVA_API_KEY=${{ secrets.CLOVA_API_KEY }}" >> .env 41 | echo "CLOVA_GATEWAY_KEY=${{ secrets.CLOVA_GATEWAY_KEY }}" >> .env 42 | 43 | - name: Run tests 44 | run: pnpm --filter server test | true 45 | 46 | - name: Docker Setup 47 | uses: docker/setup-buildx-action@v3 48 | 49 | - name: Login to Docker Hub 50 | uses: docker/login-action@v3 51 | with: 52 | username: ${{ secrets.DOCKERHUB_USERNAME }} 53 | password: ${{ secrets.DOCKERHUB_TOKEN }} 54 | 55 | - name: Build and Push Docker Image 56 | uses: docker/build-push-action@v5 57 | with: 58 | context: . 59 | file: ./Dockerfile.server 60 | push: true 61 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/troublepainter-server:latest 62 | build-args: | 63 | REDIS_HOST=${{ secrets.REDIS_HOST }} 64 | REDIS_PORT=${{ secrets.REDIS_PORT }} 65 | CLOVA_API_KEY=${{ secrets.CLOVA_API_KEY }} 66 | CLOVA_GATEWAY_KEY=${{ secrets.CLOVA_GATEWAY_KEY }} 67 | 68 | - name: Deploy to Server 69 | uses: appleboy/ssh-action@v1.0.0 70 | with: 71 | host: ${{ secrets.SSH_HOST }} 72 | username: mira 73 | key: ${{ secrets.SSH_PRIVATE_KEY }} 74 | script: | 75 | cd /home/mira/web30-stop-troublepainter 76 | export DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} 77 | docker pull ${{ secrets.DOCKERHUB_USERNAME }}/troublepainter-server:latest 78 | docker compose up -d server -------------------------------------------------------------------------------- /.github/workflows/storybook-ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Storybook Production 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - develop 8 | paths: 9 | - 'client/**' 10 | - 'core/**' 11 | - '.github/workflows/storybook-*.yml' 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | deploy: 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: write 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup pnpm 28 | uses: pnpm/action-setup@v3 29 | with: 30 | version: 9 31 | run_install: false 32 | 33 | - name: Setup Node 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: 20 37 | cache: 'pnpm' 38 | cache-dependency-path: '**/pnpm-lock.yaml' 39 | 40 | - name: Install all dependencies 41 | run: pnpm install --frozen-lockfile 42 | 43 | - name: Build core package 44 | run: | 45 | echo "Building core package..." 46 | pnpm --filter @troublepainter/core build 47 | echo "Core build output:" 48 | ls -la core/dist/ 49 | 50 | - name: Verify core build 51 | run: | 52 | if [ ! -f "core/dist/index.mjs" ]; then 53 | echo "Core build failed - index.mjs not found" 54 | exit 1 55 | fi 56 | 57 | - name: Build storybook 58 | working-directory: ./client 59 | run: | 60 | echo "Building Storybook with core from $(realpath ../core/dist/)" 61 | pnpm build-storybook 62 | 63 | - name: Deploy 64 | uses: peaceiris/actions-gh-pages@v3 65 | with: 66 | github_token: ${{ secrets.GITHUB_TOKEN }} 67 | publish_dir: ./client/storybook-static 68 | destination_dir: ${{ github.ref == 'refs/heads/main' && 'storybook' || 'storybook-develop' }} 69 | keep_files: true 70 | commit_message: 'docs: deploy storybook' 71 | -------------------------------------------------------------------------------- /.github/workflows/typedoc-ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Documentation 2 | 3 | on: 4 | push: 5 | branches: ['main', 'develop'] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup pnpm 17 | uses: pnpm/action-setup@v2 18 | with: 19 | version: 8 20 | run_install: false 21 | 22 | - name: Setup Node 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 18 26 | cache: 'pnpm' 27 | cache-dependency-path: '**/pnpm-lock.yaml' 28 | 29 | - name: Install dependencies 30 | run: pnpm install 31 | 32 | - name: Build documentation 33 | run: pnpm typedoc 34 | 35 | - name: Deploy 36 | uses: peaceiris/actions-gh-pages@v3 37 | with: 38 | github_token: ${{ secrets.GITHUB_TOKEN }} 39 | publish_dir: ./docs 40 | keep_files: true 41 | destination_dir: ./ 42 | commit_message: 'docs: deploy documentation' 43 | -------------------------------------------------------------------------------- /.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 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | *storybook.log 27 | 28 | # TypeDoc 29 | docs 30 | 31 | # ------------------------------------------------------------------------------------------ 32 | 33 | # compiled output 34 | /dist 35 | /node_modules 36 | /build 37 | 38 | # Logs 39 | logs 40 | *.log 41 | npm-debug.log* 42 | pnpm-debug.log* 43 | yarn-debug.log* 44 | yarn-error.log* 45 | lerna-debug.log* 46 | 47 | # OS 48 | .DS_Store 49 | 50 | # Tests 51 | /coverage 52 | /.nyc_output 53 | 54 | # IDEs and editors 55 | /.idea 56 | .project 57 | .classpath 58 | .c9/ 59 | *.launch 60 | .settings/ 61 | *.sublime-workspace 62 | 63 | # IDE - VSCode 64 | .vscode/* 65 | !.vscode/settings.json 66 | !.vscode/tasks.json 67 | !.vscode/launch.json 68 | !.vscode/extensions.json 69 | 70 | # dotenv environment variable files 71 | .env 72 | .env.development.local 73 | .env.test.local 74 | .env.production.local 75 | .env.local 76 | 77 | # temp directory 78 | .temp 79 | .tmp 80 | 81 | # Runtime data 82 | pids 83 | *.pid 84 | *.seed 85 | *.pid.lock 86 | 87 | # Diagnostic reports (https://nodejs.org/api/report.html) 88 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 89 | -------------------------------------------------------------------------------- /Dockerfile.nginx: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS builder 2 | 3 | RUN corepack enable && corepack prepare pnpm@9.12.3 --activate 4 | 5 | WORKDIR /app 6 | 7 | COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ 8 | COPY core/package.json ./core/ 9 | COPY client/package.json ./client/ 10 | 11 | RUN pnpm install --frozen-lockfile 12 | 13 | COPY . . 14 | 15 | ARG VITE_API_URL 16 | ARG VITE_SOCKET_URL 17 | 18 | RUN echo "VITE_API_URL=$VITE_API_URL" > client/.env && echo "VITE_SOCKET_URL=$VITE_SOCKET_URL" >> client/.env && pnpm --filter @troublepainter/core build && pnpm --filter client build 19 | 20 | FROM nginx:alpine 21 | 22 | COPY nginx.conf /etc/nginx/templates/default.conf.template 23 | COPY --from=builder /app/client/dist /usr/share/nginx/html 24 | 25 | EXPOSE 80 443 26 | 27 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /Dockerfile.server: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS builder 2 | 3 | RUN corepack enable && corepack prepare pnpm@9.12.3 --activate 4 | 5 | WORKDIR /app 6 | 7 | COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ 8 | COPY core/package.json ./core/ 9 | COPY server/package.json ./server/ 10 | 11 | RUN pnpm install --frozen-lockfile 12 | 13 | COPY . . 14 | 15 | RUN pnpm --filter @troublepainter/core build 16 | RUN pnpm --filter server build 17 | 18 | FROM node:20-alpine AS production 19 | WORKDIR /app 20 | 21 | COPY --from=builder /app/pnpm-workspace.yaml ./ 22 | COPY --from=builder /app/server/package.json ./server/package.json 23 | COPY --from=builder /app/server/dist ./server/dist 24 | COPY --from=builder /app/core/package.json ./core/package.json 25 | COPY --from=builder /app/core/dist ./core/dist 26 | 27 | RUN corepack enable && corepack prepare pnpm@9.12.3 --activate && cd server && pnpm install --prod 28 | 29 | WORKDIR /app/server 30 | 31 | ENV NODE_ENV=production 32 | ENV PORT=3000 33 | 34 | EXPOSE 3000 35 | 36 | CMD ["node", "dist/main.js"] -------------------------------------------------------------------------------- /client/.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_URL='' 2 | VITE_SOCKET_URL='' -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "as-needed", 8 | "jsxSingleQuote": false, 9 | "trailingComma": "all", 10 | "bracketSpacing": true, 11 | "bracketSameLine": false, 12 | "arrowParens": "always", 13 | "proseWrap": "preserve", 14 | "htmlWhitespaceSensitivity": "css", 15 | "endOfLine": "auto", 16 | "embeddedLanguageFormatting": "auto", 17 | "singleAttributePerLine": false, 18 | "plugins": ["prettier-plugin-tailwindcss"] 19 | } 20 | -------------------------------------------------------------------------------- /client/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import type { StorybookConfig } from '@storybook/react-vite'; 3 | 4 | const config: StorybookConfig = { 5 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 6 | addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], 7 | framework: { 8 | name: '@storybook/react-vite', 9 | options: {}, 10 | }, 11 | docs: { 12 | autodocs: 'tag', 13 | }, 14 | viteFinal: async (config) => { 15 | if (config.resolve) { 16 | config.resolve.alias = { 17 | ...config.resolve.alias, 18 | '@': path.resolve(__dirname, '../src'), 19 | '@troublepainter/core': path.resolve(__dirname, '../../core/dist/index.mjs'), 20 | }; 21 | } 22 | 23 | return { 24 | ...config, 25 | build: { 26 | ...config.build, 27 | commonjsOptions: { 28 | include: [/@troublepainter\/core/, /node_modules/], 29 | }, 30 | }, 31 | }; 32 | }, 33 | }; 34 | 35 | export default config; 36 | -------------------------------------------------------------------------------- /client/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react'; 2 | import '@/index.css'; 3 | 4 | const preview: Preview = { 5 | parameters: { 6 | controls: { 7 | matchers: { 8 | color: /(background|color)$/i, 9 | date: /Date$/i, 10 | }, 11 | }, 12 | }, 13 | }; 14 | 15 | export default preview; 16 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }); 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from 'eslint-plugin-react'; 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: '18.3' } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs['jsx-runtime'].rules, 48 | }, 49 | }); 50 | ``` 51 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "start": "vite preview", 10 | "lint": "eslint .", 11 | "lint:fix": "eslint . --fix", 12 | "lint:strict": "eslint . --max-warnings=0", 13 | "format": "prettier --write .", 14 | "format:check": "prettier --check .", 15 | "check": "pnpm format:check && pnpm lint:strict", 16 | "fix": "pnpm format && pnpm lint:fix", 17 | "storybook": "storybook dev -p 6006", 18 | "build-storybook": "storybook build" 19 | }, 20 | "dependencies": { 21 | "@lottiefiles/dotlottie-react": "^0.10.1", 22 | "@lottiefiles/react-lottie-player": "^3.5.4", 23 | "@troublepainter/core": "workspace:*", 24 | "class-variance-authority": "^0.7.0", 25 | "clsx": "^2.1.1", 26 | "react": "^18.3.1", 27 | "react-dom": "^18.3.1", 28 | "react-router-dom": "^6.28.0", 29 | "socket.io-client": "^4.8.1", 30 | "tailwind-merge": "^2.5.4", 31 | "zustand": "^5.0.1" 32 | }, 33 | "devDependencies": { 34 | "@chromatic-com/storybook": "^3.2.2", 35 | "@eslint/js": "^9.13.0", 36 | "@storybook/addon-essentials": "^8.4.1", 37 | "@storybook/addon-interactions": "^8.4.1", 38 | "@storybook/addon-onboarding": "^8.4.1", 39 | "@storybook/blocks": "^8.4.1", 40 | "@storybook/react": "^8.4.1", 41 | "@storybook/react-vite": "^8.4.1", 42 | "@storybook/test": "^8.4.1", 43 | "@types/node": "^22.9.0", 44 | "@types/react": "^18.3.12", 45 | "@types/react-dom": "^18.3.1", 46 | "@types/socket.io-client": "^3.0.0", 47 | "@typescript-eslint/eslint-plugin": "^8.12.2", 48 | "@typescript-eslint/parser": "^8.12.2", 49 | "@vitejs/plugin-react": "^4.3.3", 50 | "autoprefixer": "^10.4.20", 51 | "eslint": "^9.14.0", 52 | "eslint-config-airbnb": "^19.0.4", 53 | "eslint-config-airbnb-base": "^15.0.0", 54 | "eslint-config-airbnb-typescript": "^18.0.0", 55 | "eslint-config-prettier": "^9.1.0", 56 | "eslint-plugin-import": "^2.31.0", 57 | "eslint-plugin-jsx-a11y": "^6.10.2", 58 | "eslint-plugin-prettier": "^5.2.1", 59 | "eslint-plugin-react": "^7.37.2", 60 | "eslint-plugin-react-hooks": "^5.0.0", 61 | "eslint-plugin-react-refresh": "^0.4.14", 62 | "eslint-plugin-storybook": "^0.10.2", 63 | "globals": "^15.11.0", 64 | "postcss": "^8.4.47", 65 | "prettier": "^3.3.3", 66 | "prettier-plugin-tailwindcss": "^0.6.8", 67 | "storybook": "^8.4.1", 68 | "tailwindcss": "^3.4.14", 69 | "tailwindcss-animate": "^1.0.7", 70 | "typescript": "~5.6.2", 71 | "typescript-eslint": "^8.11.0", 72 | "vite": "^5.4.10" 73 | }, 74 | "eslintConfig": { 75 | "extends": [ 76 | "plugin:storybook/recommended" 77 | ] 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /client/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /client/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /client/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/public/apple-touch-icon.png -------------------------------------------------------------------------------- /client/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/public/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/public/favicon-32x32.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/src/App.css -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { ToastContainer } from '@/components/toast/ToastContainer'; 3 | 4 | interface AppChildrenProps { 5 | children: ReactNode; 6 | } 7 | 8 | // 전역 상태 등 추가할 예정 9 | const App = ({ children }: AppChildrenProps) => { 10 | return ( 11 | <> 12 | {children} 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default App; 19 | -------------------------------------------------------------------------------- /client/src/api/gameApi.ts: -------------------------------------------------------------------------------- 1 | import { API_CONFIG, fetchApi } from './api.config'; 2 | 3 | export interface CreateRoomResponse { 4 | roomId: string; 5 | } 6 | 7 | export const gameApi = { 8 | createRoom: () => fetchApi(API_CONFIG.ENDPOINTS.GAME.CREATE_ROOM, { method: 'POST' }), 9 | }; 10 | -------------------------------------------------------------------------------- /client/src/assets/arrow.svg: -------------------------------------------------------------------------------- 1 | 7 | 13 | 14 | -------------------------------------------------------------------------------- /client/src/assets/big-timer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/src/assets/big-timer.gif -------------------------------------------------------------------------------- /client/src/assets/bucket-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /client/src/assets/crown-first.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/src/assets/crown-first.png -------------------------------------------------------------------------------- /client/src/assets/help-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/src/assets/left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/assets/lottie/game-win.lottie: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/src/assets/lottie/game-win.lottie -------------------------------------------------------------------------------- /client/src/assets/lottie/loading.lottie: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/src/assets/lottie/loading.lottie -------------------------------------------------------------------------------- /client/src/assets/lottie/round-loss.lottie: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/src/assets/lottie/round-loss.lottie -------------------------------------------------------------------------------- /client/src/assets/lottie/round-win.lottie: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/src/assets/lottie/round-win.lottie -------------------------------------------------------------------------------- /client/src/assets/pen-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /client/src/assets/podium.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/src/assets/podium.gif -------------------------------------------------------------------------------- /client/src/assets/profile-placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/src/assets/profile-placeholder.png -------------------------------------------------------------------------------- /client/src/assets/redo-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /client/src/assets/right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/assets/small-timer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/src/assets/small-timer.gif -------------------------------------------------------------------------------- /client/src/assets/small-timer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/src/assets/small-timer.png -------------------------------------------------------------------------------- /client/src/assets/sound-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /client/src/assets/sounds/entry-sound-effect.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/src/assets/sounds/entry-sound-effect.mp3 -------------------------------------------------------------------------------- /client/src/assets/sounds/game-loss.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/src/assets/sounds/game-loss.mp3 -------------------------------------------------------------------------------- /client/src/assets/sounds/game-win.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/fed309361e237fbef18162e12b3d56e7273be0a3/client/src/assets/sounds/game-win.mp3 -------------------------------------------------------------------------------- /client/src/components/bgm-button/BackgroundMusicButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import soundLogo from '@/assets/sound-logo.svg'; 3 | import { useBackgroundMusic } from '@/hooks/useBackgroundMusic'; 4 | import { cn } from '@/utils/cn'; 5 | 6 | export const BackgroundMusicButton = () => { 7 | const { volume, togglePlay, adjustVolume } = useBackgroundMusic(); 8 | const [isHovered, setIsHovered] = useState(false); 9 | 10 | const isMuted = volume === 0; 11 | 12 | return ( 13 |
setIsHovered(true)} 16 | onMouseLeave={() => setIsHovered(false)} 17 | > 18 | {/* 음소거/재생 토글 버튼 */} 19 | 29 | 30 | {/* 볼륨 슬라이더 */} 31 |
37 | adjustVolume(Number(e.target.value))} 44 | className="h-1 w-24 appearance-none rounded-full bg-chartreuseyellow-200" 45 | aria-label="배경음악 볼륨 조절" 46 | /> 47 |
48 |
49 | ); 50 | }; 51 | 52 | export default BackgroundMusicButton; 53 | -------------------------------------------------------------------------------- /client/src/components/canvas/CanvasToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent } from 'react'; 2 | import { LINEWIDTH_VARIABLE, DRAWING_MODE } from '@/constants/canvasConstants'; 3 | import { useCanvasStore } from '@/stores/useCanvasStore'; 4 | import { CanvasStore, PenModeType } from '@/types/canvas.types'; 5 | 6 | const CV = ['#000', '#f257c9', '#e2f724', '#4eb4c2', '#d9d9d9']; 7 | //임시 색상 코드 8 | 9 | const CanvasToolBar = () => { 10 | const lineWidth = useCanvasStore((state: CanvasStore) => state.penSetting.lineWidth); 11 | const setPenSetting = useCanvasStore((state: CanvasStore) => state.action.setPenSetting); 12 | const setPenMode = useCanvasStore((state: CanvasStore) => state.action.setPenMode); 13 | 14 | const handleChangeToolColor = (colorNum: number) => { 15 | setPenSetting({ colorNum }); 16 | }; 17 | const handleChangeLineWidth = (lineWidth: string) => { 18 | setPenSetting({ lineWidth: Number(lineWidth) }); 19 | }; 20 | const handleChangeToolMode = (modeNum: PenModeType) => { 21 | setPenMode(modeNum); 22 | }; 23 | 24 | return ( 25 |
26 |
27 | {CV.map((color, i) => { 28 | return ( 29 | 32 | ); 33 | })} 34 |
35 |
36 | ) => handleChangeLineWidth(e.target.value)} 43 | /> 44 |
45 |
46 | 47 | 48 |
49 |
50 | ); 51 | }; 52 | 53 | export default CanvasToolBar; 54 | -------------------------------------------------------------------------------- /client/src/components/canvas/DrawingArea.tsx: -------------------------------------------------------------------------------- 1 | import CanvasToolBar from './CanvasToolbar'; 2 | import MainCanvas from './MainCanvas'; 3 | 4 | const DrawingArea = () => { 5 | return ( 6 |
7 | 8 | 9 |
10 | ); 11 | }; 12 | 13 | export default DrawingArea; 14 | -------------------------------------------------------------------------------- /client/src/components/chat/ChatBubbleUI.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes, memo } from 'react'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | import { cn } from '@/utils/cn'; 4 | 5 | const chatBubbleVariants = cva( 6 | 'select-text break-all px-2.5 inline-flex max-w-[85%] items-center justify-center rounded-lg border-2 border-violet-950 text-violet-950 text-base min-h-8 lg:text-lg 2xl:py-0.5 2xl:text-xl', 7 | { 8 | variants: { 9 | variant: { 10 | default: 'bg-halfbaked-200', 11 | secondary: 'bg-chartreuseyellow-200', 12 | }, 13 | }, 14 | defaultVariants: { 15 | variant: 'default', 16 | }, 17 | }, 18 | ); 19 | 20 | export interface ChatBubbleProps extends HTMLAttributes, VariantProps { 21 | content: string; 22 | nickname?: string; 23 | } 24 | 25 | const ChatBubble = memo(({ className, variant, content, nickname, ...props }: ChatBubbleProps) => { 26 | const isOtherUser = Boolean(nickname); 27 | const ariaLabel = isOtherUser ? `${nickname}님의 메시지: ${content}` : `내 메시지: ${content}`; 28 | 29 | return ( 30 |
35 | {isOtherUser && ( 36 | 39 | )} 40 |

41 | {content} 42 |

43 |
44 | ); 45 | }); 46 | 47 | export { ChatBubble, chatBubbleVariants }; 48 | -------------------------------------------------------------------------------- /client/src/components/chat/ChatButtle.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ChatBubble } from './ChatBubbleUI'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | 4 | type Story = StoryObj; 5 | 6 | export default { 7 | component: ChatBubble, 8 | title: 'components/chat/ChatBubbleUI', 9 | argTypes: { 10 | content: { 11 | control: 'text', 12 | description: '채팅 메시지 내용', 13 | table: { 14 | type: { summary: 'string' }, 15 | }, 16 | }, 17 | nickname: { 18 | control: 'text', 19 | description: '사용자 닉네임 (있으면 다른 사용자의 메시지)', 20 | table: { 21 | type: { summary: 'string' }, 22 | }, 23 | }, 24 | variant: { 25 | control: 'select', 26 | options: ['default', 'secondary'], 27 | description: '채팅 버블 스타일', 28 | }, 29 | className: { 30 | control: 'text', 31 | description: '추가 스타일링', 32 | }, 33 | }, 34 | parameters: { 35 | docs: { 36 | description: { 37 | component: ` 38 | 채팅 메시지를 표시하는 버블 컴포넌트입니다. 39 | 40 | ### 특징 41 | - 내 메시지와 다른 사용자의 메시지를 구분하여 표시 42 | - 다른 사용자의 메시지일 경우 닉네임 표시 43 | - 두 가지 스타일 variant 지원 (default: 하늘색, secondary: 노란색) 44 | `, 45 | }, 46 | }, 47 | }, 48 | decorators: [ 49 | (Story) => ( 50 |
51 | 52 |
53 | ), 54 | ], 55 | tags: ['autodocs'], 56 | } satisfies Meta; 57 | 58 | export const MyMessage: Story = { 59 | args: { 60 | content: '안녕하세요!', 61 | variant: 'secondary', 62 | }, 63 | parameters: { 64 | docs: { 65 | description: { 66 | story: '내가 보낸 메시지입니다. 오른쪽 정렬되며 닉네임이 표시되지 않습니다.', 67 | }, 68 | }, 69 | }, 70 | }; 71 | 72 | export const OtherUserMessage: Story = { 73 | args: { 74 | content: '반갑습니다!', 75 | nickname: '사용자1', 76 | variant: 'default', 77 | }, 78 | parameters: { 79 | docs: { 80 | description: { 81 | story: '다른 사용자가 보낸 메시지입니다. 왼쪽 정렬되며 닉네임이 표시됩니다.', 82 | }, 83 | }, 84 | }, 85 | }; 86 | 87 | export const LongMessage: Story = { 88 | args: { 89 | content: 90 | '이것은 매우 긴 메시지입니다. 채팅 버블이 어떻게 긴 텍스트를 처리하는지 보여주기 위한 예시입니다. 최대 너비는 85%로 제한됩니다.', 91 | nickname: '사용자2', 92 | variant: 'default', 93 | }, 94 | parameters: { 95 | docs: { 96 | description: { 97 | story: '긴 메시지가 주어졌을 때의 레이아웃을 보여줍니다.', 98 | }, 99 | }, 100 | }, 101 | }; 102 | -------------------------------------------------------------------------------- /client/src/components/chat/ChatContatiner.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { ChatInput } from '@/components/chat/ChatInput'; 3 | import { ChatList } from '@/components/chat/ChatList'; 4 | import { useChatSocket } from '@/hooks/socket/useChatSocket'; 5 | 6 | export const ChatContatiner = memo(() => { 7 | // 채팅 소켓 연결 : 최상위 관리 8 | useChatSocket(); 9 | 10 | return ( 11 |
12 | 13 | 14 |
15 | ); 16 | }); 17 | -------------------------------------------------------------------------------- /client/src/components/chat/ChatList.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { ChatBubble } from '@/components/chat/ChatBubbleUI'; 3 | import { useScrollToBottom } from '@/hooks/useScrollToBottom'; 4 | import { useChatSocketStore } from '@/stores/socket/chatSocket.store'; 5 | import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; 6 | 7 | export const ChatList = memo(() => { 8 | const messages = useChatSocketStore((state) => state.messages); 9 | const currentPlayerId = useGameSocketStore((state) => state.currentPlayerId); 10 | const { containerRef } = useScrollToBottom([messages]); 11 | 12 | return ( 13 |
14 |

15 | 여기에다가 답하고 16 |
채팅할 수 있습니다. 17 |

18 | 19 | {messages.map((message) => { 20 | const isOthers = message.playerId !== currentPlayerId; 21 | return ( 22 | 28 | ); 29 | })} 30 |
31 | ); 32 | }); 33 | -------------------------------------------------------------------------------- /client/src/components/lobby/InviteButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Button } from '@/components/ui/Button'; 3 | import { useShortcuts } from '@/hooks/useShortcuts'; 4 | import { useToastStore } from '@/stores/toast.store'; 5 | import { cn } from '@/utils/cn'; 6 | 7 | export const InviteButton = () => { 8 | const [copied, setCopied] = useState(false); 9 | const actions = useToastStore((state) => state.actions); 10 | 11 | const handleCopyInvite = async () => { 12 | if (copied) return; 13 | try { 14 | await navigator.clipboard.writeText(window.location.href); 15 | setCopied(true); 16 | setTimeout(() => setCopied(false), 2000); 17 | 18 | actions.addToast({ 19 | title: '초대 링크 복사', 20 | description: '친구에게 링크를 공유해 방에 초대해보세요!', 21 | variant: 'success', 22 | duration: 2000, 23 | }); 24 | } catch (error) { 25 | console.error(error); 26 | actions.addToast({ 27 | title: '복사 실패', 28 | description: '나중에 다시 시도해주세요.', 29 | variant: 'error', 30 | }); 31 | } 32 | }; 33 | 34 | // 게임 초대 단축키 적용 35 | useShortcuts([ 36 | { 37 | key: 'GAME_INVITE', 38 | action: () => void handleCopyInvite(), 39 | }, 40 | ]); 41 | 42 | return ( 43 | 65 | ); 66 | }; 67 | 68 | export default InviteButton; 69 | -------------------------------------------------------------------------------- /client/src/components/lobby/StartButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/Button'; 2 | import { useGameStart } from '@/hooks/useStartButton'; 3 | import { cn } from '@/utils/cn'; 4 | 5 | export const StartButton = () => { 6 | const { isHost, buttonConfig, handleStartGame, isStarting } = useGameStart(); 7 | return ( 8 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /client/src/components/modal/NavigationModal.tsx: -------------------------------------------------------------------------------- 1 | import { KeyboardEvent, useCallback } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Button } from '@/components/ui/Button'; 4 | import { Modal } from '@/components/ui/Modal'; 5 | import { useNavigationModalStore } from '@/stores/navigationModal.store'; 6 | 7 | export const NavigationModal = () => { 8 | const navigate = useNavigate(); 9 | const isOpen = useNavigationModalStore((state) => state.isOpen); 10 | const actions = useNavigationModalStore((state) => state.actions); 11 | 12 | const handleConfirmExit = () => { 13 | actions.closeModal(); 14 | navigate('/', { replace: true }); 15 | }; 16 | 17 | const handleKeyDown = useCallback( 18 | (e: KeyboardEvent) => { 19 | switch (e.key) { 20 | case 'Enter': 21 | handleConfirmExit(); 22 | break; 23 | case 'Escape': 24 | actions.closeModal(); 25 | break; 26 | } 27 | }, 28 | [actions, navigate], 29 | ); 30 | 31 | return ( 32 | 40 |
41 |

42 | 정말 게임을 나가실거에요...?? 43 |
44 | 퇴장하면 다시 돌아오기 힘들어요! 🥺💔 45 |

46 |
47 | 55 | 63 |
64 |
65 |
66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /client/src/components/modal/RoleModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { Modal } from '@/components/ui/Modal'; 3 | import { PLAYING_ROLE_TEXT } from '@/constants/gameConstant'; 4 | import { useModal } from '@/hooks/useModal'; 5 | import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; 6 | 7 | const RoleModal = () => { 8 | const room = useGameSocketStore((state) => state.room); 9 | const roundAssignedRole = useGameSocketStore((state) => state.roundAssignedRole); 10 | const { isModalOpened, closeModal, handleKeyDown, openModal } = useModal(5000); 11 | 12 | useEffect(() => { 13 | if (roundAssignedRole) openModal(); 14 | }, [roundAssignedRole, room?.currentRound]); 15 | 16 | return ( 17 | 24 | 25 | {roundAssignedRole ? PLAYING_ROLE_TEXT[roundAssignedRole] : ''} 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default RoleModal; 32 | -------------------------------------------------------------------------------- /client/src/components/player/PlayerCardList.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { PlayerRole, PlayerStatus } from '@troublepainter/core'; 3 | import { PlayerCard } from '@/components/ui/player-card/PlayerCard'; 4 | import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; 5 | 6 | const PlayerCardList = memo(() => { 7 | // 개별 selector 사용으로 변경 8 | const players = useGameSocketStore((state) => state.players); 9 | const hostId = useGameSocketStore((state) => state.room?.hostId); 10 | const roundAssignedRole = useGameSocketStore((state) => state.roundAssignedRole); 11 | const currentPlayerId = useGameSocketStore((state) => state.currentPlayerId); 12 | 13 | if (!players?.length) return null; 14 | 15 | const getPlayerRole = (playerRole: PlayerRole | undefined, myRole: PlayerRole | null) => { 16 | if (myRole === PlayerRole.GUESSER) return playerRole === PlayerRole.GUESSER ? playerRole : null; 17 | return playerRole; 18 | }; 19 | 20 | return ( 21 | <> 22 | {players.map((player) => { 23 | const playerRole = getPlayerRole(player.role, roundAssignedRole) || null; 24 | 25 | return ( 26 | 36 | ); 37 | })} 38 | 39 | ); 40 | }); 41 | 42 | export { PlayerCardList }; 43 | -------------------------------------------------------------------------------- /client/src/components/result/PodiumPlayers.tsx: -------------------------------------------------------------------------------- 1 | import { Player } from '@troublepainter/core'; 2 | import { cn } from '@/utils/cn'; 3 | 4 | const positionStyles = { 5 | first: { 6 | containerStyle: 'absolute w-[40%] left-[30%] top-[29%]', 7 | scoreStyle: 'bottom-[36%] left-[48%]', 8 | }, 9 | second: { 10 | containerStyle: 'absolute w-[40%] left-[1%] bottom-[37%]', 11 | scoreStyle: 'bottom-[23%] left-[18%]', 12 | }, 13 | third: { 14 | containerStyle: 'absolute w-[40%] right-[1%] bottom-[28%]', 15 | scoreStyle: 'bottom-[18%] right-[17.5%]', 16 | }, 17 | }; 18 | 19 | interface PodiumPlayersProps { 20 | players: Player[]; 21 | position: 'first' | 'second' | 'third'; 22 | } 23 | 24 | const PodiumPlayers = ({ players, position }: PodiumPlayersProps) => { 25 | if (!players || players.length === 0 || players[0].score === 0) return null; 26 | 27 | const { containerStyle, scoreStyle } = positionStyles[position]; 28 | 29 | return ( 30 | <> 31 | 32 | {String(players[0].score).padStart(2, '0')} 33 | 34 |
35 | {players.map((player) => ( 36 |
37 | {`${player.nickname} 46 | 47 | {player.nickname} 48 | 49 |
50 | ))} 51 |
52 | 53 | ); 54 | }; 55 | 56 | export default PodiumPlayers; 57 | -------------------------------------------------------------------------------- /client/src/components/setting/Setting.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes, memo, useCallback, useEffect, useState } from 'react'; 2 | import { RoomSettings } from '@troublepainter/core'; 3 | import { SettingContent } from '@/components/setting/SettingContent'; 4 | import { SHORTCUT_KEYS } from '@/constants/shortcutKeys'; 5 | import { gameSocketHandlers } from '@/handlers/socket/gameSocket.handler'; 6 | import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; 7 | import { cn } from '@/utils/cn'; 8 | 9 | type SettingKey = keyof RoomSettings; 10 | 11 | export interface RoomSettingItem { 12 | key: SettingKey; 13 | label: string; 14 | options: number[]; 15 | shortcutKey: keyof typeof SHORTCUT_KEYS; 16 | } 17 | 18 | export const ROOM_SETTINGS: RoomSettingItem[] = [ 19 | { label: '라운드 수', key: 'totalRounds', options: [3, 5, 7, 9, 11], shortcutKey: 'DROPDOWN_TOTAL_ROUNDS' }, 20 | { label: '최대 플레이어 수', key: 'maxPlayers', options: [4, 5], shortcutKey: 'DROPDOWN_MAX_PLAYERS' }, 21 | { label: '제한 시간', key: 'drawTime', options: [15, 20, 25, 30], shortcutKey: 'DROPDOWN_DRAW_TIME' }, 22 | //{ label: '픽셀 수', key: 'maxPixels', options: [300, 500] }, 23 | ]; 24 | 25 | const Setting = memo(({ className, ...props }: HTMLAttributes) => { 26 | // 개별 selector로 필요한 상태만 구독 27 | const roomSettings = useGameSocketStore((state) => state.roomSettings); 28 | const isHost = useGameSocketStore((state) => state.isHost); 29 | const actions = useGameSocketStore((state) => state.actions); 30 | 31 | const [selectedValues, setSelectedValues] = useState( 32 | roomSettings ?? { 33 | totalRounds: 5, 34 | maxPlayers: 5, 35 | drawTime: 30, 36 | }, 37 | ); 38 | 39 | useEffect(() => { 40 | if (!roomSettings) return; 41 | setSelectedValues(roomSettings); 42 | }, [roomSettings]); 43 | 44 | const handleSettingChange = useCallback( 45 | (key: keyof RoomSettings, value: string) => { 46 | const newSettings = { 47 | ...selectedValues, 48 | [key]: Number(value), 49 | }; 50 | setSelectedValues(newSettings); 51 | void gameSocketHandlers.updateSettings({ 52 | settings: { ...newSettings, drawTime: newSettings.drawTime + 5 }, 53 | }); 54 | actions.updateRoomSettings(newSettings); 55 | }, 56 | [selectedValues, actions], 57 | ); 58 | 59 | return ( 60 |
64 | {/* Setting title */} 65 |
66 |

Setting

67 |
68 | 69 | {/* Setting content */} 70 | 76 |
77 | ); 78 | }); 79 | 80 | export { Setting }; 81 | -------------------------------------------------------------------------------- /client/src/components/setting/SettingContent.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { RoomSettings } from '@troublepainter/core'; 3 | import { RoomSettingItem } from '@/components/setting/Setting'; 4 | import { SettingItem } from '@/components/setting/SettingItem'; 5 | 6 | interface SettingContentProps { 7 | settings: RoomSettingItem[]; 8 | values: Partial; 9 | isHost: boolean; 10 | onSettingChange: (key: keyof RoomSettings, value: string) => void; 11 | } 12 | 13 | export const SettingContent = memo(({ settings, values, isHost, onSettingChange }: SettingContentProps) => ( 14 |
15 |
16 | {settings.map(({ label, key, options, shortcutKey }) => ( 17 | 27 | ))} 28 |
29 |
30 | )); 31 | -------------------------------------------------------------------------------- /client/src/components/setting/SettingItem.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useCallback } from 'react'; 2 | import { RoomSettings } from '@troublepainter/core'; 3 | import Dropdown from '@/components/ui/Dropdown'; 4 | import { SHORTCUT_KEYS } from '@/constants/shortcutKeys'; 5 | 6 | interface SettingItemProps { 7 | label: string; 8 | settingKey: keyof RoomSettings; 9 | value?: number; 10 | options: number[]; 11 | onSettingChange: (key: keyof RoomSettings, value: string) => void; 12 | isHost: boolean; 13 | shortcutKey: keyof typeof SHORTCUT_KEYS; 14 | } 15 | 16 | export const SettingItem = memo( 17 | ({ label, settingKey, value, options, onSettingChange, isHost, shortcutKey }: SettingItemProps) => { 18 | const handleChange = useCallback( 19 | (value: string) => { 20 | onSettingChange(settingKey, value); 21 | }, 22 | [settingKey, onSettingChange], 23 | ); 24 | 25 | return ( 26 |
27 | {label} 28 | {!isHost ? ( 29 | {value || ''} 30 | ) : ( 31 | 38 | )} 39 |
40 | ); 41 | }, 42 | ); 43 | -------------------------------------------------------------------------------- /client/src/components/ui/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from './Button'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import helpIcon from '@/assets/help-icon.svg'; 4 | 5 | type Story = StoryObj; 6 | 7 | export default { 8 | component: Button, 9 | title: 'components/ui/Button', 10 | argTypes: { 11 | variant: { 12 | control: 'select', 13 | options: ['primary', 'secondary', 'transperent'], 14 | description: '버튼 스타일', 15 | table: { 16 | defaultValue: { summary: 'primary' }, 17 | }, 18 | }, 19 | size: { 20 | control: 'select', 21 | options: ['text', 'icon'], 22 | description: '버튼 크기', 23 | table: { 24 | defaultValue: { summary: 'text' }, 25 | }, 26 | }, 27 | children: { 28 | control: 'text', 29 | description: '버튼 내용', 30 | }, 31 | className: { 32 | control: 'text', 33 | description: '추가 스타일링', 34 | }, 35 | }, 36 | parameters: { 37 | docs: { 38 | description: { 39 | component: '다양한 상황에서 사용할 수 있는 기본 버튼 컴포넌트입니다.', 40 | }, 41 | }, 42 | }, 43 | tags: ['autodocs'], 44 | } satisfies Meta; 45 | 46 | export const Primary: Story = { 47 | args: { 48 | variant: 'primary', 49 | size: 'text', 50 | children: 'Primary Button', 51 | }, 52 | }; 53 | 54 | export const Secondary: Story = { 55 | args: { 56 | variant: 'secondary', 57 | size: 'text', 58 | children: 'Secondary Button', 59 | }, 60 | }; 61 | 62 | export const Transparent: Story = { 63 | args: { 64 | variant: 'transperent', 65 | size: 'icon', 66 | children: Help Icon, 67 | }, 68 | }; 69 | -------------------------------------------------------------------------------- /client/src/components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | import { cn } from '@/utils/cn'; 4 | 5 | const buttonVariants = cva( 6 | 'inline-flex items-center justify-center gap-2 whitespace-nowrap transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 7 | { 8 | variants: { 9 | variant: { 10 | primary: 'border-2 border-violet-950 bg-violet-500 hover:bg-violet-600', 11 | secondary: 'border-2 border-violet-950 bg-eastbay-900 hover:bg-eastbay-950', 12 | transperent: 'hover:brightness-95', 13 | }, 14 | size: { 15 | text: 'h-14 w-full rounded-2xl text-2xl font-medium text-stroke-md', 16 | icon: 'h-10 w-10', 17 | }, 18 | }, 19 | defaultVariants: { 20 | variant: 'primary', 21 | size: 'text', 22 | }, 23 | }, 24 | ); 25 | 26 | export interface ButtonProps 27 | extends React.ButtonHTMLAttributes, 28 | VariantProps { 29 | asChild?: boolean; 30 | } 31 | 32 | const Button = React.forwardRef(({ className, variant, size, ...props }, ref) => { 33 | return 34 | 35 |
42 |
43 | {options.map((option, index) => ( 44 | 55 | ))} 56 |
57 |
58 | 59 | ); 60 | }; 61 | 62 | export default Dropdown; 63 | -------------------------------------------------------------------------------- /client/src/components/ui/HelpContainer.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEvent } from 'react'; 2 | import helpIcon from '@/assets/help-icon.svg'; 3 | import HelpRollingModal from '@/components/modal/HelpRollingModal'; 4 | import { Button } from '@/components/ui/Button'; 5 | import { useModal } from '@/hooks/useModal'; 6 | 7 | const HelpContainer = ({}) => { 8 | const { isModalOpened, closeModal, openModal, handleKeyDown } = useModal(); 9 | 10 | const handleOpenHelpModal = (e: MouseEvent) => { 11 | e.preventDefault(); 12 | 13 | openModal(); 14 | }; 15 | return ( 16 | 28 | ); 29 | }; 30 | 31 | export default HelpContainer; 32 | -------------------------------------------------------------------------------- /client/src/components/ui/Input.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from './Input'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | 4 | type Story = StoryObj; 5 | 6 | export default { 7 | title: 'components/ui/Input', 8 | component: Input, 9 | argTypes: { 10 | label: { 11 | control: 'text', 12 | description: '입력 필드의 레이블', 13 | }, 14 | placeholder: { 15 | control: 'text', 16 | description: '입력 필드의 플레이스홀더', 17 | }, 18 | className: { 19 | control: 'text', 20 | description: '추가 스타일링', 21 | }, 22 | }, 23 | parameters: { 24 | docs: { 25 | description: { 26 | component: '사용자 입력을 받을 수 있는 기본 입력 필드 컴포넌트입니다.', 27 | }, 28 | }, 29 | }, 30 | tags: ['autodocs'], 31 | } satisfies Meta; 32 | 33 | export const Default: Story = { 34 | args: { 35 | label: 'Default Label', 36 | placeholder: 'Default placeholder', 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /client/src/components/ui/Input.tsx: -------------------------------------------------------------------------------- 1 | import { InputHTMLAttributes, forwardRef, useId } from 'react'; 2 | import { cn } from '@/utils/cn'; 3 | 4 | export interface InputProps extends InputHTMLAttributes { 5 | placeholder: string; 6 | label?: string; 7 | } 8 | 9 | const Input = forwardRef(({ className, label, ...props }, ref) => { 10 | const inputId = useId(); 11 | 12 | return ( 13 | <> 14 | 17 | 27 | 28 | ); 29 | }); 30 | 31 | Input.displayName = 'Input'; 32 | 33 | export { Input }; 34 | -------------------------------------------------------------------------------- /client/src/components/ui/Logo.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from './Logo'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | 4 | type Story = StoryObj; 5 | 6 | export default { 7 | component: Logo, 8 | title: 'components/game/Logo', 9 | argTypes: { 10 | variant: { 11 | control: 'select', 12 | options: ['main', 'side'], 13 | description: '로고 배치', 14 | table: { 15 | defaultValue: { summary: 'main' }, 16 | }, 17 | }, 18 | ariaLabel: { 19 | control: 'text', 20 | description: '로고 이미지 설명', 21 | }, 22 | className: { 23 | control: 'text', 24 | description: '추가 스타일링', 25 | }, 26 | }, 27 | parameters: { 28 | docs: { 29 | description: { 30 | component: 31 | '프로젝트의 메인 로고와 보조 로고를 표시하는 컴포넌트입니다. 반응형 디자인을 지원하며 접근성을 고려한 설명을 포함합니다.', 32 | }, 33 | }, 34 | }, 35 | tags: ['autodocs'], 36 | } satisfies Meta; 37 | 38 | export const Main: Story = { 39 | args: { 40 | variant: 'main', 41 | ariaLabel: '로고 설명', 42 | }, 43 | }; 44 | 45 | export const Side: Story = { 46 | args: { 47 | variant: 'side', 48 | ariaLabel: '로고 설명', 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /client/src/components/ui/Logo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | import { CDN } from '@/constants/cdn'; 4 | import { cn } from '@/utils/cn'; 5 | 6 | const logoVariants = cva('w-auto', { 7 | variants: { 8 | variant: { 9 | main: 'h-40 sm:h-64', 10 | side: 'h-20 xs:h-24', 11 | }, 12 | }, 13 | defaultVariants: { 14 | variant: 'main', 15 | }, 16 | }); 17 | 18 | export type LogoVariant = 'main' | 'side'; 19 | 20 | interface LogoInfo { 21 | src: string; 22 | alt: string; 23 | description: string; 24 | } 25 | 26 | const LOGO_INFO: Record = { 27 | main: { 28 | src: CDN.MAIN_LOGO, 29 | alt: '메인 로고', 30 | description: '우리 프로젝트를 대표하는 메인 로고 이미지입니다', 31 | }, 32 | side: { 33 | src: CDN.SIDE_LOGO, 34 | alt: '보조 로고', 35 | description: '우리 프로젝트를 대표하는 보조 로고 이미지입니다', 36 | }, 37 | } as const; 38 | 39 | export interface LogoProps 40 | extends Omit, 'src' | 'alt' | 'aria-label'>, 41 | VariantProps { 42 | /** 43 | * 로고 이미지 설명을 위한 사용자 정의 aria-label 44 | */ 45 | ariaLabel?: string; 46 | } 47 | 48 | const Logo = React.forwardRef( 49 | ({ className, variant = 'main', ariaLabel, ...props }, ref) => { 50 | return ( 51 | {LOGO_INFO[variant 59 | ); 60 | }, 61 | ); 62 | Logo.displayName = 'Logo'; 63 | 64 | export { Logo, logoVariants }; 65 | -------------------------------------------------------------------------------- /client/src/components/ui/Modal.stories.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { Modal, ModalProps } from './Modal'; 3 | import type { Meta, StoryObj } from '@storybook/react'; 4 | import { useModal } from '@/hooks/useModal'; 5 | 6 | type Story = StoryObj; 7 | 8 | export default { 9 | title: 'components/ui/Modal', 10 | component: Modal, 11 | argTypes: { 12 | title: { 13 | control: 'text', 14 | description: '모달 제목', 15 | defaultValue: '예시 모달', 16 | }, 17 | isModalOpened: { 18 | control: 'boolean', 19 | description: '모달 열림/닫힘 상태', 20 | }, 21 | closeModal: { 22 | description: '모달 닫는 함수', 23 | action: 'closed', 24 | }, 25 | handleKeyDown: { 26 | description: '키보드 이벤트 처리 함수', 27 | action: 'closed', 28 | }, 29 | children: { 30 | control: 'text', 31 | description: '모달 내부 컨텐츠', 32 | defaultValue: '모달 내용입니다. 배경을 클릭하거나 focusing된 상태에서 ESC 키로 닫을 수 있습니다.', 33 | }, 34 | className: { 35 | control: 'text', 36 | description: '추가 스타일링', 37 | }, 38 | }, 39 | parameters: { 40 | docs: { 41 | description: { 42 | component: 43 | '사용자에게 정보를 표시하거나 작업을 수행하기 위한 모달 컴포넌트입니다.

모달 열기 버튼을 누르면 모달이 뜹니다.', 44 | }, 45 | }, 46 | }, 47 | tags: ['autodocs'], 48 | } satisfies Meta; 49 | 50 | const DefaultModalExample = (args: ModalProps) => { 51 | const { isModalOpened, openModal, closeModal, handleKeyDown } = useModal(); 52 | 53 | useEffect(() => { 54 | if (args.isModalOpened && !isModalOpened) openModal(); 55 | else if (!args.isModalOpened && isModalOpened) closeModal(); 56 | }, [args.isModalOpened]); 57 | 58 | return ( 59 |
60 | 61 | 62 |
63 | ); 64 | }; 65 | 66 | const AutoCloseModalExample = (args: ModalProps) => { 67 | const { isModalOpened, openModal } = useModal(3000); 68 | 69 | return ( 70 |
71 | 72 | 73 |

이 모달은 3초 후에 자동으로 닫힙니다.

74 |
75 |
76 | ); 77 | }; 78 | 79 | export const Default: Story = { 80 | parameters: { 81 | docs: { 82 | description: { 83 | story: 84 | '기본적인 모달 사용 예시입니다. 모달은 overlay를 클릭하거나, overlay나 모달이 focusing 됐을 때 ESC 키로 닫을 수 있습니다.', 85 | }, 86 | }, 87 | }, 88 | args: { 89 | title: 'default Modal', 90 | }, 91 | render: (args) => , 92 | }; 93 | 94 | export const AutoClose: Story = { 95 | parameters: { 96 | docs: { 97 | description: { 98 | story: '3초 후 자동으로 닫히는 모달 예시입니다.', 99 | }, 100 | }, 101 | }, 102 | args: { 103 | title: 'AutoClose Modal', 104 | }, 105 | render: (args) => , 106 | }; 107 | -------------------------------------------------------------------------------- /client/src/components/ui/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes, KeyboardEvent, PropsWithChildren, useEffect, useRef } from 'react'; 2 | import ReactDOM from 'react-dom'; // Import ReactDOM explicitly 3 | import { cn } from '@/utils/cn'; 4 | 5 | export interface ModalProps extends PropsWithChildren> { 6 | title?: string; 7 | closeModal?: () => void; 8 | isModalOpened: boolean; 9 | handleKeyDown?: (e: KeyboardEvent) => void; 10 | } 11 | 12 | const Modal = ({ className, handleKeyDown, closeModal, isModalOpened, title, children, ...props }: ModalProps) => { 13 | const modalRoot = document.getElementById('modal-root'); 14 | const modalRef = useRef(null); 15 | 16 | if (!modalRoot) return null; 17 | 18 | useEffect(() => { 19 | if (isModalOpened && modalRef.current) { 20 | modalRef.current.focus(); 21 | } 22 | }, [isModalOpened]); 23 | 24 | return ReactDOM.createPortal( 25 |
35 |
42 | 43 |
e.stopPropagation()} 50 | onKeyDown={handleKeyDown} 51 | tabIndex={0} 52 | {...props} 53 | > 54 | {title && ( 55 |
56 |

{title}

57 |
58 | )} 59 | 60 |
{children}
61 |
62 |
, 63 | modalRoot, 64 | ); 65 | }; 66 | 67 | Modal.displayName = 'Modal'; 68 | 69 | export { Modal }; 70 | -------------------------------------------------------------------------------- /client/src/components/ui/QuizTitle.stories.tsx: -------------------------------------------------------------------------------- 1 | import { QuizTitle } from './QuizTitle'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | 4 | type Story = StoryObj; 5 | 6 | export default { 7 | component: QuizTitle, 8 | title: 'components/game/QuizTitle', 9 | argTypes: { 10 | currentRound: { 11 | control: 'number', 12 | description: '현재 라운드', 13 | table: { 14 | type: { summary: 'number' }, 15 | }, 16 | }, 17 | totalRound: { 18 | control: 'number', 19 | description: '전체 라운드', 20 | table: { 21 | type: { summary: 'number' }, 22 | }, 23 | }, 24 | title: { 25 | control: 'text', 26 | description: '제시어', 27 | table: { 28 | type: { summary: 'string' }, 29 | }, 30 | }, 31 | remainingTime: { 32 | control: 'number', 33 | description: '남은 시간 (초)', 34 | table: { 35 | type: { summary: 'number' }, 36 | }, 37 | }, 38 | className: { 39 | control: 'text', 40 | description: '추가 스타일링', 41 | }, 42 | }, 43 | parameters: { 44 | docs: { 45 | description: { 46 | component: ` 47 | 게임의 현재 상태를 보여주는 컴포넌트입니다. 48 | 49 | ### 기능 50 | - 현재 라운드 / 전체 라운드 표시 51 | - 퀴즈 제시어 표시 52 | - 남은 드로잉 시간 표시 (10초 이하일 때 깜빡이는 효과) 53 | `, 54 | }, 55 | }, 56 | }, 57 | decorators: [ 58 | (Story) => ( 59 |
60 | 61 |
62 | ), 63 | ], 64 | tags: ['autodocs'], 65 | } satisfies Meta; 66 | 67 | export const Default: Story = { 68 | args: { 69 | currentRound: 1, 70 | totalRound: 10, 71 | title: '사과', 72 | remainingTime: 30, 73 | }, 74 | }; 75 | 76 | export const UrgentTimer: Story = { 77 | args: { 78 | currentRound: 5, 79 | totalRound: 10, 80 | title: '바나나', 81 | remainingTime: 8, 82 | }, 83 | parameters: { 84 | docs: { 85 | description: { 86 | story: '남은 시간이 10초 이하일 때는 타이머가 깜빡이는 효과가 적용됩니다.', 87 | }, 88 | }, 89 | }, 90 | }; 91 | -------------------------------------------------------------------------------- /client/src/components/ui/QuizTitle.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes } from 'react'; 2 | import flashingTimer from '@/assets/small-timer.gif'; 3 | import Timer from '@/assets/small-timer.png'; 4 | import { cn } from '@/utils/cn'; 5 | 6 | export interface QuizTitleProps extends HTMLAttributes { 7 | currentRound: number; 8 | totalRound: number; 9 | title: string; 10 | remainingTime: number; 11 | isHidden: boolean; 12 | } 13 | 14 | const QuizTitle = ({ 15 | className, 16 | currentRound, 17 | totalRound, 18 | remainingTime, 19 | title, 20 | isHidden, 21 | ...props 22 | }: QuizTitleProps) => { 23 | return ( 24 | <> 25 |
33 | {/* 라운드 정보 */} 34 |

35 | {currentRound} 36 | of 37 | {totalRound} 38 |

39 | 40 | {/* 제시어 */} 41 |

{title}

42 | 43 | {/* 타이머 */} 44 |
45 |
46 | {remainingTime > 10 ? ( 47 | 타이머 48 | ) : ( 49 | 타이머 50 | )} 51 | 52 | 53 | {remainingTime} 54 | 55 |
56 |
57 |
58 | 59 | ); 60 | }; 61 | 62 | export { QuizTitle }; 63 | -------------------------------------------------------------------------------- /client/src/components/ui/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes, KeyboardEvent, forwardRef } from 'react'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | import { cn } from '@/utils/cn'; 4 | 5 | const toastVariants = cva('flex items-center justify-between rounded-lg border-2 border-violet-950 p-4 shadow-lg', { 6 | variants: { 7 | variant: { 8 | default: 'bg-violet-200 text-violet-950', 9 | error: 'bg-red-100 text-red-900 border-red-900', 10 | success: 'bg-green-100 text-green-900 border-green-900', 11 | warning: 'bg-yellow-100 text-yellow-900 border-yellow-900', 12 | }, 13 | }, 14 | defaultVariants: { 15 | variant: 'default', 16 | }, 17 | }); 18 | 19 | interface ToastProps extends HTMLAttributes, VariantProps { 20 | title?: string; 21 | description?: string; 22 | onClose?: () => void; 23 | } 24 | 25 | const Toast = forwardRef( 26 | ({ className, variant, title, description, onClose, ...props }, ref) => { 27 | const handleKeyDown = (e: KeyboardEvent) => { 28 | if (e.key === 'Escape' && onClose) { 29 | onClose(); 30 | } 31 | }; 32 | return ( 33 |
42 | {/* Content */} 43 |
44 | {title && ( 45 |
46 | {title} 47 |
48 | )} 49 | {description && ( 50 |
51 | {description} 52 |
53 | )} 54 |
55 | 56 | {/* Close Button */} 57 | {onClose && ( 58 | 67 | )} 68 |
69 | ); 70 | }, 71 | ); 72 | 73 | Toast.displayName = 'Toast'; 74 | 75 | export { Toast, type ToastProps, toastVariants }; 76 | -------------------------------------------------------------------------------- /client/src/components/ui/player-card/PlayerCard.tsx: -------------------------------------------------------------------------------- 1 | import { PlayerRole } from '@troublepainter/core'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | import { PlayerCardInfo } from '@/components/ui/player-card/PlayerInfo'; 4 | import { PlayerCardProfile } from '@/components/ui/player-card/PlayerProfile'; 5 | import { PlayerCardStatus } from '@/components/ui/player-card/PlayerStatus'; 6 | import { cn } from '@/utils/cn'; 7 | 8 | const playerCardVariants = cva( 9 | 'flex h-20 w-20 items-center gap-2 duration-200 lg:aspect-[3/1] lg:w-full lg:items-center lg:justify-between lg:rounded-lg lg:border-2 lg:p-1 lg:transition-colors xl:p-3', 10 | { 11 | variants: { 12 | status: { 13 | // 게임 참여 전 상태 14 | NOT_PLAYING: 'bg-transparent lg:bg-eastbay-400', 15 | // 게임 진행 중 상태 16 | PLAYING: 'bg-transparent lg:bg-eastbay-400', 17 | }, 18 | isMe: { 19 | true: 'bg-transparent lg:bg-violet-500 lg:border-violet-800', 20 | false: 'lg:border-halfbaked-800', 21 | }, 22 | }, 23 | defaultVariants: { 24 | status: 'NOT_PLAYING', 25 | isMe: false, 26 | }, 27 | }, 28 | ); 29 | 30 | interface PlayerCardProps extends VariantProps { 31 | /// 공통 필수 32 | // 사용자 이름 33 | nickname: string; 34 | 35 | /// 게임방 필수 36 | // 사용자가 1등일 경우 37 | isWinner?: boolean; 38 | // 사용자 점수 (게임 중일 때만 표시) 39 | score?: number; 40 | // 사용자 역할 (그림꾼, 방해꾼 등) 41 | role?: PlayerRole | null; 42 | // 방장 확인 props 43 | isHost: boolean | null; 44 | 45 | /// 공통 선택 46 | // 추가 스타일링을 위한 className 47 | className?: string; 48 | // 프로필 이미지 URL (없을 경우 기본 이미지 사용) 49 | profileImage?: string; 50 | } 51 | 52 | /** 53 | * 사용자 정보를 표시하는 카드 컴포넌트입니다. 54 | * 55 | * @component 56 | * @example 57 | * // 대기 상태의 사용자 58 | * 62 | * 63 | * // 게임 중인 1등 사용자 64 | * 71 | */ 72 | const PlayerCard = ({ 73 | nickname, 74 | isWinner, 75 | score, 76 | role = null, 77 | status = 'NOT_PLAYING', 78 | isHost = false, 79 | isMe = false, 80 | profileImage, 81 | className, 82 | }: PlayerCardProps) => { 83 | return ( 84 |
85 |
86 | 95 | 96 |
97 | 98 |
99 | ); 100 | }; 101 | 102 | export { PlayerCard, type PlayerCardProps, playerCardVariants }; 103 | -------------------------------------------------------------------------------- /client/src/components/ui/player-card/PlayerInfo.tsx: -------------------------------------------------------------------------------- 1 | import { PlayerRole } from '@troublepainter/core'; 2 | import { PLAYING_ROLE_TEXT } from '@/constants/gameConstant'; 3 | import { cn } from '@/utils/cn'; 4 | 5 | interface PlayerCardInfoProps { 6 | nickname: string; 7 | role?: PlayerRole | null; 8 | className?: string; 9 | } 10 | 11 | export const PlayerCardInfo = ({ nickname, role, className }: PlayerCardInfoProps) => { 12 | return ( 13 | /* 사용자 정보 섹션 */ 14 |
15 |
16 |
25 | {`${nickname}`} 26 |
27 |
28 |
29 |
38 | {role ? PLAYING_ROLE_TEXT[role] : '???'} 39 |
40 |
41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /client/src/components/ui/player-card/PlayerProfile.tsx: -------------------------------------------------------------------------------- 1 | import crownFirst from '@/assets/crown-first.png'; 2 | import profilePlaceholder from '@/assets/profile-placeholder.png'; 3 | import { cn } from '@/utils/cn'; 4 | 5 | interface PlayerCardProfileProps { 6 | nickname: string; 7 | profileImage?: string; 8 | isWinner?: boolean; 9 | score?: number; 10 | isHost: boolean; 11 | isMe: boolean | null; 12 | showScore?: boolean; 13 | className?: string; 14 | } 15 | 16 | export const PlayerCardProfile = ({ 17 | nickname, 18 | profileImage, 19 | isWinner, 20 | score, 21 | isHost, 22 | isMe, 23 | showScore = false, 24 | className, 25 | }: PlayerCardProfileProps) => { 26 | // 순위에 따른 Crown Image 렌더링 로직 27 | const showCrown = isWinner !== undefined; 28 | 29 | return ( 30 |
31 |
39 | {`${nickname}의 44 | 45 | {/* 모바일 상태 오버레이 */} 46 | {showScore ? ( 47 |
53 | {score} 54 |
55 | ) : ( 56 | <> 57 | {((isHost && !isMe) || isMe) && ( 58 |
64 | {isMe ? '나!' : '방장'} 65 |
66 | )} 67 | 68 | )} 69 | 70 | {/* 왕관 이미지 */} 71 | {showCrown && ( 72 | {`1등 77 | )} 78 |
79 |
80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /client/src/components/ui/player-card/PlayerStatus.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/utils/cn'; 2 | 3 | interface PlayerCardStatusProps { 4 | score?: number; 5 | isHost: boolean | null; 6 | isPlaying: boolean; 7 | isMe: boolean | null; 8 | className?: string; 9 | } 10 | 11 | export const PlayerCardStatus = ({ score, isHost, isPlaying, isMe, className }: PlayerCardStatusProps) => { 12 | return ( 13 | /* 데스크탑 점수/상태 표시 섹션 */ 14 |
15 | {score !== undefined && isPlaying && ( 16 |
17 |
{score}
18 |
19 | )} 20 | 21 | {!isPlaying && isHost && ( 22 |
28 | 방장 29 |
30 | )} 31 |
32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /client/src/constants/backgroundConstants.ts: -------------------------------------------------------------------------------- 1 | export const SIZE = 55; 2 | export const GAP = 40; 3 | export const OFFSET = SIZE; 4 | export const PARTICLE_SIZE = SIZE / 3; 5 | 6 | export const RANDOM_POINT_RANGE_WIDTH = 20; 7 | export const RANDOM_POINT_RANGE_HEIGHT = 30; 8 | 9 | export const CURSOR_WIDTH = 20; 10 | export const CURSOR_LENGTH = 7; 11 | export const DELETE_INTERVAL = 30; 12 | -------------------------------------------------------------------------------- /client/src/constants/canvasConstants.ts: -------------------------------------------------------------------------------- 1 | export const DRAWING_MODE = { 2 | PEN: 0, 3 | FILL: 1, 4 | }; 5 | 6 | export const LINEWIDTH_VARIABLE = { 7 | MIN_WIDTH: 4, 8 | MAX_WIDTH: 20, 9 | STEP_WIDTH: 2, 10 | }; 11 | 12 | export const MAINCANVAS_RESOLUTION_WIDTH = 1000; 13 | export const MAINCANVAS_RESOLUTION_HEIGHT = 625; 14 | //해상도 비율 변경 시 CanvasUI의 aspect-[16/10] 도 수정해야 정상적으로 렌더링됩니다. 15 | 16 | export const COLORS_INFO = [ 17 | { color: '검정', backgroundColor: '#000000' }, 18 | { color: '분홍', backgroundColor: '#FF69B4' }, 19 | { color: '노랑', backgroundColor: '#FFFF00' }, 20 | { color: '하늘', backgroundColor: '#87CEEB' }, 21 | { color: '회색', backgroundColor: '#808080' }, 22 | ]; 23 | 24 | export const DEFAULT_MAX_PIXELS = 50000; // 기본값 설정 25 | -------------------------------------------------------------------------------- /client/src/constants/cdn.ts: -------------------------------------------------------------------------------- 1 | const CDN_BASE = 'https://kr.object.ncloudstorage.com/troublepainter-assets'; 2 | 3 | export const CDN = { 4 | BACKGROUND_MUSIC: `${CDN_BASE}/sounds/background-music.mp3`, 5 | MAIN_LOGO: `${CDN_BASE}/logo/main-logo.png`, 6 | SIDE_LOGO: `${CDN_BASE}/logo/side-logo.png`, 7 | // tailwind config 설정 8 | // BACKGROUND: `${CDN_BASE}/patterns/background.png`, 9 | } as const; 10 | -------------------------------------------------------------------------------- /client/src/constants/gameConstant.ts: -------------------------------------------------------------------------------- 1 | import { PlayerRole } from '@troublepainter/core'; 2 | import { PlayingRoleText } from '@/types/game.types'; 3 | 4 | export const PLAYING_ROLE_TEXT: Record = { 5 | [PlayerRole.DEVIL]: '방해꾼', 6 | [PlayerRole.GUESSER]: '구경꾼', 7 | [PlayerRole.PAINTER]: '그림꾼', 8 | }; 9 | -------------------------------------------------------------------------------- /client/src/constants/shortcutKeys.ts: -------------------------------------------------------------------------------- 1 | export const SHORTCUT_KEYS = { 2 | // 설정 관련 3 | DROPDOWN_TOTAL_ROUNDS: { 4 | key: '1', 5 | alternativeKeys: ['1', '!'], 6 | description: '라운드 수 설정', 7 | }, 8 | DROPDOWN_MAX_PLAYERS: { 9 | key: '2', 10 | alternativeKeys: ['2', '@'], 11 | description: '플레이어 수 설정', 12 | }, 13 | DROPDOWN_DRAW_TIME: { 14 | key: '3', 15 | alternativeKeys: ['3', '#'], 16 | description: '제한시간 설정', 17 | }, 18 | // 게임 관련 19 | CHAT: { 20 | key: 'Enter', 21 | alternativeKeys: null, 22 | description: '채팅', 23 | }, 24 | GAME_START: { 25 | key: 's', 26 | alternativeKeys: ['s', 'S', 'ㄴ'], 27 | description: '게임 시작', 28 | }, 29 | GAME_INVITE: { 30 | key: 'i', 31 | alternativeKeys: ['i', 'I', 'ㅑ'], 32 | description: '초대하기', 33 | }, 34 | } as const; 35 | -------------------------------------------------------------------------------- /client/src/constants/socket-error-messages.ts: -------------------------------------------------------------------------------- 1 | import { SocketErrorCode } from '@troublepainter/core'; 2 | import { SocketNamespace } from '@/stores/socket/socket.config'; 3 | 4 | export const ERROR_MESSAGES: Record = { 5 | // 클라이언트 에러 (4xxx) 6 | [SocketErrorCode.BAD_REQUEST]: '잘못된 요청입니다. 다시 시도해 주세요.', 7 | [SocketErrorCode.UNAUTHORIZED]: '인증이 필요합니다.', 8 | [SocketErrorCode.FORBIDDEN]: '접근 권한이 없습니다.', 9 | [SocketErrorCode.NOT_FOUND]: '요청한 리소스를 찾을 수 없습니다.', 10 | [SocketErrorCode.VALIDATION_ERROR]: '입력 데이터가 유효하지 않습니다.', 11 | [SocketErrorCode.RATE_LIMIT]: '너무 많은 요청을 보냈습니다. 잠시 후 다시 시도해주세요.', 12 | 13 | // 서버 에러 (5xxx) 14 | [SocketErrorCode.INTERNAL_ERROR]: '서버 내부 오류가 발생했습니다.', 15 | [SocketErrorCode.NOT_IMPLEMENTED]: '아직 구현되지 않은 기능입니다.', 16 | [SocketErrorCode.SERVICE_UNAVAILABLE]: '서비스를 일시적으로 사용할 수 없습니다.', 17 | 18 | // 게임 로직 에러 (6xxx) 19 | [SocketErrorCode.GAME_NOT_STARTED]: '게임이 아직 시작되지 않았습니다.', 20 | [SocketErrorCode.GAME_ALREADY_STARTED]: '이미 게임이 진행 중입니다.', 21 | [SocketErrorCode.INVALID_TURN]: '유효하지 않은 턴입니다.', 22 | [SocketErrorCode.ROOM_FULL]: '방이 가득 찼습니다.', 23 | [SocketErrorCode.ROOM_NOT_FOUND]: '해당 방을 찾을 수 없습니다.', 24 | [SocketErrorCode.PLAYER_NOT_FOUND]: '플레이어를 찾을 수 없습니다.', 25 | [SocketErrorCode.INSUFFICIENT_PLAYERS]: '게임 시작을 위한 플레이어 수가 부족합니다.', 26 | 27 | // 연결 관련 에러 (7xxx) 28 | [SocketErrorCode.CONNECTION_ERROR]: '연결 오류가 발생했습니다.', 29 | [SocketErrorCode.CONNECTION_TIMEOUT]: '연결 시간이 초과되었습니다.', 30 | [SocketErrorCode.CONNECTION_CLOSED]: '연결이 종료되었습니다.', 31 | } as const; 32 | 33 | export const getErrorTitle = (namespace: SocketNamespace): string => { 34 | switch (namespace) { 35 | case SocketNamespace.GAME: 36 | return '게임 오류'; 37 | case SocketNamespace.DRAWING: 38 | return '드로잉 오류'; 39 | case SocketNamespace.CHAT: 40 | return '채팅 오류'; 41 | default: 42 | return '연결 오류'; 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /client/src/handlers/canvas/cursorInOutHandler.ts: -------------------------------------------------------------------------------- 1 | import { RefObject } from 'react'; 2 | import { Point } from '@troublepainter/core'; 3 | import { getCanvasContext } from '@/utils/getCanvasContext'; 4 | 5 | const handleInCanvas = (canvasRef: RefObject, point: Point, brushSize: number) => { 6 | const { canvas, ctx } = getCanvasContext(canvasRef); 7 | ctx.clearRect(0, 0, canvas.width, canvas.height); 8 | 9 | ctx.beginPath(); 10 | ctx.lineWidth = 2; 11 | ctx.arc(point.x, point.y, brushSize / 2, 0, 2 * Math.PI); 12 | ctx.stroke(); 13 | }; 14 | 15 | const handleOutCanvas = (canvasRef: RefObject) => { 16 | const { canvas, ctx } = getCanvasContext(canvasRef); 17 | ctx.clearRect(0, 0, canvas.width, canvas.height); 18 | }; 19 | 20 | export { handleInCanvas, handleOutCanvas }; 21 | -------------------------------------------------------------------------------- /client/src/handlers/socket/chatSocket.handler.ts: -------------------------------------------------------------------------------- 1 | import { useSocketStore } from '@/stores/socket/socket.store'; 2 | 3 | export const chatSocketHandlers = { 4 | sendMessage: (message: string): Promise => { 5 | const socket = useSocketStore.getState().sockets.chat; 6 | if (!socket) throw new Error('Chat Socket not connected'); 7 | 8 | return new Promise((resolve) => { 9 | socket.emit('sendMessage', { message: message.trim() }); 10 | resolve(); 11 | }); 12 | }, 13 | }; 14 | 15 | export type ChatSocketHandlers = typeof chatSocketHandlers; 16 | -------------------------------------------------------------------------------- /client/src/handlers/socket/drawingSocket.handler.ts: -------------------------------------------------------------------------------- 1 | import type { CRDTMessage } from '@troublepainter/core'; 2 | import { useSocketStore } from '@/stores/socket/socket.store'; 3 | 4 | export const drawingSocketHandlers = { 5 | // 드로잉 데이터 전송 6 | sendDrawing: (drawingData: CRDTMessage): Promise => { 7 | const socket = useSocketStore.getState().sockets.drawing; 8 | if (!socket) throw new Error('Socket not connected'); 9 | 10 | return new Promise((resolve) => { 11 | socket.emit('draw', { drawingData }); 12 | resolve(); 13 | }); 14 | }, 15 | }; 16 | 17 | export type DrawSocketHandlers = typeof drawingSocketHandlers; 18 | -------------------------------------------------------------------------------- /client/src/hooks/socket/useChatSocket.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { ChatResponse } from '@troublepainter/core'; 3 | import { useParams } from 'react-router-dom'; 4 | import { useChatSocketStore } from '@/stores/socket/chatSocket.store'; 5 | import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; 6 | import { SocketNamespace } from '@/stores/socket/socket.config'; 7 | import { useSocketStore } from '@/stores/socket/socket.store'; 8 | 9 | /** 10 | * 채팅 소켓 연결과 메시지 처리를 관리하는 커스텀 훅입니다. 11 | * 12 | * @remarks 13 | * - 소켓 연결 생명주기 관리 14 | * - 메시지 송수신 처리 15 | * - 메시지 영속성을 위한 채팅 스토어 통합 16 | * 17 | * @returns 18 | * - `messages` - 채팅 메시지 배열 19 | * - `isConnected` - 소켓 연결 상태 20 | * - `currentPlayerId` - 현재 사용자 ID 21 | * - `sendMessage` - 새 메시지 전송 함수 22 | * 23 | * @example 24 | * ```typescript 25 | * useChatSocket(); 26 | * 27 | * // 메시지 전송 28 | * sendMessage("안녕하세요"); 29 | * ``` 30 | */ 31 | export const useChatSocket = () => { 32 | const { roomId } = useParams<{ roomId: string }>(); 33 | const sockets = useSocketStore((state) => state.sockets); 34 | const socketActions = useSocketStore((state) => state.actions); 35 | const currentPlayerId = useGameSocketStore((state) => state.currentPlayerId); 36 | const chatActions = useChatSocketStore((state) => state.actions); 37 | 38 | // Socket 연결 설정 39 | useEffect(() => { 40 | if (!roomId || !currentPlayerId) return; 41 | 42 | socketActions.connect(SocketNamespace.CHAT, { 43 | roomId, 44 | playerId: currentPlayerId, 45 | }); 46 | 47 | return () => { 48 | socketActions.disconnect(SocketNamespace.CHAT); 49 | chatActions.clearMessages(); 50 | }; 51 | }, [roomId, currentPlayerId, socketActions]); 52 | 53 | // 메시지 수신 이벤트 리스너 54 | useEffect(() => { 55 | const socket = sockets.chat; 56 | if (!socket || !currentPlayerId) return; 57 | 58 | const handleMessageReceived = (response: ChatResponse) => { 59 | chatActions.addMessage(response); 60 | }; 61 | 62 | socket.on('messageReceived', handleMessageReceived); 63 | 64 | return () => { 65 | socket.off('messageReceived', handleMessageReceived); 66 | }; 67 | }, [sockets.chat, currentPlayerId, chatActions]); 68 | }; 69 | -------------------------------------------------------------------------------- /client/src/hooks/useCoordinateScale.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useCallback, useEffect, useRef } from 'react'; 2 | import { Point } from '@troublepainter/core'; 3 | 4 | /** 5 | * 캔버스 크기 변경 시 사용하는 hook입니다. 6 | * 드로잉 좌표를 올바른 곳에 맞춰주는 조정값을 계산하여 RefObject 객체로 반환해줍니다. 7 | * 8 | * - 리턴 배열의 첫 번째 인자는 조정값이며, 두 번째 인자는 조정값을 곱한 계산 좌표를 구해주는 콜백입니다. 9 | * 10 | * @param resolutionWidth - 해당소 width 크기를 받습니다. 11 | * @param canvas - 조정값 계산을 적용할 canvas RefObject 객체를 받습니다. 12 | * @returns [RefObject 조정값, 조정값 반영 함수] 13 | * 14 | * @example 15 | * - hook 호출부 16 | * ```typescript 17 | * const [coordinateScaleRef, convertCoordinate] = useCoordinateScale(MAINCANVAS_RESOLUTION_WIDTH, mainCanvasRef); 18 | * ``` 19 | * - 조정값 사용 20 | * ```typescript 21 | * const [drawX, drawY] = convertCoordinate(getDrawPoint(e, canvas)); 22 | * ``` 23 | * @category Hooks 24 | */ 25 | 26 | export const useCoordinateScale = (resolutionWidth: number, canvas: RefObject) => { 27 | const coordinateScale = useRef(1); 28 | const resizeObserver = useRef(null); 29 | 30 | const handleResizeCanvas = useCallback((entires: ResizeObserverEntry[]) => { 31 | const canvas = entires[0].target; 32 | coordinateScale.current = resolutionWidth / canvas.getBoundingClientRect().width; 33 | }, []); 34 | 35 | useEffect(() => { 36 | if (!canvas.current) return; 37 | 38 | coordinateScale.current = resolutionWidth / canvas.current.getBoundingClientRect().width; 39 | resizeObserver.current = new ResizeObserver(handleResizeCanvas); 40 | resizeObserver.current.observe(canvas.current); 41 | 42 | return () => { 43 | if (resizeObserver.current) resizeObserver.current.disconnect(); 44 | }; 45 | }, []); 46 | 47 | const convertCoordinate = ({ x, y }: Point): Point => { 48 | return { x: x * coordinateScale.current, y: y * coordinateScale.current }; 49 | }; 50 | 51 | return { coordinateScale, convertCoordinate }; 52 | }; 53 | -------------------------------------------------------------------------------- /client/src/hooks/useCreateRoom.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { ApiError } from '@/api/api.config'; 3 | import { CreateRoomResponse, gameApi } from '@/api/gameApi'; 4 | import { useToastStore } from '@/stores/toast.store'; 5 | 6 | /** 7 | * 게임 방 생성을 위한 커스텀 훅입니다. 8 | * 9 | * @returns mutation 객체를 반환합니다. onSuccess 핸들러는 컴포넌트에서 처리해야 합니다. 10 | * 11 | * @example 12 | * const Component = () => { 13 | * const { isExiting, transitionTo } = usePageTransition(); 14 | * const createRoom = useCreateRoom(); 15 | * 16 | * const handleCreateRoom = async () => { 17 | * const response = await createRoom.mutateAsync(); 18 | * transitionTo(`/lobby/${response.data.roomId}`); 19 | * }; 20 | * 21 | * return ( 22 | * 28 | * ); 29 | * }; 30 | */ 31 | export const useCreateRoom = () => { 32 | const actions = useToastStore((state) => state.actions); 33 | const [isLoading, setIsLoading] = useState(false); 34 | 35 | // 방 생성 함수 36 | const createRoom = async (): Promise => { 37 | setIsLoading(true); 38 | try { 39 | const response = await gameApi.createRoom(); 40 | 41 | // 성공 토스트 메시지 42 | // actions.addToast({ 43 | // title: '방 생성 성공', 44 | // description: `방이 생성됐습니다! 초대 버튼을 눌러 초대 후 게임을 즐겨보세요!`, 45 | // variant: 'success', 46 | // }); 47 | 48 | return response; 49 | } catch (error) { 50 | if (error instanceof ApiError) { 51 | // 에러 토스트 메시지 52 | actions.addToast({ 53 | title: '방 생성 실패', 54 | description: error.message, 55 | variant: 'error', 56 | }); 57 | console.error(error); 58 | } 59 | } finally { 60 | setIsLoading(false); 61 | } 62 | }; 63 | 64 | return { createRoom, isLoading }; 65 | }; 66 | -------------------------------------------------------------------------------- /client/src/hooks/useModal.ts: -------------------------------------------------------------------------------- 1 | import { KeyboardEvent, useState } from 'react'; 2 | import { timer } from '@/utils/timer'; 3 | 4 | /** 5 | * Modal을 열고 닫는 기능을 제공하는 커스텀 훅입니다. 6 | * 모달이 열릴 때 `autoCloseDelay`가 설정된 경우 자동으로 모달을 닫을 수 있습니다. 7 | * 'Escape' 키를 누르면 모달을 닫는 기능이 포함되어 있습니다. 8 | * 9 | * @param {number} autoCloseDelay - 모달이 자동으로 닫히기까지의 지연 시간(밀리초 단위) 10 | * @returns {Object} 모달의 상태와 조작을 위한 함수들 11 | * @returns {boolean} isModalOpened - 모달이 열려 있는지 여부 12 | * @returns {Function} openModal - 모달을 여는 함수 13 | * @returns {Function} closeModal - 모달을 닫는 함수 14 | * @returns {Function} handleKeyDown - 'Escape' 키 이벤트를 처리하여 모달을 닫는 함수 15 | * 16 | * @example 17 | * const { openModal, closeModal, handleKeyDown, isModalOpened } = useModal(5000); 18 | * 19 | * // 모달 열기 20 | * openModal(); 21 | * 22 | * // 모달 닫기 23 | * closeModal(); 24 | * 25 | * @category Hooks 26 | */ 27 | 28 | export const useModal = (autoCloseDelay?: number) => { 29 | const [isModalOpened, setModalOpened] = useState(false); 30 | 31 | const closeModal = () => { 32 | setModalOpened(false); 33 | }; 34 | 35 | const openModal = () => { 36 | setModalOpened(true); 37 | if (autoCloseDelay) { 38 | return timer({ handleComplete: closeModal, delay: autoCloseDelay }); 39 | } 40 | }; 41 | 42 | const handleKeyDown = (e: KeyboardEvent) => { 43 | if (e.key === 'Escape') closeModal(); 44 | }; 45 | 46 | return { openModal, closeModal, handleKeyDown, isModalOpened }; 47 | }; 48 | -------------------------------------------------------------------------------- /client/src/hooks/usePageTransition.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | 4 | interface UsePageTransitionOptions { 5 | /** 6 | * 트랜지션 애니메이션의 지속 시간 (밀리초) 7 | * 지속 시간이 끝나면 navigate 됨 8 | * @default 1000 9 | */ 10 | duration?: number; 11 | } 12 | 13 | /** 14 | * 페이지 전환 애니메이션을 관리하는 커스텀 훅입니다. 15 | * 16 | * @description 17 | * 페이지 전환 시 애니메이션을 자연스럽게 처리하며, 라우팅과 상태 관리를 캡슐화합니다. 18 | * 필수로 `PixelTransitionContainer` 컴포넌트와 함께 사용해야 합니다. 19 | * 20 | * @param options - 페이지 전환 옵션 21 | * @returns 전환 상태와 페이지 전환 함수를 포함하는 객체 22 | * 23 | * @example 24 | * ```tsx 25 | * const MyPage = () => { 26 | * const { isExiting, transitionTo } = usePageTransition(); 27 | * 28 | * const handleNavigate = () => { 29 | * transitionTo('/next-page'); 30 | * }; 31 | * 32 | * return ( 33 | * 34 | * 35 | * 36 | * ); 37 | * }; 38 | * ``` 39 | */ 40 | export const usePageTransition = ({ duration = 1000 }: UsePageTransitionOptions = {}) => { 41 | const navigate = useNavigate(); 42 | const [isExiting, setIsExiting] = useState(false); 43 | 44 | const transitionTo = useCallback( 45 | (path: string) => { 46 | setIsExiting(true); 47 | setTimeout(() => { 48 | navigate(path); 49 | }, duration); 50 | }, 51 | [navigate, duration], 52 | ); 53 | 54 | return { 55 | /** 현재 페이지가 전환 중인지 여부 */ 56 | isExiting, 57 | /** 58 | * 지정된 경로로 애니메이션과 함께 페이지를 전환합니다 59 | * @param path - 이동할 페이지의 경로 60 | */ 61 | transitionTo, 62 | }; 63 | }; 64 | -------------------------------------------------------------------------------- /client/src/hooks/usePlayerRanking.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; 3 | 4 | /** 5 | * 플레이어 점수를 기준으로 상위 3위까지의 순위를 계산하고 반환하는 커스텀 훅입니다. 6 | * 7 | * 이 훅은 `useGameSocketStore`를 통해 플레이어 데이터를 가져온 뒤, 8 | * 점수가 0보다 큰 플레이어만 고려하여 1위, 2위, 3위 그룹으로 나눕니다. 9 | * 10 | * @returns {Object} 각 순위별 플레이어 그룹을 포함한 객체: 11 | * - `firstPlacePlayers`: 가장 높은 점수를 가진 플레이어 배열. 12 | * - `secondPlacePlayers`: 두 번째로 높은 점수를 가진 플레이어 배열. 13 | * - `thirdPlacePlayers`: 세 번째로 높은 점수를 가진 플레이어 배열. 14 | * 15 | * 각 배열은 해당 순위에 플레이어가 없을 경우 빈 배열로 반환됩니다. 16 | * 17 | * @example 18 | * // React 컴포넌트에서의 사용 예 19 | * const { firstPlacePlayers, secondPlacePlayers, thirdPlacePlayers } = usePlayerRankings(); 20 | * 21 | * console.log('1위 플레이어:', firstPlacePlayers); 22 | * console.log('2위 플레이어:', secondPlacePlayers); 23 | * console.log('3위 플레이어:', thirdPlacePlayers); 24 | * 25 | * @category Hooks 26 | */ 27 | 28 | export const usePlayerRankings = () => { 29 | const players = useGameSocketStore((state) => state.players ?? []); 30 | 31 | const rankedPlayers = useMemo(() => { 32 | const validPlayers = players.filter((player) => player.score > 0); 33 | const sortedScores = [...new Set(validPlayers.map((p) => p.score))].sort((a, b) => b - a); 34 | return sortedScores.slice(0, 3).map((score) => validPlayers.filter((player) => player.score === score)); 35 | }, [players]); 36 | 37 | return { 38 | firstPlacePlayers: rankedPlayers[0] ?? [], 39 | secondPlacePlayers: rankedPlayers[1] ?? [], 40 | thirdPlacePlayers: rankedPlayers[2] ?? [], 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /client/src/hooks/useScrollToBottom.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useCallback, useEffect, useRef, useState } from 'react'; 2 | 3 | interface UseScrollToBottom { 4 | /** 스크롤 컨테이너 요소를 참조하기 위한 ref 객체 */ 5 | containerRef: RefObject; 6 | /** 현재 자동 스크롤 상태 여부 */ 7 | isScrollLocked: boolean; 8 | /** 자동 스크롤 상태를 변경하는 함수 */ 9 | setScrollLocked: (locked: boolean) => void; 10 | } 11 | 12 | /** 13 | * 스크롤 가능한 컨테이너의 자동 스크롤 동작을 관리하는 커스텀 훅입니다. 14 | * 15 | * @remarks 16 | * 하단 자동 스크롤 기능과 수동 스크롤 잠금 기능을 제공합니다. 17 | * 18 | * @param dependencies - 스크롤 업데이트를 트리거할 의존성 배열 19 | * 20 | * @returns 21 | * - `containerRef` - 스크롤 컨테이너에 연결할 ref 22 | * - `isScrollLocked` - 스크롤 자동 잠금 상태 23 | * - `setScrollLocked` - 스크롤 잠금 상태를 설정하는 함수 24 | * 25 | * @example 26 | * ```typescript 27 | * const { containerRef, isScrollLocked } = useScrollToBottom([messages]); 28 | * 29 | * return ( 30 | *
31 | * {messages.map(message => ( 32 | * 33 | * ))} 34 | *
35 | * ); 36 | * ``` 37 | */ 38 | export const useScrollToBottom = (dependencies: unknown[] = []): UseScrollToBottom => { 39 | const containerRef = useRef(null); 40 | const [isScrollLocked, setScrollLocked] = useState(true); 41 | 42 | const scrollToBottom = useCallback(() => { 43 | if (containerRef.current && isScrollLocked) { 44 | containerRef.current.scrollTop = containerRef.current.scrollHeight; 45 | } 46 | }, [isScrollLocked]); 47 | 48 | const handleScroll = useCallback(() => { 49 | if (!containerRef.current) return; 50 | 51 | const { scrollTop, scrollHeight, clientHeight } = containerRef.current; 52 | const isAtBottom = scrollHeight - (scrollTop + clientHeight) < 50; 53 | 54 | setScrollLocked(isAtBottom); 55 | }, []); 56 | 57 | useEffect(() => { 58 | const container = containerRef.current; 59 | if (!container) return; 60 | 61 | container.addEventListener('scroll', handleScroll); 62 | return () => container.removeEventListener('scroll', handleScroll); 63 | }, [handleScroll]); 64 | 65 | useEffect(() => { 66 | scrollToBottom(); 67 | }, [...dependencies, scrollToBottom]); 68 | 69 | return { 70 | containerRef, 71 | isScrollLocked, 72 | setScrollLocked, 73 | }; 74 | }; 75 | -------------------------------------------------------------------------------- /client/src/hooks/useShortcuts.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | import { SHORTCUT_KEYS } from '@/constants/shortcutKeys'; 3 | 4 | interface ShortcutConfig { 5 | key: keyof typeof SHORTCUT_KEYS | null; 6 | action: () => void; 7 | disabled?: boolean; 8 | } 9 | 10 | export const useShortcuts = (configs: ShortcutConfig[]) => { 11 | const handleKeyDown = useCallback( 12 | (e: KeyboardEvent) => { 13 | // input 요소에서는 단축키 비활성화 14 | if ( 15 | e.target instanceof HTMLInputElement || 16 | e.target instanceof HTMLTextAreaElement || 17 | e.target instanceof HTMLSelectElement 18 | ) { 19 | return; 20 | } 21 | 22 | configs.forEach(({ key, action, disabled }) => { 23 | if (!key || disabled) return; 24 | 25 | const shortcut = SHORTCUT_KEYS[key]; 26 | const pressedKey = e.key.toLowerCase(); 27 | const isMainKey = pressedKey === shortcut.key.toLowerCase(); 28 | const isAlternativeKey = shortcut.alternativeKeys?.some((key) => key.toLowerCase() === pressedKey); 29 | 30 | if (isMainKey || isAlternativeKey) { 31 | e.preventDefault(); 32 | action(); 33 | } 34 | }); 35 | }, 36 | [configs], 37 | ); 38 | 39 | useEffect(() => { 40 | window.addEventListener('keydown', handleKeyDown); 41 | return () => window.removeEventListener('keydown', handleKeyDown); 42 | }, [handleKeyDown]); 43 | }; 44 | -------------------------------------------------------------------------------- /client/src/hooks/useStartButton.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useState } from 'react'; 2 | import { gameSocketHandlers } from '@/handlers/socket/gameSocket.handler'; 3 | import { useShortcuts } from '@/hooks/useShortcuts'; 4 | import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; 5 | 6 | export const START_BUTTON_STATUS = { 7 | NOT_HOST: { 8 | title: '방장만 게임을 시작할 수 있습니다', 9 | content: '방장만 시작 가능', 10 | disabled: true, 11 | }, 12 | NOT_ENOUGH_PLAYERS: { 13 | title: '게임을 시작하려면 최소 4명의 플레이어가 필요합니다', 14 | content: '4명 이상 게임 시작 가능', 15 | disabled: true, 16 | }, 17 | CAN_START: { 18 | title: undefined, 19 | content: '게임 시작', 20 | disabled: false, 21 | }, 22 | } as const; 23 | 24 | export const useGameStart = () => { 25 | const [isStarting, setIsStarting] = useState(false); 26 | 27 | const players = useGameSocketStore((state) => state.players); 28 | const isHost = useGameSocketStore((state) => state.isHost); 29 | const room = useGameSocketStore((state) => state.room); 30 | const currentPlayerId = useGameSocketStore((state) => state.currentPlayerId); 31 | 32 | const buttonConfig = useMemo(() => { 33 | if (!isHost) return START_BUTTON_STATUS.NOT_HOST; 34 | if (players.length < 4) return START_BUTTON_STATUS.NOT_ENOUGH_PLAYERS; 35 | return START_BUTTON_STATUS.CAN_START; 36 | }, [isHost, players.length]); 37 | 38 | const handleStartGame = useCallback(() => { 39 | if (!room || buttonConfig.disabled || !room.roomId || !currentPlayerId) return; 40 | void gameSocketHandlers.gameStart(); 41 | setIsStarting(true); 42 | }, [room, buttonConfig.disabled, room?.roomId, currentPlayerId]); 43 | 44 | // 게임 초대 단축키 적용 45 | useShortcuts([ 46 | { 47 | key: 'GAME_START', 48 | action: () => void handleStartGame(), 49 | }, 50 | ]); 51 | 52 | return { 53 | isHost, 54 | buttonConfig, 55 | handleStartGame, 56 | isStarting, 57 | }; 58 | }; 59 | -------------------------------------------------------------------------------- /client/src/hooks/useTimeout.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | /** 4 | * 지정된 시간이 지난 후 콜백 함수를 실행하는 커스텀 훅입니다. 5 | * setTimeout과 유사하게 동작하지만 더 정확한 타이밍을 위해 내부적으로 setInterval을 사용합니다. 6 | * 7 | * @param callback - 지연 시간 후 실행할 함수 8 | * @param delay - 콜백 실행 전 대기할 시간 (밀리초) 9 | * 10 | * @example 11 | * ```tsx 12 | * // 5초 후에 콜백 실행 13 | * useTimeout(() => { 14 | * console.log('5초가 지났습니다'); 15 | * }, 5000); 16 | * ``` 17 | * 18 | * @remarks 19 | * - 경과 시간을 확인하기 위해 내부적으로 setInterval을 사용합니다 20 | * - 컴포넌트 언마운트 시 자동으로 정리(cleanup)됩니다 21 | * - callback이나 delay가 변경되면 타이머가 재설정됩니다 22 | * 23 | * @category Hooks 24 | */ 25 | 26 | export function useTimeout(callback: () => void, delay: number) { 27 | useEffect(() => { 28 | const startTime = Date.now(); 29 | 30 | const timer = setInterval(() => { 31 | const elapsedTime = Date.now() - startTime; 32 | 33 | if (elapsedTime >= delay) { 34 | clearInterval(timer); 35 | callback(); 36 | } 37 | }, 1000); 38 | 39 | return () => { 40 | clearInterval(timer); 41 | }; 42 | }, [callback, delay]); 43 | } 44 | -------------------------------------------------------------------------------- /client/src/hooks/useTimer.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { TimerType } from '@troublepainter/core'; 3 | import { useTimerStore } from '@/stores/timer.store'; 4 | 5 | export const useTimer = () => { 6 | const actions = useTimerStore((state) => state.actions); 7 | const timers = useTimerStore((state) => state.timers); 8 | 9 | const intervalRefs = useRef>({ 10 | [TimerType.DRAWING]: null, 11 | [TimerType.GUESSING]: null, 12 | [TimerType.ENDING]: null, 13 | }); 14 | 15 | useEffect(() => { 16 | const manageTimer = (timerType: TimerType, value: number | null) => { 17 | // 이전 인터벌 정리 18 | if (intervalRefs.current[timerType]) { 19 | clearInterval(intervalRefs.current[timerType]!); 20 | intervalRefs.current[timerType] = null; 21 | } 22 | 23 | // 새로운 타이머 설정 24 | if (value !== null && value > 0) { 25 | intervalRefs.current[timerType] = setInterval(() => { 26 | actions.decreaseTimer(timerType); 27 | }, 1000); 28 | } 29 | }; 30 | 31 | // 각 타이머 타입에 대해 처리 32 | Object.entries(timers).forEach(([type, value]) => { 33 | if (type in TimerType) { 34 | manageTimer(type as TimerType, value); 35 | } 36 | }); 37 | 38 | // 클린업 39 | return () => { 40 | Object.values(intervalRefs.current).forEach((interval) => { 41 | if (interval) clearInterval(interval); 42 | }); 43 | }; 44 | }, [ 45 | timers.DRAWING !== null && timers.DRAWING > 0, 46 | timers.GUESSING !== null && timers.GUESSING > 0, 47 | timers.ENDING !== null && timers.ENDING > 0, 48 | actions, 49 | ]); // timers와 actions만 의존성으로 설정 50 | 51 | return timers; 52 | }; 53 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | /* 스크롤바 hide 기능 */ 7 | /* Hide scrollbar for Chrome, Safari and Opera */ 8 | .scrollbar-hide::-webkit-scrollbar { 9 | display: none; 10 | } 11 | /* Hide scrollbar for IE, Edge and Firefox */ 12 | .scrollbar-hide { 13 | -ms-overflow-style: none; /* IE and Edge */ 14 | scrollbar-width: none; /* Firefox */ 15 | } 16 | 17 | .scrollbar-custom { 18 | scrollbar-width: thin; /* Firefox */ 19 | } 20 | 21 | /* ===== 텍스트 드래그 방지 CSS ===== */ 22 | * { 23 | -webkit-user-select: none; /* Chrome, Safari */ 24 | -moz-user-select: none; /* Firefox */ 25 | -ms-user-select: none; /* Internet Explorer/Edge */ 26 | user-select: none; /* 표준 */ 27 | } 28 | 29 | img { 30 | -webkit-user-drag: none; 31 | -khtml-user-drag: none; 32 | -moz-user-drag: none; 33 | -o-user-drag: none; 34 | user-drag: none; 35 | } 36 | 37 | /* ===== Scrollbar CSS ===== */ 38 | /* Firefox */ 39 | * { 40 | scrollbar-width: auto; 41 | scrollbar-color: rgb(67, 79, 115) rgba(178, 199, 222, 0.5); 42 | } 43 | /* Chrome, Safari and Opera etc. */ 44 | *::-webkit-scrollbar { 45 | width: 6px; 46 | /* height: 6px; */ 47 | } 48 | *::-webkit-scrollbar-track { 49 | background-color: rgba(178, 199, 222, 0.5); /* eastbay-500 with opacity */ 50 | border-radius: 9999px; 51 | } 52 | *::-webkit-scrollbar-thumb { 53 | background-color: rgb(67, 79, 115); /* eastbay-900 */ 54 | border-radius: 9999px; 55 | } 56 | *::-webkit-scrollbar-thumb:hover { 57 | background-color: rgb(84, 103, 161); /* eastbay-700 */ 58 | } 59 | 60 | /* 가로 스크롤바 변형 */ 61 | .scrollbar-custom.horizontal::-webkit-scrollbar-track { 62 | background-color: rgba(178, 199, 222, 0.5); 63 | } 64 | .scrollbar-custom.horizontal::-webkit-scrollbar-thumb { 65 | background-color: rgb(67, 79, 115); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /client/src/layouts/BrowserNavigationGuard.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useLocation, useNavigate } from 'react-router-dom'; 3 | import { useNavigationModalStore } from '@/stores/navigationModal.store'; 4 | 5 | const BrowserNavigationGuard = () => { 6 | const navigate = useNavigate(); 7 | const location = useLocation(); 8 | const modalActions = useNavigationModalStore((state) => state.actions); 9 | 10 | useEffect(() => { 11 | // 새로고침, beforeunload 이벤트 핸들러 12 | const handleBeforeUnload = (e: BeforeUnloadEvent) => { 13 | // 브라우저 기본 경고 메시지 표시 14 | e.preventDefault(); 15 | e.returnValue = ''; // 레거시 브라우저 지원 16 | 17 | // 새로고침 시 메인으로 이동하도록 세션스토리지에 플래그 저장 18 | sessionStorage.setItem('shouldRedirect', 'true'); 19 | 20 | // 사용자 정의 메시지 반환 (일부 브라우저에서는 무시될 수 있음) 21 | return '게임을 종료하시겠습니까? 현재 진행 상태가 저장되지 않을 수 있습니다.'; 22 | }; 23 | 24 | // popstate 이벤트 핸들러 (브라우저 뒤로가기/앞으로가기) 25 | const handlePopState = (e: PopStateEvent) => { 26 | e.preventDefault(); // 기본 동작 중단 27 | modalActions.openModal(); 28 | 29 | // 취소 시 현재 URL 유지를 위해 history stack에 다시 추가하도록 조작 30 | window.history.pushState(null, '', location.pathname); 31 | }; 32 | 33 | // 초기 진입 시 history stack에 현재 상태 추가 34 | window.history.pushState(null, '', location.pathname); 35 | 36 | // 이벤트 리스너 등록 37 | window.addEventListener('beforeunload', handleBeforeUnload); 38 | window.addEventListener('popstate', handlePopState); 39 | 40 | // 새로고침 후 리다이렉트 체크 41 | const shouldRedirect = sessionStorage.getItem('shouldRedirect'); 42 | if (shouldRedirect === 'true' && location.pathname !== '/') { 43 | navigate('/', { replace: true }); 44 | sessionStorage.removeItem('shouldRedirect'); 45 | } 46 | 47 | return () => { 48 | window.removeEventListener('beforeunload', handleBeforeUnload); 49 | window.removeEventListener('popstate', handlePopState); 50 | }; 51 | }, [navigate, location.pathname]); 52 | 53 | return null; 54 | }; 55 | 56 | export default BrowserNavigationGuard; 57 | -------------------------------------------------------------------------------- /client/src/layouts/GameHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from '@/components/ui/Logo'; 2 | import { useNavigationModalStore } from '@/stores/navigationModal.store'; 3 | 4 | const GameHeader = () => { 5 | const { openModal } = useNavigationModalStore((state) => state.actions); 6 | 7 | return ( 8 |
9 | 15 |
16 | ); 17 | }; 18 | 19 | export default GameHeader; 20 | -------------------------------------------------------------------------------- /client/src/layouts/RootLayout.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { Outlet } from 'react-router-dom'; 3 | import BackgroundMusicButton from '@/components/bgm-button/BackgroundMusicButton'; 4 | import HelpContainer from '@/components/ui/HelpContainer'; 5 | import { playerIdStorageUtils } from '@/utils/playerIdStorage'; 6 | 7 | const RootLayout = () => { 8 | // 레이아웃 마운트 시 localStorage 초기화 9 | useEffect(() => { 10 | playerIdStorageUtils.removeAllPlayerIds(); 11 | }, []); 12 | 13 | return ( 14 |
15 | 16 | 17 | {/* 상단 네비게이션 영역: Help 아이콘 컴포넌트 */} 18 | 19 | 20 | {/* 메인 컨텐츠 */} 21 | 22 |
23 | ); 24 | }; 25 | 26 | export default RootLayout; 27 | -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import '@/index.css'; 4 | import { RouterProvider } from 'react-router-dom'; 5 | import App from '@/App.tsx'; 6 | import { router } from '@/routes'; 7 | 8 | createRoot(document.getElementById('root')!).render( 9 | 10 | 11 | 12 | 13 | , 14 | ); 15 | -------------------------------------------------------------------------------- /client/src/pages/GameRoomPage.tsx: -------------------------------------------------------------------------------- 1 | import RoleModal from '@/components/modal/RoleModal'; 2 | import RoundEndModal from '@/components/modal/RoundEndModal'; 3 | import QuizStageContainer from '@/components/quiz/QuizStage'; 4 | 5 | const GameRoomPage = () => { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | 12 | ); 13 | }; 14 | 15 | export default GameRoomPage; 16 | -------------------------------------------------------------------------------- /client/src/pages/LobbyPage.tsx: -------------------------------------------------------------------------------- 1 | import { InviteButton } from '@/components/lobby/InviteButton'; 2 | import { StartButton } from '@/components/lobby/StartButton'; 3 | import { Setting } from '@/components/setting/Setting'; 4 | 5 | const LobbyPage = () => { 6 | return ( 7 | <> 8 | {/* 중앙 영역 - 대기 화면 */} 9 |
10 |

11 | Get Ready for the next battle 12 |

13 | 14 | 15 |
16 | 17 | 18 | 19 |
20 |
21 | 22 | ); 23 | }; 24 | export default LobbyPage; 25 | -------------------------------------------------------------------------------- /client/src/pages/MainPage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import Background from '@/components/ui/BackgroundCanvas'; 3 | import { Button } from '@/components/ui/Button'; 4 | import { Logo } from '@/components/ui/Logo'; 5 | import { PixelTransitionContainer } from '@/components/ui/PixelTransitionContainer'; 6 | import { useCreateRoom } from '@/hooks/useCreateRoom'; 7 | import { usePageTransition } from '@/hooks/usePageTransition'; 8 | import { cn } from '@/utils/cn'; 9 | 10 | const MainPage = () => { 11 | const { createRoom, isLoading } = useCreateRoom(); 12 | const { isExiting, transitionTo } = usePageTransition(); 13 | 14 | useEffect(() => { 15 | // 현재 URL을 루트로 변경 16 | window.history.replaceState(null, '', '/'); 17 | }, []); 18 | 19 | const handleCreateRoom = async () => { 20 | // transitionTo(`/lobby/${roomId}`); 21 | const response = await createRoom(); 22 | if (response && response.roomId) { 23 | transitionTo(`/lobby/${response.roomId}`); 24 | } 25 | }; 26 | 27 | return ( 28 | 29 |
35 | 40 |
41 | 42 |
43 | 44 | 51 |
52 |
53 | ); 54 | }; 55 | 56 | export default MainPage; 57 | -------------------------------------------------------------------------------- /client/src/pages/ResultPage.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | import { TerminationType } from '@troublepainter/core'; 3 | import { useNavigate } from 'react-router-dom'; 4 | import podium from '@/assets/podium.gif'; 5 | import PodiumPlayers from '@/components/result/PodiumPlayers'; 6 | import { usePlayerRankings } from '@/hooks/usePlayerRanking'; 7 | import { useTimeout } from '@/hooks/useTimeout'; 8 | import { useGameSocketStore } from '@/stores/socket/gameSocket.store'; 9 | import { useToastStore } from '@/stores/toast.store'; 10 | 11 | const ResultPage = () => { 12 | const navigate = useNavigate(); 13 | const roomId = useGameSocketStore((state) => state.room?.roomId); 14 | const terminateType = useGameSocketStore((state) => state.gameTerminateType); 15 | const gameActions = useGameSocketStore((state) => state.actions); 16 | const toastActions = useToastStore((state) => state.actions); 17 | const { firstPlacePlayers, secondPlacePlayers, thirdPlacePlayers } = usePlayerRankings(); 18 | 19 | useEffect(() => { 20 | const description = 21 | terminateType === TerminationType.PLAYER_DISCONNECT 22 | ? '나간 플레이어가 있어요. 20초 후 대기실로 이동합니다!' 23 | : '20초 후 대기실로 이동합니다!'; 24 | const variant = terminateType === TerminationType.PLAYER_DISCONNECT ? 'warning' : 'success'; 25 | 26 | toastActions.addToast({ 27 | title: '게임 종료', 28 | description, 29 | variant, 30 | duration: 20000, 31 | }); 32 | }, [terminateType, toastActions]); 33 | 34 | const handleTimeout = useCallback(() => { 35 | gameActions.resetGame(); 36 | navigate(`/lobby/${roomId}`); 37 | }, [gameActions, navigate, roomId]); 38 | 39 | useTimeout(handleTimeout, 20000); 40 | 41 | return ( 42 |
43 | 44 | 45 | GAME 46 | 47 | 48 | ENDS 49 | 50 | 51 | 52 | 53 | 54 |
55 | ); 56 | }; 57 | 58 | export default ResultPage; 59 | -------------------------------------------------------------------------------- /client/src/routes.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter } from 'react-router-dom'; 2 | import GameLayout from '@/layouts/GameLayout'; 3 | import RootLayout from '@/layouts/RootLayout'; 4 | import GameRoomPage from '@/pages/GameRoomPage'; 5 | import LobbyPage from '@/pages/LobbyPage'; 6 | import MainPage from '@/pages/MainPage'; 7 | import ResultPage from '@/pages/ResultPage'; 8 | 9 | export const router = createBrowserRouter( 10 | [ 11 | { 12 | element: , 13 | children: [ 14 | { 15 | path: '/', 16 | element: , 17 | }, 18 | { 19 | element: , 20 | children: [ 21 | { 22 | path: '/lobby/:roomId', 23 | element: , 24 | }, 25 | { 26 | path: '/game/:roomId', 27 | element: , 28 | }, 29 | { 30 | path: '/game/:roomId/result', 31 | element: , 32 | }, 33 | ], 34 | }, 35 | ], 36 | }, 37 | ], 38 | { 39 | future: { 40 | v7_relativeSplatPath: true, 41 | v7_fetcherPersist: true, 42 | v7_normalizeFormMethod: true, 43 | v7_partialHydration: true, 44 | }, 45 | }, 46 | ); 47 | -------------------------------------------------------------------------------- /client/src/stores/navigationModal.store.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface NavigationModalStore { 4 | isOpen: boolean; 5 | actions: { 6 | openModal: () => void; 7 | closeModal: () => void; 8 | }; 9 | } 10 | 11 | export const useNavigationModalStore = create((set) => ({ 12 | isOpen: false, 13 | actions: { 14 | openModal: () => set({ isOpen: true }), 15 | closeModal: () => set({ isOpen: false }), 16 | }, 17 | })); 18 | -------------------------------------------------------------------------------- /client/src/stores/socket/chatSocket.store.ts: -------------------------------------------------------------------------------- 1 | import { ChatResponse } from '@troublepainter/core'; 2 | import { create } from 'zustand'; 3 | import { devtools } from 'zustand/middleware'; 4 | 5 | export interface ChatState { 6 | messages: ChatResponse[]; 7 | } 8 | 9 | const initialState: ChatState = { 10 | messages: [], 11 | }; 12 | 13 | export interface ChatStore { 14 | actions: { 15 | addMessage: (message: ChatResponse) => void; 16 | clearMessages: () => void; 17 | }; 18 | } 19 | 20 | /** 21 | * 채팅 상태와 액션을 관리하는 ㄴtore입니다. 22 | * 23 | * @remarks 24 | * 채팅 메시지 저장소를 관리하고 메시지 관리를 위한 액션을 제공합니다. 25 | * 26 | * @example 27 | * ```typescript 28 | * const { messages, actions } = useChatSocketStore(); 29 | * actions.addMessage(newMessage); 30 | * ``` 31 | */ 32 | export const useChatSocketStore = create()( 33 | devtools( 34 | (set) => ({ 35 | ...initialState, 36 | actions: { 37 | addMessage: (message) => 38 | set((state) => ({ 39 | messages: [...state.messages, message], 40 | })), 41 | 42 | clearMessages: () => set({ messages: [] }), 43 | }, 44 | }), 45 | { name: 'ChatStore' }, 46 | ), 47 | ); 48 | -------------------------------------------------------------------------------- /client/src/stores/timer.store.ts: -------------------------------------------------------------------------------- 1 | import { TimerType } from '@troublepainter/core'; 2 | import { create } from 'zustand'; 3 | import { devtools } from 'zustand/middleware'; 4 | 5 | interface TimerState { 6 | timers: Record; 7 | } 8 | 9 | interface TimerActions { 10 | actions: { 11 | updateTimer: (timerType: TimerType, time: number) => void; 12 | decreaseTimer: (timerType: TimerType) => void; 13 | resetTimers: () => void; 14 | }; 15 | } 16 | 17 | const initialState: TimerState = { 18 | timers: { 19 | [TimerType.DRAWING]: null, 20 | [TimerType.GUESSING]: null, 21 | [TimerType.ENDING]: null, 22 | }, 23 | }; 24 | 25 | export const useTimerStore = create()( 26 | devtools( 27 | (set) => ({ 28 | ...initialState, 29 | actions: { 30 | updateTimer: (timerType, time) => { 31 | set( 32 | (state) => ({ 33 | timers: { 34 | ...state.timers, 35 | [timerType]: time, 36 | }, 37 | }), 38 | false, 39 | 'timers/update', 40 | ); 41 | }, 42 | 43 | decreaseTimer: (timerType) => { 44 | set( 45 | (state) => ({ 46 | timers: { 47 | ...state.timers, 48 | [timerType]: Math.max(0, (state.timers[timerType] ?? 0) - 1), 49 | }, 50 | }), 51 | false, 52 | 'timers/decrease', 53 | ); 54 | }, 55 | 56 | resetTimers: () => { 57 | set({ timers: initialState.timers }, false, 'timers/reset'); 58 | }, 59 | }, 60 | }), 61 | { name: 'TimerStore' }, 62 | ), 63 | ); 64 | -------------------------------------------------------------------------------- /client/src/stores/toast.store.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { devtools } from 'zustand/middleware'; 3 | 4 | const MAX_TOASTS = 5; 5 | 6 | export interface ToastConfig { 7 | id?: string; 8 | title?: string; 9 | description?: string; 10 | duration?: number; 11 | variant?: 'default' | 'error' | 'success' | 'warning'; 12 | } 13 | 14 | interface ToastState { 15 | toasts: ToastConfig[]; 16 | actions: { 17 | addToast: (config: ToastConfig) => void; 18 | removeToast: (id: string) => void; 19 | clearToasts: () => void; 20 | }; 21 | } 22 | 23 | /** 24 | * 토스트 알림을 전역적으로 관리하는 Store입니다. 25 | * 26 | * @example 27 | * ```typescript 28 | * const { toasts, actions } = useToastStore(); 29 | * 30 | * // 토스트 추가 31 | * actions.addToast({ 32 | * title: '성공!', 33 | * description: '작업이 완료되었습니다.', 34 | * variant: 'success', 35 | * duration: 3000 36 | * }); 37 | * ``` 38 | */ 39 | export const useToastStore = create()( 40 | devtools( 41 | (set) => ({ 42 | toasts: [], 43 | actions: { 44 | addToast: (config) => { 45 | const id = new Date().getTime().toString(); 46 | // 새로운 토스트 준비 47 | const newToast = { 48 | ...config, 49 | id, 50 | }; 51 | 52 | set((state) => { 53 | if (config.duration !== Infinity) { 54 | setTimeout(() => { 55 | set((state) => ({ 56 | toasts: state.toasts.filter((t) => t.id !== id), 57 | })); 58 | }, config.duration || 3000); 59 | } 60 | 61 | // 현재 토스트가 최대 개수에 도달한 경우 62 | if (state.toasts.length >= MAX_TOASTS) { 63 | // 가장 오래된 토스트를 제외하고 새 토스트 추가 64 | return { 65 | toasts: [...state.toasts.slice(1), newToast], 66 | }; 67 | } 68 | 69 | // 최대 개수에 도달하지 않은 경우 단순 추가 70 | return { 71 | toasts: [...state.toasts, newToast], 72 | }; 73 | }); 74 | }, 75 | 76 | removeToast: (id) => 77 | set((state) => ({ 78 | toasts: state.toasts.filter((toast) => toast.id !== id), 79 | })), 80 | 81 | clearToasts: () => set({ toasts: [] }), 82 | }, 83 | }), 84 | { name: 'ToastStore' }, 85 | ), 86 | ); 87 | -------------------------------------------------------------------------------- /client/src/stores/useCanvasStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { CanvasStore, SelectingPenOptions } from '@/types/canvas.types'; 3 | 4 | const canvasDefaultConfig = { 5 | inkRemaining: 500, 6 | canDrawing: false, 7 | penSetting: { 8 | mode: 0, 9 | colorNum: 0, 10 | lineWidth: 2, //짝수 단위가 좋음 11 | }, 12 | }; 13 | 14 | export const useCanvasStore = create((set) => ({ 15 | ...canvasDefaultConfig, 16 | action: { 17 | setCanDrawing: (canDrawing: boolean) => { 18 | set(() => ({ canDrawing })); 19 | }, 20 | setPenSetting: (penSetting: SelectingPenOptions) => { 21 | set((state) => { 22 | const newPenSettingState = { ...state.penSetting, ...penSetting }; 23 | return { ...state, penSetting: newPenSettingState }; 24 | }); 25 | }, 26 | setPenMode: (mode: number) => { 27 | set((state) => { 28 | const newPenSettingState = { ...state.penSetting, mode }; 29 | return { ...state, penSetting: newPenSettingState }; 30 | }); 31 | }, 32 | }, 33 | })); 34 | -------------------------------------------------------------------------------- /client/src/stores/useStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | export const useStore = create((set) => ({ 4 | bears: 0, 5 | increasePopulation: () => set((state: { bears: number }) => ({ bears: state.bears + 1 })), 6 | removeAllBears: () => set({ bears: 0 }), 7 | updateBears: (newBears: unknown) => set({ bears: newBears }), 8 | })); 9 | -------------------------------------------------------------------------------- /client/src/types/canvas.types.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent, TouchEvent } from 'react'; 2 | import { DrawingData } from '@troublepainter/core'; 3 | import { DRAWING_MODE } from '@/constants/canvasConstants'; 4 | 5 | interface PenOptions { 6 | mode: number; 7 | colorNum: number; 8 | lineWidth: number; 9 | } 10 | 11 | export type SelectingPenOptions = Partial; 12 | 13 | export type PenModeType = (typeof DRAWING_MODE)[keyof typeof DRAWING_MODE]; 14 | 15 | export interface CanvasStore { 16 | canDrawing: boolean; 17 | penSetting: PenOptions; 18 | action: { 19 | setCanDrawing: (canDrawing: boolean) => void; 20 | setPenSetting: (penSetting: SelectingPenOptions) => void; 21 | setPenMode: (penMode: PenModeType) => void; 22 | }; 23 | } 24 | 25 | export interface RGBA { 26 | r: number; 27 | g: number; 28 | b: number; 29 | a: number; 30 | } 31 | 32 | export interface StrokeHistoryEntry { 33 | strokeIds: string[]; 34 | isLocal: boolean; 35 | drawingData: DrawingData; 36 | timestamp: number; 37 | } 38 | 39 | export interface DrawingOptions { 40 | maxPixels?: number; 41 | } 42 | 43 | export type DrawingMode = (typeof DRAWING_MODE)[keyof typeof DRAWING_MODE]; 44 | 45 | export interface CanvasEventHandlers { 46 | onMouseDown?: (e: MouseEvent) => void; 47 | onMouseMove?: (e: MouseEvent) => void; 48 | onMouseUp?: (e: MouseEvent) => void; 49 | onMouseLeave?: (e: MouseEvent) => void; 50 | onTouchStart?: (e: TouchEvent) => void; 51 | onTouchMove?: (e: TouchEvent) => void; 52 | onTouchEnd?: (e: TouchEvent) => void; 53 | onTouchCancel?: (e: TouchEvent) => void; 54 | } 55 | -------------------------------------------------------------------------------- /client/src/types/chat.types.ts: -------------------------------------------------------------------------------- 1 | export interface Message { 2 | nickname: string; 3 | content: string; 4 | isOthers: boolean; 5 | id: number; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/types/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.lottie' { 2 | const src: string; 3 | export default src; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/types/game.types.ts: -------------------------------------------------------------------------------- 1 | export type PlayingRoleText = '그림꾼' | '방해꾼' | '구경꾼'; 2 | -------------------------------------------------------------------------------- /client/src/types/socket.types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChatClientEvents, 3 | ChatServerEvents, 4 | DrawingClientEvents, 5 | DrawingServerEvents, 6 | GameClientEvents, 7 | GameServerEvents, 8 | } from '@troublepainter/core'; 9 | import { Socket } from 'socket.io-client'; 10 | 11 | // 소켓 타입 정의 12 | // ---------------------------------------------------------------------------------------------------------------------- 13 | export type GameSocket = Socket; 14 | export type DrawingSocket = Socket; 15 | export type ChatSocket = Socket; 16 | -------------------------------------------------------------------------------- /client/src/utils/checkProduction.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 현재 환경이 프로덕션인지 확인하는 유틸리티 함수입니다. 3 | * 4 | * @remarks 5 | * - window.location.origin을 기준으로 프로덕션 환경을 판단합니다. 6 | * - troublepainter.site 도메인이 포함되어 있으면 프로덕션으로 간주합니다. 7 | * 8 | * @returns 프로덕션 환경 여부를 나타내는 boolean 값 9 | * 10 | * @example 11 | * ```typescript 12 | * if (isProduction()) { 13 | * // 프로덕션 환경에서만 실행될 코드 14 | * } 15 | * ``` 16 | * 17 | * @category Utils 18 | */ 19 | export const checkProduction = () => { 20 | const PRODUCTION_URL = 'troublepainter.site'; 21 | return window.location.origin.includes(PRODUCTION_URL); 22 | }; -------------------------------------------------------------------------------- /client/src/utils/checkTimerDifference.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 두 타이머 값의 차이가 주어진 임계값 이상인지 확인합니다. 3 | * 4 | * @remarks 5 | * - 타이머 값의 차이는 절대값으로 계산됩니다. 6 | * - 임계값 이상이면 `true`, 그렇지 않으면 `false`를 반환합니다. 7 | * 8 | * @example 9 | * ```typescript 10 | * const isDifferenceExceeded = checkTimerDifference(10, 5, 3); 11 | * console.log(isDifferenceExceeded); // true 12 | * 13 | * const isDifferenceExceeded = checkTimerDifference(10, 8, 3); 14 | * console.log(isDifferenceExceeded); // false 15 | * ``` 16 | * 17 | * @param time1 - 첫 번째 타이머 값 (초 단위) 18 | * @param time2 - 두 번째 타이머 값 (초 단위) 19 | * @param threshold - 두 타이머 값 차이에 대한 임계값 20 | * 21 | * @returns 두 타이머 값의 차이가 임계값 이상인지 여부를 나타내는 `boolean` 22 | * 23 | * @category Utility 24 | */ 25 | export function checkTimerDifference(time1: number, time2: number, threshold: number) { 26 | const timeDifference = Math.abs(time1 - time2); 27 | return timeDifference >= threshold; 28 | } 29 | -------------------------------------------------------------------------------- /client/src/utils/cn.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | /** 5 | * Tailwind CSS 클래스들을 병합하는 유틸리티 함수입니다. 6 | * 7 | * - clsx를 사용하여 조건부 클래스를 처리합니다. 8 | * - tailwind-merge를 사용하여 Tailwind 클래스 충돌을 해결합니다. 9 | * 10 | * @param inputs - 병합할 클래스들의 배열. ClassValue 타입은 문자열, 객체, 배열 등을 포함할 수 있습니다. 11 | * @returns 병합된 클래스 문자열 12 | * 13 | * @example 14 | * ```tsx 15 | * // 버튼 컴포넌트에서의 사용 예시 16 | * const buttonVariants = cva( 17 | * 'inline-flex items-center justify-center', 18 | * { 19 | * variants: { 20 | * variant: { 21 | * primary: 'bg-violet-500 hover:bg-violet-600', 22 | * // ... 23 | * }, 24 | * size: { 25 | * sm: 'h-11 text-2xl', 26 | * // ... 27 | * } 28 | * }, 29 | * defaultVariants: { 30 | * variant: 'primary', 31 | * size: 'sm' 32 | * } 33 | * } 34 | * ); 35 | * 36 | * const Button = ({ className, variant, size, ...props }) => { 37 | * return ( 38 | *