diff --git a/index.html b/index.html index dd84abd..d4b7ac8 100644 --- a/index.html +++ b/index.html @@ -16,6 +16,12 @@ gtag('config', 'G-6JGGHCCMBB'); + + + =18" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2966,6 +2984,12 @@ "node": ">=10.13.0" } }, + "node_modules/enquire.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/enquire.js/-/enquire.js-2.1.6.tgz", + "integrity": "sha512-/KujNpO+PT63F7Hlpu4h3pE3TokKRHN26JYmQpPyjkRD/N57R7bPDNojMXdi7uveAKjYB7yQnartCxZnFWr0Xw==", + "license": "MIT" + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -3815,6 +3839,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "license": "MIT", + "peer": true + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3930,6 +3961,15 @@ "dev": true, "license": "MIT" }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4227,6 +4267,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4913,6 +4959,23 @@ "react-dom": ">=18" } }, + "node_modules/react-slick": { + "version": "0.30.3", + "resolved": "https://registry.npmjs.org/react-slick/-/react-slick-0.30.3.tgz", + "integrity": "sha512-B4x0L9GhkEWUMApeHxr/Ezp2NncpGc+5174R02j+zFiWuYboaq98vmxwlpafZfMjZic1bjdIqqmwLDcQY0QaFA==", + "license": "MIT", + "dependencies": { + "classnames": "^2.2.5", + "enquire.js": "^2.1.6", + "json2mq": "^0.2.0", + "lodash.debounce": "^4.0.8", + "resize-observer-polyfill": "^1.5.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-toastify": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz", @@ -4940,6 +5003,12 @@ "node": ">=8" } }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -5124,6 +5193,15 @@ "node": ">=18" } }, + "node_modules/slick-carousel": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/slick-carousel/-/slick-carousel-1.8.1.tgz", + "integrity": "sha512-XB9Ftrf2EEKfzoQXt3Nitrt/IPbT+f1fgqBdoxO3W/+JYvtEOW6EgxnWfr9GH6nmULv7Y2tPmEX3koxThVmebA==", + "license": "MIT", + "peerDependencies": { + "jquery": ">=1.8.0" + } + }, "node_modules/socket.io-client": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", @@ -5209,6 +5287,12 @@ "dev": true, "license": "MIT" }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", diff --git a/package.json b/package.json index e28f605..0f8a8c8 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,16 @@ "dependencies": { "@tailwindcss/vite": "^4.1.11", "@types/react-responsive": "^8.0.8", + "@types/react-slick": "^0.23.13", "axios": "^1.10.0", "jwt-decode": "^4.0.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-responsive": "^10.0.1", "react-router-dom": "^7.6.3", + "react-slick": "^0.30.3", "react-toastify": "^11.0.5", + "slick-carousel": "^1.8.1", "socket.io-client": "^4.8.1", "tailwindcss": "^4.1.11", "use-sound": "^5.0.0", diff --git a/src/components/canvas/PixelCanvas.tsx b/src/components/canvas/PixelCanvas.tsx index 7c81255..681b059 100644 --- a/src/components/canvas/PixelCanvas.tsx +++ b/src/components/canvas/PixelCanvas.tsx @@ -10,8 +10,9 @@ import { fetchCanvasData as fetchCanvasDataUtil } from '../../api/canvasFetch'; import NotFoundPage from '../../pages/NotFoundPage'; import { useCanvasInteraction } from '../../hooks/useCanvasInteraction'; import useSound from 'use-sound'; -import { useModalStore } from '../../store/modalStore'; // useModalStore import 추가 - +import { useModalStore } from '../../store/modalStore'; +import ImageEditorUI from '../../utils/ImageEditorUI'; +import * as DrawingUtils from '../../utils/canvasDrawing'; import { INITIAL_POSITION, MIN_SCALE, @@ -50,7 +51,7 @@ function PixelCanvas({ const sourceCanvasRef = useRef(null!); const scaleRef = useRef(1); const viewPosRef = useRef<{ x: number; y: number }>(INITIAL_POSITION); - const DRAG_THRESHOLD = 5; // 5px 이상 움직이면 드래그로 간주 + const DRAG_THRESHOLD = 5; const fixedPosRef = useRef<{ x: number; y: number; color: string } | null>( null ); @@ -60,10 +61,8 @@ function PixelCanvas({ color: string; } | null>(null); const flashingPixelRef = useRef<{ x: number; y: number } | null>(null); - const imageTransparencyRef = useRef(0.5); - // state를 각각 가져오도록 하여 불필요한 리렌더링을 방지합니다。 const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); const [hasError, setHasError] = useState(false); const [canvasType, setCanvasType] = useState(null); @@ -96,28 +95,20 @@ function PixelCanvas({ const imageTransparency = useCanvasUiStore( (state) => state.imageTransparency ); - const setImageTransparency = useCanvasUiStore( - (state) => state.setImageTransparency - ); const isLoading = useCanvasUiStore((state) => state.isLoading); const setIsLoading = useCanvasUiStore((state) => state.setIsLoading); const showCanvas = useCanvasUiStore((state) => state.showCanvas); const setShowCanvas = useCanvasUiStore((state) => state.setShowCanvas); const targetPixel = useCanvasUiStore((state) => state.targetPixel); const setTargetPixel = useCanvasUiStore((state) => state.setTargetPixel); - const startCooldown = useCanvasUiStore((state) => state.startCooldown); + const { openCanvasEndedModal } = useModalStore(); - const { openCanvasEndedModal } = useModalStore(); // openCanvasEndedModal 가져오기 - - // 이미지 관련 상태 (Zustand로 이동하지 않는 부분) const imageCanvasRef = useRef(null); const [imagePosition, setImagePosition] = useState({ x: 0, y: 0 }); const [imageSize, setImageSize] = useState({ width: 0, height: 0 }); const [isDraggingImage, setIsDraggingImage] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); - - // 이미지 리사이즈 핸들 상태 (Zustand로 이동하지 않는 부분) const [isResizing, setIsResizing] = useState(false); const [resizeHandle, setResizeHandle] = useState<'se' | 'e' | 's' | null>( null @@ -129,255 +120,147 @@ function PixelCanvas({ height: 0, }); - // 리사이즈 핸들 클릭 감지 const getResizeHandle = useCallback( (wx: number, wy: number) => { if (!imageCanvasRef.current || isImageFixed) return null; - const hs = 12 / scaleRef.current; const right = imagePosition.x + imageSize.width; const bottom = imagePosition.y + imageSize.height; - // 우하단 핸들 (대각선 리사이즈) - if ( - wx >= right - hs && - wx <= right && - wy >= bottom - hs && - wy <= bottom - ) { + if (wx >= right - hs && wx <= right && wy >= bottom - hs && wy <= bottom) return 'se'; - } - // 우측 핸들 (가로 리사이즈) if ( wx >= right - hs && wx <= right && wy >= imagePosition.y + imageSize.height / 2 - hs / 2 && wy <= imagePosition.y + imageSize.height / 2 + hs / 2 - ) { + ) return 'e'; - } - // 하단 핸들 (세로 리사이즈) if ( wy >= bottom - hs && wy <= bottom && wx >= imagePosition.x + imageSize.width / 2 - hs / 2 && wx <= imagePosition.x + imageSize.width / 2 + hs / 2 - ) { + ) return 's'; - } return null; }, - [imagePosition, imageSize, isImageFixed, scaleRef] + [imagePosition, imageSize, isImageFixed] ); - const draw = useCallback(() => { - const src = sourceCanvasRef.current; - if (!src) return; - - const canvas = renderCanvasRef.current; - const ctx = canvas?.getContext('2d'); - if (ctx && canvas) { - ctx.save(); - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.translate(viewPosRef.current.x, viewPosRef.current.y); - ctx.scale(scaleRef.current, scaleRef.current); - ctx.fillStyle = INITIAL_BACKGROUND_COLOR; - ctx.fillRect(0, 0, canvasSize.width, canvasSize.height); - - const gradient = ctx.createLinearGradient( - 0, - 0, - canvasSize.width, - canvasSize.height - ); - - if (canvasType === CanvasType.EVENT_COLORLIMIT) { - gradient.addColorStop(0, 'rgba(0, 0, 0, 0.8)'); - gradient.addColorStop(0.25, 'rgba(50, 50, 50, 0.8)'); - gradient.addColorStop(0.5, 'rgba(100, 100, 100, 0.8)'); - gradient.addColorStop(0.75, 'rgba(150, 150, 150, 0.8)'); - gradient.addColorStop(1, 'rgba(200, 200, 200, 0.8)'); - } else { - gradient.addColorStop(0, 'rgba(34, 197, 94, 0.8)'); - gradient.addColorStop(0.25, 'rgba(59, 130, 246, 0.8)'); - gradient.addColorStop(0.5, 'rgba(168, 85, 247, 0.8)'); - gradient.addColorStop(0.75, 'rgba(236, 72, 153, 0.8)'); - gradient.addColorStop(1, 'rgba(34, 197, 94, 0.8)'); - } - - ctx.strokeStyle = gradient; - ctx.lineWidth = 3 / scaleRef.current; - ctx.strokeRect(-1, -1, canvasSize.width + 2, canvasSize.height + 2); + // ### DRAW FUNCTIONS START ### + + const drawBaseLayer = useCallback(() => { + const renderCtx = renderCanvasRef.current?.getContext('2d'); + const sourceCanvas = sourceCanvasRef.current; + if (!renderCtx || !sourceCanvas) return; + + const canvas = renderCtx.canvas; + renderCtx.save(); + renderCtx.clearRect(0, 0, canvas.width, canvas.height); + renderCtx.translate(viewPosRef.current.x, viewPosRef.current.y); + renderCtx.scale(scaleRef.current, scaleRef.current); + + DrawingUtils.drawCanvasBackground(renderCtx, canvasSize, canvasType); + renderCtx.imageSmoothingEnabled = false; + renderCtx.drawImage(sourceCanvas, 0, 0); + + if ( + !isImageFixed && + imageCanvasRef.current && + !(imageCanvasRef.current as any)._isGroupImage + ) { + DrawingUtils.drawGrid(renderCtx, canvasSize); + } - ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; - ctx.lineWidth = 1 / scaleRef.current; - ctx.strokeRect(0, 0, canvasSize.width, canvasSize.height); - ctx.imageSmoothingEnabled = false; - ctx.drawImage(src, 0, 0); + renderCtx.restore(); + }, [canvasSize, canvasType, isImageFixed]); + + const drawImageLayer = useCallback(() => { + const renderCtx = renderCanvasRef.current?.getContext('2d'); + const imageCanvas = imageCanvasRef.current; + if (!renderCtx || !imageCanvas) return; + + renderCtx.save(); + renderCtx.translate(viewPosRef.current.x, viewPosRef.current.y); + renderCtx.scale(scaleRef.current, scaleRef.current); + + DrawingUtils.drawAttachedImage( + renderCtx, + imageCanvas, + imagePosition, + imageSize, + isImageFixed, + imageTransparencyRef.current + ); - // 이미지 편집 모드일 때만 격자 그리기 (방장 이미지는 제외) - if ( - !isImageFixed && - imageCanvasRef.current && - !(imageCanvasRef.current as any)._isGroupImage - ) { - ctx.strokeStyle = 'rgba(255,255,255, 0.12)'; - ctx.lineWidth = 1 / scaleRef.current; - ctx.beginPath(); - for (let x = 0; x <= canvasSize.width; x++) { - ctx.moveTo(x, 0); - ctx.lineTo(x, canvasSize.height); - } - for (let y = 0; y <= canvasSize.height; y++) { - ctx.moveTo(0, y); - ctx.lineTo(canvasSize.width, y); - } - ctx.stroke(); - } + renderCtx.restore(); + }, [imagePosition, imageSize, isImageFixed]); - // 이미지 렌더링 - if (imageCanvasRef.current) { - try { - // 투명도 설정 - ctx.globalAlpha = imageTransparencyRef.current; - ctx.imageSmoothingEnabled = false; - - // 편집 모드일 때 경계선 표시 (방장 이미지는 제외) - if (!isImageFixed && !(imageCanvasRef.current as any)._isGroupImage) { - ctx.strokeStyle = 'rgba(0, 255, 255, 0.8)'; - ctx.lineWidth = 2 / scaleRef.current; - ctx.strokeRect( - imagePosition.x - 1, - imagePosition.y - 1, - imageSize.width + 2, - imageSize.height + 2 - ); - } - - // 이미지 그리기 - ctx.drawImage( - imageCanvasRef.current, - imagePosition.x, - imagePosition.y, - imageSize.width, - imageSize.height - ); + const drawPreviewLayer = useCallback(() => { + const pctx = previewCanvasRef.current?.getContext('2d'); + if (!pctx) return; - // 투명도 초기화 - ctx.globalAlpha = 1.0; - } catch (error) { - console.error('이미지 그리기 실패:', error); - } + const preview = pctx.canvas; + pctx.save(); + pctx.clearRect(0, 0, preview.width, preview.height); + pctx.translate(viewPosRef.current.x, viewPosRef.current.y); + pctx.scale(scaleRef.current, scaleRef.current); - if (!isImageFixed) { - // 리사이즈 핸들 (네모) - 이미지 위에 그리기 - const hs = 10 / scaleRef.current; - - // 모든 핸들에 동일한 색상 적용 (하늘색) - ctx.fillStyle = 'rgba(0, 191, 255, 0.95)'; - ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'; - ctx.lineWidth = 2 / scaleRef.current; - - // 대각선 리사이즈 핸들 (우하단) - ctx.beginPath(); - ctx.rect( - imagePosition.x + imageSize.width - hs, - imagePosition.y + imageSize.height - hs, - hs, - hs - ); - ctx.fill(); - ctx.stroke(); - - // 수평 리사이즈 핸들 (우측 중앙) - ctx.beginPath(); - ctx.rect( - imagePosition.x + imageSize.width - hs, - imagePosition.y + imageSize.height / 2 - hs / 2, - hs, - hs - ); - ctx.fill(); - ctx.stroke(); - - // 수직 리사이즈 핸들 (하단 중앙) - ctx.beginPath(); - ctx.rect( - imagePosition.x + imageSize.width / 2 - hs / 2, - imagePosition.y + imageSize.height - hs, - hs, - hs - ); - ctx.fill(); - ctx.stroke(); - } - } - ctx.restore(); + if (fixedPosRef.current && fixedPosRef.current.color !== 'transparent') { + const { x, y, color: fx } = fixedPosRef.current; + pctx.fillStyle = fx; + pctx.fillRect(x, y, 1, 1); } - const preview = previewCanvasRef.current; - const pctx = preview?.getContext('2d'); - if (pctx && preview) { - pctx.save(); - pctx.clearRect(0, 0, preview.width, preview.height); - pctx.translate(viewPosRef.current.x, viewPosRef.current.y); - pctx.scale(scaleRef.current, scaleRef.current); - - if (fixedPosRef.current && fixedPosRef.current.color !== 'transparent') { - const { x, y, color: fx } = fixedPosRef.current; - pctx.fillStyle = fx; - pctx.fillRect(x, y, 1, 1); - } - - if (fixedPosRef.current) { - const { x, y } = fixedPosRef.current; - pctx.strokeStyle = 'rgba(255,255,0,0.9)'; - pctx.lineWidth = 3 / scaleRef.current; - pctx.strokeRect(x, y, 1, 1); - } - if (previewPixelRef.current) { - const { x, y, color: px } = previewPixelRef.current; - pctx.fillStyle = px; - pctx.fillRect(x, y, 1, 1); - } - - pctx.restore(); + if (fixedPosRef.current) { + const { x, y } = fixedPosRef.current; + pctx.strokeStyle = 'rgba(255,255,0,0.9)'; + pctx.lineWidth = 3 / scaleRef.current; + pctx.strokeRect(x, y, 1, 1); + } + if (previewPixelRef.current) { + const { x, y, color: px } = previewPixelRef.current; + pctx.fillStyle = px; + pctx.fillRect(x, y, 1, 1); } - // Flashing pixel effect if (flashingPixelRef.current) { const { x, y } = flashingPixelRef.current; - const currentTime = Date.now(); - const isVisible = Math.floor(currentTime / 500) % 2 === 0; // Blink every 500ms - + const isVisible = Math.floor(Date.now() / 500) % 2 === 0; if (isVisible) { - const flashCtx = previewCanvasRef.current?.getContext('2d'); - if (flashCtx) { - flashCtx.save(); - flashCtx.translate(viewPosRef.current.x, viewPosRef.current.y); - flashCtx.scale(scaleRef.current, scaleRef.current); - flashCtx.strokeStyle = 'rgba(255, 0, 0, 0.9)'; // Red border - flashCtx.lineWidth = 4 / scaleRef.current; - flashCtx.strokeRect(x, y, 1, 1); - flashCtx.restore(); - } + pctx.strokeStyle = 'rgba(255, 0, 0, 0.9)'; + pctx.lineWidth = 4 / scaleRef.current; + pctx.strokeRect(x, y, 1, 1); } } - }, [canvasSize, imagePosition, imageSize, isImageFixed, imageMode]); - // 이미지 첨부 핸들러 + pctx.restore(); + }, []); + + const drawAll = useCallback(() => { + // const startTime = performance.now(); // 측정 시작 + + drawBaseLayer(); + drawImageLayer(); + drawPreviewLayer(); + + // const endTime = performance.now(); // 측정 종료 + // const renderTime = endTime - startTime; + // console.log(`Canvas render time: ${renderTime.toFixed(2)} ms`); // 콘솔에 출력 + }, [drawBaseLayer, drawImageLayer, drawPreviewLayer]); + + // ### DRAW FUNCTIONS END ### + const handleImageAttach = useCallback( - (file: File, options?: any) => { - // 팔레트 닫기 + (file: File) => { setShowPalette(false); - if (file.size > 10 * 1024 * 1024) { toast.error( '이미지 파일이 너무 큽니다. 10MB 이하의 파일을 선택해주세요.' ); return; } - const supportedTypes = [ 'image/jpeg', 'image/jpg', @@ -425,27 +308,22 @@ function PixelCanvas({ }); setShowImageControls(true); setIsImageFixed(false); - toast.success('이미지가 성공적으로 첨부되었습니다!'); - draw(); + drawAll(); } }; - img.onerror = () => { toast.error('이미지를 불러올 수 없습니다. 다른 이미지를 시도해주세요.'); }; - img.src = URL.createObjectURL(file); }, - [canvasSize, draw, setIsImageFixed, setShowImageControls, setShowPalette] + [canvasSize, drawAll, setIsImageFixed, setShowImageControls, setShowPalette] ); - // 이미지 확대축소 const handleImageScale = useCallback( (scaleFactor: number) => { const newWidth = imageSize.width * scaleFactor; const newHeight = imageSize.height * scaleFactor; - if ( newWidth > 10 && newHeight > 10 && @@ -454,25 +332,20 @@ function PixelCanvas({ ) { const centerX = imagePosition.x + imageSize.width / 2; const centerY = imagePosition.y + imageSize.height / 2; - const newX = centerX - newWidth / 2; const newY = centerY - newHeight / 2; - setImageSize({ width: newWidth, height: newHeight }); setImagePosition({ x: newX, y: newY }); - draw(); + drawAll(); } }, - [imageSize, imagePosition, canvasSize, draw] + [imageSize, imagePosition, canvasSize, drawAll] ); - // 이미지 확정 const confirmImage = useCallback(() => { setIsImageFixed(true); setShowImageControls(false); toast.success('이미지가 고정되었습니다!'); - - // 그룹 이미지 업로드 처리를 위한 이벤트 발생 document.dispatchEvent( new CustomEvent('group-image-confirmed', { detail: { @@ -485,18 +358,17 @@ function PixelCanvas({ ); }, [setIsImageFixed, setShowImageControls, imagePosition, imageSize]); - // 이미지 취소 const cancelImage = useCallback(() => { imageCanvasRef.current = null; setShowImageControls(false); setIsImageFixed(false); toast.info('이미지가 제거되었습니다.'); - draw(); - }, [draw, setIsImageFixed, setShowImageControls]); + drawAll(); + }, [drawAll, setIsImageFixed, setShowImageControls]); const { sendPixel } = usePixelSocket({ sourceCanvasRef, - draw, + draw: drawAll, canvas_id, onCooldownReceived: (cooldownData) => { if (cooldownData.cooldown) { @@ -513,7 +385,6 @@ function PixelCanvas({ const worldY = Math.floor( (screenY - viewPosRef.current.y) / scaleRef.current ); - const overlayCanvas = interactionCanvasRef.current; if (!overlayCanvas) return; const overlayCtx = overlayCanvas.getContext('2d'); @@ -540,7 +411,7 @@ function PixelCanvas({ setHoverPos(null); } }, - [canvasSize, viewPosRef, scaleRef, setHoverPos, interactionCanvasRef] + [canvasSize, setHoverPos] ); const clearOverlay = useCallback(() => { @@ -549,16 +420,16 @@ function PixelCanvas({ if (!overlayCanvas) return; const overlayCtx = overlayCanvas.getContext('2d'); overlayCtx?.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); - }, [setHoverPos, interactionCanvasRef]); + }, [setHoverPos]); const resetAndCenter = useCallback(() => { const canvas = renderCanvasRef.current; if (!canvas || canvas.clientWidth === 0 || canvasSize.width === 0) return; if (!isImageFixed && imageCanvasRef.current) { - draw(); + drawAll(); return; } - // 화면 크기에 맞게 스케일 계산 + const viewportWidth = canvas.clientWidth; const viewportHeight = canvas.clientHeight; @@ -568,41 +439,37 @@ function PixelCanvas({ scaleRef.current = Math.max(Math.min(scaleX, scaleY), MIN_SCALE); scaleRef.current = Math.min(scaleRef.current, MAX_SCALE); - // 캔버스를 화면 중앙에 배치 viewPosRef.current.x = (viewportWidth - canvasSize.width * scaleRef.current) / 2; viewPosRef.current.y = (viewportHeight - canvasSize.height * scaleRef.current) / 2; - draw(); + drawAll(); clearOverlay(); - }, [draw, clearOverlay, canvasSize, scaleRef, viewPosRef, renderCanvasRef]); + }, [drawAll, clearOverlay, canvasSize, isImageFixed]); const centerOnPixel = useCallback( (screenX: number, screenY: number) => { const canvas = renderCanvasRef.current; if (!canvas) return; - const worldX = Math.floor( (screenX - viewPosRef.current.x) / scaleRef.current ); const worldY = Math.floor( (screenY - viewPosRef.current.y) / scaleRef.current ); - if ( worldX < 0 || worldX >= canvasSize.width || worldY < 0 || worldY >= canvasSize.height - ) { + ) return; - } - const viewportCenterX = canvas.clientWidth / 2; - const viewportCenterY = canvas.clientHeight / 2; - const targetX = viewportCenterX - (worldX + 0.5) * scaleRef.current; - const targetY = viewportCenterY - (worldY + 0.5) * scaleRef.current; + const targetX = + canvas.clientWidth / 2 - (worldX + 0.5) * scaleRef.current; + const targetY = + canvas.clientHeight / 2 - (worldY + 0.5) * scaleRef.current; const startX = viewPosRef.current.x; const startY = viewPosRef.current.y; @@ -613,113 +480,76 @@ function PixelCanvas({ const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); const eased = 1 - Math.pow(1 - progress, 3); - viewPosRef.current.x = startX + (targetX - startX) * eased; viewPosRef.current.y = startY + (targetY - startY) * eased; - - draw(); - + drawAll(); if (progress < 1) { requestAnimationFrame(animate); - updateOverlay(screenX, screenY); - } else { - updateOverlay(screenX, screenY); } + updateOverlay(screenX, screenY); }; requestAnimationFrame(animate); }, - [draw, updateOverlay, canvasSize, viewPosRef, scaleRef, renderCanvasRef] + [drawAll, updateOverlay, canvasSize] ); const zoomCanvas = useCallback( (scaleChange: number) => { const canvas = renderCanvasRef.current; if (!canvas) return; - const centerX = canvas.clientWidth / 2; const centerY = canvas.clientHeight / 2; - const xs = (centerX - viewPosRef.current.x) / scaleRef.current; const ys = (centerY - viewPosRef.current.y) / scaleRef.current; - - const newScale = Math.max( + scaleRef.current = Math.max( MIN_SCALE, Math.min(MAX_SCALE, scaleRef.current * scaleChange) ); - - scaleRef.current = newScale; viewPosRef.current.x = centerX - xs * scaleRef.current; viewPosRef.current.y = centerY - ys * scaleRef.current; - - draw(); + drawAll(); updateOverlay(centerX, centerY); }, - [draw, updateOverlay, viewPosRef, scaleRef, renderCanvasRef] + [drawAll, updateOverlay] ); - const handleZoomIn = useCallback(() => { - zoomCanvas(1.2); - }, [zoomCanvas]); - - const handleZoomOut = useCallback(() => { - zoomCanvas(1 / 1.2); - }, [zoomCanvas]); + const handleZoomIn = useCallback(() => zoomCanvas(1.2), [zoomCanvas]); + const handleZoomOut = useCallback(() => zoomCanvas(1 / 1.2), [zoomCanvas]); const centerOnWorldPixel = useCallback( (worldX: number, worldY: number) => { const canvas = renderCanvasRef.current; - if (!canvas) return; - - // Check if the target pixel is within canvas bounds if ( + !canvas || worldX < 0 || worldX >= canvasSize.width || worldY < 0 || worldY >= canvasSize.height - ) { - console.warn( - `Target pixel (${worldX}, ${worldY}) is out of canvas bounds.` - ); + ) return; - } - const viewportCenterX = canvas.clientWidth / 2; - const viewportCenterY = canvas.clientHeight / 2; - - // Calculate the target view position to center the world pixel - // (worldX + 0.5) to center on the pixel, not its top-left corner - const targetX = viewportCenterX - (worldX + 0.5) * scaleRef.current; - const targetY = viewportCenterY - (worldY + 0.5) * scaleRef.current; + const targetX = + canvas.clientWidth / 2 - (worldX + 0.5) * scaleRef.current; + const targetY = + canvas.clientHeight / 2 - (worldY + 0.5) * scaleRef.current; const startX = viewPosRef.current.x; const startY = viewPosRef.current.y; - const duration = 1000; // Animation duration in ms + const duration = 1000; const startTime = performance.now(); const animate = (currentTime: number) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); - // Ease-out cubic function const eased = 1 - Math.pow(1 - progress, 3); - viewPosRef.current.x = startX + (targetX - startX) * eased; viewPosRef.current.y = startY + (targetY - startY) * eased; - - draw(); - + drawAll(); if (progress < 1) { requestAnimationFrame(animate); } else { - // Ensure final position is exact - viewPosRef.current.x = targetX; - viewPosRef.current.y = targetY; - draw(); - - // Set fixedPosRef to highlight the target pixel - fixedPosRef.current = { x: worldX, y: worldY, color: 'transparent' }; // Use transparent or a default color - draw(); // Redraw to show the fixedPosRef - - // Optionally, update overlay for the centered pixel + fixedPosRef.current = { x: worldX, y: worldY, color: 'transparent' }; + drawAll(); const screenX = worldX * scaleRef.current + viewPosRef.current.x; const screenY = worldY * scaleRef.current + viewPosRef.current.y; updateOverlay(screenX, screenY); @@ -727,42 +557,34 @@ function PixelCanvas({ }; requestAnimationFrame(animate); }, - [draw, canvasSize, updateOverlay, viewPosRef, scaleRef, renderCanvasRef] + [drawAll, canvasSize, updateOverlay] ); - const handleCooltime = useCallback(() => { - startCooldown(3); - }, [startCooldown]); + const handleCooltime = useCallback(() => startCooldown(3), [startCooldown]); const handleConfirm = useCallback(() => { const pos = fixedPosRef.current; if (!pos) return; - handleCooltime(); previewPixelRef.current = { x: pos.x, y: pos.y, color }; - flashingPixelRef.current = { x: pos.x, y: pos.y }; // Set flashing pixel - draw(); + flashingPixelRef.current = { x: pos.x, y: pos.y }; + drawAll(); sendPixel({ x: pos.x, y: pos.y, color }); - - // 10초 카운트 다운 소리 - // playCountDown(); - - // The flashingPixelRef will now be cleared when cooldown ends, not after 1 second. setTimeout(() => { previewPixelRef.current = null; pos.color = 'transparent'; stopCountDown(); - draw(); + drawAll(); }, 1000); - }, [color, draw, sendPixel, handleCooltime, playCountDown, stopCountDown]); + }, [color, drawAll, sendPixel, handleCooltime, stopCountDown]); const handleSelectColor = useCallback( (newColor: string) => { if (!fixedPosRef.current) return; fixedPosRef.current.color = newColor; - draw(); + drawAll(); }, - [draw] + [drawAll] ); const { @@ -796,7 +618,7 @@ function PixelCanvas({ setImagePosition, imageSize, setImageSize, - draw, + draw: drawAll, updateOverlay, clearOverlay, centerOnPixel, @@ -807,7 +629,6 @@ function PixelCanvas({ handleConfirm, }); - // fetchCanvasData 분리 useEffect(() => { fetchCanvasDataUtil({ id: initialCanvasId, @@ -837,214 +658,136 @@ function PixelCanvas({ useEffect(() => { if (initialCanvasId && initialCanvasId !== canvas_id) { setCanvasId(initialCanvasId); - console.log('Canvas ID changed:', initialCanvasId); } }, [initialCanvasId, canvas_id, setCanvasId]); - // 그룹 이미지 업로드를 위한 이벤트 리스너 useEffect(() => { const handleCanvasImageAttach = (event: Event) => { const customEvent = event as CustomEvent; const { file, groupUpload, onConfirm } = customEvent.detail; - if (groupUpload && file) { - // 그룹 이미지 업로드인 경우 확정 이벤트 리스너 추가 const handleGroupImageConfirmed = (confirmEvent: Event) => { - const confirmCustomEvent = confirmEvent as CustomEvent; - const imageData = confirmCustomEvent.detail; - - // 그룹 이미지 확정 콜백 호출 - if (onConfirm) { - onConfirm(imageData); - } - - // 이벤트 리스너 제거 + if (onConfirm) onConfirm((confirmEvent as CustomEvent).detail); document.removeEventListener( 'group-image-confirmed', handleGroupImageConfirmed ); }; - - // 이미지 확정 이벤트 리스너 추가 document.addEventListener( 'group-image-confirmed', handleGroupImageConfirmed ); } - - // 파일 처리 - handleImageAttach(file, customEvent.detail); + handleImageAttach(file); }; - document.addEventListener('canvas-image-attach', handleCanvasImageAttach); - - return () => { + return () => document.removeEventListener( 'canvas-image-attach', handleCanvasImageAttach ); - }; }, [handleImageAttach]); - // 투명도 상태가 변경될 때 ref 값 업데이트 및 draw 함수 호출 useEffect(() => { imageTransparencyRef.current = imageTransparency; - if (imageCanvasRef.current) { - draw(); - } - }, [imageTransparency, draw]); + if (imageCanvasRef.current) drawAll(); + }, [imageTransparency, drawAll]); - // Listen for targetPixel changes from chat and center the canvas useEffect(() => { if (targetPixel) { centerOnWorldPixel(targetPixel.x, targetPixel.y); - // Reset targetPixel to null after processing to prevent re-triggering setTargetPixel(null); } }, [targetPixel, centerOnWorldPixel, setTargetPixel]); - // 그룹 이미지 수신 이벤트 리스너 - 편집 기능 없이 바로 그리기 useEffect(() => { const handleGroupImageReceived = (event: Event) => { - const customEvent = event as CustomEvent; - const { url, x, y, width, height } = customEvent.detail; - - console.log('방장 이미지 수신:', { url, x, y, width, height }); - - // 이미지 로드 + const { url, x, y, width, height } = (event as CustomEvent).detail; const img = new Image(); + resetAndCenter(); img.crossOrigin = 'anonymous'; - img.onload = () => { - // 먼저 이미지 고정 상태 설정 setIsImageFixed(true); setShowImageControls(false); - - // 이미지 캠버스 생성 const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); - if (ctx) { - // 이미지를 캠버스에 그리기 ctx.drawImage(img, 0, 0); - - // 방장 이미지임을 표시 - const groupCanvas = canvas as any; - groupCanvas._isGroupImage = true; - - // 캠버스 설정 - imageCanvasRef.current = groupCanvas; - - // 이미지 크기와 위치 설정 - const numX = Number(x); - const numY = Number(y); - const numWidth = Number(width); - const numHeight = Number(height); - + (canvas as any)._isGroupImage = true; + imageCanvasRef.current = canvas; + const [numX, numY, numWidth, numHeight] = [ + Number(x), + Number(y), + Number(width), + Number(height), + ]; setImageSize({ width: numWidth, height: numHeight }); setImagePosition({ x: numX, y: numY }); - - // 이미지가 있는 위치로 화면 이동 - centerOnWorldPixel(numX + numWidth / 2, numY + numHeight / 2); - - // 화면 그리기 - draw(); + drawAll(); } }; - - img.onerror = () => { - toast.error('이미지를 불러오는데 실패했습니다.'); - }; - + img.onerror = () => toast.error('이미지를 불러오는데 실패했습니다.'); img.src = url; }; - document.addEventListener('group-image-received', handleGroupImageReceived); - - return () => { + return () => document.removeEventListener( 'group-image-received', handleGroupImageReceived ); - }; - }, [ - centerOnWorldPixel, - draw, - setImagePosition, - setImageSize, - setIsImageFixed, - setShowImageControls, - ]); + }, [drawAll, resetAndCenter, setIsImageFixed, setShowImageControls]); - // Animation loop for flashing pixel useEffect(() => { let animationFrameId: number; - const animate = () => { - draw(); + drawPreviewLayer(); animationFrameId = requestAnimationFrame(animate); }; - - // Start animation loop if there's a cooldown or a pixel is flashing if (cooldown || flashingPixelRef.current) { animationFrameId = requestAnimationFrame(animate); } + return () => cancelAnimationFrame(animationFrameId); + }, [cooldown, drawPreviewLayer]); - return () => { - cancelAnimationFrame(animationFrameId); - }; - }, [cooldown, draw]); - - // Countdown timer for event canvases useEffect(() => { let timerInterval: number; - const calculateTimeLeft = () => { if ( - canvasType === CanvasType.EVENT_COMMON || - (canvasType === CanvasType.EVENT_COLORLIMIT && endedAt) + (canvasType === CanvasType.EVENT_COMMON || + canvasType === CanvasType.EVENT_COLORLIMIT) && + endedAt ) { - const endDate = new Date(endedAt!); - const now = new Date(); - const difference = endDate.getTime() - now.getTime(); - + const difference = new Date(endedAt).getTime() - new Date().getTime(); if (difference > 0) { const days = Math.floor(difference / (1000 * 60 * 60 * 24)); const hours = Math.floor((difference / (1000 * 60 * 60)) % 24); const minutes = Math.floor((difference / (1000 * 60)) % 60); const seconds = Math.floor((difference / 1000) % 60); - setTimeLeft( - `D-${days} ${String(hours).padStart(2, '0')}:${String( - minutes - ).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` + `D-${days} ${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` ); } else { setTimeLeft('캔버스 종료'); - openCanvasEndedModal(); // 캔버스 종료 시 모달 열기 + openCanvasEndedModal(); clearInterval(timerInterval); } } else { setTimeLeft(null); } }; - - calculateTimeLeft(); // Initial calculation - timerInterval = setInterval(calculateTimeLeft, 1000); // Update every second - + calculateTimeLeft(); + timerInterval = window.setInterval(calculateTimeLeft, 1000); return () => clearInterval(timerInterval); - }, [canvasType, endedAt, openCanvasEndedModal]); // 의존성 배열에 openCanvasEndedModal 추가 + }, [canvasType, endedAt, openCanvasEndedModal]); useEffect(() => { const rootElement = rootRef.current; if (!rootElement) return; - const observer = new ResizeObserver((entries) => { const { width, height } = entries[0].contentRect; if (width === 0 || height === 0) return; - [ renderCanvasRef.current, previewCanvasRef.current, @@ -1056,22 +799,16 @@ function PixelCanvas({ canvas.height = Math.round(height * dpr); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; - - const ctx = canvas.getContext('2d'); - ctx?.scale(dpr, dpr); + canvas.getContext('2d')?.scale(dpr, dpr); } }); - resetAndCenter(); }); - observer.observe(rootElement); return () => observer.disconnect(); }, [resetAndCenter]); - if (hasError) { - return ; - } + if (hasError) return ; return (
)}
)} {showImageControls && !isImageFixed && ( -
-
- {/* 제목 */} -
-
-

- 이미지 편집 모드 -

-
- - {/* 모드 선택 */} -
-
- - -
-
- - {/* 사용법 안내 */} -
- {imageMode ? ( -
-
- 🖼️ 이미지 모드 -
-
-
• 좌클릭 드래그: 이미지 이동
-
• 마우스 휠: 이미지 크기 조절
-
• 핸들 드래그: 정밀 크기 조절
-
-
- ) : ( -
-
- 🎨 캔버스 모드 -
-
-
• 좌클릭 드래그: 캔버스 이동
-
• 마우스 휠: 캔버스 확대/축소
-
• 이미지는 고정된 상태
-
-
- )} -
- - {/* 액션 버튼 */} -
- - -
- {/* 하단 안내 */} -
- 확정하면 픽셀 그리기가 가능합니다 -
-
-
+ )}
); diff --git a/src/components/canvas/StarfieldCanvas.tsx b/src/components/canvas/StarfieldCanvas.tsx index ec2751d..6a31294 100644 --- a/src/components/canvas/StarfieldCanvas.tsx +++ b/src/components/canvas/StarfieldCanvas.tsx @@ -1,5 +1,6 @@ import React, { useRef, useEffect } from 'react'; import './StarfieldCanvas.css'; +import { Star, createStars, createStarImage } from '../../utils/starfieldUtils'; type StarfieldCanvasProps = { viewPosRef: React.RefObject<{ x: number; y: number }>; @@ -8,6 +9,7 @@ type StarfieldCanvasProps = { const StarfieldCanvas = ({ viewPosRef }: StarfieldCanvasProps) => { const canvasRef = useRef(null); const animationFrameIdRef = useRef(0); + const starsRef = useRef([]); useEffect(() => { const canvas = canvasRef.current; @@ -16,144 +18,50 @@ const StarfieldCanvas = ({ viewPosRef }: StarfieldCanvasProps) => { const ctx = canvas.getContext('2d'); if (!ctx) return; - let w = (canvas.width = window.innerWidth); - let h = (canvas.height = window.innerHeight); - - const hue = 217; - const stars: any[] = []; - let count = 0; - const maxStars = 400; // Reduced for a sparser effect - - const canvas2 = document.createElement('canvas'); - const ctx2 = canvas2.getContext('2d'); - canvas2.width = 100; - canvas2.height = 100; - const half = canvas2.width / 2; - const gradient2 = ctx2!.createRadialGradient( - half, - half, - 0, - half, - half, - half - ); - gradient2.addColorStop(0.025, '#fff'); - gradient2.addColorStop(0.1, `hsl(${hue}, 61%, 33%)`); - gradient2.addColorStop(0.25, `hsl(${hue}, 64%, 6%)`); - gradient2.addColorStop(1, 'transparent'); - - ctx2!.fillStyle = gradient2; - ctx2!.beginPath(); - ctx2!.arc(half, half, half, 0, Math.PI * 2); - ctx2!.fill(); - - function random(min: number, max?: 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) { - const max = Math.max(x, y); - const diameter = Math.round(Math.sqrt(max * max + max * max)); - return diameter / 2; - } - - class Star { - orbitRadius: number; - radius: number; - orbitX: number; - orbitY: number; - timePassed: number; - speed: number; - alpha: number; - parallaxFactor: number; // New: for parallax effect - - constructor() { - this.orbitRadius = random(maxOrbit(w, h)); - this.radius = random(60, this.orbitRadius) / 12; - this.orbitX = w / 2; - this.orbitY = h / 2; - this.timePassed = random(0, maxStars); - this.speed = random(this.orbitRadius) / 400000; - this.alpha = random(2, 10) / 10; - this.parallaxFactor = random(2, 10) / 10; // Assign a random parallax factor - count++; - stars[count] = this; - } - - draw() { - const canvasX = - Math.sin(this.timePassed) * this.orbitRadius + this.orbitX; - const canvasY = - Math.cos(this.timePassed) * this.orbitRadius + this.orbitY; - const twinkle = random(10); - - if (twinkle === 1 && this.alpha > 0) { - this.alpha -= 0.05; - } else if (twinkle === 2 && this.alpha < 1) { - this.alpha += 0.05; - } - - // Calculate parallax offset - const parallaxX = viewPosRef.current - ? viewPosRef.current.x * this.parallaxFactor * 0.1 // Adjust multiplier for desired effect - : 0; - const parallaxY = viewPosRef.current - ? viewPosRef.current.y * this.parallaxFactor * 0.1 // Adjust multiplier for desired effect - : 0; - - ctx!.globalAlpha = this.alpha; - ctx!.drawImage( - canvas2, - canvasX - this.radius / 2 + parallaxX, - canvasY - this.radius / 2 + parallaxY, - this.radius, - this.radius - ); - this.timePassed += this.speed; - } - } - - for (let i = 0; i < maxStars; i++) { - new Star(); - } - - const animation = () => { - ctx!.globalCompositeOperation = 'source-over'; - ctx!.globalAlpha = 0.8; - ctx!.fillStyle = 'black'; // Solid black background - ctx!.fillRect(0, 0, w, h); - - ctx!.globalCompositeOperation = 'lighter'; - for (let i = 1, l = stars.length; i < l; i++) { - stars[i].draw(); - } - - animationFrameIdRef.current = window.requestAnimationFrame(animation); + // --- 초기 설정 --- + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + + const starImage = createStarImage(); + starsRef.current = createStars(canvas.width, canvas.height); + + // --- 애니메이션 루프 --- + const animate = () => { + ctx.globalCompositeOperation = 'source-over'; + ctx.globalAlpha = 1; + ctx.fillStyle = 'black'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.globalCompositeOperation = 'lighter'; + + const currentViewPos = viewPosRef.current ?? { x: 0, y: 0 }; + starsRef.current.forEach((star) => { + star.update(); + star.draw(ctx, starImage, currentViewPos); + }); + + animationFrameIdRef.current = window.requestAnimationFrame(animate); }; - animation(); + animate(); + // --- 이벤트 핸들러 --- const handleResize = () => { - w = canvas.width = window.innerWidth; - h = canvas.height = window.innerHeight; + if (!canvas) return; + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + // 화면 크기가 변하면 별들을 다시 생성하여 자연스럽게 분포시킵니다. + starsRef.current = createStars(canvas.width, canvas.height); }; window.addEventListener('resize', handleResize); + // --- 클린업 --- return () => { - if (animationFrameIdRef.current) { - window.cancelAnimationFrame(animationFrameIdRef.current); - } + window.cancelAnimationFrame(animationFrameIdRef.current); window.removeEventListener('resize', handleResize); }; - }, [viewPosRef]); // Add viewPosRef to dependency array + }, [viewPosRef]); // viewPosRef는 ref 객체이므로 한번만 실행됩니다. return ; }; diff --git a/src/components/game/GameCanvas.tsx b/src/components/game/GameCanvas.tsx index 2eb7805..8d01c06 100644 --- a/src/components/game/GameCanvas.tsx +++ b/src/components/game/GameCanvas.tsx @@ -351,6 +351,28 @@ function GameCanvas({ [playExplosion, stopGameMusic] ); + const onGameErrorNotice = useCallback( + (data: { message: string }) => { + playExplosion(); + stopGameMusic(); + // React로 모달 열기 + setShowDeathModal(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 = data.message; + document.body.appendChild(messageDiv); + + // 1초 후 메시지 제거 + setTimeout(() => { + if (document.body.contains(messageDiv)) { + document.body.removeChild(messageDiv); + } + }, 1000); + }, + [playExplosion, stopGameMusic] + ); + // 폭발 효과 생성 함수 const createExplosionEffect = useCallback((x: number, y: number) => { // 폭발 효과를 더 화려하게 개선 @@ -510,6 +532,7 @@ function GameCanvas({ canvas_id, onDeadPixels, onDeadNotice, + onGameErrorNotice, onGameResult, onCanvasCloseAlarm: useCallback( (data: { @@ -1084,10 +1107,6 @@ function GameCanvas({ isGameMode: true, // 게임 모드 활성화 }); - if (hasError) { - return ; - } - // 게임 나가기 핸들러 const handleExit = useCallback(() => { setShowExitModal(true); @@ -1108,6 +1127,9 @@ function GameCanvas({ setShowExitModal(false); }, []); + if (hasError) { + return ; + } return (
void; onDeadNotice?: (data: { message: string }) => void; + onGameErrorNotice?: (data: { message: string }) => void; onGameResult?: (data: { results: Array<{ username: string; @@ -34,6 +35,7 @@ export const useGameSocketIntegration = ({ canvas_id, onDeadPixels, onDeadNotice, + onGameErrorNotice, onGameResult, onCanvasCloseAlarm, }: GameSocketProps) => { @@ -54,6 +56,7 @@ export const useGameSocketIntegration = ({ canvas_id, onDeadPixels, onDeadNotice, + onGameErrorNotice, onGameResult, onCanvasCloseAlarm ); diff --git a/src/components/modal/AlbumModalContent.tsx b/src/components/modal/AlbumModalContent.tsx index a82b2ff..e629672 100644 --- a/src/components/modal/AlbumModalContent.tsx +++ b/src/components/modal/AlbumModalContent.tsx @@ -465,7 +465,7 @@ const AlbumModalContent: React.FC = () => { {album.top_own_user_name} - ({album.top_own_user_count}회) + ({album.top_own_user_count}개) diff --git a/src/components/modal/DeathModal.tsx b/src/components/modal/DeathModal.tsx index d5a61e5..31c7a5c 100644 --- a/src/components/modal/DeathModal.tsx +++ b/src/components/modal/DeathModal.tsx @@ -1,26 +1,55 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; interface DeathModalProps { isOpen: boolean; } const DeathModal: React.FC = ({ isOpen }) => { + const [isTransparent, setIsTransparent] = useState(false); + + useEffect(() => { + if (isOpen) { + // 3초 후에 투명 모드로 전환 + const timer = setTimeout(() => { + setIsTransparent(true); + }, 3000); + + return () => clearTimeout(timer); + } else { + setIsTransparent(false); + } + }, [isOpen]); + if (!isOpen) return null; return ( -
-
-
☠️
-

+
+
+
☠️
+

당신은 탈락했습니다!

-

모든 생명을 잃었습니다.

-

- 전장이 마무리될 때까지 잠시만 기다려주세요. +

+ 모든 생명을 잃었습니다. +

+

+ {isTransparent + ? '관전 모드로 전환되었습니다.' + : '전장이 마무리될 때까지 잠시만 기다려주세요.'}

); }; -export default DeathModal; \ No newline at end of file +export default DeathModal; diff --git a/src/components/modal/HelpModalContent.tsx b/src/components/modal/HelpModalContent.tsx index 968d58c..ff574c4 100644 --- a/src/components/modal/HelpModalContent.tsx +++ b/src/components/modal/HelpModalContent.tsx @@ -1,6 +1,213 @@ import React from 'react'; +import Slider from 'react-slick'; +import { useViewport } from '../../hooks/useViewport'; export default function HelpModalContent() { + const { width } = useViewport(); + const isMobile = width < 768; + + const settings = { + dots: true, + infinite: true, + speed: 500, + slidesToShow: 1, + slidesToScroll: 1, + autoplay: true, + autoplaySpeed: 5000, + }; + + const sections = [ + { + title: '게임 소개', + iconColor: 'blue', + content: ( +

+ 여러 사용자가 함께 참여하여 하나의 캔버스에 픽셀 아트를 그리는 협업 + 게임입니다. 각자의 창의성을 발휘하여 멋진 작품을 만들어보세요! +

(모바일도 게임을 즐길 수 있지만 PC에 최적화 되었어요!)

+

+ ), + }, + { + title: '기본 조작법', + iconColor: 'green', + content: isMobile ? ( +
    +
  • + + + 터치: 픽셀 선택 + +
  • +
  • + + + 두 손가락으로 확대/축소: 캔버스 확대/축소 + +
  • +
  • + + + 한 손가락으로 드래그: 캔버스 이동 + +
  • +
  • + + + 우측 최상단 컬러 피커: 원하는 색상 선택 + +
  • +
  • + + + 우측 최상단 체크 버튼: 색상 칠하기 + +
  • +
+ ) : ( +
    +
  • + + + 클릭: 선택한 색상으로 픽셀 칠하기 + +
  • +
  • + + + 마우스 휠: 캔버스 확대/축소 + +
  • +
  • + + + 드래그: 캔버스 이동 + +
  • +
  • + + + 컬러 팔레트: 원하는 색상 선택 + +
  • +
  • + + + 키보드 방향키: 선택 픽셀 이동 + +
  • +
  • + + + Enter: 선택 색상으로 픽셀 칠하기 + +
  • +
+ ), + }, + { + title: '게임 규칙', + iconColor: 'yellow', + content: ( +
    +
  • + + + 쿨타임: 픽셀을 칠한 후 일정 시간 대기 필요 + +
  • +
  • + + + 로그인 필수: 픽셀을 칠하려면 로그인이 필요합니다 + +
  • +
  • + + + 실시간 동기화: 다른 사용자의 작업이 실시간으로 + 반영됩니다 + +
  • +
  • + + + 협업 정신: 다른 사용자의 작품을 존중해주세요 + +
  • +
+ ), + }, + { + title: '추가 기능', + iconColor: 'purple', + content: ( +
    +
  • + + + 이미지 업로드: 참고용 이미지를 업로드할 수 + 있습니다 + +
  • +
  • + + + 투명도 조절: 업로드한 이미지의 투명도를 조절할 수 + 있습니다 + +
  • +
  • + + + 채팅: 다른 사용자들과 실시간으로 소통할 수 + 있습니다 + +
  • +
  • + + + 그룹 기능: 그룹을 만들어 함께 작업할 수 있습니다 + +
  • +
+ ), + }, + { + title: '유용한 팁', + iconColor: 'red', + content: ( +
    +
  • + + 작은 디테일부터 시작해서 점진적으로 확장해보세요 +
  • +
  • + + 다른 사용자들과 협력하여 더 큰 작품을 만들어보세요 +
  • +
  • + + 채팅을 통해 작업 계획을 공유해보세요 +
  • +
+ ), + }, + ]; + + const renderSections = () => { + return sections.map((section, index) => ( +
+

+ {section.title} +

+ {section.content} +
+ )); + }; + return (
{/* 헤더 */} @@ -27,204 +234,24 @@ export default function HelpModalContent() {
{/* 콘텐츠 */} -
- {/* 게임 소개 */} -
-

- - - - 게임 소개 -

-

- 여러 사용자가 함께 참여하여 하나의 캔버스에 픽셀 아트를 그리는 협업 - 게임입니다. 각자의 창의성을 발휘하여 멋진 작품을 만들어보세요! -

(모바일도 게임을 즐길 수 있지만 PC에 최적화 되었어요!)

-

-
- - {/* 기본 조작법 */} -
-

- - - - 기본 조작법 -

-
    -
  • - - - 클릭: 선택한 색상으로 픽셀 칠하기 - -
  • -
  • - - - 마우스 휠: 캔버스 확대/축소 - -
  • -
  • - - - 드래그: 캔버스 이동 - -
  • -
  • - - - 컬러 팔레트: 원하는 색상 선택 - -
  • -
  • - - - 키보드 방향키: 선택 픽셀 이동 - -
  • -
  • - - - Enter: 선택 색상으로 픽셀 칠하기 - -
  • -
-
- - {/* 게임 규칙 */} -
-

- - - - 게임 규칙 -

-
    -
  • - - - 쿨타임: 픽셀을 칠한 후 일정 시간 대기 필요 - -
  • -
  • - - - 로그인 필수: 픽셀을 칠하려면 로그인이 - 필요합니다 - -
  • -
  • - - - 실시간 동기화: 다른 사용자의 작업이 실시간으로 - 반영됩니다 - -
  • -
  • - - - 협업 정신: 다른 사용자의 작품을 존중해주세요 - -
  • -
-
- - {/* 추가 기능 */} -
-

- - - - 추가 기능 -

-
    -
  • - - - 이미지 업로드: 참고용 이미지를 업로드할 수 - 있습니다 - -
  • -
  • - - - 투명도 조절: 업로드한 이미지의 투명도를 조절할 - 수 있습니다 - -
  • -
  • - - - 채팅: 다른 사용자들과 실시간으로 소통할 수 - 있습니다 - -
  • -
  • - - - 그룹 기능: 그룹을 만들어 함께 작업할 수 - 있습니다 - -
  • -
-
- - {/* 팁 */} -
-

- - - - 유용한 팁 -

-
    -
  • - - 작은 디테일부터 시작해서 점진적으로 확장해보세요 -
  • -
  • - - 다른 사용자들과 협력하여 더 큰 작품을 만들어보세요 -
  • -
  • - - 채팅을 통해 작업 계획을 공유해보세요 -
  • -
-
-
+ {isMobile ? ( + + {sections.map((section, index) => ( +
+

+ {section.title} +

+ {section.content} +
+ ))} +
+ ) : ( +
+ {renderSections()} +
+ )} {/* 푸터 */}
diff --git a/src/hooks/useCanvasInteraction.ts b/src/hooks/useCanvasInteraction.ts index 30e6394..b47fbca 100644 --- a/src/hooks/useCanvasInteraction.ts +++ b/src/hooks/useCanvasInteraction.ts @@ -56,7 +56,7 @@ interface UseCanvasInteractionProps { DRAG_THRESHOLD: number; handleConfirm: () => void; - + // Game mode isGameMode?: boolean; } @@ -277,7 +277,7 @@ export const useCanvasInteraction = ({ y: pixelY, color: 'transparent', }; - + // 게임 모드일 경우 팔레트를 표시하지 않고 노란색 테두리만 표시 if (isGameMode) { // 확정 버튼을 클릭할 때까지 대기 @@ -285,7 +285,7 @@ export const useCanvasInteraction = ({ } else { setShowPalette(true); } - + centerOnPixel(sx, sy); } } @@ -506,7 +506,7 @@ export const useCanvasInteraction = ({ const ys = (offsetY - viewPosRef.current.y) / scaleRef.current; const delta = -e.deltaY; const newScale = - delta > 0 ? scaleRef.current * 1.2 : scaleRef.current / 1.2; + delta > 0 ? scaleRef.current * 1.1 : scaleRef.current / 1.1; if (newScale >= MIN_SCALE && newScale <= MAX_SCALE) { scaleRef.current = newScale; diff --git a/src/hooks/useGameSocket.ts b/src/hooks/useGameSocket.ts index 0d036f1..71407c3 100644 --- a/src/hooks/useGameSocket.ts +++ b/src/hooks/useGameSocket.ts @@ -18,6 +18,7 @@ export const useGameSocket = ( username: string; }) => void, onDeadNotice?: (data: { message: string }) => void, + onGameErrorNotice?: (data: { message: string }) => void, onGameResult?: (data: { results: Array<{ username: string; @@ -40,6 +41,7 @@ export const useGameSocket = ( const pixelCallbackRef = useRef(onPixelReceived); const deadPixelsCallbackRef = useRef(onDeadPixels); const deadNoticeCallbackRef = useRef(onDeadNotice); + const gameErrorNoticeCallbackRef = useRef(onGameErrorNotice); const gameResultCallbackRef = useRef(onGameResult); const canvasCloseAlarmCallbackRef = useRef(onCanvasCloseAlarm); @@ -47,6 +49,7 @@ export const useGameSocket = ( pixelCallbackRef.current = onPixelReceived; deadPixelsCallbackRef.current = onDeadPixels; deadNoticeCallbackRef.current = onDeadNotice; + gameErrorNoticeCallbackRef.current = onGameErrorNotice; gameResultCallbackRef.current = onGameResult; canvasCloseAlarmCallbackRef.current = onCanvasCloseAlarm; @@ -70,14 +73,21 @@ export const useGameSocket = ( deadPixelsCallbackRef.current?.(data); }); } - + // 사망 알림 이벤트 리스너 if (deadNoticeCallbackRef.current) { socketService.onDeadNotice((data) => { deadNoticeCallbackRef.current?.(data); }); } - + + // 사망자 색칠 시도 오류 이벤트 리스너 + if (gameErrorNoticeCallbackRef.current) { + socketService.onGameErrorNotice((data) => { + gameErrorNoticeCallbackRef.current?.(data); + }); + } + // 게임 결과 이벤트 리스너 if (gameResultCallbackRef.current) { socketService.onGameResult((data) => { @@ -118,6 +128,9 @@ export const useGameSocket = ( if (deadNoticeCallbackRef.current) { socketService.offDeadNotice(deadNoticeCallbackRef.current); } + if (gameErrorNoticeCallbackRef.current) { + socketService.offGameErrorNotice(gameErrorNoticeCallbackRef.current); + } if (gameResultCallbackRef.current) { socketService.offGameResult(gameResultCallbackRef.current); } @@ -130,7 +143,13 @@ export const useGameSocket = ( // 소켓 연결 해제 socketService.disconnect(); }; - }, [canvas_id, accessToken, user, useCanvasStore.getState().canvas_id, onCanvasCloseAlarm]); + }, [ + canvas_id, + accessToken, + user, + useCanvasStore.getState().canvas_id, + onCanvasCloseAlarm, + ]); const sendGameResult = (data: { x: number; diff --git a/src/main.tsx b/src/main.tsx index f1bc685..364db39 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,8 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css'; +import 'slick-carousel/slick/slick.css'; +import 'slick-carousel/slick/slick-theme.css'; import Router from './router/router.tsx'; diff --git a/src/services/socketService.ts b/src/services/socketService.ts index c767de8..39606e4 100644 --- a/src/services/socketService.ts +++ b/src/services/socketService.ts @@ -234,6 +234,20 @@ class SocketService { } } + // 사망자가 색칠을 시도할 때 에러 이벤트 수신 + onGameErrorNotice(callback: (data: { message: string }) => void) { + if (this.socket) { + this.socket.on('game_error', callback); + } + } + + // 사망자가 색칠을 시도할 때 에러 이벤트 제거 + offGameErrorNotice(callback: (data: { message: string }) => void) { + if (this.socket) { + this.socket.off('game_error', callback); + } + } + // 게임 결과 이벤트 수신 onGameResult( callback: (data: { diff --git a/src/setupTests.ts b/src/setupTests.ts deleted file mode 100644 index e2d935f..0000000 --- a/src/setupTests.ts +++ /dev/null @@ -1,9 +0,0 @@ -import '@testing-library/jest-dom'; // 이 줄을 추가합니다. - -class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -} - -window.ResizeObserver = ResizeObserver; \ No newline at end of file diff --git a/src/utils/ImageEditorUI.tsx b/src/utils/ImageEditorUI.tsx new file mode 100644 index 0000000..3b5e327 --- /dev/null +++ b/src/utils/ImageEditorUI.tsx @@ -0,0 +1,97 @@ +import React from 'react'; + +type ImageEditorUIProps = { + imageMode: boolean; + setImageMode: (mode: boolean) => void; + onConfirm: () => void; + onCancel: () => void; +}; + +const ImageEditorUI = ({ + imageMode, + setImageMode, + onConfirm, + onCancel, +}: ImageEditorUIProps) => { + return ( +
+
+
+
+

이미지 편집 모드

+
+ +
+
+ + +
+
+ +
+ {imageMode ? ( +
+
+ 🖼️ 이미지 모드 +
+
+
• 좌클릭 드래그: 이미지 이동
+
• 마우스 휠: 이미지 크기 조절
+
• 핸들 드래그: 정밀 크기 조절
+
+
+ ) : ( +
+
+ 🎨 캔버스 모드 +
+
+
• 좌클릭 드래그: 캔버스 이동
+
• 마우스 휠: 캔버스 확대/축소
+
• 이미지는 고정된 상태
+
+
+ )} +
+ +
+ + +
+
+ 확정하면 픽셀 그리기가 가능합니다 +
+
+
+ ); +}; + +export default ImageEditorUI; diff --git a/src/utils/canvasDrawing.ts b/src/utils/canvasDrawing.ts new file mode 100644 index 0000000..a0cf0a2 --- /dev/null +++ b/src/utils/canvasDrawing.ts @@ -0,0 +1,139 @@ +import { CanvasType } from '../components/canvas/canvasConstants'; + +/** + * 캔버스 경계선과 배경을 그립니다. + */ +export function drawCanvasBackground( + ctx: CanvasRenderingContext2D, + canvasSize: { width: number; height: number }, + canvasType: CanvasType | null +) { + ctx.fillStyle = '#111827'; + ctx.fillRect(0, 0, canvasSize.width, canvasSize.height); + + const gradient = ctx.createLinearGradient( + 0, + 0, + canvasSize.width, + canvasSize.height + ); + + if (canvasType === CanvasType.EVENT_COLORLIMIT) { + gradient.addColorStop(0, 'rgba(0, 0, 0, 0.8)'); + gradient.addColorStop(0.25, 'rgba(50, 50, 50, 0.8)'); + gradient.addColorStop(0.5, 'rgba(100, 100, 100, 0.8)'); + gradient.addColorStop(0.75, 'rgba(150, 150, 150, 0.8)'); + gradient.addColorStop(1, 'rgba(200, 200, 200, 0.8)'); + } else { + gradient.addColorStop(0, 'rgba(34, 197, 94, 0.8)'); + gradient.addColorStop(0.25, 'rgba(59, 130, 246, 0.8)'); + gradient.addColorStop(0.5, 'rgba(168, 85, 247, 0.8)'); + gradient.addColorStop(0.75, 'rgba(236, 72, 153, 0.8)'); + gradient.addColorStop(1, 'rgba(34, 197, 94, 0.8)'); + } + + ctx.strokeStyle = gradient; + ctx.lineWidth = 3 / ctx.getTransform().a; + ctx.strokeRect(-1, -1, canvasSize.width + 2, canvasSize.height + 2); + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1 / ctx.getTransform().a; + ctx.strokeRect(0, 0, canvasSize.width, canvasSize.height); +} + +/** + * 편집 모드에서 격자무늬를 그립니다. + */ +export function drawGrid( + ctx: CanvasRenderingContext2D, + canvasSize: { width: number; height: number } +) { + ctx.strokeStyle = 'rgba(255,255,255, 0.12)'; + ctx.lineWidth = 1 / ctx.getTransform().a; + ctx.beginPath(); + for (let x = 0; x <= canvasSize.width; x++) { + ctx.moveTo(x, 0); + ctx.lineTo(x, canvasSize.height); + } + for (let y = 0; y <= canvasSize.height; y++) { + ctx.moveTo(0, y); + ctx.lineTo(canvasSize.width, y); + } + ctx.stroke(); +} + +/** + * 첨부된 이미지와 리사이즈 핸들을 그립니다. + */ +export function drawAttachedImage( + ctx: CanvasRenderingContext2D, + imageCanvas: HTMLCanvasElement, + position: { x: number; y: number }, + size: { width: number; height: number }, + isFixed: boolean, + transparency: number +) { + try { + ctx.globalAlpha = transparency; + ctx.imageSmoothingEnabled = false; + + if (!isFixed && !(imageCanvas as any)._isGroupImage) { + ctx.strokeStyle = 'rgba(0, 255, 255, 0.8)'; + ctx.lineWidth = 2 / ctx.getTransform().a; + ctx.strokeRect( + position.x - 1, + position.y - 1, + size.width + 2, + size.height + 2 + ); + } + + ctx.drawImage(imageCanvas, position.x, position.y, size.width, size.height); + + ctx.globalAlpha = 1.0; + + if (!isFixed) { + const scale = ctx.getTransform().a; + const hs = 10 / scale; + + ctx.fillStyle = 'rgba(0, 191, 255, 0.95)'; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'; + ctx.lineWidth = 2 / scale; + + // 우하단 핸들 + ctx.beginPath(); + ctx.rect( + position.x + size.width - hs, + position.y + size.height - hs, + hs, + hs + ); + ctx.fill(); + ctx.stroke(); + + // 우측 핸들 + ctx.beginPath(); + ctx.rect( + position.x + size.width - hs, + position.y + size.height / 2 - hs / 2, + hs, + hs + ); + ctx.fill(); + ctx.stroke(); + + // 하단 핸들 + ctx.beginPath(); + ctx.rect( + position.x + size.width / 2 - hs / 2, + position.y + size.height - hs, + hs, + hs + ); + ctx.fill(); + ctx.stroke(); + } + } catch (error) { + console.error('이미지 그리기 실패:', error); + } +} diff --git a/src/utils/starfieldUtils.ts b/src/utils/starfieldUtils.ts new file mode 100644 index 0000000..4b3b2f6 --- /dev/null +++ b/src/utils/starfieldUtils.ts @@ -0,0 +1,105 @@ +// utils/starfieldUtils.ts + +// --- Constants --- +const MAX_STARS = 400; +const HUE = 217; +const PARALLAX_MULTIPLIER = 0.1; + +// --- Helper Functions --- +function random(min: number, max?: number) { + if (max === undefined) { + max = min; + min = 0; + } + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function maxOrbit(x: number, y: number) { + const max = Math.max(x, y); + return Math.round(Math.sqrt(max * max + max * max)) / 2; +} + +// --- Star Class --- +export class Star { + orbitRadius: number; + radius: number; + orbitX: number; + orbitY: number; + timePassed: number; + speed: number; + alpha: number; + parallaxFactor: number; + + constructor(canvasWidth: number, canvasHeight: number) { + this.orbitRadius = random(maxOrbit(canvasWidth, canvasHeight)); + this.radius = random(60, this.orbitRadius) / 12; + this.orbitX = canvasWidth / 2; + this.orbitY = canvasHeight / 2; + this.timePassed = random(0, MAX_STARS); + this.speed = random(this.orbitRadius) / 400000; + this.alpha = random(2, 10) / 10; + this.parallaxFactor = random(2, 10) / 10; + } + + update() { + this.timePassed += this.speed; + + const twinkle = random(10); + if (twinkle === 1 && this.alpha > 0) { + this.alpha -= 0.05; + } else if (twinkle === 2 && this.alpha < 1) { + this.alpha += 0.05; + } + } + + draw( + ctx: CanvasRenderingContext2D, + starImage: HTMLCanvasElement, + viewPos: { x: number; y: number } + ) { + const canvasX = Math.sin(this.timePassed) * this.orbitRadius + this.orbitX; + const canvasY = Math.cos(this.timePassed) * this.orbitRadius + this.orbitY; + + const parallaxX = viewPos.x * this.parallaxFactor * PARALLAX_MULTIPLIER; + const parallaxY = viewPos.y * this.parallaxFactor * PARALLAX_MULTIPLIER; + + ctx.globalAlpha = this.alpha; + ctx.drawImage( + starImage, + canvasX - this.radius / 2 + parallaxX, + canvasY - this.radius / 2 + parallaxY, + this.radius, + this.radius + ); + } +} + +// --- Factory Functions --- +export function createStars(canvasWidth: number, canvasHeight: number): Star[] { + const stars = []; + for (let i = 0; i < MAX_STARS; i++) { + stars.push(new Star(canvasWidth, canvasHeight)); + } + return stars; +} + +export function createStarImage(): HTMLCanvasElement { + const starCanvas = document.createElement('canvas'); + const ctx = starCanvas.getContext('2d')!; + starCanvas.width = 100; + starCanvas.height = 100; + const half = starCanvas.width / 2; + const gradient = ctx.createRadialGradient(half, half, 0, half, half, half); + + gradient.addColorStop(0.025, '#fff'); + gradient.addColorStop(0.1, `hsl(${HUE}, 61%, 33%)`); + gradient.addColorStop(0.25, `hsl(${HUE}, 64%, 6%)`); + gradient.addColorStop(1, 'transparent'); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(half, half, half, 0, Math.PI * 2); + ctx.fill(); + + return starCanvas; +} diff --git a/tsconfig.app.json b/tsconfig.app.json index b7606cd..4a301a8 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -23,5 +23,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src", "test/useSocket.test.ts", "test/GameAPI.test.ts"] + "include": ["src"] }