diff --git a/public/help-images/mypage_2.PNG b/public/help-images/mypage_2.PNG index a634be2..3ca9448 100644 Binary files a/public/help-images/mypage_2.PNG and b/public/help-images/mypage_2.PNG differ diff --git a/src/App.tsx b/src/App.tsx index 2013ffc..a670fcf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import CanvasEndedModal from './components/modal/CanvasEndedModal'; // CanvasEnd import NotificationToast from './components/toast/NotificationToast'; // NotificationToast import 추가 import { useToastStore } from './store/toastStore'; // useToastStore import 추가 import GameModalContent from './components/modal/GameModalContent'; +import GameAlertModal from './components/modal/GameAlertModal'; type DecodedToken = { sub: { @@ -172,6 +173,7 @@ function App() { {isCanvasEndedModalOpen && } + {/* NotificationToast 컴포넌트 추가 */} {/* 로딩 완료 후 채팅 컴포넌트 표시 */} diff --git a/src/components/canvas/CanvasUIMobile.tsx b/src/components/canvas/CanvasUIMobile.tsx index a3531c6..41aa8c5 100644 --- a/src/components/canvas/CanvasUIMobile.tsx +++ b/src/components/canvas/CanvasUIMobile.tsx @@ -69,21 +69,31 @@ export default function CanvasUIMobile({ openGameModal, } = useModalStore(); - // 드롭다움 열림, 닫힘 상태 - const [isMenuOpen, setIsMenuOpen] = useState(false); - const menuRef = useRef(null); + // 사이드바 열림, 닫힘 상태 + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const sidebarRef = useRef(null); + const toggleButtonRef = useRef(null); // useEffect(() => { // showInstructionsToast(); // }, []); useEffect(() => { - if (!isMenuOpen) return; + if (!isSidebarOpen) return; function handleClickOutside(event: MouseEvent) { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setIsMenuOpen(false); // 메뉴를 닫습니다. + const target = event.target as Node; + + // 토글 버튼이나 사이드바 내부 클릭은 무시 + if ( + toggleButtonRef.current?.contains(target) || + sidebarRef.current?.contains(target) + ) { + return; } + + // 외부 클릭 시 사이드바 닫기 + setIsSidebarOpen(false); } document.addEventListener('mousedown', handleClickOutside); @@ -91,46 +101,15 @@ export default function CanvasUIMobile({ return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [isMenuOpen]); + }, [isSidebarOpen]); return ( <> {/* 컬러 피커 */} -
- {canvasType === CanvasType.EVENT_COLORLIMIT ? ( -
- {['#000000', '#808080', '#c0c0c0', '#ffffff'].map((c) => ( -
- ) : ( - { - const newColor = e.target.value; - setColor(newColor); - onSelectColor(newColor); - }} - className='h-8 w-8 cursor-pointer rounded-full p-0' - title='색상 선택' - /> - )} - -
+
+
+ 확정
+ {canvasType === CanvasType.EVENT_COLORLIMIT ? ( +
+ {['#000000', '#808080', '#c0c0c0', '#ffffff'].map((c) => ( +
+ ) : ( +
+ 색상 선택 + { + const newColor = e.target.value; + setColor(newColor); + onSelectColor(newColor); + }} + className='h-8 w-8 cursor-pointer rounded-full border-2 border-blue-400 p-0 shadow-md' + title='색상 선택' + /> +
+ )} +
-
- {onImageAttach && ( -
-
-
+
+ )}
-
{ + console.log('토글 버튼 클릭됨, 현재 상태:', isSidebarOpen); + setIsSidebarOpen(!isSidebarOpen); + }} + className='pointer-events-auto fixed top-[22px] left-[10px] z-[10000] flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-xl transition-all duration-300 hover:scale-110 hover:from-blue-600 hover:to-purple-700 active:scale-95' + title={isSidebarOpen ? '메뉴 닫기' : '메뉴 열기'} > - {/* 항상 보이는 메뉴 토글 버튼 (햄버거 아이콘) */} - - {/* isMenuOpen이 true일 때만 드롭다운 메뉴가 보입니다. */} - {isMenuOpen && ( -
- {/* 로그인/마이페이지 버튼 */} -
+ {/* X 아이콘 (사이드바가 열려있을 때) */} + + + + + {/* 작은 알림 점 (사이드바가 닫혀있을 때만 표시) */} + {!isSidebarOpen && ( +
+ )} +
+ + + {/* 사이드바 */} + {isSidebarOpen && ( +
+
+ {/* 사이드바 헤더 */} +
+

{''}

+
+ + {/* 메뉴 아이템들 */} +
+ {/* 로그인/마이페이지 */} - - {isLoggedIn ? '마이페이지' : '로그인'} - -
- {/* 그룹 버튼 */} -
+ + {/* 그룹 */} - - 그룹 - -
- {/* 캔버스 버튼 */} -
+ + {/* 캔버스 */} - - 캔버스 - -
- {/* 게임 버튼 */} -
+ + {/* 게임 */} - - 게임 - -
- {/* 앨범 버튼 */} -
+ + {/* 갤러리 */} - - 앨범 - -
- {/* BGM 버튼 */} -
+ + {/* BGM */} - - {isBgmPlaying ? 'BGM 끄기' : 'BGM 켜기'} - -
- {/* 도움말 버튼 */} -
+ + {/* 도움말 */} - - 도움말 -
- )} -
+
+ )} + {/* 좌표 표시창 */}
{hoverPos ? `(${hoverPos.x}, ${hoverPos.y})` : 'OutSide'}
- {/* 쿨타임 창 : 쿨타임 중에만 표시*/} - {cooldown && ( -
-
- {/* 외부 링 */} -
- {/* 중간 링 */} -
- {/* 내부 원 */} -
- - {timeLeft} - -
- {/* 글로우 효과 */} -
-
-
-
- )} + {/* 쿨타임 창 제거 - 확정 버튼으로 이동 */} ); } diff --git a/src/components/canvas/CanvasUIPC.tsx b/src/components/canvas/CanvasUIPC.tsx index 8bad20c..a8030a3 100644 --- a/src/components/canvas/CanvasUIPC.tsx +++ b/src/components/canvas/CanvasUIPC.tsx @@ -94,7 +94,7 @@ export default function CanvasUIPC({ <> {/* 이미지 업로드 */} -
+
{onImageAttach && (
@@ -206,11 +206,11 @@ export default function CanvasUIPC({ {/* isMenuOpen이 true일 때만 드롭다운 메뉴가 보입니다. */} {isMenuOpen && ( -
+
{/* 로그인/마이페이지 버튼 */} {/* BGM 버튼 */} +
@@ -1342,7 +1355,13 @@ function GameCanvas({ d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z' > - {timeLeft}초 대기 + + {timeLeft}초 대기 +
) : ( '확정' diff --git a/src/components/game/GameReadyModal.tsx b/src/components/game/GameReadyModal.tsx index 3e21dc3..2915819 100644 --- a/src/components/game/GameReadyModal.tsx +++ b/src/components/game/GameReadyModal.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import type { WaitingRoomData } from '../../api/GameAPI'; import { useTimeSyncStore } from '../../store/timeSyncStore'; -import { useToastStore } from '../../store/toastStore'; interface GameReadyModalProps { isOpen: boolean; @@ -20,16 +19,21 @@ const GameReadyModal = ({ remainingTime, }: GameReadyModalProps) => { const navigate = useNavigate(); - const { showToast } = useToastStore(); const { getSynchronizedServerTime } = useTimeSyncStore(); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [timeUntilStart, setTimeUntilStart] = useState(null); + const [showTips, setShowTips] = useState(0); + + const tips = [ + '검은색 픽셀은 바로 색칠할 수 있어요!', + '다른 플레이어의 픽셀은 문제를 맞춰야 색칠할 수 있어요.', + '오답 시 생명이 차감됩니다. (최대 2개)', + '생명을 모두 잃으면 게임에서 탈락해요!', + '가장 많은 픽셀을 차지한 플레이어가 승리합니다.', + ]; useEffect(() => { - if (!isOpen) { - return; - } + if (!isOpen) return; // 부모 컴포넌트에서 전달받은 remainingTime 사용 if (remainingTime !== undefined) { @@ -38,20 +42,24 @@ const GameReadyModal = ({ // 시간이 0이하인 경우 모달 닫기 if (remainingTime <= 0) { - setTimeout(() => onClose(), 1000); // 1초 후 모달 닫기 + setTimeout(() => onClose(), 1000); } } else { setLoading(false); } + + // 팁 자동 변경 + const tipInterval = setInterval(() => { + setShowTips((prev) => (prev + 1) % tips.length); + }, 3000); + + return () => clearInterval(tipInterval); }, [isOpen, onClose, remainingTime]); useEffect(() => { - if (timeUntilStart === null || timeUntilStart <= 0) { - return; - } + if (timeUntilStart === null || timeUntilStart <= 0) return; const timer = setInterval(() => { - // useTimeSyncStore를 사용하여 더 정확한 시간 계산 if (remainingTime === undefined) { setTimeUntilStart((prev) => { const newValue = prev !== null ? prev - 1 : 0; @@ -72,124 +80,157 @@ const GameReadyModal = ({ navigate('/'); }; - if (!isOpen) { - return null; - } + if (!isOpen) return null; return ( -
+
-
-

- 게임 준비 -

- + {/* 상단 장식 효과 */} +
+
+
+ + {/* 헤더 */} +
+
+
+ + + +
+
+

+ BATTLE{' '} + READY +

+
+
+
+ 준비 중 +
-
- {loading && ( -
-
-
-
-

- 게임 정보를 불러오는 중... -

-
+ {/* 메인 콘텐츠 */} +
+ {/* 타이머 및 색상 */} +
+ {/* 타이머 */} +
+
+
+
+
+
+ + {remainingTime !== undefined && remainingTime > 0 + ? `${remainingTime}` + : timeUntilStart !== null && timeUntilStart > 0 + ? `${timeUntilStart}` + : '--'} +
- )} - {error && ( -
-
-

{error}

-
+ {/* 색상 표시 */} +
+ 당신의 색상
- )} - - {!loading && !error && ( - <> -
-
-

- 서버와 동기화 중... 곧 게임이 시작됩니다. -

-
-
- -
-
-

- 할당된 색상: {color || '로딩 중...'} -

-
-
+
+
+
+
+
+ + {/* 게임 규칙 */} +
+

+ 게임 규칙 +

+
    +
  • + + 1 + + 검은색 픽셀은 바로 색칠할 수 있습니다. +
  • +
  • + + 2 + + 다른 플레이어의 픽셀은 문제를 맞춰야 색칠할 수 있습니다. +
  • +
  • + + 3 + + 오답 시 생명이 차감됩니다 (최대 2개). +
  • +
  • + + 4 + + 생명을 모두 잃으면 게임에서 탈락합니다. +
  • +
  • + + 5 + + 가장 많은 픽셀을 차지한 플레이어가 승리합니다. +
  • +
+
+
-
-
-

색상 할당 완료!

-
-
-
-
-
- - {remainingTime !== undefined && remainingTime > 0 - ? `${remainingTime}` - : timeUntilStart !== null && timeUntilStart > 0 - ? `${timeUntilStart}` - : '--'} - -
-
-
-
-
-
- - )} + {/* 푸터 */} +
+
+ + {/* 애니메이션 효과 */}
diff --git a/src/components/modal/AlbumModalContent.tsx b/src/components/modal/AlbumModalContent.tsx index e629672..40c591b 100644 --- a/src/components/modal/AlbumModalContent.tsx +++ b/src/components/modal/AlbumModalContent.tsx @@ -212,7 +212,7 @@ const AlbumModalContent: React.FC = () => { return (
-

앨범 갤러리

+

갤러리

완성된 캔버스 작품들을 확인해보세요.

diff --git a/src/components/modal/CanvasModalContent.tsx b/src/components/modal/CanvasModalContent.tsx index ee2d8f1..bd5a2b5 100644 --- a/src/components/modal/CanvasModalContent.tsx +++ b/src/components/modal/CanvasModalContent.tsx @@ -77,6 +77,8 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { const [isDragging, setIsDragging] = useState(false); const [startX, setStartX] = useState(0); const [scrollStart, setScrollStart] = useState(0); + const [dragDistance, setDragDistance] = useState(0); // 드래그 거리 추가 + const DRAG_THRESHOLD = 10; // 드래그로 간주할 최소 거리 (픽셀) const { canvas_id } = useCanvasStore(); @@ -286,7 +288,7 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { }; const handleCanvasSelect = (e: React.MouseEvent, canvasId: number) => { - if (isDragging) { + if (isDragging && dragDistance > DRAG_THRESHOLD) { e.preventDefault(); return; } @@ -392,6 +394,7 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { setIsDragging(true); setStartX(e.pageX - scrollRef.current.offsetLeft); setScrollStart(scrollRef.current.scrollLeft); + setDragDistance(0); // 드래그 거리 초기화 } }; @@ -409,6 +412,9 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { const newScrollLeft = scrollStart - walk; scrollRef.current.scrollLeft = newScrollLeft; setScrollLeft(newScrollLeft); + + // 드래그 거리 업데이트 + setDragDistance(Math.abs(walk)); } }; @@ -416,6 +422,44 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { setIsDragging(false); }; + // 터치 이벤트 핸들러 추가 + const handleTouchStart = (e: React.TouchEvent, type: 'active' | 'event') => { + const scrollRef = type === 'active' ? activeScrollRef : eventScrollRef; + if (scrollRef.current && e.touches.length === 1) { + setIsDragging(true); + setStartX(e.touches[0].pageX - scrollRef.current.offsetLeft); + setScrollStart(scrollRef.current.scrollLeft); + setDragDistance(0); // 드래그 거리 초기화 + // 터치 시작 시 기본 스크롤 방지 + e.preventDefault(); + } + }; + + const handleTouchMove = (e: React.TouchEvent, type: 'active' | 'event') => { + if (!isDragging || e.touches.length !== 1) return; + // 드래그 중일 때 기본 스크롤과 선택 방지 + e.preventDefault(); + + const scrollRef = type === 'active' ? activeScrollRef : eventScrollRef; + const setScrollLeft = + type === 'active' ? setActiveScrollLeft : setEventScrollLeft; + + if (scrollRef.current) { + const x = e.touches[0].pageX - scrollRef.current.offsetLeft; + const walk = (x - startX) * 1.5; // 드래그 감도 조정 + const newScrollLeft = scrollStart - walk; + scrollRef.current.scrollLeft = newScrollLeft; + setScrollLeft(newScrollLeft); + + // 드래그 거리 업데이트 + setDragDistance(Math.abs(walk)); + } + }; + + const handleTouchEnd = () => { + setIsDragging(false); + }; + if (loading) { return (
@@ -554,11 +598,19 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => {
handleMouseDown(e, 'active')} onMouseMove={(e) => handleMouseMove(e, 'active')} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} + onTouchStart={(e) => handleTouchStart(e, 'active')} + onTouchMove={(e) => handleTouchMove(e, 'active')} + onTouchEnd={handleTouchEnd} > {publicCanvases.map((canvas) => (
{
handleMouseDown(e, 'event')} onMouseMove={(e) => handleMouseMove(e, 'event')} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} + onTouchStart={(e) => handleTouchStart(e, 'event')} + onTouchMove={(e) => handleTouchMove(e, 'event')} + onTouchEnd={handleTouchEnd} > {eventCanvases .filter( diff --git a/src/components/modal/GameAlertModal.tsx b/src/components/modal/GameAlertModal.tsx new file mode 100644 index 0000000..c7cef76 --- /dev/null +++ b/src/components/modal/GameAlertModal.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState } from 'react'; +import { useModalStore } from '../../store/modalStore'; +import { useNavigate } from 'react-router-dom'; + +export default function GameAlertModal() { + const { + isGameAlertOpen, + gameAlertMessage, + gameAlertCanvasId, + closeGameAlert, + } = useModalStore(); + const navigate = useNavigate(); + const [countdown, setCountdown] = useState(30); + const [startTime, setStartTime] = useState(null); + + useEffect(() => { + if (isGameAlertOpen) { + // 모달이 열릴 때 시작 시간 설정 + const now = Date.now(); + setStartTime(now); + setCountdown(30); + + // 25초 후 자동으로 닫히도록 설정 + const closeTimer = setTimeout(() => { + closeGameAlert(); + }, 25000); + + // 카운트다운 인터벌 설정 + const countdownInterval = setInterval(() => { + const elapsedTime = Math.floor((Date.now() - now) / 1000); + const remainingTime = 30 - elapsedTime; + if (remainingTime >= 0) { + setCountdown(remainingTime); + } else { + clearInterval(countdownInterval); + } + }, 1000); + + return () => { + clearTimeout(closeTimer); + clearInterval(countdownInterval); + }; + } + }, [isGameAlertOpen, closeGameAlert]); + + if (!isGameAlertOpen) return null; + + const handleJoinGame = () => { + navigate(`/canvas/pixels?canvas_id=${gameAlertCanvasId}`, { + state: { isGame: true }, + }); + closeGameAlert(); + }; + + return ( +
+
+
+
+
+ + + +
+
+ +

+ 게임 알림 +

+

+ {gameAlertMessage.includes('게임 시작') + ? `게임 시작 ${countdown}초 전: ${gameAlertMessage.split(': ')[1] || ''}` + : gameAlertMessage.replace(/\d+초/, `${countdown}초`)} +

+ +
+ + +
+
+
+ ); +} diff --git a/src/components/modal/GameModalContent.tsx b/src/components/modal/GameModalContent.tsx index c03e33d..359ad81 100644 --- a/src/components/modal/GameModalContent.tsx +++ b/src/components/modal/GameModalContent.tsx @@ -70,6 +70,8 @@ const GameModalContent = ({ onClose }: GameModalContentProps) => { const [isDragging, setIsDragging] = useState(false); const [startX, setStartX] = useState(0); const [scrollStart, setScrollStart] = useState(0); + const [dragDistance, setDragDistance] = useState(0); // 드래그 거리 추가 + const DRAG_THRESHOLD = 10; // 드래그로 간주할 최소 거리 (픽셀) const { openGameModal } = useModalStore(); const { canvas_id } = useCanvasStore(); @@ -279,7 +281,7 @@ const GameModalContent = ({ onClose }: GameModalContentProps) => { }; const handleCanvasSelect = (e: React.MouseEvent, canvasId: number) => { - if (isDragging) { + if (isDragging && dragDistance > DRAG_THRESHOLD) { e.preventDefault(); return; } @@ -340,6 +342,7 @@ const GameModalContent = ({ onClose }: GameModalContentProps) => { setIsDragging(true); setStartX(e.pageX - scrollRef.current.offsetLeft); setScrollStart(scrollRef.current.scrollLeft); + setDragDistance(0); // 드래그 거리 초기화 } }; @@ -357,6 +360,9 @@ const GameModalContent = ({ onClose }: GameModalContentProps) => { const newScrollLeft = scrollStart - walk; scrollRef.current.scrollLeft = newScrollLeft; setScrollLeft(newScrollLeft); + + // 드래그 거리 업데이트 + setDragDistance(Math.abs(walk)); } }; @@ -364,6 +370,44 @@ const GameModalContent = ({ onClose }: GameModalContentProps) => { setIsDragging(false); }; + // 터치 이벤트 핸들러 추가 + const handleTouchStart = (e: React.TouchEvent, type: 'active' | 'event') => { + const scrollRef = type === 'active' ? activeScrollRef : eventScrollRef; + if (scrollRef.current && e.touches.length === 1) { + setIsDragging(true); + setStartX(e.touches[0].pageX - scrollRef.current.offsetLeft); + setScrollStart(scrollRef.current.scrollLeft); + setDragDistance(0); // 드래그 거리 초기화 + // 터치 시작 시 기본 스크롤 방지 + e.preventDefault(); + } + }; + + const handleTouchMove = (e: React.TouchEvent, type: 'active' | 'event') => { + if (!isDragging || e.touches.length !== 1) return; + // 드래그 중일 때 기본 스크롤과 선택 방지 + e.preventDefault(); + + const scrollRef = type === 'active' ? activeScrollRef : eventScrollRef; + const setScrollLeft = + type === 'active' ? setActiveScrollLeft : setEventScrollLeft; + + if (scrollRef.current) { + const x = e.touches[0].pageX - scrollRef.current.offsetLeft; + const walk = (x - startX) * 1.5; // 드래그 감도 조정 + const newScrollLeft = scrollStart - walk; + scrollRef.current.scrollLeft = newScrollLeft; + setScrollLeft(newScrollLeft); + + // 드래그 거리 업데이트 + setDragDistance(Math.abs(walk)); + } + }; + + const handleTouchEnd = () => { + setIsDragging(false); + }; + if (loading) { return (
@@ -500,11 +544,19 @@ const GameModalContent = ({ onClose }: GameModalContentProps) => {
handleMouseDown(e, 'event')} onMouseMove={(e) => handleMouseMove(e, 'event')} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} + onTouchStart={(e) => handleTouchStart(e, 'event')} + onTouchMove={(e) => handleTouchMove(e, 'event')} + onTouchEnd={handleTouchEnd} > {canvases .map((canvas) => { diff --git a/src/components/modal/HelpModalContent.tsx b/src/components/modal/HelpModalContent.tsx index 24b00e4..5ab1772 100644 --- a/src/components/modal/HelpModalContent.tsx +++ b/src/components/modal/HelpModalContent.tsx @@ -459,7 +459,7 @@ export default function HelpModalContent({ onClose }: HelpModalContentProps) {

{ // Refs managed within the hook const isPanningRef = useRef(false); @@ -461,7 +463,7 @@ export const useCanvasInteraction = ({ moved = true; break; case 'Enter': - if (!isChatOpen && !cooldown && isLoggedIn) { + if (!isChatOpen && !cooldown && isLoggedIn && !showQuestionModal) { handleConfirm(); } break; @@ -485,7 +487,7 @@ export const useCanvasInteraction = ({ return () => { window.removeEventListener('keydown', handleKeyDown); }; - }, [draw, handleConfirm, canvasSize, isChatOpen]); + }, [draw, handleConfirm, canvasSize, isChatOpen, showQuestionModal]); useEffect(() => { const interactionCanvas = interactionCanvasRef.current; diff --git a/src/hooks/useSocket.ts b/src/hooks/useSocket.ts index 8cca7e4..e28e45d 100644 --- a/src/hooks/useSocket.ts +++ b/src/hooks/useSocket.ts @@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react'; import socketService from '../services/socketService'; import { useAuthStore } from '../store/authStrore'; import { toast } from 'react-toastify'; -import { useToastStore } from '../store/toastStore'; // 추가 +import { useToastStore } from '../store/toastStore'; +import { useModalStore } from '../store/modalStore'; import { useTimeSyncStore } from '../store/timeSyncStore'; interface PixelData { @@ -29,6 +30,7 @@ export const useSocket = ( const cooldownCallbackRef = useRef(onCooldownReceived); const [isConnected, setIsConnected] = useState(false); const showToast = useToastStore((state) => state.showToast); + const openGameAlert = useModalStore((state) => state.openGameAlert); // 콜백 함수 업데이트 pixelCallbackRef.current = onPixelReceived; @@ -75,11 +77,10 @@ export const useSocket = ( data.remain_time ?? 0, // Ensure remaining_time is a number Date.now() ); - showToast( + openGameAlert( `게임 시작 30초 전: ${data.title}`, - String(data.canvas_id), - 25000 - ); // 25초 후 자동 사라짐 + String(data.canvas_id) + ); } ); @@ -131,7 +132,7 @@ export const useSocket = ( socketService.disconnect(); setIsConnected(false); }; - }, [canvas_id, accessToken, showToast]); // showToast 의존성 추가 + }, [canvas_id, accessToken, showToast, openGameAlert]); // openGameAlert 의존성 추가 const sendPixel = (pixel: PixelData) => { if (!canvas_id) return; diff --git a/src/store/canvasUiStore.ts b/src/store/canvasUiStore.ts index b886b4a..680ca31 100644 --- a/src/store/canvasUiStore.ts +++ b/src/store/canvasUiStore.ts @@ -58,7 +58,7 @@ export const useCanvasUiStore = create((set, get) => ({ ? newTimeLeft(state.timeLeft) : newTimeLeft, })), - showPalette: false, + showPalette: true, setShowPalette: (showPalette) => set({ showPalette }), showImageControls: false, setShowImageControls: (showImageControls) => set({ showImageControls }), diff --git a/src/store/modalStore.ts b/src/store/modalStore.ts index 0de2232..71e3933 100644 --- a/src/store/modalStore.ts +++ b/src/store/modalStore.ts @@ -38,6 +38,12 @@ type ModalState = { isCanvasEndedModalOpen: boolean; openCanvasEndedModal: () => void; closeCanvasEndedModal: () => void; + + isGameAlertOpen: boolean; + gameAlertMessage: string; + gameAlertCanvasId: string; + openGameAlert: (message: string, canvasId: string) => void; + closeGameAlert: () => void; }; export const useModalStore = create((set) => ({ @@ -53,7 +59,8 @@ export const useModalStore = create((set) => ({ isGroupModalOpen: false, isChatOpen: false, isHelpModalOpen: false, - isCanvasEndedModalOpen: false, // 추가 + isCanvasEndedModalOpen: false, + isGameAlertOpen: false, }), closeLoginModal: () => set({ isLoginModalOpen: false }), @@ -179,4 +186,16 @@ export const useModalStore = create((set) => ({ isCanvasEndedModalOpen: true, // 추가 }), closeCanvasEndedModal: () => set({ isCanvasEndedModalOpen: false }), + + // 게임 알림 모달 상태 + isGameAlertOpen: false, + gameAlertMessage: '', + gameAlertCanvasId: '', + openGameAlert: (message: string, canvasId: string) => + set({ + isGameAlertOpen: true, + gameAlertMessage: message, + gameAlertCanvasId: canvasId, + }), + closeGameAlert: () => set({ isGameAlertOpen: false }), })); diff --git a/src/workers/starfield.worker.ts b/src/workers/starfield.worker.ts new file mode 100644 index 0000000..19469a7 --- /dev/null +++ b/src/workers/starfield.worker.ts @@ -0,0 +1,160 @@ +// starfield.worker.ts + +interface Star { + orbitRadius: number; + radius: number; + orbitX: number; + orbitY: number; + timePassed: number; + speed: number; + alpha: number; + parallaxFactor: number; +} + +interface WorkerMessage { + type: 'init' | 'update' | 'resize'; + canvasWidth?: number; + canvasHeight?: number; + viewPos?: { x: number; y: number }; + maxStars?: number; +} + +let stars: Star[] = []; +let canvasWidth = 0; +let canvasHeight = 0; +let maxStars = 400; + +function random(min: number, max?: number): number { + if (max === undefined) { + max = min; + min = 0; + } + if (min > max) { + [min, max] = [max, min]; + } + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function maxOrbit(x: number, y: number): number { + const max = Math.max(x, y); + return Math.round(Math.sqrt(max * max + max * max)) / 2; +} + +function createStar(): Star { + return { + orbitRadius: random(maxOrbit(canvasWidth, canvasHeight)), + radius: random(60, maxOrbit(canvasWidth, canvasHeight)) / 12, + orbitX: canvasWidth / 2, + orbitY: canvasHeight / 2, + timePassed: random(0, maxStars), + speed: random(maxOrbit(canvasWidth, canvasHeight)) / 400000, + alpha: random(2, 10) / 10, + parallaxFactor: random(2, 10) / 10, + }; +} + +function updateStars(): Star[] { + return stars.map((star) => { + // 별 위치 업데이트 + star.timePassed += star.speed; + + // 깜빡임 효과 + const twinkle = random(10); + if (twinkle === 1 && star.alpha > 0) { + star.alpha -= 0.05; + } else if (twinkle === 2 && star.alpha < 1) { + star.alpha += 0.05; + } + + return star; + }); +} + +function getStarPositions(viewPos: { x: number; y: number }): Array<{ + x: number; + y: number; + radius: number; + alpha: number; +}> { + return stars.map((star) => { + const canvasX = Math.sin(star.timePassed) * star.orbitRadius + star.orbitX; + const canvasY = Math.cos(star.timePassed) * star.orbitRadius + star.orbitY; + + const parallaxX = viewPos.x * star.parallaxFactor * 0.1; + const parallaxY = viewPos.y * star.parallaxFactor * 0.1; + + return { + x: canvasX + parallaxX, + y: canvasY + parallaxY, + radius: star.radius, + alpha: star.alpha, + }; + }); +} + +// Worker 메시지 처리 +self.onmessage = (e: MessageEvent) => { + const { + type, + canvasWidth: width, + canvasHeight: height, + viewPos, + maxStars: starsCount, + } = e.data; + + switch (type) { + case 'init': + if (width && height) { + canvasWidth = width; + canvasHeight = height; + maxStars = starsCount || 400; + + // 별들 초기화 + stars = []; + for (let i = 0; i < maxStars; i++) { + stars.push(createStar()); + } + + self.postMessage({ + type: 'initialized', + starCount: stars.length, + }); + } + break; + + case 'update': + if (viewPos) { + // 별들 업데이트 + updateStars(); + + // 위치 정보 전송 + const positions = getStarPositions(viewPos); + self.postMessage({ + type: 'positions', + positions, + }); + } + break; + + case 'resize': + if (width && height) { + canvasWidth = width; + canvasHeight = height; + + // 별들 재생성 + stars = []; + for (let i = 0; i < maxStars; i++) { + stars.push(createStar()); + } + + self.postMessage({ + type: 'resized', + starCount: stars.length, + }); + } + break; + } +}; + +// Worker 시작 알림 +self.postMessage({ type: 'worker_ready' });