- AchievementToast, PrestigeModal, AchievementsScreen 컴포넌트 추가 - BottomTabBar에 업적 탭 연결 - useGameStore 프레스티지/업적 상태 로직 수정 - pages/index.tsx 라우팅 업데이트 Co-Authored-By: Paperclip <noreply@paperclip.ing>
339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|