├── client ├── src │ ├── App.css │ ├── vite-env.d.ts │ ├── types │ │ ├── game.types.ts │ │ ├── declarations.d.ts │ │ ├── chat.types.ts │ │ ├── socket.types.ts │ │ └── canvas.types.ts │ ├── assets │ │ ├── podium.gif │ │ ├── big-timer.gif │ │ ├── crown-first.png │ │ ├── small-timer.gif │ │ ├── small-timer.png │ │ ├── sounds │ │ │ ├── game-win.mp3 │ │ │ ├── game-loss.mp3 │ │ │ └── entry-sound-effect.mp3 │ │ ├── lottie │ │ │ ├── game-win.lottie │ │ │ ├── loading.lottie │ │ │ ├── round-loss.lottie │ │ │ └── round-win.lottie │ │ ├── profile-placeholder.png │ │ ├── arrow.svg │ │ ├── right.svg │ │ ├── left.svg │ │ ├── redo-icon.svg │ │ ├── help-icon.svg │ │ ├── sound-logo.svg │ │ ├── pen-icon.svg │ │ └── bucket-icon.svg │ ├── api │ │ └── gameApi.ts │ ├── constants │ │ ├── gameConstant.ts │ │ ├── backgroundConstants.ts │ │ ├── cdn.ts │ │ ├── canvasConstants.ts │ │ ├── shortcutKeys.ts │ │ └── socket-error-messages.ts │ ├── components │ │ ├── canvas │ │ │ ├── DrawingArea.tsx │ │ │ └── CanvasToolbar.tsx │ │ ├── chat │ │ │ ├── ChatContatiner.tsx │ │ │ ├── ChatList.tsx │ │ │ ├── ChatBubbleUI.tsx │ │ │ └── ChatButtle.stories.tsx │ │ ├── lobby │ │ │ ├── StartButton.tsx │ │ │ └── InviteButton.tsx │ │ ├── ui │ │ │ ├── Input.stories.tsx │ │ │ ├── Input.tsx │ │ │ ├── HelpContainer.tsx │ │ │ ├── Logo.stories.tsx │ │ │ ├── player-card │ │ │ │ ├── PlayerStatus.tsx │ │ │ │ ├── PlayerInfo.tsx │ │ │ │ ├── PlayerProfile.tsx │ │ │ │ └── PlayerCard.tsx │ │ │ ├── Button.tsx │ │ │ ├── Button.stories.tsx │ │ │ ├── Logo.tsx │ │ │ ├── Dropdown.stories.tsx │ │ │ ├── QuizTitle.stories.tsx │ │ │ ├── QuizTitle.tsx │ │ │ ├── Modal.tsx │ │ │ ├── Dropdown.tsx │ │ │ ├── Toast.tsx │ │ │ └── Modal.stories.tsx │ │ ├── modal │ │ │ ├── RoleModal.tsx │ │ │ └── NavigationModal.tsx │ │ ├── setting │ │ │ ├── SettingContent.tsx │ │ │ ├── SettingItem.tsx │ │ │ └── Setting.tsx │ │ ├── player │ │ │ └── PlayerCardList.tsx │ │ ├── bgm-button │ │ │ └── BackgroundMusicButton.tsx │ │ └── result │ │ │ └── PodiumPlayers.tsx │ ├── stores │ │ ├── useStore.ts │ │ ├── navigationModal.store.ts │ │ ├── useCanvasStore.ts │ │ ├── socket │ │ │ └── chatSocket.store.ts │ │ ├── timer.store.ts │ │ └── toast.store.ts │ ├── App.tsx │ ├── pages │ │ ├── GameRoomPage.tsx │ │ ├── LobbyPage.tsx │ │ ├── MainPage.tsx │ │ └── ResultPage.tsx │ ├── main.tsx │ ├── handlers │ │ ├── socket │ │ │ ├── chatSocket.handler.ts │ │ │ └── drawingSocket.handler.ts │ │ └── canvas │ │ │ └── cursorInOutHandler.ts │ ├── utils │ │ ├── checkProduction.ts │ │ ├── checkTimerDifference.ts │ │ ├── getCanvasContext.ts │ │ ├── hexToRGBA.ts │ │ ├── formatDate.ts │ │ ├── cn.ts │ │ ├── getDrawPoint.ts │ │ ├── playerIdStorage.ts │ │ ├── soundManager.ts │ │ └── timer.ts │ ├── layouts │ │ ├── GameHeader.tsx │ │ ├── RootLayout.tsx │ │ └── BrowserNavigationGuard.tsx │ ├── hooks │ │ ├── useTimeout.ts │ │ ├── useShortcuts.ts │ │ ├── useModal.ts │ │ ├── usePlayerRanking.ts │ │ ├── usePageTransition.ts │ │ ├── useTimer.ts │ │ ├── useCoordinateScale.ts │ │ ├── useStartButton.tsx │ │ ├── useCreateRoom.ts │ │ ├── socket │ │ │ └── useChatSocket.ts │ │ └── useScrollToBottom.ts │ ├── routes.tsx │ └── index.css ├── .env.example ├── postcss.config.js ├── public │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ └── site.webmanifest ├── tsconfig.json ├── .storybook │ ├── preview.ts │ └── main.ts ├── vite.config.ts ├── .prettierrc ├── tsconfig.node.json ├── tsconfig.app.json ├── README.md └── package.json ├── server ├── .env.example ├── tsconfig.build.json ├── src │ ├── common │ │ ├── enums │ │ │ ├── game.timer.enum.ts │ │ │ ├── game.status.enum.ts │ │ │ └── socket.error-code.enum.ts │ │ ├── types │ │ │ └── game.types.ts │ │ ├── services │ │ │ └── timer.service.ts │ │ └── clova-client.ts │ ├── redis │ │ ├── redis.module.ts │ │ └── redis.service.ts │ ├── game │ │ ├── game.controller.ts │ │ ├── game.gateway.spec.ts │ │ ├── game.service.spec.ts │ │ ├── game.controller.spec.ts │ │ └── game.module.ts │ ├── chat │ │ ├── chat.module.ts │ │ ├── chat.gateway.spec.ts │ │ ├── chat.service.spec.ts │ │ ├── chat.service.ts │ │ ├── chat.repository.ts │ │ └── chat.gateway.ts │ ├── drawing │ │ ├── drawing.module.ts │ │ ├── drawing.service.ts │ │ ├── drawing.gateway.spec.ts │ │ ├── drawing.repository.ts │ │ └── drawing.gateway.ts │ ├── app.module.ts │ ├── main.ts │ ├── filters │ │ └── ws-exception.filter.ts │ └── exceptions │ │ └── game.exception.ts ├── nest-cli.json ├── test │ ├── jest-e2e.json │ └── app.e2e-spec.ts ├── .prettierrc ├── tsconfig.json ├── .eslintrc.js └── package.json ├── core ├── index.ts ├── types │ ├── index.ts │ ├── crdt.types.ts │ └── game.types.ts ├── crdt │ ├── index.ts │ ├── test │ │ └── test-types.ts │ └── LWWRegister.ts ├── tsup.config.ts ├── vitest.config.ts ├── .prettierrc ├── playwright.config.ts ├── tsconfig.json └── package.json ├── pnpm-workspace.yaml ├── tsconfig.base.json ├── .github ├── ISSUE_TEMPLATE │ └── feature-template.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── docker-compose-deploy.yml │ ├── apply-issue-template.yml │ ├── typedoc-ci-cd.yml │ ├── storybook-ci-cd.yml │ ├── client-ci-cd.yml │ └── server-ci-cd.yml ├── Dockerfile.nginx ├── docker-compose.yml ├── package.json ├── typedoc.json ├── Dockerfile.server ├── nginx.conf └── .gitignore /client/src/App.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | REDIS_HOST='' 2 | REDIS_PORT='' -------------------------------------------------------------------------------- /client/.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_URL='' 2 | VITE_SOCKET_URL='' -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './crdt'; 2 | export * from './types'; 3 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'core' 3 | - 'client' 4 | - 'server' 5 | -------------------------------------------------------------------------------- /client/src/types/game.types.ts: -------------------------------------------------------------------------------- 1 | export type PlayingRoleText = '그림꾼' | '방해꾼' | '구경꾼'; 2 | -------------------------------------------------------------------------------- /client/src/types/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.lottie' { 2 | const src: string; 3 | export default src; 4 | } 5 | -------------------------------------------------------------------------------- /core/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './crdt.types'; 2 | export * from './game.types'; 3 | export * from './socket.types'; 4 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /core/crdt/index.ts: -------------------------------------------------------------------------------- 1 | export * from '../types/crdt.types'; 2 | export * from './LWWRegister'; 3 | export * from './LWWMap'; 4 | -------------------------------------------------------------------------------- /client/src/assets/podium.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/src/assets/podium.gif -------------------------------------------------------------------------------- /client/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/public/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/public/favicon-32x32.png -------------------------------------------------------------------------------- /client/src/assets/big-timer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/src/assets/big-timer.gif -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] 4 | } 5 | -------------------------------------------------------------------------------- /server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /client/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/public/apple-touch-icon.png -------------------------------------------------------------------------------- /client/src/assets/crown-first.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/src/assets/crown-first.png -------------------------------------------------------------------------------- /client/src/assets/small-timer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/src/assets/small-timer.gif -------------------------------------------------------------------------------- /client/src/assets/small-timer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/src/assets/small-timer.png -------------------------------------------------------------------------------- /client/src/assets/sounds/game-win.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/src/assets/sounds/game-win.mp3 -------------------------------------------------------------------------------- /client/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /client/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /client/src/assets/lottie/game-win.lottie: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/src/assets/lottie/game-win.lottie -------------------------------------------------------------------------------- /client/src/assets/lottie/loading.lottie: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/src/assets/lottie/loading.lottie -------------------------------------------------------------------------------- /client/src/assets/sounds/game-loss.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/src/assets/sounds/game-loss.mp3 -------------------------------------------------------------------------------- /client/src/types/chat.types.ts: -------------------------------------------------------------------------------- 1 | export interface Message { 2 | nickname: string; 3 | content: string; 4 | isOthers: boolean; 5 | id: number; 6 | } 7 | -------------------------------------------------------------------------------- /server/src/common/enums/game.timer.enum.ts: -------------------------------------------------------------------------------- 1 | export enum TimerType { 2 | DRAWING = 'DRAWING', 3 | GUESSING = 'GUESSING', 4 | ENDING = 'ENDING', 5 | } 6 | -------------------------------------------------------------------------------- /client/src/assets/lottie/round-loss.lottie: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/src/assets/lottie/round-loss.lottie -------------------------------------------------------------------------------- /client/src/assets/lottie/round-win.lottie: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/src/assets/lottie/round-win.lottie -------------------------------------------------------------------------------- /client/src/assets/profile-placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/src/assets/profile-placeholder.png -------------------------------------------------------------------------------- /client/src/assets/sounds/entry-sound-effect.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web30-stop-troublepainter/HEAD/client/src/assets/sounds/entry-sound-effect.mp3 -------------------------------------------------------------------------------- /server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /core/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['index.ts'], 5 | format: ['cjs', 'esm'], 6 | dts: true, 7 | splitting: false, 8 | sourcemap: true, 9 | clean: true, 10 | }); 11 | -------------------------------------------------------------------------------- /server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /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"} -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./client/src/hooks/**/*.ts", 4 | "./client/src/hooks/**/*.tsx", 5 | "./client/src/utils/**/*.ts", 6 | "./client/src/stores/**/*.ts", 7 | "./server/src/utils/**/*.ts" 8 | ], 9 | "exclude": ["**/node_modules", "**/dist"] 10 | } 11 | -------------------------------------------------------------------------------- /client/src/assets/arrow.svg: -------------------------------------------------------------------------------- 1 | 7 | 13 | 14 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /server/src/redis/redis.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { RedisService } from './redis.service'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | 5 | @Module({ 6 | imports: [ConfigModule], 7 | providers: [RedisService], 8 | exports: [RedisService], 9 | }) 10 | export class RedisModule {} 11 | -------------------------------------------------------------------------------- /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/.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 | -------------------------------------------------------------------------------- /core/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import path from 'path'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | globals: true, 7 | include: ['**/test/*.test.ts'], 8 | environment: 'node', 9 | }, 10 | resolve: { 11 | alias: { 12 | '@': path.resolve(__dirname, './'), 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /client/src/assets/right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "as-needed", 8 | "trailingComma": "all", 9 | "bracketSpacing": true, 10 | "arrowParens": "always", 11 | "proseWrap": "preserve", 12 | "endOfLine": "auto", 13 | "embeddedLanguageFormatting": "auto" 14 | } 15 | -------------------------------------------------------------------------------- /client/src/assets/left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import react from '@vitejs/plugin-react'; 3 | import { defineConfig } from 'vite'; 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | assetsInclude: ['**/*.lottie'], 9 | resolve: { 10 | alias: { 11 | '@': path.resolve(__dirname, './src'), 12 | }, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /core/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "as-needed", 8 | "trailingComma": "all", 9 | "bracketSpacing": true, 10 | "arrowParens": "always", 11 | "proseWrap": "preserve", 12 | "endOfLine": "auto", 13 | "embeddedLanguageFormatting": "auto" 14 | } 15 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /server/src/game/game.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post } from '@nestjs/common'; 2 | import { GameService } from './game.service'; 3 | 4 | @Controller('game') 5 | export class GameController { 6 | constructor(private readonly gameService: GameService) {} 7 | 8 | @Post('rooms') 9 | async createRoom() { 10 | const roomId = await this.gameService.createRoom(); 11 | return { roomId }; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /server/src/chat/chat.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ChatGateway } from './chat.gateway'; 3 | import { RedisModule } from 'src/redis/redis.module'; 4 | import { ChatService } from './chat.service'; 5 | import { ChatRepository } from './chat.repository'; 6 | 7 | @Module({ 8 | imports: [RedisModule], 9 | providers: [ChatGateway, ChatService, ChatRepository], 10 | }) 11 | export class ChatModule {} 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /server/src/drawing/drawing.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { DrawingGateway } from './drawing.gateway'; 3 | import { RedisModule } from 'src/redis/redis.module'; 4 | import { DrawingService } from './drawing.service'; 5 | import { DrawingRepository } from './drawing.repository'; 6 | 7 | @Module({ 8 | imports: [RedisModule], 9 | providers: [DrawingGateway, DrawingService, DrawingRepository], 10 | }) 11 | export class DrawingModule {} 12 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /core/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { PlaywrightTestConfig, devices } from '@playwright/test'; 2 | 3 | const config: PlaywrightTestConfig = { 4 | testDir: './crdt/test', 5 | workers: 5, 6 | fullyParallel: true, 7 | timeout: 180000, 8 | reporter: 'html', 9 | use: { 10 | baseURL: 'http://localhost:5173/', 11 | trace: 'on-first-retry', 12 | }, 13 | projects: [ 14 | { 15 | name: 'chromium', 16 | use: { ...devices['Desktop Chrome'] }, 17 | }, 18 | ], 19 | }; 20 | 21 | export default config; 22 | -------------------------------------------------------------------------------- /core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "declaration": true, 7 | "outDir": "./dist", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "types": ["vitest/globals"], 13 | "paths": { 14 | "@/*": ["./*"] 15 | } 16 | }, 17 | "include": ["index.ts", "crdt/**/*", "types/**/*"], 18 | "exclude": ["node_modules", "dist"] 19 | } 20 | -------------------------------------------------------------------------------- /server/src/chat/chat.gateway.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ChatGateway } from './chat.gateway'; 3 | 4 | describe('ChatGateway', () => { 5 | let gateway: ChatGateway; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [ChatGateway], 10 | }).compile(); 11 | 12 | gateway = module.get(ChatGateway); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(gateway).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /server/src/chat/chat.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ChatService } from './chat.service'; 3 | 4 | describe('ChatService', () => { 5 | let service: ChatService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [ChatService], 10 | }).compile(); 11 | 12 | service = module.get(ChatService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /server/src/drawing/drawing.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DrawingRepository } from './drawing.repository'; 3 | 4 | @Injectable() 5 | export class DrawingService { 6 | constructor(private readonly drawingRepository: DrawingRepository) {} 7 | 8 | async existsRoom(roomId: string) { 9 | return await this.drawingRepository.existsRoom(roomId); 10 | } 11 | 12 | async existsPlayer(roomId: string, playerId: string) { 13 | return await this.drawingRepository.existsPlayer(roomId, playerId); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/src/game/game.gateway.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { GameGateway } from './game.gateway'; 3 | 4 | describe('GameGateway', () => { 5 | let gateway: GameGateway; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [GameGateway], 10 | }).compile(); 11 | 12 | gateway = module.get(GameGateway); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(gateway).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /server/src/game/game.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { GameService } from './game.service'; 3 | 4 | describe('GameService', () => { 5 | let service: GameService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [GameService], 10 | }).compile(); 11 | 12 | service = module.get(GameService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /server/src/drawing/drawing.gateway.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { DrawingGateway } from './drawing.gateway'; 3 | 4 | describe('DrawingGateway', () => { 5 | let gateway: DrawingGateway; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [DrawingGateway], 10 | }).compile(); 11 | 12 | gateway = module.get(DrawingGateway); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(gateway).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /server/src/game/game.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { GameController } from './game.controller'; 3 | 4 | describe('GameController', () => { 5 | let controller: GameController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [GameController], 10 | }).compile(); 11 | 12 | controller = module.get(GameController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /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 | }; -------------------------------------------------------------------------------- /server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { RedisModule } from './redis/redis.module'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { GameModule } from './game/game.module'; 5 | import { ChatModule } from './chat/chat.module'; 6 | import { DrawingModule } from './drawing/drawing.module'; 7 | 8 | @Module({ 9 | imports: [ 10 | ConfigModule.forRoot({ 11 | isGlobal: true, 12 | envFilePath: '.env', 13 | }), 14 | RedisModule, 15 | GameModule, 16 | ChatModule, 17 | DrawingModule, 18 | ], 19 | }) 20 | export class AppModule {} 21 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { IoAdapter } from '@nestjs/platform-socket.io'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | 8 | app.setGlobalPrefix('api'); 9 | 10 | app.enableCors({ 11 | origin: '*', 12 | methods: '*', 13 | }); 14 | 15 | const httpServer = app.getHttpServer(); 16 | const ioAdapter = new IoAdapter(httpServer); 17 | app.useWebSocketAdapter(ioAdapter); 18 | 19 | await app.listen(process.env.PORT ?? 3000); 20 | } 21 | bootstrap(); 22 | -------------------------------------------------------------------------------- /server/src/common/enums/game.status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum PlayerStatus { 2 | NOT_PLAYING = 'NOT_PLAYING', 3 | PLAYING = 'PLAYING', 4 | } 5 | 6 | export enum PlayerRole { 7 | PAINTER = 'PAINTER', 8 | DEVIL = 'DEVIL', 9 | GUESSER = 'GUESSER', 10 | } 11 | 12 | export enum RoomStatus { 13 | WAITING = 'WAITING', 14 | DRAWING = 'DRAWING', 15 | GUESSING = 'GUESSING', 16 | } 17 | 18 | export enum Difficulty { 19 | EASY = 'EASY', 20 | NORMAL = 'NORMAL', 21 | HARD = 'HARD', 22 | } 23 | 24 | export enum TerminationType { 25 | SUCCESS = 'SUCCESS', 26 | PLAYER_DISCONNECT = 'PLAYER_DISCONNECT', 27 | } 28 | -------------------------------------------------------------------------------- /server/src/common/types/game.types.ts: -------------------------------------------------------------------------------- 1 | import { PlayerRole, PlayerStatus, RoomStatus } from '../enums/game.status.enum'; 2 | 3 | export interface Player { 4 | playerId: string; 5 | role: PlayerRole | null; 6 | status: PlayerStatus; 7 | nickname: string; 8 | profileImage: string | null; 9 | score: number; 10 | } 11 | 12 | export interface Room { 13 | roomId: string; 14 | hostId: string | null; 15 | status: RoomStatus; 16 | currentRound?: number; 17 | currentWord?: string; 18 | } 19 | 20 | export interface RoomSettings { 21 | maxPlayers: number; 22 | totalRounds: number; 23 | drawTime: number; 24 | } 25 | -------------------------------------------------------------------------------- /server/src/drawing/drawing.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { RedisService } from 'src/redis/redis.service'; 3 | 4 | @Injectable() 5 | export class DrawingRepository { 6 | constructor(private readonly redisService: RedisService) {} 7 | 8 | async existsRoom(roomId: string) { 9 | const exists = await this.redisService.exists(`room:${roomId}`); 10 | return exists === 1; 11 | } 12 | 13 | async existsPlayer(roomId: string, playerId: string) { 14 | const exists = await this.redisService.exists(`room:${roomId}:player:${playerId}`); 15 | return exists === 1; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /server/src/common/enums/socket.error-code.enum.ts: -------------------------------------------------------------------------------- 1 | export enum SocketErrorCode { 2 | BAD_REQUEST = 4000, 3 | UNAUTHORIZED = 4001, 4 | FORBIDDEN = 4003, 5 | NOT_FOUND = 4004, 6 | VALIDATION_ERROR = 4400, 7 | RATE_LIMIT = 4429, 8 | 9 | INTERNAL_ERROR = 5000, 10 | NOT_IMPLEMENTED = 5001, 11 | SERVICE_UNAVAILABLE = 5003, 12 | 13 | GAME_NOT_STARTED = 6001, 14 | GAME_ALREADY_STARTED = 6002, 15 | INVALID_TURN = 6003, 16 | ROOM_FULL = 6004, 17 | ROOM_NOT_FOUND = 6005, 18 | PLAYER_NOT_FOUND = 6006, 19 | INSUFFICIENT_PLAYERS = 6007, 20 | 21 | CONNECTION_ERROR = 7000, 22 | CONNECTION_TIMEOUT = 7001, 23 | CONNECTION_CLOSED = 7002, 24 | } 25 | -------------------------------------------------------------------------------- /server/src/game/game.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { GameService } from './game.service'; 3 | import { GameController } from './game.controller'; 4 | import { GameGateway } from './game.gateway'; 5 | import { RedisModule } from 'src/redis/redis.module'; 6 | import { GameRepository } from './game.repository'; 7 | import { TimerService } from 'src/common/services/timer.service'; 8 | import { ClovaClient } from 'src/common/clova-client'; 9 | 10 | @Module({ 11 | imports: [RedisModule], 12 | providers: [GameService, GameGateway, GameRepository, TimerService, ClovaClient], 13 | controllers: [GameController], 14 | }) 15 | export class GameModule {} 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "target": "ES2022", 6 | "lib": ["ES2023"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "Bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | // "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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;"] -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], 10 | root: true, 11 | env: { 12 | node: true, 13 | jest: true, 14 | }, 15 | ignorePatterns: ['.eslintrc.js'], 16 | rules: { 17 | '@typescript-eslint/interface-name-prefix': 'off', 18 | '@typescript-eslint/explicit-function-return-type': 'off', 19 | '@typescript-eslint/explicit-module-boundary-types': 'off', 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | 'prettier/prettier': ['error', { endOfLine: 'auto' }], 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /server/src/filters/ws-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { Catch, ArgumentsHost } from '@nestjs/common'; 2 | import { BaseWsExceptionFilter } from '@nestjs/websockets'; 3 | import { Socket } from 'socket.io'; 4 | import { GameException } from '../exceptions/game.exception'; 5 | 6 | @Catch() 7 | export class WsExceptionFilter extends BaseWsExceptionFilter { 8 | catch(exception: any, host: ArgumentsHost) { 9 | const client = host.switchToWs().getClient(); 10 | 11 | if (exception instanceof GameException) { 12 | client.emit('error', { 13 | code: exception.code, 14 | message: exception.message, 15 | }); 16 | } else { 17 | client.emit('error', { 18 | code: 'INTERNAL_ERROR', 19 | message: 'Internal server error', 20 | }); 21 | } 22 | 23 | console.error('WebSocket Error:', exception); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | server: 3 | image: ${DOCKERHUB_USERNAME}/troublepainter-server:latest 4 | container_name: troublepainter_server 5 | environment: 6 | - NODE_ENV=production 7 | - REDIS_HOST=${REDIS_HOST} 8 | - REDIS_PORT=${REDIS_PORT} 9 | - CLOVA_API_KEY=${CLOVA_API_KEY} 10 | - CLOVA_GATEWAY_KEY=${CLOVA_GATEWAY_KEY} 11 | networks: 12 | - app_network 13 | restart: unless-stopped 14 | 15 | nginx: 16 | image: ${DOCKERHUB_USERNAME}/troublepainter-nginx:latest 17 | container_name: troublepainter_nginx 18 | volumes: 19 | - /etc/letsencrypt:/etc/letsencrypt 20 | ports: 21 | - "80:80" 22 | - "443:443" 23 | depends_on: 24 | - server 25 | networks: 26 | - app_network 27 | restart: unless-stopped 28 | 29 | networks: 30 | app_network: 31 | name: app_network 32 | driver: bridge -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /client/src/assets/redo-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "troublepainter-monorepo", 3 | "private": true, 4 | "scripts": { 5 | "typedoc": "typedoc", 6 | "dev:client": "pnpm --filter client dev", 7 | "dev:server": "pnpm --filter server start:dev", 8 | "dev": "pnpm --filter client dev & pnpm --filter server start:dev", 9 | "build": "pnpm -r build", 10 | "build:core": "pnpm --filter @troublepainter/core build", 11 | "build:full": "pnpm build:core && pnpm build", 12 | "start": "pnpm -r --parallel start", 13 | "start:client": "pnpm --filter client start", 14 | "start:server": "pnpm --filter server start", 15 | "run:full": "pnpm build:full && pnpm start", 16 | "clean": "pnpm -r clean", 17 | "test": "pnpm -r test" 18 | }, 19 | "devDependencies": { 20 | "typedoc": "^0.26.11", 21 | "typedoc-plugin-extras": "^3.1.0" 22 | }, 23 | "dependencies": { 24 | "@troublepainter/core": "workspace:*" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@troublepainter/core", 3 | "version": "0.0.1", 4 | "private": true, 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.mjs", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/index.d.ts", 11 | "import": "./dist/index.mjs", 12 | "require": "./dist/index.js" 13 | } 14 | }, 15 | "scripts": { 16 | "build": "tsup", 17 | "dev": "tsup --watch", 18 | "test": "vitest", 19 | "test:watch": "vitest watch", 20 | "test:e2e": "playwright test", 21 | "test:e2e:ui": "playwright test --ui", 22 | "test:e2e:debug": "playwright test --debug", 23 | "format": "prettier --write .", 24 | "format:check": "prettier --check ." 25 | }, 26 | "devDependencies": { 27 | "@playwright/test": "^1.49.0", 28 | "@types/pngjs": "^6.0.5", 29 | "pngjs": "^7.0.0", 30 | "tsup": "^8.0.0", 31 | "typescript": "^5.0.0", 32 | "vitest": "^2.1.5" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Webebeb Team's Tools Docs", 3 | "out": "docs", 4 | "entryPointStrategy": "expand", 5 | "exclude": [ 6 | "**/*.stories.tsx", 7 | "**/*.test.ts", 8 | "**/node_modules/**", 9 | "**/dist/**" 10 | ], 11 | "entryPoints": [ 12 | "./client/src/utils/**/*.ts", 13 | "./client/src/hooks/**/*.ts", 14 | "./client/src/hooks/**/*.tsx", 15 | "./client/src/stores/**/*.ts", 16 | "./server/src/utils/**/*.ts" 17 | ], 18 | "categorizeByGroup": true, 19 | "categoryOrder": ["Client Utils", "Client Hooks", "Server Utils"], 20 | "navigationLinks": { 21 | "Documentation": "/web30-stop-troublepainter/", 22 | "Storybook (Main)": "/web30-stop-troublepainter/storybook", 23 | "Storybook (Develop)": "/web30-stop-troublepainter/storybook-develop" 24 | }, 25 | "plugin": ["typedoc-plugin-extras"], 26 | "favicon": "./client/public/favicon.ico", 27 | "skipErrorChecking": true, 28 | "tsconfig": "./tsconfig.base.json" 29 | } 30 | -------------------------------------------------------------------------------- /client/src/utils/getCanvasContext.ts: -------------------------------------------------------------------------------- 1 | import { RefObject } from 'react'; 2 | 3 | interface CanvasContext { 4 | canvas: HTMLCanvasElement; 5 | ctx: CanvasRenderingContext2D; 6 | } 7 | 8 | /** 9 | * Canvas 컨텍스트를 안전하게 가져오는 유틸리티 함수입니다. 10 | * 11 | * - canvas 객체를 담은 RefObject 객체를 매개변수로 넘기면 canvas객체와 Context2D 객체를 반환합니다. 12 | * - canvas 객체가 정상적으로 생성되지 않았을 경우 에러를 던집니다. 13 | * 14 | * @param canvasRef - canvas 객체를 담은 RefObject 객체 15 | * @returns canvas와 Context2D가 포함된 객체 16 | * @throws {Error} canvas 객체가 정상적으로 생성되지 않았을 경우 17 | * 18 | * @example 19 | * ```typescript 20 | * const { canvas, ctx } = getCanvasContext(canvasRef); 21 | * ``` 22 | * 23 | * @category Utils 24 | */ 25 | export const getCanvasContext = (canvasRef: RefObject): CanvasContext => { 26 | const canvas = canvasRef.current; 27 | if (!canvas) throw new Error('Canvas 요소를 찾지 못했습니다.'); 28 | 29 | const ctx = canvas.getContext('2d'); 30 | if (!ctx) throw new Error('2D context를 가져오는데 실패했습니다.'); 31 | 32 | return { canvas, ctx }; 33 | }; 34 | -------------------------------------------------------------------------------- /client/src/assets/help-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /core/types/crdt.types.ts: -------------------------------------------------------------------------------- 1 | export interface Point { 2 | x: number; 3 | y: number; 4 | } 5 | 6 | export interface StrokeStyle { 7 | color: string; 8 | width: number; 9 | } 10 | 11 | export interface DrawingData { 12 | points: Point[]; 13 | style: StrokeStyle; 14 | timestamp: number; 15 | } 16 | 17 | export type RegisterState = { 18 | peerId: string; 19 | timestamp: number; 20 | value: T; 21 | isDeactivated?: boolean; 22 | }; 23 | 24 | export type MapState = { 25 | [key: string]: RegisterState; 26 | }; 27 | 28 | export enum CRDTMessageTypes { 29 | SYNC = 'sync', 30 | UPDATE = 'update', 31 | } 32 | 33 | export type CRDTSyncMessage = { 34 | type: CRDTMessageTypes.SYNC; 35 | state: MapState; 36 | }; 37 | 38 | export type CRDTUpdateMessage = { 39 | type: CRDTMessageTypes.UPDATE; 40 | state: { 41 | key: string; 42 | register: RegisterState; 43 | isDeactivated?: boolean; 44 | }; 45 | }; 46 | 47 | export type CRDTMessage = CRDTSyncMessage | CRDTUpdateMessage; 48 | -------------------------------------------------------------------------------- /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/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/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "incremental": true, 5 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 6 | "target": "ES2020", 7 | "useDefineForClassFields": true, 8 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 9 | "module": "ESNext", 10 | "skipLibCheck": true, 11 | 12 | /* Bundler mode */ 13 | "moduleResolution": "Bundler", 14 | "allowImportingTsExtensions": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | // "noUncheckedSideEffectImports": true 26 | 27 | "baseUrl": ".", 28 | "paths": { 29 | "@/*": ["./src/*"] 30 | } 31 | }, 32 | "include": ["src/**/*.ts", "src/**/*.tsx", ".storybook/**/*", "vite.config.ts"], 33 | "exclude": ["node_modules", "dist"] 34 | } 35 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /server/src/chat/chat.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ChatRepository } from './chat.repository'; 3 | import { BadRequestException, PlayerNotFoundException } from 'src/exceptions/game.exception'; 4 | 5 | @Injectable() 6 | export class ChatService { 7 | constructor(private readonly chatRepository: ChatRepository) {} 8 | 9 | async sendMessage(roomId: string, playerId: string, message: string) { 10 | if (!message?.trim()) { 11 | throw new BadRequestException('Message cannot be empty'); 12 | } 13 | 14 | const player = await this.chatRepository.getPlayer(roomId, playerId); 15 | if (!player) throw new PlayerNotFoundException(); 16 | 17 | return { 18 | playerId, 19 | nickname: player.nickname, 20 | message, 21 | createdAt: new Date(), 22 | }; 23 | } 24 | 25 | async existsRoom(roomId: string) { 26 | return await this.chatRepository.existsRoom(roomId); 27 | } 28 | 29 | async existsPlayer(roomId: string, playerId: string) { 30 | return await this.chatRepository.existsPlayer(roomId, playerId); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /core/types/game.types.ts: -------------------------------------------------------------------------------- 1 | export interface Player { 2 | playerId?: string; // 서버는 필요없음 3 | nickname: string; 4 | profileImage?: string; 5 | status: PlayerStatus; 6 | role?: PlayerRole; 7 | score: number; 8 | } 9 | 10 | export interface Room { 11 | roomId?: string; // 서버는 필요없음 12 | hostId: string; 13 | status: RoomStatus; 14 | currentRound: number; 15 | currentWord?: string; 16 | } 17 | 18 | export interface RoomSettings { 19 | maxPlayers: number; // 최대 플레이어 수 20 | drawTime: number; // 그리기 제한시간 21 | totalRounds: number; // 총 라운드 수 22 | } 23 | 24 | export enum PlayerStatus { 25 | NOT_PLAYING = 'NOT_PLAYING', 26 | PLAYING = 'PLAYING', 27 | } 28 | export enum PlayerRole { 29 | PAINTER = 'PAINTER', 30 | DEVIL = 'DEVIL', 31 | GUESSER = 'GUESSER', 32 | } 33 | export enum RoomStatus { 34 | WAITING = 'WAITING', 35 | DRAWING = 'DRAWING', 36 | GUESSING = 'GUESSING', 37 | } 38 | 39 | export enum TimerType { 40 | DRAWING = 'DRAWING', 41 | GUESSING = 'GUESSING', 42 | ENDING = 'ENDING', 43 | } 44 | 45 | export enum TerminationType { 46 | SUCCESS = 'SUCCESS', 47 | PLAYER_DISCONNECT = 'PLAYER_DISCONNECT', 48 | } 49 | -------------------------------------------------------------------------------- /server/src/chat/chat.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { RedisService } from 'src/redis/redis.service'; 3 | import { Player } from 'src/common/types/game.types'; 4 | 5 | @Injectable() 6 | export class ChatRepository { 7 | constructor(private readonly redisService: RedisService) {} 8 | 9 | async getPlayer(roomId: string, playerId: string) { 10 | const player = await this.redisService.hgetall(`room:${roomId}:players:${playerId}`); 11 | if (!player || Object.keys(player).length === 0) return null; 12 | 13 | return { 14 | ...player, 15 | role: player.role === '' ? null : player.role, 16 | profileImage: player.userImg === '' ? null : player.userImg, 17 | score: parseInt(player.score, 10) || 0, 18 | } as Player; 19 | } 20 | 21 | async existsRoom(roomId: string) { 22 | const exists = await this.redisService.exists(`room:${roomId}`); 23 | return exists === 1; 24 | } 25 | 26 | async existsPlayer(roomId: string, playerId: string) { 27 | const exists = await this.redisService.exists(`room:${roomId}:player:${playerId}`); 28 | return exists === 1; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name www.troublepainter.site; 4 | return 301 https://$server_name$request_uri; 5 | } 6 | server { 7 | listen 443 ssl; 8 | server_name www.troublepainter.site; 9 | 10 | ssl_certificate /etc/letsencrypt/live/www.troublepainter.site/fullchain.pem; 11 | ssl_certificate_key /etc/letsencrypt/live/www.troublepainter.site/privkey.pem; 12 | 13 | gzip on; 14 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; 15 | 16 | location / { 17 | root /usr/share/nginx/html; 18 | index index.html; 19 | try_files $uri $uri/ /index.html; 20 | } 21 | 22 | location /api { 23 | proxy_pass http://server:3000; 24 | proxy_http_version 1.1; 25 | proxy_set_header Host $host; 26 | proxy_cache_bypass $http_upgrade; 27 | } 28 | 29 | location /socket.io { 30 | proxy_pass http://server:3000; 31 | proxy_http_version 1.1; 32 | proxy_set_header Upgrade $http_upgrade; 33 | proxy_set_header Connection "upgrade"; 34 | proxy_set_header Host $host; 35 | } 36 | } -------------------------------------------------------------------------------- /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/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/utils/hexToRGBA.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 16진수 색상 표기법(#000000)을 rgba 값을 담은 객체 구조로 반환하는 함수입니다. 3 | * 4 | * @param hex - 16진수 색상 표기법을 string 타입으로 받습니다. (#000 등 축약형은 사용할 수 없습니다) 5 | * @param alpha - 투명도입니다. 전달값이 없으면 자동으로 255가 채워집니다. (0: 투명, 255: 불투명) 6 | * @returns RGBA 객체 7 | * 8 | * @example 9 | * ```typescript 10 | * // 투명한 검은색 11 | * hexToRGBA('#000000', 0); 12 | * 13 | * // 불투명한 흰색 14 | * hexToRGBA('#ffffff', 255); 15 | * ``` 16 | * 17 | * @category Utils 18 | */ 19 | export function hexToRGBA(hex: string, alpha = 255) { 20 | hex = hex.replace('#', ''); 21 | 22 | const r = parseInt(hex.substring(0, 2), 16); 23 | const g = parseInt(hex.substring(2, 4), 16); 24 | const b = parseInt(hex.substring(4, 6), 16); 25 | 26 | return { r, g, b, a: alpha }; 27 | } 28 | 29 | /* 컬러 기능 확장 시 사용하면 좋을 것 같음. 30 | class Color { 31 | r: number; 32 | g: number; 33 | b: number; 34 | a: number; 35 | 36 | constructor(color: string) { 37 | const ctx = document.createElement('canvas').getContext('2d')!; 38 | ctx.fillStyle = color; 39 | this.r = parseInt(ctx.fillStyle.slice(1, 3), 16); 40 | this.g = parseInt(ctx.fillStyle.slice(3, 5), 16); 41 | this.b = parseInt(ctx.fillStyle.slice(5, 7), 16); 42 | } 43 | } 44 | */ 45 | -------------------------------------------------------------------------------- /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/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/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/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/utils/formatDate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 날짜를 지정된 형식의 문자열로 포맷팅하는 유틸리티 함수입니다. 3 | * 4 | * - 날짜 객체를 받아 원하는 형식의 문자열로 변환합니다. 5 | * - 기본 형식은 'YYYY-MM-DD'입니다. 6 | * - 유효하지 않은 날짜가 입력되면 에러를 발생시킵니다. 7 | * 8 | * @param date - 포맷팅할 Date 객체 9 | * @param format - 날짜 포맷 문자열 (기본값: 'YYYY-MM-DD') 10 | * @returns 포맷팅된 날짜 문자열 11 | * @throws {Error} 유효하지 않은 날짜가 입력된 경우 12 | * 13 | * @example 14 | * ```typescript 15 | * // 기본 포맷 (YYYY-MM-DD) 16 | * formatDate(new Date()); // "2024-03-04" 17 | * 18 | * // 커스텀 포맷 19 | * formatDate(new Date(), 'YYYY년 MM월 DD일'); // "2024년 03월 04일" 20 | * formatDate(new Date(), 'MM/DD/YYYY'); // "03/04/2024" 21 | * 22 | * // 에러 케이스 23 | * formatDate(new Date('invalid')); // Error: Invalid date provided 24 | * ``` 25 | * 26 | * @category Utils 27 | */ 28 | export function formatDate(date: Date, format: string = 'YYYY-MM-DD'): string { 29 | if (!(date instanceof Date) || isNaN(date.getTime())) { 30 | throw new Error('Invalid date provided'); 31 | } 32 | 33 | const year = date.getFullYear(); 34 | const month = date.getMonth() + 1; 35 | const day = date.getDate(); 36 | 37 | const replacements: Record = { 38 | YYYY: year.toString(), 39 | MM: month.toString().padStart(2, '0'), 40 | DD: day.toString().padStart(2, '0'), 41 | }; 42 | 43 | return format.replace(/YYYY|MM|DD/g, (match) => replacements[match]); 44 | } 45 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /core/crdt/test/test-types.ts: -------------------------------------------------------------------------------- 1 | import { BrowserContext, Page } from '@playwright/test'; 2 | 3 | export interface DrawingClient { 4 | context: BrowserContext; 5 | page: Page; 6 | } 7 | 8 | export interface TestResult { 9 | clientCount: number; 10 | testType: TestType; 11 | diffRatio: number; 12 | success: boolean; 13 | diffImagePath?: string; 14 | } 15 | 16 | export type TestType = 17 | | 'static-identical' // 이미 그려진 동일 이미지 비교 18 | | 'static-different' // 이미 그려진 다른 이미지 비교 19 | | 'concurrent-identical' // 동시에 같은 그림 그리기 20 | | 'concurrent-different' // 동시에 다른 그림 그리기 21 | | 'random-drawing'; // 랜덤 그리기 22 | 23 | // 테스트 설정 24 | export const TEST_CONFIG = { 25 | url: 'http://localhost:5173/', 26 | syncWaitTime: 1000, 27 | clientCounts: [2, 3, 5], 28 | viewport: { width: 1280, height: 720 }, 29 | timeouts: { 30 | test: 300000, 31 | page: 30000, 32 | domReady: 10000, 33 | canvas: 10000, 34 | }, 35 | } as const; 36 | 37 | export type DrawingFunction = (page: Page, clientIndex?: number) => Promise; 38 | 39 | export const ACCEPTANCE_CRITERIA = { 40 | 'static-identical': { maxDiff: 0.01 }, // 동일 이미지는 차이가 매우 적어야 함 41 | 'static-different': { minDiff: 0.1, expectedDiff: 0.2 }, // 다른 이미지는 충분한 차이가 있어야 함 42 | 'concurrent-identical': { maxDiff: 0.01 }, // 동시 동일 그리기도 차이가 매우 적어야 함 43 | 'concurrent-different': { minDiff: 0.45 }, // 공유 캔버스라 최종 상태는 동기화되어야 함 44 | 'random-drawing': { maxDiff: 0.05 }, // 공유 캔버스라 최종 상태는 동기화되어야 함 45 | } as const; 46 | -------------------------------------------------------------------------------- /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.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 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 | -------------------------------------------------------------------------------- /server/src/common/services/timer.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Server } from 'socket.io'; 3 | 4 | interface TimerCallbacks { 5 | onTick: (remaining: number) => void; 6 | onTimeUp: () => void; 7 | } 8 | 9 | @Injectable() 10 | export class TimerService { 11 | private timers = new Map(); 12 | 13 | constructor() {} 14 | 15 | startTimer(server: Server, roomId: string, duration: number, callbacks: TimerCallbacks) { 16 | this.stopGameTimer(roomId); 17 | 18 | const startTime = Date.now(); 19 | const endTime = startTime + duration; 20 | 21 | const intervalId = setInterval(() => { 22 | const now = Date.now(); 23 | const remaining = Math.max(0, endTime - now); 24 | 25 | callbacks.onTick(remaining); 26 | 27 | if (remaining === 0) { 28 | this.stopGameTimer(roomId); 29 | callbacks.onTimeUp(); 30 | } 31 | }, 5000); 32 | 33 | this.timers.set(roomId, { 34 | intervalId, 35 | endTime, 36 | duration, 37 | }); 38 | } 39 | 40 | stopGameTimer(roomId: string) { 41 | const timer = this.timers.get(roomId); 42 | 43 | if (timer) { 44 | clearInterval(timer.intervalId); 45 | this.timers.delete(roomId); 46 | } 47 | } 48 | 49 | getRemainingTime(roomId: string) { 50 | const timer = this.timers.get(roomId); 51 | if (!timer) return 0; 52 | 53 | return Math.max(0, timer.endTime - Date.now()); 54 | } 55 | 56 | clearAllTimers() { 57 | this.timers.forEach((timer) => { 58 | clearInterval(timer.intervalId); 59 | }); 60 | this.timers.clear(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /server/src/redis/redis.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import Redis from 'ioredis'; 4 | 5 | @Injectable() 6 | export class RedisService { 7 | private readonly redis: Redis; 8 | 9 | constructor(private configService: ConfigService) { 10 | this.redis = new Redis({ 11 | host: this.configService.get('REDIS_HOST'), 12 | port: parseInt(this.configService.get('REDIS_PORT'), 10), 13 | }); 14 | } 15 | 16 | async hset(key: string, value: Record): Promise { 17 | await this.redis.hset(key, value); 18 | } 19 | 20 | async hget(key: string, field: string): Promise { 21 | const value = await this.redis.hget(key, field); 22 | return value; 23 | } 24 | 25 | async hgetall(key: string) { 26 | const value = await this.redis.hgetall(key); 27 | return Object.keys(value).length > 0 ? value : null; 28 | } 29 | 30 | async del(key: string): Promise { 31 | await this.redis.del(key); 32 | } 33 | 34 | async lpush(key: string, value: string): Promise { 35 | await this.redis.lpush(key, value); 36 | } 37 | 38 | async lrange(key: string, start: number, stop: number): Promise { 39 | const values = await this.redis.lrange(key, start, stop); 40 | return values; 41 | } 42 | 43 | async lrangeAll(key: string): Promise { 44 | return await this.lrange(key, 0, -1); 45 | } 46 | 47 | async lrem(key: string, count: number, value: string): Promise { 48 | await this.redis.lrem(key, count, value); 49 | } 50 | 51 | async exists(key: string): Promise { 52 | return await this.redis.exists(key); 53 | } 54 | 55 | multi() { 56 | return this.redis.multi(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /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/src/components/ui/Dropdown.stories.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import Dropdown, { DropdownProps } from './Dropdown'; 3 | import type { Meta, StoryObj } from '@storybook/react'; 4 | 5 | type Story = StoryObj; 6 | 7 | const sampleOptions = ['옵션 1', '옵션 2', '옵션 3']; 8 | 9 | export default { 10 | title: 'components/ui/Dropdown', 11 | component: Dropdown, 12 | argTypes: { 13 | options: { 14 | control: 'object', 15 | description: '드롭다운에 표시될 옵션 목록', 16 | }, 17 | selectedValue: { 18 | control: 'select', 19 | options: sampleOptions, 20 | description: '현재 선택된 값', 21 | }, 22 | handleChange: { 23 | description: '값이 변경될 때 호출되는 함수', 24 | action: 'changed', 25 | }, 26 | className: { 27 | control: 'text', 28 | description: '추가 스타일링', 29 | }, 30 | }, 31 | parameters: { 32 | docs: { 33 | description: { 34 | component: 35 | '사용자가 여러 옵션 중 하나를 선택할 수 있는 드롭다운 컴포넌트입니다.

드롭다운을 클릭하고 옵션을 선택해보세요.', 36 | }, 37 | }, 38 | }, 39 | tags: ['autodocs'], 40 | } satisfies Meta; 41 | 42 | const DefaultExample = (args: DropdownProps) => { 43 | const [selectedValue, setSelectedValue] = useState(args.selectedValue); 44 | 45 | useEffect(() => { 46 | setSelectedValue(args.selectedValue); 47 | }, [args.selectedValue]); 48 | 49 | const handleChange = (value: string) => { 50 | setSelectedValue(value); 51 | args.handleChange(value); 52 | }; 53 | 54 | return ; 55 | }; 56 | 57 | export const Default: Story = { 58 | args: { 59 | selectedValue: sampleOptions[0], 60 | options: sampleOptions, 61 | }, 62 | render: (args) => , 63 | }; 64 | -------------------------------------------------------------------------------- /client/src/utils/getDrawPoint.ts: -------------------------------------------------------------------------------- 1 | import { TouchEvent as ReactTouchEvent, MouseEvent as ReactMouseEvent } from 'react'; 2 | import { Point } from '@troublepainter/core'; 3 | 4 | /** 5 | * TouchEvent에서 첫 번째 터치 좌표를 가져오는 Util 함수입니다. 6 | * - 이벤트 객체와 캔버스 객체를 매개변수로 받아, 캔버스 Element 요소 크기 기준의 상대 좌표를 반환합니다. 7 | * 8 | * @param e - TouchEvent 객체 9 | * @param canvas - HTMLCanvasElement 객체 10 | * @returns 사용자 정의 Point 타입 객체 11 | * 12 | * @example 13 | * ```typescript 14 | * if (e.nativeEvent instanceof TouchEvent) return getTouchPoint(canvas, e.nativeEvent); 15 | * ``` 16 | * 17 | * @category Utils 18 | */ 19 | const getTouchPoint = (canvas: HTMLCanvasElement, e: TouchEvent): Point => { 20 | const { clientX, clientY } = e.touches[0]; 21 | const { top, left } = canvas.getBoundingClientRect(); 22 | return { x: clientX - left, y: clientY - top }; 23 | }; 24 | 25 | /** 26 | * Canvas 클릭 혹은 터치 좌표를 가져오는 Util 함수입니다. 27 | * - 이벤트 객체와 캔버스 객체를 매개변수로 받아, 캔버스 Element 요소 크기 기준의 상대 좌표를 반환합니다. 28 | * - 좌표는 캔버스 좌측 상단이 (0,0) 입니다. 29 | * 30 | * @param e - MouseEvent 혹은 TouchEvent 31 | * @param canvas - HTMLCanvasElement 객체 32 | * @returns 사용자 정의 Point 타입 객체 33 | * @throws {Error} 인자 e가 MouseEvent나 TouchEvent가 아닐 경우 에러를 던집니다. 34 | * 35 | * @example 36 | * ```typescript 37 | * const canvas = canvasRef.current; 38 | * if (canvas.current) const {x, y} = getDrawPoint(e, canvas.current); 39 | * ``` 40 | * 41 | * @category Utils 42 | */ 43 | export const getDrawPoint = ( 44 | e: ReactTouchEvent | ReactMouseEvent, 45 | canvas: HTMLCanvasElement, 46 | ): Point => { 47 | if (e.nativeEvent instanceof MouseEvent) return { x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY }; 48 | else if (e.nativeEvent instanceof TouchEvent) return getTouchPoint(canvas, e.nativeEvent); 49 | else throw new Error('mouse 혹은 touch 이벤트가 아닙니다.'); 50 | }; 51 | -------------------------------------------------------------------------------- /client/src/utils/playerIdStorage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 플레이어 ID를 로컬 스토리지에서 관리하는 유틸리티입니다. 3 | * 4 | * @remarks 5 | * 각 방에 접속한 플레이어의 ID를 브라우저의 localStorage에 저장하고 관리합니다. 6 | * 이를 통해 페이지 새로고침이나 재접속 시에도 플레이어 식별이 가능합니다. 7 | * 8 | * @example 9 | * ```typescript 10 | * // 플레이어 ID 저장 11 | * playerIdStorageUtils.setPlayerId("room123", "player456"); 12 | * 13 | * // 저장된 플레이어 ID 조회 14 | * const playerId = playerIdStorageUtils.getPlayerId("room123"); 15 | * 16 | * // 방 퇴장 시 해당 방의 플레이어 ID 제거 17 | * playerIdStorageUtils.removePlayerId("room123"); 18 | * ``` 19 | * 20 | * @category Utils 21 | */ 22 | 23 | /** 로컬 스토리지에 사용되는 키 형식 정의 */ 24 | export const STORAGE_KEYS = { 25 | /** 26 | * 방 ID를 기반으로 플레이어 ID 스토리지 키를 생성합니다. 27 | * @param roomId - 방 식별자 28 | * @returns 스토리지 키 문자열 29 | */ 30 | PLAYER_ID: (roomId: string) => `playerId_${roomId}`, 31 | } as const; 32 | 33 | export const playerIdStorageUtils = { 34 | /** 35 | * 특정 방의 플레이어 ID를 조회합니다. 36 | * @param roomId - 방 식별자 37 | * @returns 플레이어 ID 또는 null 38 | */ 39 | getPlayerId: (roomId: string) => localStorage.getItem(STORAGE_KEYS.PLAYER_ID(roomId)), 40 | 41 | /** 42 | * 특정 방의 플레이어 ID를 저장합니다. 43 | * @param roomId - 방 식별자 44 | * @param playerId - 저장할 플레이어 ID 45 | */ 46 | setPlayerId: (roomId: string, playerId: string) => localStorage.setItem(STORAGE_KEYS.PLAYER_ID(roomId), playerId), 47 | 48 | /** 49 | * 특정 방의 플레이어 ID를 삭제합니다. 50 | * @param roomId - 방 식별자 51 | */ 52 | removePlayerId: (roomId: string) => localStorage.removeItem(STORAGE_KEYS.PLAYER_ID(roomId)), 53 | 54 | /** 55 | * 모든 방의 플레이어 ID를 삭제합니다. 56 | * @remarks 57 | * 주로 새로운 접속 시도나 전체 초기화가 필요할 때 사용됩니다. 58 | */ 59 | removeAllPlayerIds: () => { 60 | Object.keys(localStorage) 61 | .filter((key) => key.startsWith('playerId_')) 62 | .forEach((key) => localStorage.removeItem(key)); 63 | }, 64 | }; 65 | -------------------------------------------------------------------------------- /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/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/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/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/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/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 | -------------------------------------------------------------------------------- /server/src/chat/chat.gateway.ts: -------------------------------------------------------------------------------- 1 | import { ConnectedSocket, MessageBody, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; 2 | import { Server, Socket } from 'socket.io'; 3 | import { ChatService } from './chat.service'; 4 | import { UseFilters } from '@nestjs/common'; 5 | import { WsExceptionFilter } from 'src/filters/ws-exception.filter'; 6 | import { BadRequestException, PlayerNotFoundException, RoomNotFoundException } from 'src/exceptions/game.exception'; 7 | 8 | @WebSocketGateway({ 9 | cors: '*', 10 | namespace: '/socket.io/chat', 11 | }) 12 | @UseFilters(WsExceptionFilter) 13 | export class ChatGateway { 14 | constructor(private readonly chatService: ChatService) {} 15 | 16 | @WebSocketServer() 17 | server: Server; 18 | 19 | handleConnection(client: Socket) { 20 | const roomId = client.handshake.auth.roomId; 21 | const playerId = client.handshake.auth.playerId; 22 | 23 | if (!roomId || !playerId) throw new BadRequestException('Room ID and Player ID are required'); 24 | 25 | const roomExists = this.chatService.existsRoom(roomId); 26 | if (!roomExists) throw new RoomNotFoundException('Room not found'); 27 | const playerExists = this.chatService.existsPlayer(roomId, playerId); 28 | if (!playerExists) throw new PlayerNotFoundException('Player not found in room'); 29 | 30 | client.data.roomId = roomId; 31 | client.data.playerId = playerId; 32 | 33 | client.join(roomId); 34 | } 35 | 36 | @SubscribeMessage('sendMessage') 37 | async handleSendMessage(@ConnectedSocket() client: Socket, @MessageBody() data: { message: string }) { 38 | const roomId = client.data.roomId; 39 | const playerId = client.data.playerId; 40 | 41 | if (!roomId || !playerId) throw new BadRequestException('Room ID and Player ID are required'); 42 | 43 | const newMessage = await this.chatService.sendMessage(roomId, playerId, data.message); 44 | 45 | client.to(roomId).emit('messageReceived', newMessage); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /server/src/drawing/drawing.gateway.ts: -------------------------------------------------------------------------------- 1 | import { UseFilters } from '@nestjs/common'; 2 | import { 3 | ConnectedSocket, 4 | MessageBody, 5 | OnGatewayConnection, 6 | SubscribeMessage, 7 | WebSocketGateway, 8 | WebSocketServer, 9 | } from '@nestjs/websockets'; 10 | import { Server, Socket } from 'socket.io'; 11 | import { BadRequestException, PlayerNotFoundException, RoomNotFoundException } from 'src/exceptions/game.exception'; 12 | import { WsExceptionFilter } from 'src/filters/ws-exception.filter'; 13 | import { DrawingService } from './drawing.service'; 14 | 15 | @WebSocketGateway({ 16 | cors: '*', 17 | namespace: '/socket.io/drawing', 18 | }) 19 | @UseFilters(WsExceptionFilter) 20 | export class DrawingGateway implements OnGatewayConnection { 21 | @WebSocketServer() 22 | server: Server; 23 | 24 | constructor(private readonly drawingService: DrawingService) {} 25 | 26 | handleConnection(client: Socket) { 27 | const roomId = client.handshake.auth.roomId; 28 | const playerId = client.handshake.auth.playerId; 29 | 30 | if (!roomId || !playerId) throw new BadRequestException('Room ID and Player ID are required'); 31 | 32 | const roomExists = this.drawingService.existsRoom(roomId); 33 | if (!roomExists) throw new RoomNotFoundException('Room not found'); 34 | const playerExists = this.drawingService.existsPlayer(roomId, playerId); 35 | if (!playerExists) throw new PlayerNotFoundException('Player not found in room'); 36 | 37 | client.data.roomId = roomId; 38 | client.data.playerId = playerId; 39 | 40 | client.join(roomId); 41 | } 42 | 43 | @SubscribeMessage('draw') 44 | async handleDraw(@ConnectedSocket() client: Socket, @MessageBody() data: { drawingData: any }) { 45 | const roomId = client.data.roomId; 46 | if (!roomId) throw new BadRequestException('Room ID is required'); 47 | 48 | client.to(roomId).emit('drawUpdated', { 49 | playerId: client.data.playerId, 50 | drawingData: data.drawingData, 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /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/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/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/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/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/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 | -------------------------------------------------------------------------------- /server/src/common/clova-client.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import axios, { AxiosInstance } from 'axios'; 4 | import { Difficulty } from './enums/game.status.enum'; 5 | 6 | @Injectable() 7 | export class ClovaClient { 8 | private readonly client: AxiosInstance; 9 | 10 | constructor(private configService: ConfigService) { 11 | const apiKey = this.configService.get('CLOVA_API_KEY'); 12 | const gatewayKey = this.configService.get('CLOVA_GATEWAY_KEY'); 13 | 14 | this.client = axios.create({ 15 | baseURL: 'https://clovastudio.stream.ntruss.com/testapp/v1', 16 | headers: { 17 | 'X-NCP-CLOVASTUDIO-API-KEY': apiKey, 18 | 'X-NCP-APIGW-API-KEY': gatewayKey, 19 | }, 20 | }); 21 | } 22 | async getDrawingWords(difficulty: Difficulty, count: number) { 23 | const categories = ['영화', '음식', '일상용품', '스포츠', '동물', '교통수단', '캐릭터', '악기', '직업', 'IT']; 24 | 25 | const selectedCategories = categories.sort(() => Math.random() - 0.5).slice(0, Math.floor(Math.random() * 2) + 2); 26 | 27 | const request = { 28 | messages: [ 29 | { 30 | role: 'system', 31 | content: '당신은 창의적인 드로잉 게임의 출제자입니다. 매번 새롭고 다양한 단어들을 제시해주세요.', 32 | }, 33 | { 34 | role: 'user', 35 | content: `${difficulty} 난이도의 명사 ${count}개를 제시해주세요. 36 | - 주로 ${selectedCategories.join(', ')} 관련 단어들 위주로 선택 37 | - 30초 안에 그릴 수 있는 단어만 선택 38 | - 단어만 나열 (1. 2. 3. 형식) 39 | - 설명이나 부연설명 없이 단어만 작성 40 | `, 41 | }, 42 | ], 43 | topP: 0.8, 44 | topK: 0, 45 | maxTokens: 256, 46 | temperature: 0.8, 47 | repeatPenalty: 5.0, 48 | stopBefore: [], 49 | includeAiFilters: true, 50 | seed: 0, 51 | }; 52 | 53 | try { 54 | const response = await this.client.post('/chat-completions/HCX-003', request); 55 | const result = response.data.result.message.content 56 | .split('\n') 57 | .map((line: string) => line.trim()) 58 | .filter((line: string) => line) 59 | .map((line: string) => line.replace(/^\d+\.\s*/, '')); 60 | return result; 61 | } catch (error) { 62 | throw new Error(`CLOVA API request failed: ${error.message}`); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /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/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/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/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes } from 'react'; 2 | import ArrowDownIcon from '@/assets/arrow.svg'; 3 | import { SHORTCUT_KEYS } from '@/constants/shortcutKeys'; 4 | import { useDropdown } from '@/hooks/useDropdown'; 5 | import { cn } from '@/utils/cn'; 6 | 7 | export interface DropdownProps extends HTMLAttributes { 8 | options: string[]; 9 | handleChange: (value: string) => void; 10 | selectedValue: string; 11 | shortcutKey?: keyof typeof SHORTCUT_KEYS; 12 | } 13 | 14 | const Dropdown = ({ options, handleChange, selectedValue, shortcutKey, className, ...props }: DropdownProps) => { 15 | const { isOpen, toggleDropdown, handleOptionClick, dropdownRef, optionRefs, handleOptionKeyDown } = useDropdown({ 16 | handleChange, 17 | shortcutKey, 18 | options, 19 | }); 20 | 21 | return ( 22 |
23 | 34 | 35 |
42 |
43 | {options.map((option, index) => ( 44 | 55 | ))} 56 |
57 |
58 |
59 | ); 60 | }; 61 | 62 | export default Dropdown; 63 | -------------------------------------------------------------------------------- /client/src/utils/soundManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 애플리케이션의 사운드 재생을 관리하는 클래스입니다. 3 | * 4 | * @remarks 5 | * - 싱글턴 패턴을 사용하여 인스턴스가 하나만 존재하도록 보장합니다. 6 | * - 효율적인 재생을 위해 사운드를 미리 로드합니다. 7 | * - 자동 재생 제한을 우아하게 처리합니다. 8 | * 9 | * @example 10 | * ```typescript 11 | * const soundManager = SoundManager.getInstance(); 12 | * soundManager.preloadSound(SOUND_IDS.ENTRY, 'path/to/entry-sound.mp3'); 13 | * await soundManager.playSound(SOUND_IDS.ENTRY, 0.5); 14 | * ``` 15 | */ 16 | export class SoundManager { 17 | private static instance: SoundManager; 18 | private audioMap: Map = new Map(); 19 | 20 | private constructor() {} 21 | 22 | /** 23 | * SoundManager의 싱글턴 인스턴스를 가져옵니다. 24 | * 25 | * @returns SoundManager 인스턴스 26 | */ 27 | static getInstance(): SoundManager { 28 | if (!SoundManager.instance) { 29 | SoundManager.instance = new SoundManager(); 30 | } 31 | return SoundManager.instance; 32 | } 33 | 34 | /** 35 | * 나중에 재생할 사운드를 미리 로드합니다. 36 | * 37 | * @param id - 사운드의 고유 식별자 38 | * @param src - 사운드 파일의 소스 URL 39 | */ 40 | preloadSound(id: string, src: string): void { 41 | if (!this.audioMap.has(id)) { 42 | const audio = new Audio(src); 43 | audio.load(); // 미리 로드 44 | this.audioMap.set(id, audio); 45 | } 46 | } 47 | 48 | /** 49 | * 미리 로드된 사운드를 재생합니다. 50 | * 51 | * @param id - 재생할 사운드의 식별자 52 | * @param volume - 볼륨 레벨 (0.0에서 1.0), 기본값은 1입니다. 53 | * @returns 사운드가 재생되기 시작할 때까지 해결되는 Promise 54 | * 55 | * @remarks 56 | * - 재생이 끝나면 오디오를 처음으로 되감습니다. 57 | * - 자동 재생 제한을 처리하고 적절한 메시지를 로그로 남깁니다. 58 | */ 59 | async playSound(id: string, volume = 1): Promise { 60 | const audio = this.audioMap.get(id); 61 | if (!audio) return; 62 | 63 | try { 64 | audio.volume = volume; 65 | await audio.play(); 66 | // 재생이 끝나면 처음으로 되감기 67 | audio.currentTime = 0; 68 | } catch (error) { 69 | if (error instanceof Error) { 70 | if (error.name === 'NotAllowedError') { 71 | console.info('브라우저 정책에 의해 사운드 자동 재생이 차단되었습니다.'); 72 | } else { 73 | console.error('사운드 재생 오류:', error); 74 | } 75 | } 76 | } 77 | } 78 | } 79 | 80 | /** 81 | * 사운드 식별자를 포함하는 상수 객체입니다. 82 | * 83 | * @example 84 | * ```typescript 85 | * soundManager.preloadSound(SOUND_IDS.ENTRY, '@/assets/sounds/entry-sound-effect.mp3'); 86 | * ``` 87 | */ 88 | export const SOUND_IDS = { 89 | ENTRY: 'entry', 90 | WIN: 'win', 91 | LOSS: 'loss', 92 | } as const; 93 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/common": "^10.0.0", 24 | "@nestjs/config": "^3.3.0", 25 | "@nestjs/core": "^10.0.0", 26 | "@nestjs/platform-express": "^10.0.0", 27 | "@nestjs/platform-socket.io": "^10.4.7", 28 | "@nestjs/websockets": "^10.4.7", 29 | "@troublepainter/core": "workspace:*", 30 | "axios": "^1.7.7", 31 | "ioredis": "^5.4.1", 32 | "reflect-metadata": "^0.2.0", 33 | "rxjs": "^7.8.1", 34 | "socket.io": "^4.8.1", 35 | "uuid": "^11.0.3" 36 | }, 37 | "devDependencies": { 38 | "@nestjs/cli": "^10.0.0", 39 | "@nestjs/schematics": "^10.0.0", 40 | "@nestjs/testing": "^10.0.0", 41 | "@types/axios": "^0.14.4", 42 | "@types/express": "^5.0.0", 43 | "@types/jest": "^29.5.2", 44 | "@types/node": "^20.3.1", 45 | "@types/socket.io": "^3.0.2", 46 | "@types/supertest": "^6.0.0", 47 | "@typescript-eslint/eslint-plugin": "^8.0.0", 48 | "@typescript-eslint/parser": "^8.0.0", 49 | "eslint": "^8.0.0", 50 | "eslint-config-prettier": "^9.0.0", 51 | "eslint-plugin-prettier": "^5.0.0", 52 | "ioredis-mock": "^8.9.0", 53 | "jest": "^29.5.0", 54 | "prettier": "^3.0.0", 55 | "source-map-support": "^0.5.21", 56 | "supertest": "^7.0.0", 57 | "ts-jest": "^29.1.0", 58 | "ts-loader": "^9.4.3", 59 | "ts-node": "^10.9.1", 60 | "tsconfig-paths": "^4.2.0", 61 | "typescript": "^5.1.3" 62 | }, 63 | "jest": { 64 | "moduleFileExtensions": [ 65 | "js", 66 | "json", 67 | "ts" 68 | ], 69 | "rootDir": "src", 70 | "testRegex": ".*\\.spec\\.ts$", 71 | "transform": { 72 | "^.+\\.(t|j)s$": "ts-jest" 73 | }, 74 | "collectCoverageFrom": [ 75 | "**/*.(t|j)s" 76 | ], 77 | "coverageDirectory": "../coverage", 78 | "testEnvironment": "node" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /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/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/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/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/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/utils/timer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 타이머 콜백 타입을 정의합니다. 3 | * `remainingTime`이 주어지면 남은 시간을 받아 처리할 수 있습니다. 4 | * @param remainingTime - 남은 시간(초) 또는 완료 콜백에서 사용되는 매개변수 5 | */ 6 | type TimerCallback = (remainingTime?: number) => void; 7 | 8 | /** 9 | * 타이머 객체에 대한 인터페이스입니다. 10 | * `handleTick`은 타이머가 진행 중일 때마다 호출되고, `handleComplete`는 타이머가 완료될 때 호출됩니다. 11 | */ 12 | interface Timer { 13 | handleTick?: TimerCallback; 14 | handleComplete?: TimerCallback; 15 | delay: number; 16 | } 17 | 18 | /** 19 | * 현재 시간과 시작 시간, 지연 시간을 기준으로 남은 시간을 계산합니다. 20 | * @param currentTime - 현재 시간(밀리초 단위) 21 | * @param startTime - 타이머가 시작된 시간(밀리초 단위) 22 | * @param delay - 타이머의 전체 지속 시간(밀리초 단위) 23 | * @returns 남은 시간(초 단위) 24 | */ 25 | function calculateRemainingTime(currentTime: number, startTime: number, delay: number) { 26 | const elapsedTime = currentTime - startTime; 27 | return Math.ceil((delay - elapsedTime) / 1000); 28 | } 29 | 30 | /** 31 | * 주어진 콜백 함수를 실행합니다. 32 | * `remainingTime`이 있을 경우에는 해당 값을 전달하고, 그렇지 않으면 콜백을 그냥 실행합니다. 33 | * @param callback - 실행할 콜백 함수 34 | * @param remainingTime - 남은 시간(초) 값 35 | */ 36 | function executeCallback(callback: TimerCallback, remainingTime: number) { 37 | if (!callback) return; 38 | 39 | if (callback.length > 0) { 40 | callback(remainingTime); 41 | } else { 42 | callback(); 43 | } 44 | } 45 | 46 | /** 47 | * 타이머를 실행하는 함수입니다. 48 | * `handleTick`과 `handleComplete` 콜백을 각각 타이머 진행 중과 완료 시 호출합니다. 49 | * 50 | * @param {Timer} param0 - 타이머 설정 객체 51 | * @param {number} param0.delay - 타이머의 전체 지속 시간(밀리초 단위) 52 | * 53 | * @returns {Function} 타이머를 취소하는 함수 54 | * 55 | * @example 56 | * export const useModal = (autoCloseDelay: number) => { 57 | * const [isModalOpened, setModalOpened] = useState(false); 58 | * 59 | * const closeModal = () => { 60 | * setModalOpened(false); 61 | * }; 62 | * 63 | * const openModal = () => { 64 | * setModalOpened(true); 65 | * if (autoCloseDelay) { 66 | * return timer({ handleComplete: closeModal, delay: autoCloseDelay }); 67 | * } 68 | * }; 69 | * 70 | * ... 71 | * 72 | * return { openModal, closeModal, handleKeyDown, isModalOpened }; 73 | * }; 74 | * 75 | * @category Utils 76 | */ 77 | export function timer({ handleTick, handleComplete, delay }: Timer): () => void { 78 | const startTime = performance.now(); 79 | let animationFrameId: number; 80 | 81 | const animate = (currentTime: number) => { 82 | const remainingTime = calculateRemainingTime(currentTime, startTime, delay); 83 | const isTimeEnd = currentTime - startTime >= delay; 84 | 85 | if (isTimeEnd) { 86 | if (handleComplete) executeCallback(handleComplete, remainingTime); 87 | if (handleTick) executeCallback(handleTick, remainingTime); 88 | } else { 89 | if (handleTick) executeCallback(handleTick, remainingTime); 90 | animationFrameId = requestAnimationFrame(animate); 91 | } 92 | }; 93 | 94 | animationFrameId = requestAnimationFrame(animate); 95 | 96 | return () => { 97 | cancelAnimationFrame(animationFrameId); 98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------