feat: 프레스티지/업적 UI 컴포넌트 추가 및 게임 상태 업데이트 (JSA-55)
- AchievementToast, PrestigeModal, AchievementsScreen 컴포넌트 추가 - BottomTabBar에 업적 탭 연결 - useGameStore 프레스티지/업적 상태 로직 수정 - pages/index.tsx 라우팅 업데이트 Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
338
src/components/AchievementToast.tsx
Normal file
338
src/components/AchievementToast.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import { css, keyframes } from '@emotion/react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { adaptive } from '@toss/tds-colors';
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
interface AchievementReward {
|
||||
gold?: number;
|
||||
permanentGoldMultiplier?: number;
|
||||
permanentSpawnMultiplier?: number;
|
||||
title?: string;
|
||||
boostType?: string;
|
||||
}
|
||||
|
||||
interface Achievement {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
reward: AchievementReward;
|
||||
isHidden: boolean;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Animations
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
const slideUp = keyframes`
|
||||
0% { transform: translateY(100px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
`;
|
||||
|
||||
const slideDown = keyframes`
|
||||
0% { transform: translateY(0); opacity: 1; }
|
||||
100% { transform: translateY(100px); opacity: 0; }
|
||||
`;
|
||||
|
||||
const titleReveal = keyframes`
|
||||
0% { transform: scale(0.6) translateY(30px); opacity: 0; }
|
||||
60% { transform: scale(1.08) translateY(-6px); opacity: 1; }
|
||||
100% { transform: scale(1) translateY(0); opacity: 1; }
|
||||
`;
|
||||
|
||||
const starSparkle = keyframes`
|
||||
0% { transform: scale(0) rotate(0deg); opacity: 0; }
|
||||
50% { transform: scale(1.3) rotate(20deg); opacity: 1; }
|
||||
100% { transform: scale(1) rotate(0deg); opacity: 1; }
|
||||
`;
|
||||
|
||||
const fadePop = keyframes`
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
`;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Toast styles
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
const toastContainerStyle = (exiting: boolean) => css`
|
||||
position: fixed;
|
||||
bottom: 84px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
z-index: 600;
|
||||
animation: ${exiting ? slideDown : slideUp} 0.35s ease-out forwards;
|
||||
`;
|
||||
|
||||
const toastCardStyle = css`
|
||||
background: linear-gradient(135deg, #1a0a00, #3d1a00);
|
||||
border: 1.5px solid #ffd700;
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
box-shadow: 0 8px 32px rgba(255, 180, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
`;
|
||||
|
||||
const toastIconStyle = css`
|
||||
font-size: 28px;
|
||||
flex-shrink: 0;
|
||||
animation: ${starSparkle} 0.5s ease-out forwards;
|
||||
`;
|
||||
|
||||
const toastContentStyle = css`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const toastHeadingStyle = css`
|
||||
font-size: 11px;
|
||||
color: #ffcc80;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
`;
|
||||
|
||||
const toastNameStyle = css`
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #ffd700;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const toastRewardStyle = css`
|
||||
font-size: 12px;
|
||||
color: #ffcc80;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const toastProgressBarStyle = css`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: rgba(255, 215, 0, 0.15);
|
||||
border-radius: 0 0 16px 16px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const TOAST_DURATION_MS = 3000;
|
||||
|
||||
const toastProgressFillStyle = css`
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #ffd700, #ff9800);
|
||||
border-radius: 0 0 16px 16px;
|
||||
animation: shrink linear ${TOAST_DURATION_MS}ms forwards;
|
||||
@keyframes shrink {
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Title popup (full-screen for title-granting achievements)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
const titleOverlayStyle = css`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
z-index: 700;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: ${fadePop} 0.3s ease-out;
|
||||
`;
|
||||
|
||||
const titleCardStyle = css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
animation: ${titleReveal} 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
`;
|
||||
|
||||
const titleGlowEmoji = css`
|
||||
font-size: 72px;
|
||||
`;
|
||||
|
||||
const titlePopupHeading = css`
|
||||
font-size: 13px;
|
||||
color: #ffcc80;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const titlePopupName = css`
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: #ffd700;
|
||||
text-align: center;
|
||||
text-shadow: 0 0 30px rgba(255, 215, 0, 0.8);
|
||||
padding: 0 24px;
|
||||
`;
|
||||
|
||||
const titlePopupSub = css`
|
||||
font-size: 13px;
|
||||
color: #b39ddb;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const titlePopupDismissStyle = css`
|
||||
margin-top: 32px;
|
||||
padding: 12px 36px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 215, 0, 0.12);
|
||||
border: 1px solid rgba(255, 215, 0, 0.4);
|
||||
color: #ffd700;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Helper
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
const CATEGORY_ICON: Record<string, string> = {
|
||||
beginner: '🌱',
|
||||
intermediate: '⚡',
|
||||
advanced: '🔥',
|
||||
prestige: '✨',
|
||||
};
|
||||
|
||||
function formatReward(reward: AchievementReward): string {
|
||||
const parts: string[] = [];
|
||||
if (reward.gold) parts.push(`💰 +${reward.gold.toLocaleString()}G`);
|
||||
if (reward.permanentGoldMultiplier)
|
||||
parts.push(`🏅 골드 +${(reward.permanentGoldMultiplier * 100).toFixed(0)}%`);
|
||||
if (reward.permanentSpawnMultiplier)
|
||||
parts.push(`🏅 스폰 +${(reward.permanentSpawnMultiplier * 100).toFixed(0)}%`);
|
||||
if (reward.title) parts.push(`👑 칭호 획득`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// AchievementToast component
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ToastItem {
|
||||
id: string;
|
||||
achievement: Achievement;
|
||||
}
|
||||
|
||||
export function AchievementToast() {
|
||||
const [queue, setQueue] = useState<ToastItem[]>([]);
|
||||
const [current, setCurrent] = useState<ToastItem | null>(null);
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const [titlePopup, setTitlePopup] = useState<Achievement | null>(null);
|
||||
|
||||
const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearDismissTimer = useCallback(() => {
|
||||
if (dismissTimer.current !== null) {
|
||||
clearTimeout(dismissTimer.current);
|
||||
dismissTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissCurrent = useCallback(() => {
|
||||
clearDismissTimer();
|
||||
setExiting(true);
|
||||
setTimeout(() => {
|
||||
setExiting(false);
|
||||
setCurrent(null);
|
||||
}, 350);
|
||||
}, [clearDismissTimer]);
|
||||
|
||||
// Show next toast when current is done
|
||||
useEffect(() => {
|
||||
if (current === null && queue.length > 0) {
|
||||
const [next, ...rest] = queue;
|
||||
setQueue(rest);
|
||||
setCurrent(next);
|
||||
}
|
||||
}, [current, queue]);
|
||||
|
||||
// Auto-dismiss after TOAST_DURATION_MS
|
||||
useEffect(() => {
|
||||
if (current === null) return;
|
||||
clearDismissTimer();
|
||||
|
||||
// If this achievement has a title reward, show full-screen popup
|
||||
if (current.achievement.reward.title) {
|
||||
dismissTimer.current = setTimeout(() => {
|
||||
dismissCurrent();
|
||||
setTitlePopup(current.achievement);
|
||||
}, TOAST_DURATION_MS);
|
||||
} else {
|
||||
dismissTimer.current = setTimeout(dismissCurrent, TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
return clearDismissTimer;
|
||||
}, [current, dismissCurrent, clearDismissTimer]);
|
||||
|
||||
// Listen to game store custom events
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const ach = (e as CustomEvent<Achievement>).detail;
|
||||
const item: ToastItem = {
|
||||
id: `${ach.id}-${Date.now()}`,
|
||||
achievement: ach,
|
||||
};
|
||||
setQueue((prev) => [...prev, item]);
|
||||
};
|
||||
|
||||
window.addEventListener('newAchievement', handler);
|
||||
return () => window.removeEventListener('newAchievement', handler);
|
||||
}, []);
|
||||
|
||||
const icon = current ? (CATEGORY_ICON[current.achievement.category] ?? '🏅') : '';
|
||||
const rewardText = current ? formatReward(current.achievement.reward) : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
{current !== null && (
|
||||
<div css={toastContainerStyle(exiting)} onClick={dismissCurrent}>
|
||||
<div css={toastCardStyle} style={{ position: 'relative' }}>
|
||||
<div css={toastIconStyle}>{icon}</div>
|
||||
<div css={toastContentStyle}>
|
||||
<div css={toastHeadingStyle}>🏆 업적 달성</div>
|
||||
<div css={toastNameStyle}>{current.achievement.name}</div>
|
||||
{rewardText && <div css={toastRewardStyle}>{rewardText}</div>}
|
||||
</div>
|
||||
<div css={toastProgressBarStyle}>
|
||||
<div key={current.id} css={toastProgressFillStyle} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{titlePopup !== null && (
|
||||
<div css={titleOverlayStyle} onClick={() => setTitlePopup(null)}>
|
||||
<div css={titleCardStyle}>
|
||||
<div css={titleGlowEmoji}>👑</div>
|
||||
<div css={titlePopupHeading}>✨ 칭호 잠금해제 ✨</div>
|
||||
<div css={titlePopupName}>{titlePopup.reward.title}</div>
|
||||
<div css={titlePopupSub}>{titlePopup.name}</div>
|
||||
</div>
|
||||
<button css={titlePopupDismissStyle} onClick={() => setTitlePopup(null)}>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user