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; } diff --git a/src/components/canvas/CanvasUIPC.tsx b/src/components/canvas/CanvasUIPC.tsx index 3abcf74..c9116e2 100644 --- a/src/components/canvas/CanvasUIPC.tsx +++ b/src/components/canvas/CanvasUIPC.tsx @@ -191,197 +191,69 @@ export default function CanvasUIPC({ {/* 항상 보이는 메뉴 토글 버튼 (햄버거 아이콘) */} {/* isMenuOpen이 true일 때만 드롭다운 메뉴가 보입니다. */} {isMenuOpen && ( -
+
{/* 로그인/마이페이지 버튼 */} -
- - - {isLoggedIn ? '마이페이지' : '로그인'} +
+ {/* 그룹 버튼 */} -
- - - 그룹 +
+ {/* 캔버스 버튼 */} -
- - - 캔버스 +
+ {/* 갤러리 버튼 */} -
- - - 갤러리 +
+ {/* BGM 버튼 */} -
- - - {isBgmPlaying ? 'BGM 끄기' : 'BGM 켜기'} +
+ {/* 도움말 버튼 */} -
- - - 도움말 +
+
)}
diff --git a/src/components/canvas/PixelCanvas.tsx b/src/components/canvas/PixelCanvas.tsx index 9771b44..7c81255 100644 --- a/src/components/canvas/PixelCanvas.tsx +++ b/src/components/canvas/PixelCanvas.tsx @@ -731,7 +731,7 @@ function PixelCanvas({ ); const handleCooltime = useCallback(() => { - startCooldown(10); + startCooldown(3); }, [startCooldown]); const handleConfirm = useCallback(() => { diff --git a/src/components/game/GameCanvas.tsx b/src/components/game/GameCanvas.tsx index 5f50f7d..2eb7805 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 { @@ -28,6 +28,8 @@ import { VIEWPORT_BACKGROUND_COLOR, } from '../canvas/canvasConstants'; import GameReadyModal from './GameReadyModal'; +import { useViewport } from '../../hooks/useViewport'; +import LifeIndicator from './LifeIndicator'; // 게임 문제 타입 정의 interface GameQuestion { @@ -56,6 +58,7 @@ function GameCanvas({ const [gameTime, setGameTime] = useState(90); // 실제 게임 시간 (초) const [totalGameDuration, setTotalGameDuration] = useState(90); // 전체 게임 시간 (초) const [lives, setLives] = useState(2); // 사용자 생명 (2개) + const [isLifeDecreasing, setIsLifeDecreasing] = useState(false); // 생명 차감 애니메이션 상태 const navigate = useNavigate(); const { canvas_id, setCanvasId } = useCanvasStore(); @@ -110,6 +113,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); @@ -604,6 +610,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) { // 검은색 픽셀이면 바로 그리기 (기존 로직과 동일) @@ -657,6 +699,7 @@ function GameCanvas({ startCooldown, setQuestionTimeLeft, waitingData, + toast, ]); // 문제 답변 제출 @@ -701,6 +744,28 @@ function GameCanvas({ setLives((prev) => Math.max(0, prev - 1)); startCooldown(1); + // 생명 차감 애니메이션 및 알림 표시 + setIsLifeDecreasing(true); + + // 생명 차감 알림 메시지 표시 + 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); + + // 애니메이션 완료 후 상태 초기화 + setTimeout(() => { + setIsLifeDecreasing(false); + }, 1000); + + // 2초 후 메시지 제거 + setTimeout(() => { + if (document.body.contains(messageDiv)) { + document.body.removeChild(messageDiv); + } + }, 2000); + sendGameResult({ x: currentPixel.x, y: currentPixel.y, @@ -721,6 +786,7 @@ function GameCanvas({ setQuestionTimeLeft, lives, setLives, + setIsLifeDecreasing, ]); // 문제 타이머 효과 @@ -745,14 +811,36 @@ function GameCanvas({ // 시간 초과시 자동으로 false 결과 전송 startCooldown(1); setLives((prev) => Math.max(0, prev - 1)); - + + // 생명 차감 애니메이션 및 알림 표시 + setIsLifeDecreasing(true); + + // 생명 차감 알림 메시지 표시 + 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); + + // 애니메이션 완료 후 상태 초기화 + setTimeout(() => { + setIsLifeDecreasing(false); + }, 1000); + + // 2초 후 메시지 제거 + setTimeout(() => { + if (document.body.contains(messageDiv)) { + document.body.removeChild(messageDiv); + } + }, 1000); + sendGameResult({ x: currentPixel.x, y: currentPixel.y, color: currentPixel.color, result: false, }); - + setShowQuestionModal(false); setShowResult(false); setCurrentPixel(null); @@ -762,7 +850,14 @@ function GameCanvas({ return () => { clearInterval(timerId); }; - }, [showQuestionModal, questionTimeLeft, currentPixel, startCooldown, setLives, sendGameResult]); + }, [ + showQuestionModal, + questionTimeLeft, + currentPixel, + startCooldown, + setLives, + sendGameResult, + ]); // 게임 데이터 및 캔버스 초기화 const { getSynchronizedServerTime } = useTimeSyncStore(); @@ -805,7 +900,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); @@ -1032,44 +1129,22 @@ function GameCanvas({ {isGameStarted && ( <> {/* 나가기 버튼 및 생명 표시 */} -
+
-
- {[...Array(2)].map((_, i) => ( -
- {i < lives ? ( - - - - ) : ( - - - - )} -
- ))} -
+
-
- {' '} - {/* 반응형 클래스 추가 */} -
- {/* 사이렌 애니메이션 요소 */} -

- {displayMessage} -

- {canvasId && ( - - )} - -
+ {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 }; +}; diff --git a/src/services/authService.ts b/src/services/authService.ts index 91d5ee0..49e923d 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -113,3 +113,29 @@ export const authService = { } }, }; + +// --- 비회원 로그인 --- +export const guestLogin = async (nickname: string) => { + try { + const response = await apiClient.post('/user/signup', { + userName: nickname, + }); + console.log(response); + const authHeader = response.headers['authorization']; + const accessToken = authHeader?.split(' ')[1]; + + // const accessToken = response.data.access_token; + + 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: [], +}