+ 여러 사용자가 함께 참여하여 하나의 캔버스에 픽셀 아트를 그리는 협업
+ 게임입니다. 각자의 창의성을 발휘하여 멋진 작품을 만들어보세요!
+
+
+ ),
+ },
+ {
+ title: '기본 조작법',
+ iconColor: 'green',
+ content: isMobile ? (
+
+ ),
+ },
+ {
+ title: '게임 규칙',
+ iconColor: 'yellow',
+ content: (
+
+ ),
+ },
+ {
+ title: '추가 기능',
+ iconColor: 'purple',
+ content: (
+
+ ),
+ },
+ {
+ title: '유용한 팁',
+ iconColor: 'red',
+ content: (
+
+ ),
+ },
+ ];
+
+ const renderSections = () => {
+ return sections.map((section, index) => (
+
{/* 헤더 */}
@@ -27,204 +234,24 @@ export default function HelpModalContent() {
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"]
}