feat: 프레스티지/업적 UI 컴포넌트 추가 및 게임 상태 업데이트 (JSA-55)

- AchievementToast, PrestigeModal, AchievementsScreen 컴포넌트 추가
- BottomTabBar에 업적 탭 연결
- useGameStore 프레스티지/업적 상태 로직 수정
- pages/index.tsx 라우팅 업데이트

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-02 04:19:19 +09:00
parent 3178d880f2
commit 9800d067b9
6 changed files with 1434 additions and 2 deletions

View 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>
)}
</>
);
}