Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/components/album/albumTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export interface AlbumItemData {
size_y: number;
participant_count: number;
total_try_count: number;
top_try_user_name: string;
top_try_user_count: number;
top_own_user_name: string;
top_own_user_count: number;
top_try_user_name?: string;
top_try_user_count?: number;
top_own_user_name?: string;
top_own_user_count?: number;
}
220 changes: 46 additions & 174 deletions src/components/canvas/CanvasUIPC.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/components/canvas/PixelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,7 @@ function PixelCanvas({
);

const handleCooltime = useCallback(() => {
startCooldown(10);
startCooldown(3);
}, [startCooldown]);

const handleConfirm = useCallback(() => {
Expand Down
151 changes: 113 additions & 38 deletions src/components/game/GameCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import useSound from 'use-sound';
import { useGameSocketIntegration } from '../gameSocketIntegration';
import { useNavigate } from 'react-router-dom';
import GameTimer from './GameTimer'; // GameTimer import 추가
import GameResultModal from '../modal/GameResultModal'; // 게임 결과 모달 import
import GameResultModal from './GameResultModal'; // 게임 결과 모달 import
import DeathModal from '../modal/DeathModal'; // 사망 모달 import
import QuestionModal from '../modal/QuestionModal'; // 문제 모달 import
import QuestionModal from './QuestionModal'; // 문제 모달 import
import ExitModal from '../modal/ExitModal'; // 나가기 모달 import

import {
Expand All @@ -28,6 +28,8 @@ import {
VIEWPORT_BACKGROUND_COLOR,
} from '../canvas/canvasConstants';
import GameReadyModal from './GameReadyModal';
import { useViewport } from '../../hooks/useViewport';
import LifeIndicator from './LifeIndicator';

// 게임 문제 타입 정의
interface GameQuestion {
Expand Down Expand Up @@ -56,6 +58,7 @@ function GameCanvas({
const [gameTime, setGameTime] = useState<number>(90); // 실제 게임 시간 (초)
const [totalGameDuration, setTotalGameDuration] = useState<number>(90); // 전체 게임 시간 (초)
const [lives, setLives] = useState(2); // 사용자 생명 (2개)
const [isLifeDecreasing, setIsLifeDecreasing] = useState(false); // 생명 차감 애니메이션 상태

const navigate = useNavigate();
const { canvas_id, setCanvasId } = useCanvasStore();
Expand Down Expand Up @@ -110,6 +113,9 @@ function GameCanvas({
} | null>(null);
const flashingPixelRef = useRef<{ x: number; y: number } | null>(null);

const { width } = useViewport();
const isMobile = width <= 768;

// 상태 관리
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const [hasError, setHasError] = useState(false);
Expand Down Expand Up @@ -604,6 +610,42 @@ function GameCanvas({
const pixelData = sourceCtx.getImageData(pos.x, pos.y, 1, 1).data;
const isBlack =
pixelData[0] === 0 && pixelData[1] === 0 && pixelData[2] === 0;
// 현재 픽셀 색상이 내 색상인지 확인
const hexToRgb = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
};

const myColor = hexToRgb(userColor);
const isMyColor =
myColor &&
Math.abs(pixelData[0] - myColor.r) < 5 &&
Math.abs(pixelData[1] - myColor.g) < 5 &&
Math.abs(pixelData[2] - myColor.b) < 5;

if (isMyColor) {
// 토스트 대신 직접 UI에 메시지 표시
const messageDiv = document.createElement('div');
messageDiv.className =
'fixed top-4 left-1/2 z-[9999] -translate-x-1/2 transform rounded-lg bg-blue-500 px-4 py-2 text-white shadow-lg';
messageDiv.textContent = '이미 내 색상으로 칠해진 픽셀입니다.';
document.body.appendChild(messageDiv);

// 3초 후 메시지 제거
setTimeout(() => {
if (document.body.contains(messageDiv)) {
document.body.removeChild(messageDiv);
}
}, 1000);

return;
}

if (isBlack) {
// 검은색 픽셀이면 바로 그리기 (기존 로직과 동일)
Expand Down Expand Up @@ -657,6 +699,7 @@ function GameCanvas({
startCooldown,
setQuestionTimeLeft,
waitingData,
toast,
]);

// 문제 답변 제출
Expand Down Expand Up @@ -701,6 +744,28 @@ function GameCanvas({
setLives((prev) => Math.max(0, prev - 1));
startCooldown(1);

// 생명 차감 애니메이션 및 알림 표시
setIsLifeDecreasing(true);

// 생명 차감 알림 메시지 표시
const messageDiv = document.createElement('div');
messageDiv.className =
'fixed top-4 left-1/2 z-[9999] -translate-x-1/2 transform rounded-lg bg-red-500 px-4 py-2 text-white shadow-lg';
messageDiv.textContent = '오답입니다! 생명이 차감되었습니다.';
document.body.appendChild(messageDiv);

// 애니메이션 완료 후 상태 초기화
setTimeout(() => {
setIsLifeDecreasing(false);
}, 1000);

// 2초 후 메시지 제거
setTimeout(() => {
if (document.body.contains(messageDiv)) {
document.body.removeChild(messageDiv);
}
}, 2000);

sendGameResult({
x: currentPixel.x,
y: currentPixel.y,
Expand All @@ -721,6 +786,7 @@ function GameCanvas({
setQuestionTimeLeft,
lives,
setLives,
setIsLifeDecreasing,
]);

// 문제 타이머 효과
Expand All @@ -745,14 +811,36 @@ function GameCanvas({
// 시간 초과시 자동으로 false 결과 전송
startCooldown(1);
setLives((prev) => Math.max(0, prev - 1));


// 생명 차감 애니메이션 및 알림 표시
setIsLifeDecreasing(true);

// 생명 차감 알림 메시지 표시
const messageDiv = document.createElement('div');
messageDiv.className =
'fixed top-4 left-1/2 z-[9999] -translate-x-1/2 transform rounded-lg bg-red-500 px-4 py-2 text-white shadow-lg';
messageDiv.textContent = '⏰ 시간 초과! 자동으로 오답 처리되었습니다.';
document.body.appendChild(messageDiv);

// 애니메이션 완료 후 상태 초기화
setTimeout(() => {
setIsLifeDecreasing(false);
}, 1000);

// 2초 후 메시지 제거
setTimeout(() => {
if (document.body.contains(messageDiv)) {
document.body.removeChild(messageDiv);
}
}, 1000);

sendGameResult({
x: currentPixel.x,
y: currentPixel.y,
color: currentPixel.color,
result: false,
});

setShowQuestionModal(false);
setShowResult(false);
setCurrentPixel(null);
Expand All @@ -762,7 +850,14 @@ function GameCanvas({
return () => {
clearInterval(timerId);
};
}, [showQuestionModal, questionTimeLeft, currentPixel, startCooldown, setLives, sendGameResult]);
}, [
showQuestionModal,
questionTimeLeft,
currentPixel,
startCooldown,
setLives,
sendGameResult,
]);

// 게임 데이터 및 캔버스 초기화
const { getSynchronizedServerTime } = useTimeSyncStore();
Expand Down Expand Up @@ -805,7 +900,9 @@ function GameCanvas({

// 게임 총 시간 계산 및 설정
const endTime = new Date(gameData.endedAt).getTime();
const calculatedTotalGameDuration = Math.floor((endTime - startTime) / 1000);
const calculatedTotalGameDuration = Math.floor(
(endTime - startTime) / 1000
);
setTotalGameDuration(calculatedTotalGameDuration);
setGameTime(calculatedTotalGameDuration);

Expand Down Expand Up @@ -1032,44 +1129,22 @@ function GameCanvas({
{isGameStarted && (
<>
{/* 나가기 버튼 및 생명 표시 */}
<div className='absolute top-4 left-4 z-50 flex items-center gap-3'>
<div
className={`absolute z-50 flex items-center gap-3 ${
isMobile ? 'bottom-4 left-4 flex-col' : 'top-4 left-4'
}`}
>
<button
onClick={handleExit}
className='rounded-lg bg-red-600 px-4 py-2 font-bold text-white shadow-lg transition-all hover:bg-red-700 active:scale-95'
>
나가기
</button>
<div className='flex items-center gap-1 rounded-lg bg-gray-900/80 px-3 py-2 backdrop-blur-sm'>
{[...Array(2)].map((_, i) => (
<div key={i} className='h-6 w-6'>
{i < lives ? (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='#ef4444'
className='h-6 w-6'
>
<path d='m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z' />
</svg>
) : (
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='#ef4444'
className='h-6 w-6'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z'
/>
</svg>
)}
</div>
))}
</div>
<LifeIndicator
lives={lives}
maxLives={2}
isLifeDecreasing={isLifeDecreasing}
/>
</div>
<GameTimer currentTime={gameTime} totalTime={totalGameDuration} />
<style>{`
Expand Down
23 changes: 15 additions & 8 deletions src/components/game/GameReadyModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { WaitingRoomData } from '../../api/GameAPI';
import { useTimeSyncStore } from '../../store/timeSyncStore';
import { useToastStore } from '../../store/toastStore';


interface GameReadyModalProps {
isOpen: boolean;
onClose: (data?: WaitingRoomData) => void;
Expand All @@ -13,7 +12,13 @@ interface GameReadyModalProps {
remainingTime?: number;
}

const GameReadyModal = ({ isOpen, onClose, canvasId, color, remainingTime }: GameReadyModalProps) => {
const GameReadyModal = ({
isOpen,
onClose,
canvasId,
color,
remainingTime,
}: GameReadyModalProps) => {
const navigate = useNavigate();
const { showToast } = useToastStore();
const { getSynchronizedServerTime } = useTimeSyncStore();
Expand All @@ -25,12 +30,12 @@ const GameReadyModal = ({ isOpen, onClose, canvasId, color, remainingTime }: Gam
if (!isOpen) {
return;
}

// 부모 컴포넌트에서 전달받은 remainingTime 사용
if (remainingTime !== undefined) {
setTimeUntilStart(remainingTime);
setLoading(false);

// 시간이 0이하인 경우 모달 닫기
if (remainingTime <= 0) {
setTimeout(() => onClose(), 1000); // 1초 후 모달 닫기
Expand All @@ -48,7 +53,7 @@ const GameReadyModal = ({ isOpen, onClose, canvasId, color, remainingTime }: Gam
const timer = setInterval(() => {
// useTimeSyncStore를 사용하여 더 정확한 시간 계산
if (remainingTime === undefined) {
setTimeUntilStart(prev => {
setTimeUntilStart((prev) => {
const newValue = prev !== null ? prev - 1 : 0;
if (newValue <= 0) {
clearInterval(timer);
Expand Down Expand Up @@ -159,9 +164,11 @@ const GameReadyModal = ({ isOpen, onClose, canvasId, color, remainingTime }: Gam
></div>
<div className='absolute inset-3 flex animate-pulse items-center justify-center rounded-full border border-green-400/60 bg-gradient-to-br from-green-900/80 to-black/70 shadow-2xl backdrop-blur-xl'>
<span className='animate-pulse font-mono text-3xl font-bold tracking-wider text-green-300'>
{remainingTime !== undefined && remainingTime > 0 ? `${remainingTime}` :
timeUntilStart !== null && timeUntilStart > 0 ? `${timeUntilStart}` :
'시작!'}
{remainingTime !== undefined && remainingTime > 0
? `${remainingTime}`
: timeUntilStart !== null && timeUntilStart > 0
? `${timeUntilStart}`
: '--'}
</span>
</div>
<div className='absolute inset-0 animate-ping rounded-full bg-green-500/15'></div>
Expand Down
20 changes: 15 additions & 5 deletions src/components/game/GameTimer.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,36 @@
import React from 'react';
import { useViewport } from '../../hooks/useViewport';

interface GameTimerProps {
currentTime: number;
totalTime: number;
}

const GameTimer: React.FC<GameTimerProps> = ({ currentTime, totalTime }) => {
const { width } = useViewport();
const isMobile = width <= 768;

const progress = totalTime > 0 ? (currentTime / totalTime) * 100 : 0;

const barHeight = isMobile ? 'h-5' : 'h-6';
const timerCircleSize = isMobile ? 'h-5 w-5 text-xs' : 'h-6 w-6 text-xs';
const containerPadding = isMobile ? 'px-2' : 'px-4';

return (
<div className='absolute top-4 left-1/2 z-50 mb-2 w-full -translate-x-1/2 transform px-4'>
<div className='relative mx-auto h-6 max-w-lg rounded-full bg-gray-900 shadow-inner'>
<div
className={`absolute top-4 left-1/2 z-50 mb-2 w-full -translate-x-1/2 transform ${containerPadding}`}>
<div
className={`relative mx-auto ${barHeight} max-w-lg rounded-full bg-gray-900 shadow-inner`}>
<div
className={`absolute top-0 left-0 h-full rounded-full transition-all duration-1000 ease-linear ${
currentTime <= 20
? 'bg-gradient-to-r from-red-600 to-red-800'
: 'bg-gradient-to-r from-purple-700 via-indigo-800 to-blue-900'
}`}
style={{ width: `${progress}%` }}
></div>
style={{ width: `${progress}%` }}></div>
<div className='absolute inset-0 flex items-center justify-start'>
<div className='flex h-6 w-6 items-center justify-center rounded-full bg-white text-xs font-bold text-gray-800 shadow-md'>
<div
className={`flex items-center justify-center rounded-full bg-white font-bold text-gray-800 shadow-md ${timerCircleSize}`}>
{currentTime}
</div>
</div>
Expand Down
Loading