Skip to content

Commit 0bf4c2c

Browse files
[feat] 뇌피셜 초시계(블라인드 타이머) 미니게임 프론트엔드 구현 (#1119)
* feat: BlindTimer 미니게임 추가 - `BlindTimerGameContext` 및 `BlindTimerGameProvider` 생성 - BlindTimer 게임 상태 및 진행 데이터 타입 정의 - WebSocket 구독을 통해 BlindTimer 게임 상태 및 진행 데이터 관리 로직 구현 * feat: BlindTimer 미니게임 UI 컴포넌트 추가 - PlayerStatusBoard, TargetTime, TimerDisplay 컴포넌트 구현 - 플레이어 상태, 목표 시간, 타이머 UI 설계 - styled-components를 사용해 스타일 구성 - 컴포넌트 상태 및 이벤트 핸들링 로직 추가 * feat: BlindTimer 미니게임 플레이 및 준비 페이지 추가 - BlindTimerGamePlayPage, BlindTimerGameReadyPage 구현 - BlindTimer UI 레이아웃 및 스타일 구성 - useBlindTimer, useBlindTimerActions 훅 추가 - WebSocket 및 게임 상태 관리 로직 통합 - styled-components로 스타일 구성 * feat: BlindTimer 미니게임 설정 및 슬라이드 구성 추가 - BlindTimer 관련 Provider, ReadyPage, PlayPage 구성 추가 - MINI_GAME_NAME_MAP, MINI_GAME_ICON_MAP에 BlindTimer 항목 추가 - BlindTimer 소개 슬라이드 및 텍스트 구성 추가 * feat: BlindTimer 미니게임 Storybook 스토리 추가 - PlayerStatusBoard, TargetTime, TimerDisplay Storybook 스토리 구성 - 다양한 상태와 시나리오를 확인하기 위한 스토리 추가 - decorators를 통해 스토리 레이아웃 최적화 - 자동 문서화 태그(tags: ['autodocs']) 적용 * fix: BlindTimerGamePlayPage 타이머 상태 로직 수정 - 게임 중지 상태에서도 타이머가 작동하지 않도록 `isPlaying` 조건에 `!isStopped` 추가 * fix: TimerDisplay 컴포넌트 중지 버튼 속성 로직 수정 - `disabled` 속성 추가로 버튼 비활성화 상태 명확화 - 불필요한 `onClick` 조건 로직 간소화
1 parent 8f7b167 commit 0bf4c2c

20 files changed

+670
-0
lines changed
Lines changed: 6 additions & 0 deletions
Loading
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { BlindTimerGameState, BlindTimerProgressData } from '@/types/miniGame/blindTimerGame';
2+
import { createContext, useContext } from 'react';
3+
4+
type BlindTimerGameContextType = {
5+
gameState: BlindTimerGameState;
6+
targetTimeMillis: number;
7+
blindDelayMillis: number;
8+
progressData: BlindTimerProgressData;
9+
};
10+
11+
export const BlindTimerGameContext = createContext<BlindTimerGameContextType | null>(null);
12+
13+
export const useBlindTimerGame = () => {
14+
const context = useContext(BlindTimerGameContext);
15+
if (!context) {
16+
throw new Error('useBlindTimerGame은 BlindTimerGameProvider 안에서 사용해야 합니다.');
17+
}
18+
return context;
19+
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useWebSocketSubscription } from '@/apis/websocket/hooks/useWebSocketSubscription';
2+
import { PropsWithChildren, useCallback, useState } from 'react';
3+
import { useIdentifier } from '../Identifier/IdentifierContext';
4+
import { BlindTimerGameContext } from './BlindTimerGameContext';
5+
import {
6+
BlindTimerGameState,
7+
BlindTimerProgressData,
8+
BlindTimerStateData,
9+
} from '@/types/miniGame/blindTimerGame';
10+
11+
const BlindTimerGameProvider = ({ children }: PropsWithChildren) => {
12+
const [gameState, setGameState] = useState<BlindTimerGameState>('DESCRIPTION');
13+
const [targetTimeMillis, setTargetTimeMillis] = useState(0);
14+
const [blindDelayMillis, setBlindDelayMillis] = useState(3000);
15+
const [progressData, setProgressData] = useState<BlindTimerProgressData>({
16+
players: [],
17+
});
18+
const { joinCode } = useIdentifier();
19+
20+
const handleStateChange = useCallback((data: BlindTimerStateData) => {
21+
setGameState(data.state);
22+
if (data.targetTimeMillis > 0) {
23+
setTargetTimeMillis(data.targetTimeMillis);
24+
}
25+
if (data.blindDelayMillis > 0) {
26+
setBlindDelayMillis(data.blindDelayMillis);
27+
}
28+
}, []);
29+
30+
const handleProgressUpdate = useCallback((data: BlindTimerProgressData) => {
31+
setProgressData(data);
32+
}, []);
33+
34+
useWebSocketSubscription(`/room/${joinCode}/blind-timer/state`, handleStateChange);
35+
useWebSocketSubscription(`/room/${joinCode}/blind-timer/progress`, handleProgressUpdate);
36+
37+
return (
38+
<BlindTimerGameContext.Provider
39+
value={{ gameState, targetTimeMillis, blindDelayMillis, progressData }}
40+
>
41+
{children}
42+
</BlindTimerGameContext.Provider>
43+
);
44+
};
45+
46+
export default BlindTimerGameProvider;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { Meta, StoryObj } from '@storybook/react-webpack5';
2+
import PlayerStatusBoard from './PlayerStatusBoard';
3+
4+
const meta = {
5+
title: 'Features/MiniGame/BlindTimerGame/PlayerStatusBoard',
6+
component: PlayerStatusBoard,
7+
parameters: {
8+
layout: 'centered',
9+
},
10+
tags: ['autodocs'],
11+
} satisfies Meta<typeof PlayerStatusBoard>;
12+
13+
export default meta;
14+
15+
type Story = StoryObj<typeof PlayerStatusBoard>;
16+
17+
export const AllPlaying: Story = {
18+
args: {
19+
myName: '엠제이',
20+
players: [
21+
{ playerName: '엠제이', stopped: false, timedOut: false },
22+
{ playerName: '꾹이', stopped: false, timedOut: false },
23+
{ playerName: '루키', stopped: false, timedOut: false },
24+
{ playerName: '한스', stopped: false, timedOut: false },
25+
],
26+
},
27+
decorators: [
28+
(Story) => (
29+
<div style={{ width: '360px' }}>
30+
<Story />
31+
</div>
32+
),
33+
],
34+
};
35+
36+
export const SomeStopped: Story = {
37+
args: {
38+
myName: '엠제이',
39+
players: [
40+
{ playerName: '엠제이', stopped: true, timedOut: false },
41+
{ playerName: '꾹이', stopped: true, timedOut: false },
42+
{ playerName: '루키', stopped: false, timedOut: false },
43+
{ playerName: '한스', stopped: false, timedOut: false },
44+
],
45+
},
46+
decorators: [
47+
(Story) => (
48+
<div style={{ width: '360px' }}>
49+
<Story />
50+
</div>
51+
),
52+
],
53+
};
54+
55+
export const WithTimeout: Story = {
56+
args: {
57+
myName: '엠제이',
58+
players: [
59+
{ playerName: '엠제이', stopped: true, timedOut: false },
60+
{ playerName: '꾹이', stopped: true, timedOut: false },
61+
{ playerName: '루키', stopped: true, timedOut: true },
62+
{ playerName: '한스', stopped: true, timedOut: true },
63+
],
64+
},
65+
decorators: [
66+
(Story) => (
67+
<div style={{ width: '360px' }}>
68+
<Story />
69+
</div>
70+
),
71+
],
72+
};
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import styled from '@emotion/styled';
2+
3+
export const Container = styled.div`
4+
display: flex;
5+
flex-wrap: wrap;
6+
justify-content: center;
7+
gap: 8px;
8+
width: 100%;
9+
max-width: 360px;
10+
margin: 0 auto;
11+
padding: 0 8px;
12+
`;
13+
14+
export const PlayerChip = styled.div<{ $stopped: boolean; $timedOut: boolean; $isMe: boolean }>`
15+
display: flex;
16+
align-items: center;
17+
gap: 6px;
18+
padding: 6px 12px;
19+
border-radius: 20px;
20+
font-size: 0.8rem;
21+
font-weight: ${({ $isMe }) => ($isMe ? 700 : 500)};
22+
background-color: ${({ theme, $stopped, $timedOut }) =>
23+
$timedOut ? theme.color.gray[200] : $stopped ? theme.color.point[50] : theme.color.gray[50]};
24+
color: ${({ theme, $stopped, $timedOut }) =>
25+
$timedOut ? theme.color.gray[400] : $stopped ? theme.color.point[500] : theme.color.gray[600]};
26+
border: 1px solid
27+
${({ theme, $stopped, $timedOut, $isMe }) =>
28+
$isMe
29+
? theme.color.point[400]
30+
: $timedOut
31+
? theme.color.gray[300]
32+
: $stopped
33+
? theme.color.point[200]
34+
: theme.color.gray[200]};
35+
`;
36+
37+
export const StatusDot = styled.span<{ $stopped: boolean; $timedOut: boolean }>`
38+
width: 6px;
39+
height: 6px;
40+
border-radius: 50%;
41+
background-color: ${({ theme, $stopped, $timedOut }) =>
42+
$timedOut ? theme.color.gray[400] : $stopped ? theme.color.point[400] : '#4caf50'};
43+
`;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { BlindTimerPlayerProgress } from '@/types/miniGame/blindTimerGame';
2+
import * as S from './PlayerStatusBoard.styled';
3+
4+
type Props = {
5+
players: BlindTimerPlayerProgress[];
6+
myName: string;
7+
};
8+
9+
const PlayerStatusBoard = ({ players, myName }: Props) => {
10+
const sorted = [...players].sort((a, b) => {
11+
if (a.playerName === myName) return -1;
12+
if (b.playerName === myName) return 1;
13+
return 0;
14+
});
15+
16+
return (
17+
<S.Container>
18+
{sorted.map((player) => {
19+
const isMe = player.playerName === myName;
20+
return (
21+
<S.PlayerChip
22+
key={player.playerName}
23+
$stopped={player.stopped}
24+
$timedOut={player.timedOut}
25+
$isMe={isMe}
26+
>
27+
<S.StatusDot $stopped={player.stopped} $timedOut={player.timedOut} />
28+
{isMe ? '나' : player.playerName}
29+
</S.PlayerChip>
30+
);
31+
})}
32+
</S.Container>
33+
);
34+
};
35+
36+
export default PlayerStatusBoard;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Meta, StoryObj } from '@storybook/react-webpack5';
2+
import TargetTime from './TargetTime';
3+
4+
const meta = {
5+
title: 'Features/MiniGame/BlindTimerGame/TargetTime',
6+
component: TargetTime,
7+
parameters: {
8+
layout: 'centered',
9+
},
10+
tags: ['autodocs'],
11+
} satisfies Meta<typeof TargetTime>;
12+
13+
export default meta;
14+
15+
type Story = StoryObj<typeof TargetTime>;
16+
17+
export const Short: Story = {
18+
args: {
19+
targetTimeMillis: 5000,
20+
},
21+
};
22+
23+
export const Medium: Story = {
24+
args: {
25+
targetTimeMillis: 11450,
26+
},
27+
};
28+
29+
export const Long: Story = {
30+
args: {
31+
targetTimeMillis: 19990,
32+
},
33+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import styled from '@emotion/styled';
2+
3+
export const Container = styled.div`
4+
display: flex;
5+
justify-content: center;
6+
align-items: center;
7+
gap: 8px;
8+
padding: 12px 20px;
9+
background-color: ${({ theme }) => theme.color.gray[800]};
10+
border-radius: 12px;
11+
width: fit-content;
12+
margin: 0 auto;
13+
`;
14+
15+
export const Label = styled.span`
16+
font-size: 1rem;
17+
font-weight: 500;
18+
color: ${({ theme }) => theme.color.gray[300]};
19+
`;
20+
21+
export const Time = styled.span`
22+
font-size: 1.4rem;
23+
font-weight: 700;
24+
color: ${({ theme }) => theme.color.yellow};
25+
font-variant-numeric: tabular-nums;
26+
`;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as S from './TargetTime.styled';
2+
3+
type Props = {
4+
targetTimeMillis: number;
5+
};
6+
7+
const formatTargetTime = (ms: number): string => {
8+
const seconds = (ms / 1000).toFixed(2);
9+
return `${seconds}초`;
10+
};
11+
12+
const TargetTime = ({ targetTimeMillis }: Props) => {
13+
return (
14+
<S.Container>
15+
<S.Label>목표</S.Label>
16+
<S.Time>{formatTargetTime(targetTimeMillis)}</S.Time>
17+
</S.Container>
18+
);
19+
};
20+
21+
export default TargetTime;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Meta, StoryObj } from '@storybook/react-webpack5';
2+
import TimerDisplay from './TimerDisplay';
3+
4+
const meta = {
5+
title: 'Features/MiniGame/BlindTimerGame/TimerDisplay',
6+
component: TimerDisplay,
7+
parameters: {
8+
layout: 'centered',
9+
},
10+
tags: ['autodocs'],
11+
} satisfies Meta<typeof TimerDisplay>;
12+
13+
export default meta;
14+
15+
type Story = StoryObj<typeof TimerDisplay>;
16+
17+
export const Counting: Story = {
18+
args: {
19+
displayTime: '2.45',
20+
isBlind: false,
21+
isStopped: false,
22+
stoppedTimeDisplay: null,
23+
onStop: () => {},
24+
},
25+
};
26+
27+
export const Blind: Story = {
28+
args: {
29+
displayTime: '??.??',
30+
isBlind: true,
31+
isStopped: false,
32+
stoppedTimeDisplay: null,
33+
onStop: () => {},
34+
},
35+
};
36+
37+
export const Stopped: Story = {
38+
args: {
39+
displayTime: '??.??',
40+
isBlind: true,
41+
isStopped: true,
42+
stoppedTimeDisplay: '11.23',
43+
onStop: () => {},
44+
},
45+
};

0 commit comments

Comments
 (0)