Skip to content

Commit b2e5650

Browse files
authored
refactor: Extract game logic to hooks/game (#55)
* refactor: Eliminate socket handler core duplication - Created helper function to handle common socket operations - Added namespace-specific wrapper functions (sendGameMessage, sendChatMessage, sendDrawingMessage) * refactor: Extract game chat logic into custom hook 'useChat' * refactor: Extract game result logic into useGameResult hook * refactor: Extract game start logic into useGameStart hook * refactor: Extract game settings logic into useGameSetting hook * refactor: Extract quiz stage UI logic into useQuizStageUI hook * refactor: Extract role and round end modal logic into custom hooks * refactor: Extract player role display logic into usePlayers hook * feat: Add getRemainingTime function to useTimer hook * refactor: Unify mouse and touch events using PointerEvent
1 parent f292120 commit b2e5650

35 files changed

+1043
-647
lines changed

client/src/components/canvas/CanvasUI.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,6 @@ interface CanvasProps extends HTMLAttributes<HTMLDivElement> {
9595
inkRemaining: number;
9696
maxPixels: number;
9797
canvasEvents: CanvasEventHandlers;
98-
isHidden: boolean;
9998
showInkRemaining: boolean;
10099
}
101100

@@ -119,7 +118,6 @@ const Canvas = forwardRef<HTMLDivElement, CanvasProps>(
119118
inkRemaining,
120119
maxPixels,
121120
canvasEvents,
122-
isHidden,
123121
showInkRemaining,
124122
...props
125123
},
@@ -131,7 +129,6 @@ const Canvas = forwardRef<HTMLDivElement, CanvasProps>(
131129
className={cn(
132130
'relative flex w-full max-w-screen-sm flex-col border-violet-500 bg-white',
133131
'sm:rounded-lg sm:border-4 sm:shadow-xl',
134-
isHidden && 'hidden',
135132
className,
136133
)}
137134
{...props}

client/src/components/canvas/GameCanvas.tsx

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent, useCallback, useEffect, useRef } from 'react';
2-
import { PlayerRole, RoomStatus } from '@troublepainter/core';
1+
import { PointerEvent, useCallback, useEffect, useRef } from 'react';
2+
import { PlayerRole } from '@troublepainter/core';
33
import { Canvas } from '@/components/canvas/CanvasUI';
44
import { COLORS_INFO, DEFAULT_MAX_PIXELS, MAINCANVAS_RESOLUTION_WIDTH } from '@/constants/canvasConstants';
55
import { handleInCanvas, handleOutCanvas } from '@/handlers/canvas/cursorInOutHandler';
@@ -13,12 +13,10 @@ import { getCanvasContext } from '@/utils/getCanvasContext';
1313
import { getDrawPoint } from '@/utils/getDrawPoint';
1414

1515
interface GameCanvasProps {
16-
isHost: boolean;
1716
role: PlayerRole;
1817
maxPixels?: number;
1918
currentRound: number;
20-
roomStatus: RoomStatus;
21-
isHidden: boolean;
19+
isDrawable: boolean;
2220
}
2321

2422
/**
@@ -50,7 +48,7 @@ interface GameCanvasProps {
5048
*
5149
* @category Components
5250
*/
53-
const GameCanvas = ({ role, maxPixels = DEFAULT_MAX_PIXELS, currentRound, roomStatus, isHidden }: GameCanvasProps) => {
51+
const GameCanvas = ({ maxPixels = DEFAULT_MAX_PIXELS, currentRound, isDrawable }: GameCanvasProps) => {
5452
const canvasRef = useRef<HTMLCanvasElement>(null);
5553
const cursorCanvasRef = useRef<HTMLCanvasElement>(null);
5654
const { convertCoordinate } = useCoordinateScale(MAINCANVAS_RESOLUTION_WIDTH, canvasRef);
@@ -73,7 +71,7 @@ const GameCanvas = ({ role, maxPixels = DEFAULT_MAX_PIXELS, currentRound, roomSt
7371
redo,
7472
makeCRDTSyncMessage,
7573
resetCanvas,
76-
} = useDrawing(canvasRef, roomStatus, {
74+
} = useDrawing(canvasRef, isDrawable, {
7775
maxPixels,
7876
});
7977

@@ -120,7 +118,7 @@ const GameCanvas = ({ role, maxPixels = DEFAULT_MAX_PIXELS, currentRound, roomSt
120118
}));
121119

122120
const handleDrawStart = useCallback(
123-
(e: ReactMouseEvent<HTMLCanvasElement> | ReactTouchEvent<HTMLCanvasElement>) => {
121+
(e: PointerEvent<HTMLCanvasElement>) => {
124122
if (!isConnected) return;
125123

126124
const { canvas } = getCanvasContext(canvasRef);
@@ -136,7 +134,7 @@ const GameCanvas = ({ role, maxPixels = DEFAULT_MAX_PIXELS, currentRound, roomSt
136134
);
137135

138136
const handleDrawMove = useCallback(
139-
(e: ReactMouseEvent<HTMLCanvasElement> | ReactTouchEvent<HTMLCanvasElement>) => {
137+
(e: PointerEvent<HTMLCanvasElement>) => {
140138
const { canvas } = getCanvasContext(canvasRef);
141139
const point = getDrawPoint(e, canvas);
142140
const convertPoint = convertCoordinate(point);
@@ -152,7 +150,7 @@ const GameCanvas = ({ role, maxPixels = DEFAULT_MAX_PIXELS, currentRound, roomSt
152150
);
153151

154152
const handleDrawLeave = useCallback(
155-
(e: ReactMouseEvent<HTMLCanvasElement> | ReactTouchEvent<HTMLCanvasElement>) => {
153+
(e: PointerEvent<HTMLCanvasElement>) => {
156154
const { canvas } = getCanvasContext(canvasRef);
157155
const point = getDrawPoint(e, canvas);
158156
const convertPoint = convertCoordinate(point);
@@ -202,8 +200,7 @@ const GameCanvas = ({ role, maxPixels = DEFAULT_MAX_PIXELS, currentRound, roomSt
202200
<Canvas
203201
canvasRef={canvasRef}
204202
cursorCanvasRef={cursorCanvasRef}
205-
isDrawable={(role === 'PAINTER' || role === 'DEVIL') && roomStatus === 'DRAWING'}
206-
isHidden={isHidden}
203+
isDrawable={isDrawable}
207204
colors={colorsWithSelect}
208205
brushSize={brushSize}
209206
setBrushSize={setBrushSize}

client/src/components/chat/ChatInput.tsx

Lines changed: 7 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,12 @@
1-
import { FormEvent, memo, useMemo, useRef, useState } from 'react';
2-
import { PlayerRole, RoomStatus, type ChatResponse } from '@troublepainter/core';
1+
import { memo, useRef } from 'react';
32
import { Input } from '@/components/ui/Input';
4-
import { chatSocketHandlers } from '@/handlers/socket/chatSocket.handler';
5-
import { gameSocketHandlers } from '@/handlers/socket/gameSocket.handler';
3+
import { useChat } from '@/hooks/game/useChat';
64
import { useShortcuts } from '@/hooks/useShortcuts';
7-
import { useChatSocketStore } from '@/stores/socket/chatSocket.store';
8-
import { useGameSocketStore } from '@/stores/socket/gameSocket.store';
9-
import { useSocketStore } from '@/stores/socket/socket.store';
105

116
export const ChatInput = memo(() => {
12-
const [inputMessage, setInputMessage] = useState('');
13-
const inputRef = useRef<HTMLInputElement | null>(null);
14-
15-
// 개별 Selector
16-
const isConnected = useSocketStore((state) => state.connected.chat);
17-
const currentPlayerId = useGameSocketStore((state) => state.currentPlayerId);
18-
const players = useGameSocketStore((state) => state.players);
19-
const roomStatus = useGameSocketStore((state) => state.room?.status);
20-
const roundAssignedRole = useGameSocketStore((state) => state.roundAssignedRole);
21-
// 챗 액션
22-
const chatActions = useChatSocketStore((state) => state.actions);
23-
24-
const shouldDisableInput = useMemo(() => {
25-
const ispainters = roundAssignedRole !== PlayerRole.GUESSER;
26-
const isDrawing = roomStatus === 'DRAWING' || roomStatus === 'GUESSING';
27-
return ispainters && isDrawing;
28-
}, [roundAssignedRole, roomStatus]);
29-
30-
const handleSubmit = (e: FormEvent) => {
31-
e.preventDefault();
32-
if (!isConnected || !inputMessage.trim()) return;
33-
void chatSocketHandlers.sendMessage(inputMessage);
7+
const { submitMessage, checkDisableInput, changeMessage, inputMessage } = useChat();
348

35-
const currentPlayer = players?.find((player) => player.playerId === currentPlayerId);
36-
if (!currentPlayer || !currentPlayerId) throw new Error('Current player not found');
37-
38-
const messageData: ChatResponse = {
39-
playerId: currentPlayerId as string,
40-
nickname: currentPlayer.nickname,
41-
message: inputMessage.trim(),
42-
createdAt: new Date().toISOString(),
43-
};
44-
chatActions.addMessage(messageData);
45-
46-
if (roomStatus === RoomStatus.GUESSING) {
47-
void gameSocketHandlers.checkAnswer({ answer: inputMessage });
48-
}
49-
50-
setInputMessage('');
51-
};
9+
const inputRef = useRef<HTMLInputElement | null>(null);
5210

5311
useShortcuts([
5412
{
@@ -68,13 +26,13 @@ export const ChatInput = memo(() => {
6826
]);
6927

7028
return (
71-
<form onSubmit={handleSubmit} className="mt-1 w-full">
29+
<form onSubmit={submitMessage} className="mt-1 w-full">
7230
<Input
7331
ref={inputRef}
7432
value={inputMessage}
75-
onChange={(e) => setInputMessage(e.target.value)}
33+
onChange={changeMessage}
7634
placeholder="메시지를 입력하세요"
77-
disabled={!isConnected || shouldDisableInput}
35+
disabled={checkDisableInput()}
7836
autoComplete="off"
7937
/>
8038
</form>

client/src/components/chat/ChatList.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useGameSocketStore } from '@/stores/socket/gameSocket.store';
66

77
export const ChatList = memo(() => {
88
const messages = useChatSocketStore((state) => state.messages);
9-
const currentPlayerId = useGameSocketStore((state) => state.currentPlayerId);
9+
const playerId = useGameSocketStore((state) => state.currentPlayerId);
1010
const { containerRef } = useScrollToBottom([messages]);
1111

1212
return (
@@ -17,7 +17,7 @@ export const ChatList = memo(() => {
1717
</p>
1818

1919
{messages.map((message) => {
20-
const isOthers = message.playerId !== currentPlayerId;
20+
const isOthers = message.playerId !== playerId;
2121
return (
2222
<ChatBubble
2323
key={`${message.playerId}-${message.createdAt}`}
Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,31 @@
11
import { Button } from '@/components/ui/Button';
2-
import { useGameStart } from '@/hooks/useStartButton';
2+
import { useGameStart } from '@/hooks/game/useGameStart';
3+
import { useShortcuts } from '@/hooks/useShortcuts';
34
import { cn } from '@/utils/cn';
45

56
export const StartButton = () => {
6-
const { isHost, buttonConfig, handleStartGame, isStarting } = useGameStart();
7+
const { startGame, checkCanStart, getStartButtonStatus, isStarting } = useGameStart();
8+
const { disabled, title, content } = getStartButtonStatus();
9+
10+
useShortcuts([
11+
{
12+
key: 'GAME_START',
13+
action: () => startGame(),
14+
},
15+
]);
16+
717
return (
818
<Button
9-
onClick={handleStartGame}
10-
disabled={buttonConfig.disabled || isStarting}
11-
title={buttonConfig.title}
19+
onClick={startGame}
20+
disabled={disabled || isStarting}
21+
title={title}
1222
className={cn(
1323
'h-full rounded-none border-0 text-xl',
1424
'sm:rounded-2xl sm:border-2 lg:text-2xl',
15-
!isHost && 'cursor-not-allowed opacity-50 hover:bg-violet-500',
25+
!checkCanStart() && 'cursor-not-allowed opacity-50 hover:bg-violet-500',
1626
)}
1727
>
18-
{isStarting ? '곧 게임이 시작됩니다!' : buttonConfig.content}
28+
{isStarting ? '곧 게임이 시작됩니다!' : content}
1929
</Button>
2030
);
2131
};

client/src/components/modal/RoleModal.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
1-
import { useEffect } from 'react';
21
import { Modal } from '@/components/ui/Modal';
32
import { PLAYING_ROLE_TEXT } from '@/constants/gameConstant';
4-
import { useModal } from '@/hooks/useModal';
3+
import { useRoleModal } from '@/hooks/game/useRoleModal';
54
import { useGameSocketStore } from '@/stores/socket/gameSocket.store';
65

76
const RoleModal = () => {
8-
const room = useGameSocketStore((state) => state.room);
7+
const { isModalOpened, closeModal, handleKeyDown } = useRoleModal();
98
const roundAssignedRole = useGameSocketStore((state) => state.roundAssignedRole);
10-
const { isModalOpened, closeModal, handleKeyDown, openModal } = useModal(5000);
11-
12-
useEffect(() => {
13-
if (roundAssignedRole) openModal();
14-
}, [roundAssignedRole, room?.currentRound]);
159

1610
return (
1711
<Modal

client/src/components/modal/RoundEndModal.tsx

Lines changed: 7 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,21 @@
1-
import { useEffect, useState } from 'react';
21
import { DotLottieReact } from '@lottiefiles/dotlottie-react';
3-
import { PlayerRole, RoomStatus } from '@troublepainter/core';
42
import roundLoss from '@/assets/lottie/round-loss.lottie';
53
import roundWin from '@/assets/lottie/round-win.lottie';
6-
import gameLoss from '@/assets/sounds/game-loss.mp3';
7-
import gameWin from '@/assets/sounds/game-win.mp3';
84
import { Modal } from '@/components/ui/Modal';
9-
import { useModal } from '@/hooks/useModal';
5+
import { useRoundEndModal } from '@/hooks/game/useRoundEndModal';
106
import { useGameSocketStore } from '@/stores/socket/gameSocket.store';
11-
import { useTimerStore } from '@/stores/timer.store';
127
import { cn } from '@/utils/cn';
13-
import { SOUND_IDS, SoundManager } from '@/utils/soundManager';
148

159
const RoundEndModal = () => {
16-
const room = useGameSocketStore((state) => state.room);
17-
const roundWinners = useGameSocketStore((state) => state.roundWinners);
18-
const players = useGameSocketStore((state) => state.players);
19-
const currentPlayerId = useGameSocketStore((state) => state.currentPlayerId);
20-
const timer = useTimerStore((state) => state.timers.ENDING);
21-
22-
const { isModalOpened, openModal, closeModal } = useModal();
23-
const [showAnimation, setShowAnimation] = useState(false);
24-
const [isAnimationFading, setIsAnimationFading] = useState(false);
25-
26-
const devil = players.find((player) => player.role === PlayerRole.DEVIL);
27-
const isDevilWin = roundWinners?.some((winner) => winner.role === PlayerRole.DEVIL);
28-
const isCurrentPlayerWinner = roundWinners?.some((winner) => winner.playerId === currentPlayerId);
29-
30-
// 컴포넌트 마운트 시 사운드 미리 로드
31-
const soundManager = SoundManager.getInstance();
32-
useEffect(() => {
33-
soundManager.preloadSound(SOUND_IDS.WIN, gameWin);
34-
soundManager.preloadSound(SOUND_IDS.LOSS, gameLoss);
35-
}, [soundManager]);
36-
37-
useEffect(() => {
38-
if (roundWinners) {
39-
setIsAnimationFading(false);
40-
setShowAnimation(true);
41-
openModal();
42-
43-
if (isCurrentPlayerWinner) {
44-
void soundManager.playSound(SOUND_IDS.WIN, 0.3);
45-
} else {
46-
void soundManager.playSound(SOUND_IDS.LOSS, 0.3);
47-
}
48-
}
49-
}, [roundWinners]);
50-
51-
useEffect(() => {
52-
if (room && room.status === RoomStatus.DRAWING) closeModal();
53-
}, [room]);
54-
55-
useEffect(() => {
56-
if (showAnimation) {
57-
// 3초 후에 페이드아웃 시작
58-
const fadeTimer = setTimeout(() => {
59-
setIsAnimationFading(true);
60-
}, 3000);
61-
62-
// 3.5초 후에 컴포넌트 제거
63-
const removeTimer = setTimeout(() => {
64-
setShowAnimation(false);
65-
}, 3500);
66-
67-
return () => {
68-
clearTimeout(fadeTimer);
69-
clearTimeout(removeTimer);
70-
};
71-
}
72-
}, [showAnimation]);
10+
const { showAnimation, isPlayerWinner, isAnimationFading, isModalOpened, timer, isDevilWin, solver, devil } =
11+
useRoundEndModal();
12+
const currentWord = useGameSocketStore((state) => state.room?.currentWord);
7313

7414
return (
7515
<>
7616
{/* 승리/패배 애니메이션 */}
7717
{showAnimation &&
78-
(isCurrentPlayerWinner ? (
18+
(isPlayerWinner ? (
7919
<DotLottieReact
8020
src={roundWin}
8121
autoplay
@@ -97,11 +37,7 @@ const RoundEndModal = () => {
9737
/>
9838
))}
9939

100-
<Modal
101-
title={room?.currentWord || ''}
102-
isModalOpened={isModalOpened}
103-
className="max-w-[26.875rem] sm:max-w-[61.75rem]"
104-
>
40+
<Modal title={currentWord || ''} isModalOpened={isModalOpened} className="max-w-[26.875rem] sm:max-w-[61.75rem]">
10541
<div className="relative flex min-h-[12rem] items-center justify-center sm:min-h-[15.75rem]">
10642
<span className="absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-full border-2 border-violet-300 text-base text-violet-300">
10743
{timer}
@@ -111,10 +47,7 @@ const RoundEndModal = () => {
11147
<> 정답을 맞춘 구경꾼이 없습니다</>
11248
) : (
11349
<>
114-
구경꾼{' '}
115-
<span className="text-violet-600">
116-
{roundWinners?.find((winner) => winner.role === PlayerRole.GUESSER)?.nickname}
117-
</span>
50+
구경꾼 <span className="text-violet-600">{solver?.nickname}</span>
11851
이(가) 정답을 맞혔습니다
11952
</>
12053
)}

0 commit comments

Comments
 (0)