Skip to content

Commit 00e4978

Browse files
[feat] 폭탄 끝말잇기 미니게임 프론트엔드 구현 (#1127)
* feat: 폭탄 릴레이 미니게임 추가 - BombRelayGameContext 및 BombRelayGameProvider 생성 - 폭탄 릴레이 게임 상태 및 진행 데이터 타입 정의 - WebSocket 구독을 통해 폭탄 릴레이 게임 상태 및 진행 데이터 관리 로직 구현 - 폭탄 릴레이 미니게임을 위한 구조 설계 및 기본 로직 추가 * feat: 폭탄 릴레이 게임 UI 컴포넌트 추가 - BombExplosionOverlay, CurrentWord, PlayerList, RoundInfo, WordFeedback, WordInput 컴포넌트 구현 - styled-components를 활용한 스타일 정의 - 게임 진행 상황, 플레이어 정보, 단어 입력 및 피드백 UI 요소 설계 및 애니메이션 추가 * feat: 폭탄 릴레이 게임 플레이 및 준비 페이지 추가 - BombRelayGamePlayPage, BombRelayGameReadyPage 구현 - 게임 상태에 따른 준비 및 플레이 로직 추가 - useBombRelayActions 훅 구현, WebSocket 사용 단어 전달 기능 추가 - BombRelayGamePlayPage 스타일 컴포넌트 구성 및 레이아웃 설계 * feat: 폭탄 릴레이 미니게임 설정 및 아이콘 추가 - BombRelay 아이콘(svg) 추가 - MINI_GAME_NAME_MAP, MINI_GAME_ICON_MAP에 BombRelay 항목 추가 - BombRelay 소개 슬라이드 구성 및 텍스트 추가 - 게임 설정(config)에 BombRelay 관련 Provider, ReadyPage, PlayPage 항목 구성 * feat: 폭탄 릴레이 미니게임 설정 및 아이콘 추가 - BombRelay 아이콘(svg) 추가 - MINI_GAME_NAME_MAP, MINI_GAME_ICON_MAP에 BombRelay 항목 추가 - BombRelay 소개 슬라이드 구성 및 텍스트 추가 - 게임 설정(config)에 BombRelay 관련 Provider, ReadyPage, PlayPage 항목 구성 * style: 폭탄 릴레이 미니게임 스타일 코드 개선 - 애니메이션 속성 줄 바꿈 및 코드 가독성 향상 - 불필요한 중첩 및 인라인 스타일 정리 - React import 방식 명시적으로 변경 (React import 추가) * style: 폭탄 릴레이 미니게임 스타일 개선 및 UI 디테일 업데이트 - 컴포넌트 레이아웃 조정 및 스타일 속성 변경 (gap, padding, border 등) - 애니메이션 키프레임 및 효과 최적화 (플로트, 바운스 등 추가) - WordInput, CurrentWord 등 UI 요소 개선으로 가독성 및 사용자 경험 향상 - RoundInfo, PlayerList, WordFeedback 등 컴포넌트 스타일 수정 및 불필요한 속성 제거 - 모든 스타일 컴포넌트의 코드 유지보수성 및 일관성 증가 * style: BombRelayGamePlayPage 레이아웃 padding 속성 수정 - Layout 컴포넌트에 `padding="0"` 속성 추가하여 여백 제거 및 스타일 일관성 유지 * fix: BombExplosionOverlay에 non-null 단언 연산자 적용 - `eliminatedPlayerName`에 non-null 단언 연산자(`!`) 적용하여 타입 에러 방지 및 안정성 강화 * fix: WordFeedback 컴포넌트 rejectReason 처리 로직 수정 - rejectReason이 없는 경우 빈 문자열 반환하도록 조건부 로직 추가 - 문자열 처리 안정성 및 가독성 향상
1 parent 0024040 commit 00e4978

File tree

22 files changed

+918
-0
lines changed

22 files changed

+918
-0
lines changed
Lines changed: 34 additions & 0 deletions
Loading
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {
2+
BombRelayGameState,
3+
BombRelayProgressData,
4+
BombRelayWordResult,
5+
} from '@/types/miniGame/bombRelayGame';
6+
import { createContext, useContext } from 'react';
7+
8+
type BombRelayGameContextType = {
9+
gameState: BombRelayGameState;
10+
currentRound: number;
11+
maxRounds: number;
12+
currentWord: string;
13+
currentTurnPlayerName: string;
14+
eliminatedPlayerName: string | null;
15+
progressData: BombRelayProgressData;
16+
lastWordResult: BombRelayWordResult | null;
17+
};
18+
19+
export const BombRelayGameContext = createContext<BombRelayGameContextType | null>(null);
20+
21+
export const useBombRelayGame = () => {
22+
const context = useContext(BombRelayGameContext);
23+
if (!context) {
24+
throw new Error('useBombRelayGame은 BombRelayGameProvider 안에서 사용해야 합니다.');
25+
}
26+
return context;
27+
};
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useWebSocketSubscription } from '@/apis/websocket/hooks/useWebSocketSubscription';
2+
import { PropsWithChildren, useCallback, useState } from 'react';
3+
import { useIdentifier } from '../Identifier/IdentifierContext';
4+
import { BombRelayGameContext } from './BombRelayGameContext';
5+
import {
6+
BombRelayGameState,
7+
BombRelayProgressData,
8+
BombRelayStateData,
9+
BombRelayWordResult,
10+
} from '@/types/miniGame/bombRelayGame';
11+
12+
const BombRelayGameProvider = ({ children }: PropsWithChildren) => {
13+
const [gameState, setGameState] = useState<BombRelayGameState>('DESCRIPTION');
14+
const [currentRound, setCurrentRound] = useState(0);
15+
const [maxRounds, setMaxRounds] = useState(0);
16+
const [currentWord, setCurrentWord] = useState('');
17+
const [currentTurnPlayerName, setCurrentTurnPlayerName] = useState('');
18+
const [eliminatedPlayerName, setEliminatedPlayerName] = useState<string | null>(null);
19+
const [progressData, setProgressData] = useState<BombRelayProgressData>({
20+
currentWord: '',
21+
currentTurnPlayerName: '',
22+
currentRound: 0,
23+
players: [],
24+
});
25+
const [lastWordResult, setLastWordResult] = useState<BombRelayWordResult | null>(null);
26+
const { joinCode } = useIdentifier();
27+
28+
const handleStateChange = useCallback((data: BombRelayStateData) => {
29+
setGameState(data.state);
30+
setCurrentRound(data.currentRound);
31+
setMaxRounds(data.maxRounds);
32+
setEliminatedPlayerName(data.eliminatedPlayerName);
33+
if (data.currentWord) {
34+
setCurrentWord(data.currentWord);
35+
}
36+
if (data.currentTurnPlayerName) {
37+
setCurrentTurnPlayerName(data.currentTurnPlayerName);
38+
}
39+
}, []);
40+
41+
const handleProgressUpdate = useCallback((data: BombRelayProgressData) => {
42+
setProgressData(data);
43+
setCurrentWord(data.currentWord);
44+
setCurrentTurnPlayerName(data.currentTurnPlayerName);
45+
setCurrentRound(data.currentRound);
46+
}, []);
47+
48+
const handleWordResult = useCallback((data: BombRelayWordResult) => {
49+
setLastWordResult(data);
50+
}, []);
51+
52+
useWebSocketSubscription(`/room/${joinCode}/bomb-relay/state`, handleStateChange);
53+
useWebSocketSubscription(`/room/${joinCode}/bomb-relay/progress`, handleProgressUpdate);
54+
useWebSocketSubscription(`/room/${joinCode}/bomb-relay/word-result`, handleWordResult);
55+
56+
return (
57+
<BombRelayGameContext.Provider
58+
value={{
59+
gameState,
60+
currentRound,
61+
maxRounds,
62+
currentWord,
63+
currentTurnPlayerName,
64+
eliminatedPlayerName,
65+
progressData,
66+
lastWordResult,
67+
}}
68+
>
69+
{children}
70+
</BombRelayGameContext.Provider>
71+
);
72+
};
73+
74+
export default BombRelayGameProvider;
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import styled from '@emotion/styled';
2+
import { keyframes } from '@emotion/react';
3+
4+
const flashBg = keyframes`
5+
0% { background-color: rgba(255, 60, 60, 0.9); }
6+
30% { background-color: rgba(255, 120, 50, 0.85); }
7+
60% { background-color: rgba(255, 60, 60, 0.8); }
8+
100% { background-color: rgba(0, 0, 0, 0.75); }
9+
`;
10+
11+
const shakeScreen = keyframes`
12+
0%, 100% { transform: translate(0, 0); }
13+
10% { transform: translate(-8px, -6px); }
14+
20% { transform: translate(8px, 4px); }
15+
30% { transform: translate(-6px, 8px); }
16+
40% { transform: translate(6px, -4px); }
17+
50% { transform: translate(-4px, 6px); }
18+
60% { transform: translate(4px, -8px); }
19+
70% { transform: translate(-8px, 4px); }
20+
80% { transform: translate(6px, 6px); }
21+
90% { transform: translate(-4px, -6px); }
22+
`;
23+
24+
const bombPop = keyframes`
25+
0% { transform: scale(0); opacity: 0; }
26+
40% { transform: scale(1.4); opacity: 1; }
27+
60% { transform: scale(0.9); }
28+
80% { transform: scale(1.1); }
29+
100% { transform: scale(1); }
30+
`;
31+
32+
const textSlideUp = keyframes`
33+
0% { transform: translateY(30px); opacity: 0; }
34+
100% { transform: translateY(0); opacity: 1; }
35+
`;
36+
37+
const particleExplode = keyframes`
38+
0% { transform: scale(0); opacity: 1; }
39+
50% { transform: scale(1.5); opacity: 0.7; }
40+
100% { transform: scale(2.5); opacity: 0; }
41+
`;
42+
43+
export const Overlay = styled.div`
44+
position: fixed;
45+
top: 0;
46+
left: 0;
47+
right: 0;
48+
bottom: 0;
49+
z-index: 1000;
50+
display: flex;
51+
flex-direction: column;
52+
align-items: center;
53+
justify-content: center;
54+
gap: 20px;
55+
animation:
56+
${flashBg} 0.8s ease forwards,
57+
${shakeScreen} 0.5s ease;
58+
`;
59+
60+
export const BombEmoji = styled.div`
61+
font-size: 5rem;
62+
animation: ${bombPop} 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
63+
`;
64+
65+
export const ExplosionRing = styled.div`
66+
position: absolute;
67+
width: 200px;
68+
height: 200px;
69+
border-radius: 50%;
70+
border: 4px solid rgba(255, 200, 50, 0.6);
71+
animation: ${particleExplode} 1s ease-out forwards;
72+
`;
73+
74+
export const EliminatedText = styled.div`
75+
font-size: 1.8rem;
76+
font-weight: 800;
77+
color: white;
78+
text-align: center;
79+
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.5);
80+
animation: ${textSlideUp} 0.5s ease 0.4s both;
81+
`;
82+
83+
export const SubText = styled.div`
84+
font-size: 1.1rem;
85+
font-weight: 600;
86+
color: rgba(255, 255, 255, 0.8);
87+
animation: ${textSlideUp} 0.5s ease 0.6s both;
88+
`;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as S from './BombExplosionOverlay.styled';
2+
3+
type Props = {
4+
eliminatedPlayerName: string;
5+
currentRound: number;
6+
isGameOver: boolean;
7+
};
8+
9+
const BombExplosionOverlay = ({ eliminatedPlayerName, currentRound, isGameOver }: Props) => {
10+
return (
11+
<S.Overlay>
12+
<S.ExplosionRing />
13+
<S.BombEmoji>💥</S.BombEmoji>
14+
<S.EliminatedText>{eliminatedPlayerName} 탈락!</S.EliminatedText>
15+
<S.SubText>{isGameOver ? '게임 종료!' : `${currentRound}라운드 종료`}</S.SubText>
16+
</S.Overlay>
17+
);
18+
};
19+
20+
export default BombExplosionOverlay;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import styled from '@emotion/styled';
2+
import { keyframes } from '@emotion/react';
3+
4+
const float = keyframes`
5+
0%, 100% { transform: translateY(0); }
6+
50% { transform: translateY(-4px); }
7+
`;
8+
9+
export const Container = styled.div`
10+
display: flex;
11+
flex-direction: column;
12+
align-items: center;
13+
gap: 16px;
14+
`;
15+
16+
export const WordCard = styled.div`
17+
background: white;
18+
border-radius: 24px;
19+
padding: 24px 48px;
20+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
21+
display: flex;
22+
flex-direction: column;
23+
align-items: center;
24+
gap: 4px;
25+
`;
26+
27+
export const Word = styled.span`
28+
font-size: 2.5rem;
29+
font-weight: 800;
30+
color: #1a1a1a;
31+
letter-spacing: 2px;
32+
`;
33+
34+
export const Label = styled.span`
35+
font-size: 0.75rem;
36+
color: #bbb;
37+
font-weight: 500;
38+
`;
39+
40+
export const NextCharContainer = styled.div`
41+
display: flex;
42+
flex-direction: column;
43+
align-items: center;
44+
gap: 4px;
45+
animation: ${float} 2s ease-in-out infinite;
46+
`;
47+
48+
export const NextCharLabel = styled.span`
49+
font-size: 0.7rem;
50+
color: #999;
51+
font-weight: 600;
52+
`;
53+
54+
export const NextChar = styled.span`
55+
width: 52px;
56+
height: 52px;
57+
border-radius: 50%;
58+
background: linear-gradient(135deg, #ff6b6b, #ff8e53);
59+
color: white;
60+
font-size: 1.5rem;
61+
font-weight: 800;
62+
display: flex;
63+
align-items: center;
64+
justify-content: center;
65+
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.35);
66+
`;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import * as S from './CurrentWord.styled';
2+
3+
type Props = {
4+
currentWord: string;
5+
};
6+
7+
const CurrentWord = ({ currentWord }: Props) => {
8+
const lastChar = currentWord.charAt(currentWord.length - 1);
9+
10+
return (
11+
<S.Container>
12+
<S.WordCard>
13+
<S.Label>현재 단어</S.Label>
14+
<S.Word>{currentWord}</S.Word>
15+
</S.WordCard>
16+
<S.NextCharContainer>
17+
<S.NextCharLabel>다음 글자</S.NextCharLabel>
18+
<S.NextChar>{lastChar}</S.NextChar>
19+
</S.NextCharContainer>
20+
</S.Container>
21+
);
22+
};
23+
24+
export default CurrentWord;

0 commit comments

Comments
 (0)