From 776d3f5d5d6d072a99602bd9eaf854abfc615687 Mon Sep 17 00:00:00 2001 From: Anas Date: Thu, 17 Jul 2025 17:21:15 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=EA=B2=8C=EC=8A=A4=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20UI=20&=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/canvas/CanvasUIPC.tsx | 16 +----- src/components/game/GameCanvas.tsx | 21 +++++-- src/components/game/GameReadyModal.tsx | 23 +++++--- .../{modal => game}/GameResultModal.tsx | 0 .../{modal => game}/QuestionModal.tsx | 0 src/components/modal/LoginModalContent.tsx | 29 ++++------ src/services/authService.ts | 23 ++++++++ src/utils/getRandomName.ts | 57 +++++++++++++++++++ tailwind.config.js | 15 +++++ 9 files changed, 139 insertions(+), 45 deletions(-) rename src/components/{modal => game}/GameResultModal.tsx (100%) rename src/components/{modal => game}/QuestionModal.tsx (100%) create mode 100644 src/utils/getRandomName.ts create mode 100644 tailwind.config.js diff --git a/src/components/canvas/CanvasUIPC.tsx b/src/components/canvas/CanvasUIPC.tsx index 3abcf74..61ac547 100644 --- a/src/components/canvas/CanvasUIPC.tsx +++ b/src/components/canvas/CanvasUIPC.tsx @@ -191,21 +191,9 @@ export default function CanvasUIPC({ {/* 항상 보이는 메뉴 토글 버튼 (햄버거 아이콘) */} {/* isMenuOpen이 true일 때만 드롭다운 메뉴가 보입니다. */} diff --git a/src/components/game/GameCanvas.tsx b/src/components/game/GameCanvas.tsx index 5f50f7d..bc3cad8 100644 --- a/src/components/game/GameCanvas.tsx +++ b/src/components/game/GameCanvas.tsx @@ -15,9 +15,9 @@ import useSound from 'use-sound'; import { useGameSocketIntegration } from '../gameSocketIntegration'; import { useNavigate } from 'react-router-dom'; import GameTimer from './GameTimer'; // GameTimer import 추가 -import GameResultModal from '../modal/GameResultModal'; // 게임 결과 모달 import +import GameResultModal from './GameResultModal'; // 게임 결과 모달 import import DeathModal from '../modal/DeathModal'; // 사망 모달 import -import QuestionModal from '../modal/QuestionModal'; // 문제 모달 import +import QuestionModal from './QuestionModal'; // 문제 모달 import import ExitModal from '../modal/ExitModal'; // 나가기 모달 import import { @@ -745,14 +745,14 @@ function GameCanvas({ // 시간 초과시 자동으로 false 결과 전송 startCooldown(1); setLives((prev) => Math.max(0, prev - 1)); - + sendGameResult({ x: currentPixel.x, y: currentPixel.y, color: currentPixel.color, result: false, }); - + setShowQuestionModal(false); setShowResult(false); setCurrentPixel(null); @@ -762,7 +762,14 @@ function GameCanvas({ return () => { clearInterval(timerId); }; - }, [showQuestionModal, questionTimeLeft, currentPixel, startCooldown, setLives, sendGameResult]); + }, [ + showQuestionModal, + questionTimeLeft, + currentPixel, + startCooldown, + setLives, + sendGameResult, + ]); // 게임 데이터 및 캔버스 초기화 const { getSynchronizedServerTime } = useTimeSyncStore(); @@ -805,7 +812,9 @@ function GameCanvas({ // 게임 총 시간 계산 및 설정 const endTime = new Date(gameData.endedAt).getTime(); - const calculatedTotalGameDuration = Math.floor((endTime - startTime) / 1000); + const calculatedTotalGameDuration = Math.floor( + (endTime - startTime) / 1000 + ); setTotalGameDuration(calculatedTotalGameDuration); setGameTime(calculatedTotalGameDuration); diff --git a/src/components/game/GameReadyModal.tsx b/src/components/game/GameReadyModal.tsx index b4fa9c4..3e21dc3 100644 --- a/src/components/game/GameReadyModal.tsx +++ b/src/components/game/GameReadyModal.tsx @@ -4,7 +4,6 @@ import type { WaitingRoomData } from '../../api/GameAPI'; import { useTimeSyncStore } from '../../store/timeSyncStore'; import { useToastStore } from '../../store/toastStore'; - interface GameReadyModalProps { isOpen: boolean; onClose: (data?: WaitingRoomData) => void; @@ -13,7 +12,13 @@ interface GameReadyModalProps { remainingTime?: number; } -const GameReadyModal = ({ isOpen, onClose, canvasId, color, remainingTime }: GameReadyModalProps) => { +const GameReadyModal = ({ + isOpen, + onClose, + canvasId, + color, + remainingTime, +}: GameReadyModalProps) => { const navigate = useNavigate(); const { showToast } = useToastStore(); const { getSynchronizedServerTime } = useTimeSyncStore(); @@ -25,12 +30,12 @@ const GameReadyModal = ({ isOpen, onClose, canvasId, color, remainingTime }: Gam if (!isOpen) { return; } - + // 부모 컴포넌트에서 전달받은 remainingTime 사용 if (remainingTime !== undefined) { setTimeUntilStart(remainingTime); setLoading(false); - + // 시간이 0이하인 경우 모달 닫기 if (remainingTime <= 0) { setTimeout(() => onClose(), 1000); // 1초 후 모달 닫기 @@ -48,7 +53,7 @@ const GameReadyModal = ({ isOpen, onClose, canvasId, color, remainingTime }: Gam const timer = setInterval(() => { // useTimeSyncStore를 사용하여 더 정확한 시간 계산 if (remainingTime === undefined) { - setTimeUntilStart(prev => { + setTimeUntilStart((prev) => { const newValue = prev !== null ? prev - 1 : 0; if (newValue <= 0) { clearInterval(timer); @@ -159,9 +164,11 @@ const GameReadyModal = ({ isOpen, onClose, canvasId, color, remainingTime }: Gam >
- {remainingTime !== undefined && remainingTime > 0 ? `${remainingTime}` : - timeUntilStart !== null && timeUntilStart > 0 ? `${timeUntilStart}` : - '시작!'} + {remainingTime !== undefined && remainingTime > 0 + ? `${remainingTime}` + : timeUntilStart !== null && timeUntilStart > 0 + ? `${timeUntilStart}` + : '--'}
diff --git a/src/components/modal/GameResultModal.tsx b/src/components/game/GameResultModal.tsx similarity index 100% rename from src/components/modal/GameResultModal.tsx rename to src/components/game/GameResultModal.tsx diff --git a/src/components/modal/QuestionModal.tsx b/src/components/game/QuestionModal.tsx similarity index 100% rename from src/components/modal/QuestionModal.tsx rename to src/components/game/QuestionModal.tsx diff --git a/src/components/modal/LoginModalContent.tsx b/src/components/modal/LoginModalContent.tsx index f4d9554..9854f7f 100644 --- a/src/components/modal/LoginModalContent.tsx +++ b/src/components/modal/LoginModalContent.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import OAuthButton from './OAuthButton'; -import { authService } from '../../services/authService'; +import { authService, guestLogin } from '../../services/authService'; +import { generateRandomNickname } from '../../utils/getRandomName'; type LoginModalContentProps = { onClose?: () => void; @@ -10,9 +11,10 @@ export default function LoginModalContent({ onClose }: LoginModalContentProps) { // 로그인 폼 자체의 상태를 스스로 관리합니다. const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [randomName, setRandomname] = useState(generateRandomNickname()); - const handleLogin = ({}) => { - // 실제 로그인 로직을 + const handleLogin = () => { + guestLogin(randomName); onClose?.(); }; @@ -32,33 +34,26 @@ export default function LoginModalContent({ onClose }: LoginModalContentProps) {

{/* 이메일 및 비밀번호 입력 */} - {/*
+
setUsername(e.target.value)} /> - setPassword(e.target.value)} - /> -
*/} +
{/* 로그인 버튼 */}
- {/*
+
-
*/} +
{/* 소셜 로그인 */} {/*

구글 계정으로 로그인

*/} diff --git a/src/services/authService.ts b/src/services/authService.ts index 91d5ee0..91eb240 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -113,3 +113,26 @@ export const authService = { } }, }; + +// --- 비회원 로그인 --- +export const guestLogin = async (nickname: string) => { + try { + const response = await apiClient.post('/users/signup', { + userName: nickname, + }); + const authHeader = response.headers['authorization']; + const accessToken = authHeader?.split(' ')[1]; + + const decodedToken = jwtDecode(accessToken); + const user = { + userId: decodedToken.sub.userId, + nickname: decodedToken.sub.nickName, + }; + + useAuthStore.getState().setAuth(accessToken, user); + toast.success(`${nickname}님 환영합니다!`); + } catch (error) { + console.error('Login failed', error); + toast.error('로그인에 실패했습니다.'); + } +}; diff --git a/src/utils/getRandomName.ts b/src/utils/getRandomName.ts new file mode 100644 index 0000000..0721859 --- /dev/null +++ b/src/utils/getRandomName.ts @@ -0,0 +1,57 @@ +export const generateRandomNickname = (): string => { + const names = [ + 'JavaScript', + 'Python', + 'Java', + 'C++', + 'C#', + 'Ruby', + 'Go', + 'Swift', + 'Kotlin', + 'TypeScript', + 'PHP', + 'Rust', + 'Scala', + 'Perl', + 'Haskell', + 'Lua', + 'Dart', + 'SQL', + 'MATLAB', + 'Assembly', + 'Flutter', + 'React', + 'Nest', + 'Next', + 'Zustand', + 'nodeJS', + 'Umlang', + 'R', + 'HTML', + 'CSS', + 'Scratch', + ]; + + const getRandomItem = (arr: string[]): string => + arr[Math.floor(Math.random() * arr.length)]; + + const generateRandomNumber = (min: number, max: number): string => + Math.floor(min + Math.random() * (max - min + 1)).toString(); + + const selectNamePart = (): string => getRandomItem(names); + + const namePart = selectNamePart(); + + const remainingLength = Math.max(0, 8 - namePart.length); + + const numberPart = + remainingLength > 0 + ? generateRandomNumber( + Math.pow(10, remainingLength - 1), + Math.pow(10, remainingLength) - 1 + ) + : ''; + + return `${namePart}${numberPart}`; +}; diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..29d7665 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,15 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + fontFamily: { + 'press-start': ['"Press Start 2P"', 'cursive'], + }, + }, + }, + plugins: [], +} From 8ffff74fb7eb62f3530ed58450996db5a9b26335 Mon Sep 17 00:00:00 2001 From: Anas Date: Thu, 17 Jul 2025 17:51:22 +0900 Subject: [PATCH 2/9] =?UTF-8?q?=EA=B2=8C=EC=9E=84=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20UI=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20useViewport?= =?UTF-8?q?=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/game/GameCanvas.tsx | 10 ++- src/components/game/GameTimer.tsx | 20 +++-- src/components/toast/NotificationToast.tsx | 90 ++++++++++++++-------- src/hooks/useViewport.ts | 13 ++++ 4 files changed, 95 insertions(+), 38 deletions(-) create mode 100644 src/hooks/useViewport.ts diff --git a/src/components/game/GameCanvas.tsx b/src/components/game/GameCanvas.tsx index bc3cad8..16f0375 100644 --- a/src/components/game/GameCanvas.tsx +++ b/src/components/game/GameCanvas.tsx @@ -28,6 +28,7 @@ import { VIEWPORT_BACKGROUND_COLOR, } from '../canvas/canvasConstants'; import GameReadyModal from './GameReadyModal'; +import { useViewport } from '../../hooks/useViewport'; // 게임 문제 타입 정의 interface GameQuestion { @@ -110,6 +111,9 @@ function GameCanvas({ } | null>(null); const flashingPixelRef = useRef<{ x: number; y: number } | null>(null); + const { width } = useViewport(); + const isMobile = width <= 768; + // 상태 관리 const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); const [hasError, setHasError] = useState(false); @@ -1041,7 +1045,11 @@ function GameCanvas({ {isGameStarted && ( <> {/* 나가기 버튼 및 생명 표시 */} -
+
- )} - -
+ {isOpen && ( +
+
+
+

+ {displayMessage} +

+
+
+ {canvasId && ( + + )} + +
+
+ )} ); }; diff --git a/src/hooks/useViewport.ts b/src/hooks/useViewport.ts new file mode 100644 index 0000000..fe334d8 --- /dev/null +++ b/src/hooks/useViewport.ts @@ -0,0 +1,13 @@ +import { useState, useEffect } from 'react'; + +export const useViewport = () => { + const [width, setWidth] = useState(window.innerWidth); + + useEffect(() => { + const handleWindowResize = () => setWidth(window.innerWidth); + window.addEventListener('resize', handleWindowResize); + return () => window.removeEventListener('resize', handleWindowResize); + }, []); + + return { width }; +}; From 84b146f652527c4c9fc09d2cb85c4494d5cb0775 Mon Sep 17 00:00:00 2001 From: yoominlee00 Date: Thu, 17 Jul 2025 19:47:12 +0900 Subject: [PATCH 3/9] SCRUM-217: duplicate-coloring --- src/components/game/GameCanvas.tsx | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/components/game/GameCanvas.tsx b/src/components/game/GameCanvas.tsx index 16f0375..059f41c 100644 --- a/src/components/game/GameCanvas.tsx +++ b/src/components/game/GameCanvas.tsx @@ -608,6 +608,42 @@ function GameCanvas({ const pixelData = sourceCtx.getImageData(pos.x, pos.y, 1, 1).data; const isBlack = pixelData[0] === 0 && pixelData[1] === 0 && pixelData[2] === 0; + // 현재 픽셀 색상이 내 색상인지 확인 + const hexToRgb = (hex: string) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : null; + }; + + const myColor = hexToRgb(userColor); + const isMyColor = + myColor && + Math.abs(pixelData[0] - myColor.r) < 5 && + Math.abs(pixelData[1] - myColor.g) < 5 && + Math.abs(pixelData[2] - myColor.b) < 5; + + if (isMyColor) { + // 토스트 대신 직접 UI에 메시지 표시 + const messageDiv = document.createElement('div'); + messageDiv.className = + 'fixed top-4 left-1/2 z-[9999] -translate-x-1/2 transform rounded-lg bg-blue-500 px-4 py-2 text-white shadow-lg'; + messageDiv.textContent = '이미 내 색상으로 칠해진 픽셀입니다.'; + document.body.appendChild(messageDiv); + + // 3초 후 메시지 제거 + setTimeout(() => { + if (document.body.contains(messageDiv)) { + document.body.removeChild(messageDiv); + } + }, 1000); + + return; + } if (isBlack) { // 검은색 픽셀이면 바로 그리기 (기존 로직과 동일) @@ -661,6 +697,7 @@ function GameCanvas({ startCooldown, setQuestionTimeLeft, waitingData, + toast, ]); // 문제 답변 제출 @@ -746,6 +783,18 @@ function GameCanvas({ } else if (questionTimeLeft === 0 && showQuestionModal) { // 시간 초과 시 자동으로 오답 처리 if (currentPixel) { + const messageDiv = document.createElement('div'); + messageDiv.className = + 'fixed top-4 left-1/2 z-[9999] -translate-x-1/2 transform rounded-lg bg-red-500 px-4 py-2 text-white shadow-lg'; + messageDiv.textContent = '⏰ 시간 초과! 자동으로 오답 처리되었습니다.'; + document.body.appendChild(messageDiv); + + // 3초 후 메시지 제거 + setTimeout(() => { + if (document.body.contains(messageDiv)) { + document.body.removeChild(messageDiv); + } + }, 1000); // 시간 초과시 자동으로 false 결과 전송 startCooldown(1); setLives((prev) => Math.max(0, prev - 1)); From 23c218d036ecfb04b80d6731c344836ababcfc07 Mon Sep 17 00:00:00 2001 From: ChangHyun Park Date: Thu, 17 Jul 2025 20:16:32 +0900 Subject: [PATCH 4/9] =?UTF-8?q?SCRUM-220=20:=20=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EC=97=90=20null=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/album/albumTypes.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/album/albumTypes.ts b/src/components/album/albumTypes.ts index 45f2eca..a09c7bb 100644 --- a/src/components/album/albumTypes.ts +++ b/src/components/album/albumTypes.ts @@ -8,8 +8,8 @@ export interface AlbumItemData { size_y: number; participant_count: number; total_try_count: number; - top_try_user_name: string; - top_try_user_count: number; - top_own_user_name: string; - top_own_user_count: number; + top_try_user_name?: string; + top_try_user_count?: number; + top_own_user_name?: string; + top_own_user_count?: number; } From 3df9db648ac5911d318f709d9bccc4fe646db7eb Mon Sep 17 00:00:00 2001 From: ChangHyun Park Date: Thu, 17 Jul 2025 20:17:21 +0900 Subject: [PATCH 5/9] =?UTF-8?q?SCRUM-220=20:=20=EA=B2=8C=EC=9E=84=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=EC=9D=98=20=EC=83=9D=EC=A1=B4=EC=9E=90?= =?UTF-8?q?=EA=B0=80=20=EC=97=86=EC=9D=84=20=EC=8B=9C=20"=EC=8A=B9?= =?UTF-8?q?=EC=9E=90=EC=97=86=EC=9D=8C"=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/modal/AlbumModalContent.tsx | 82 +++++++++++++--------- 1 file changed, 48 insertions(+), 34 deletions(-) diff --git a/src/components/modal/AlbumModalContent.tsx b/src/components/modal/AlbumModalContent.tsx index 5667b48..a82b2ff 100644 --- a/src/components/modal/AlbumModalContent.tsx +++ b/src/components/modal/AlbumModalContent.tsx @@ -86,12 +86,9 @@ const AlbumModalContent: React.FC = () => { if (response.isSuccess) { const albumsData: AlbumItemData[] = response.data .filter((canvas: AlbumItemData) => { - // 필터링: top_try_user_name, top_try_user_count, top_own_user_name, top_own_user_count 중 하나라도 null이면 제외 return ( canvas.top_try_user_name !== null && - canvas.top_try_user_count !== null && - canvas.top_own_user_name !== null && - canvas.top_own_user_count !== null + canvas.top_try_user_count !== null ); }) .map((canvas: AlbumItemData) => ({ @@ -294,13 +291,11 @@ const AlbumModalContent: React.FC = () => { ); } - const currentAlbum = albums[currentIndex]; - return (
{/* CSS 애니메이션 스타일 */}