feat: 프레스티지/업적 UI 컴포넌트 추가 및 게임 상태 업데이트 (JSA-55)
- AchievementToast, PrestigeModal, AchievementsScreen 컴포넌트 추가 - BottomTabBar에 업적 탭 연결 - useGameStore 프레스티지/업적 상태 로직 수정 - pages/index.tsx 라우팅 업데이트 Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -2,10 +2,12 @@ import { css } from '@emotion/react';
|
||||
import { useEffect } from 'react';
|
||||
import { BottomTabBar } from '../src/components/BottomTabBar';
|
||||
import { OfflineRewardModal } from '../src/components/OfflineRewardModal';
|
||||
import { AchievementToast } from '../src/components/AchievementToast';
|
||||
import { ElementsScreen } from '../src/components/screens/ElementsScreen';
|
||||
import { EvolutionScreen } from '../src/components/screens/EvolutionScreen';
|
||||
import { FusionScreen } from '../src/components/screens/FusionScreen';
|
||||
import { ShopScreen } from '../src/components/screens/ShopScreen';
|
||||
import { AchievementsScreen } from '../src/components/screens/AchievementsScreen';
|
||||
import { SettingsScreen } from '../src/components/screens/SettingsScreen';
|
||||
import { useIdleTick } from '../src/hooks/useIdleTick';
|
||||
import { useGameStore } from '../src/store/useGameStore';
|
||||
@@ -50,11 +52,13 @@ export default function IndexPage() {
|
||||
return (
|
||||
<div css={rootStyle}>
|
||||
<OfflineRewardModal />
|
||||
<AchievementToast />
|
||||
<div css={contentStyle}>
|
||||
{activeTab === 'elements' && <ElementsScreen />}
|
||||
{activeTab === 'evolution' && <EvolutionScreen />}
|
||||
{activeTab === 'fusion' && <FusionScreen />}
|
||||
{activeTab === 'shop' && <ShopScreen />}
|
||||
{activeTab === 'achievements' && <AchievementsScreen />}
|
||||
{activeTab === 'settings' && <SettingsScreen />}
|
||||
</div>
|
||||
<BottomTabBar activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,9 @@ interface TabItem {
|
||||
const TABS: TabItem[] = [
|
||||
{ key: 'elements', label: '원소', icon: '⚗️' },
|
||||
{ key: 'evolution', label: '강화', icon: '⬆️' },
|
||||
{ key: 'fusion', label: '합성', icon: '✨' },
|
||||
{ key: 'fusion', label: '합성', icon: '🔮' },
|
||||
{ key: 'shop', label: '상점', icon: '🛒' },
|
||||
{ key: 'achievements', label: '업적', icon: '🏆' },
|
||||
{ key: 'settings', label: '설정', icon: '⚙️' },
|
||||
];
|
||||
|
||||
|
||||
508
src/components/PrestigeModal.tsx
Normal file
508
src/components/PrestigeModal.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
import { css, keyframes } from '@emotion/react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { adaptive } from '@toss/tds-colors';
|
||||
import { useGameStore } from '../store/useGameStore';
|
||||
import prestigeTableData from '../data/prestige.json';
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Prestige table
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
interface PrestigeEntry {
|
||||
count: number;
|
||||
goldMultiplier: number;
|
||||
spawnMultiplier: number;
|
||||
starterGold: number;
|
||||
title: string;
|
||||
titleEmoji: string;
|
||||
titleColor: string;
|
||||
}
|
||||
|
||||
const PRESTIGE_TABLE = prestigeTableData as PrestigeEntry[];
|
||||
|
||||
function getNextEntry(currentCount: number): PrestigeEntry {
|
||||
const nextCount = currentCount + 1;
|
||||
const entry = PRESTIGE_TABLE.find((e) => e.count === nextCount);
|
||||
if (!entry) {
|
||||
const last = PRESTIGE_TABLE[PRESTIGE_TABLE.length - 1];
|
||||
const over = nextCount - last.count;
|
||||
return {
|
||||
count: nextCount,
|
||||
goldMultiplier: parseFloat((last.goldMultiplier + 0.5 * over).toFixed(1)),
|
||||
spawnMultiplier: parseFloat((last.spawnMultiplier + 0.1 * over).toFixed(1)),
|
||||
starterGold: last.starterGold + 5000 * over,
|
||||
title: '원소의 주인',
|
||||
titleEmoji: '🌌',
|
||||
titleColor: '#9C27B0',
|
||||
};
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Particle animation
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
const particleFloat = keyframes`
|
||||
0% { transform: translateY(0) scale(1); opacity: 1; }
|
||||
100% { transform: translateY(-100vh) scale(0); opacity: 0; }
|
||||
`;
|
||||
|
||||
const screenFlash = keyframes`
|
||||
0% { opacity: 0; }
|
||||
20% { opacity: 0.8; }
|
||||
100% { opacity: 0; }
|
||||
`;
|
||||
|
||||
const titleReveal = keyframes`
|
||||
0% { transform: scale(0.5) translateY(40px); opacity: 0; }
|
||||
60% { transform: scale(1.1) translateY(-8px); opacity: 1; }
|
||||
100% { transform: scale(1) translateY(0); opacity: 1; }
|
||||
`;
|
||||
|
||||
const fadeInUp = keyframes`
|
||||
0% { transform: translateY(60px); opacity: 0; }
|
||||
100% { transform: translateY(0); opacity: 1; }
|
||||
`;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Styles
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
const overlayStyle = css`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
`;
|
||||
|
||||
const modalStyle = css`
|
||||
width: 100%;
|
||||
background: ${adaptive.background};
|
||||
border-radius: 24px 24px 0 0;
|
||||
padding: 24px 20px 40px;
|
||||
animation: ${fadeInUp} 0.3s ease-out;
|
||||
`;
|
||||
|
||||
const handleStyle = css`
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
background: ${adaptive.grey300};
|
||||
border-radius: 2px;
|
||||
margin: 0 auto 20px;
|
||||
`;
|
||||
|
||||
const modalTitleStyle = css`
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #ff6f00;
|
||||
margin: 0 0 4px;
|
||||
`;
|
||||
|
||||
const modalSubtitleStyle = css`
|
||||
font-size: 13px;
|
||||
color: ${adaptive.grey500};
|
||||
margin: 0 0 20px;
|
||||
`;
|
||||
|
||||
const sectionLabelStyle = css`
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: ${adaptive.grey500};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const resetListStyle = css`
|
||||
background: #fff3e0;
|
||||
border: 1px solid #ffcc80;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
const resetItemStyle = css`
|
||||
font-size: 13px;
|
||||
color: #e65100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
const bonusCardStyle = css`
|
||||
background: linear-gradient(135deg, #1a0a00, #3d1a00);
|
||||
border: 1.5px solid #ffd700;
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const bonusRowStyle = css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const bonusLabelStyle = css`
|
||||
font-size: 13px;
|
||||
color: #ffcc80;
|
||||
`;
|
||||
|
||||
const bonusValueStyle = css`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #ffd700;
|
||||
`;
|
||||
|
||||
const titlePreviewStyle = css`
|
||||
background: linear-gradient(135deg, #311b92, #512da8);
|
||||
border: 1px solid #7c4dff;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const titleEmojiStyle = css`
|
||||
font-size: 28px;
|
||||
`;
|
||||
|
||||
const titleTextStyle = css`
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #e8d5ff;
|
||||
`;
|
||||
|
||||
const titleSubStyle = css`
|
||||
font-size: 11px;
|
||||
color: #b39ddb;
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
const buttonRowStyle = css`
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const cancelButtonStyle = css`
|
||||
flex: 1;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: ${adaptive.greyBackground};
|
||||
color: ${adaptive.grey700};
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const confirmButtonStyle = (disabled: boolean) => css`
|
||||
flex: 2;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: ${disabled
|
||||
? adaptive.grey200
|
||||
: 'linear-gradient(135deg, #ff6f00, #ffd700)'};
|
||||
color: ${disabled ? adaptive.grey400 : '#3d1a00'};
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
cursor: ${disabled ? 'not-allowed' : 'pointer'};
|
||||
box-shadow: ${disabled ? 'none' : '0 4px 16px rgba(255, 180, 0, 0.4)'};
|
||||
`;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Particle layer
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
interface Particle {
|
||||
id: number;
|
||||
x: number;
|
||||
delay: number;
|
||||
duration: number;
|
||||
size: number;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
const PARTICLE_EMOJIS = ['✨', '⭐', '🌟', '💫', '🔥', '💛', '🌙'];
|
||||
|
||||
function generateParticles(count: number): Particle[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
delay: Math.random() * 1.5,
|
||||
duration: 1.5 + Math.random() * 1.5,
|
||||
size: 14 + Math.random() * 18,
|
||||
emoji: PARTICLE_EMOJIS[Math.floor(Math.random() * PARTICLE_EMOJIS.length)],
|
||||
}));
|
||||
}
|
||||
|
||||
const particleLayerStyle = css`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 400;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const flashLayerStyle = css`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 50% 40%, #ffd700 0%, #ff6f00 60%, transparent 100%);
|
||||
z-index: 390;
|
||||
pointer-events: none;
|
||||
animation: ${screenFlash} 1.2s ease-out forwards;
|
||||
`;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Title unlock popup
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
interface TitlePopupProps {
|
||||
entry: PrestigeEntry;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
const titlePopupOverlayStyle = css`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
z-index: 500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const titlePopupCardStyle = 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 titlePopupEmojiStyle = css`
|
||||
font-size: 72px;
|
||||
`;
|
||||
|
||||
const titlePopupHeadingStyle = css`
|
||||
font-size: 14px;
|
||||
color: #ffcc80;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const titlePopupNameStyle = css`
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #ffd700;
|
||||
text-align: center;
|
||||
text-shadow: 0 0 30px rgba(255, 215, 0, 0.8);
|
||||
`;
|
||||
|
||||
const titlePopupDismissStyle = css`
|
||||
margin-top: 24px;
|
||||
padding: 12px 32px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 215, 0, 0.15);
|
||||
border: 1px solid rgba(255, 215, 0, 0.4);
|
||||
color: #ffd700;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
animation: ${fadeInUp} 0.4s ease-out 1s both;
|
||||
`;
|
||||
|
||||
function TitlePopup({ entry, onDismiss }: TitlePopupProps) {
|
||||
const [particles] = useState(() => generateParticles(40));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div css={flashLayerStyle} />
|
||||
<div css={particleLayerStyle}>
|
||||
{particles.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
css={css`
|
||||
position: absolute;
|
||||
left: ${p.x}%;
|
||||
bottom: -10px;
|
||||
font-size: ${p.size}px;
|
||||
animation: ${particleFloat} ${p.duration}s ${p.delay}s ease-out both;
|
||||
`}
|
||||
>
|
||||
{p.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div css={titlePopupOverlayStyle} onClick={onDismiss}>
|
||||
<div css={titlePopupCardStyle}>
|
||||
<div css={titlePopupEmojiStyle}>{entry.titleEmoji}</div>
|
||||
<div css={titlePopupHeadingStyle}>✨ 칭호 잠금해제 ✨</div>
|
||||
<div css={titlePopupNameStyle}>{entry.title}</div>
|
||||
</div>
|
||||
<button css={titlePopupDismissStyle} onClick={onDismiss}>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// PrestigeModal
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
interface PrestigeModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PrestigeModal({ onClose }: PrestigeModalProps) {
|
||||
const prestigeCount = useGameStore((s) => s.prestigeCount);
|
||||
const prestige = useGameStore((s) => s.prestige);
|
||||
const elements = useGameStore((s) => s.elements);
|
||||
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [showParticles, setShowParticles] = useState(false);
|
||||
const [showTitlePopup, setShowTitlePopup] = useState(false);
|
||||
const completedEntry = useRef<PrestigeEntry | null>(null);
|
||||
const [particles] = useState(() => generateParticles(60));
|
||||
|
||||
const nextEntry = getNextEntry(prestigeCount);
|
||||
|
||||
const canConfirm =
|
||||
(elements['creation'] ?? 0) >= 1 && (elements['spirit'] ?? 0) >= 1;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!canConfirm || confirming) return;
|
||||
setConfirming(true);
|
||||
setShowParticles(true);
|
||||
|
||||
// 약간의 딜레이 후 실제 prestige 실행 (연출 효과용)
|
||||
setTimeout(() => {
|
||||
const result = prestige();
|
||||
if (result.success) {
|
||||
completedEntry.current = nextEntry;
|
||||
setTimeout(() => {
|
||||
setShowParticles(false);
|
||||
setShowTitlePopup(true);
|
||||
}, 2000);
|
||||
} else {
|
||||
setShowParticles(false);
|
||||
setConfirming(false);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleTitleDismiss = () => {
|
||||
setShowTitlePopup(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (showTitlePopup && completedEntry.current) {
|
||||
return <TitlePopup entry={completedEntry.current} onDismiss={handleTitleDismiss} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showParticles && (
|
||||
<>
|
||||
<div css={flashLayerStyle} />
|
||||
<div css={particleLayerStyle}>
|
||||
{particles.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
css={css`
|
||||
position: absolute;
|
||||
left: ${p.x}%;
|
||||
bottom: -10px;
|
||||
font-size: ${p.size}px;
|
||||
animation: ${particleFloat} ${p.duration}s ${p.delay}s ease-out both;
|
||||
`}
|
||||
>
|
||||
{p.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div css={overlayStyle} onClick={confirming ? undefined : onClose}>
|
||||
<div css={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div css={handleStyle} />
|
||||
<h2 css={modalTitleStyle}>✨ 프레스티지 (환생)</h2>
|
||||
<p css={modalSubtitleStyle}>
|
||||
환생 시 진행이 초기화되고 영구 보너스를 획득합니다
|
||||
</p>
|
||||
|
||||
{/* 초기화 항목 */}
|
||||
<div css={sectionLabelStyle}>초기화될 항목</div>
|
||||
<div css={resetListStyle}>
|
||||
<div css={resetItemStyle}>⚠️ 원소 인벤토리 → 초기값 (4종 각 5개)</div>
|
||||
<div css={resetItemStyle}>⚠️ 골드 → 0 (스타터 골드로 교체)</div>
|
||||
<div css={resetItemStyle}>⚠️ 강화 레벨 → 모두 0으로 리셋</div>
|
||||
<div css={resetItemStyle}>⚠️ 상점 버프 → 초기화</div>
|
||||
</div>
|
||||
|
||||
{/* 획득할 프레스티지 보너스 */}
|
||||
<div css={sectionLabelStyle}>획득할 영구 보너스 ({nextEntry.count}회차)</div>
|
||||
<div css={bonusCardStyle}>
|
||||
<div css={bonusRowStyle}>
|
||||
<span css={bonusLabelStyle}>💰 골드 수입 배율</span>
|
||||
<span css={bonusValueStyle}>×{nextEntry.goldMultiplier}</span>
|
||||
</div>
|
||||
<div css={bonusRowStyle}>
|
||||
<span css={bonusLabelStyle}>⚡ 원소 스폰 배율</span>
|
||||
<span css={bonusValueStyle}>×{nextEntry.spawnMultiplier}</span>
|
||||
</div>
|
||||
<div css={bonusRowStyle}>
|
||||
<span css={bonusLabelStyle}>🪙 스타터 골드</span>
|
||||
<span css={bonusValueStyle}>{nextEntry.starterGold.toLocaleString()}G</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 칭호 잠금해제 미리보기 */}
|
||||
<div css={sectionLabelStyle}>잠금해제될 칭호</div>
|
||||
<div css={titlePreviewStyle}>
|
||||
<span css={titleEmojiStyle}>{nextEntry.titleEmoji}</span>
|
||||
<div>
|
||||
<div css={titleTextStyle}>{nextEntry.title}</div>
|
||||
<div css={titleSubStyle}>{nextEntry.count}회 환생 달성 칭호</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div css={buttonRowStyle}>
|
||||
<button
|
||||
css={cancelButtonStyle}
|
||||
onClick={onClose}
|
||||
disabled={confirming}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
css={confirmButtonStyle(!canConfirm || confirming)}
|
||||
onClick={handleConfirm}
|
||||
disabled={!canConfirm || confirming}
|
||||
>
|
||||
{confirming ? '환생 중...' : '환생하기 ✨'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
581
src/components/screens/AchievementsScreen.tsx
Normal file
581
src/components/screens/AchievementsScreen.tsx
Normal file
@@ -0,0 +1,581 @@
|
||||
import { css, keyframes } from '@emotion/react';
|
||||
import { useState } from 'react';
|
||||
import { adaptive } from '@toss/tds-colors';
|
||||
import { useGameStore } from '../../store/useGameStore';
|
||||
import achievementsData from '../../data/achievements.json';
|
||||
import elementsData from '../../data/elements.json';
|
||||
import { PrestigeModal } from '../PrestigeModal';
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
interface AchievementCondition {
|
||||
type: string;
|
||||
value: number;
|
||||
target?: string;
|
||||
tier?: number;
|
||||
}
|
||||
|
||||
interface AchievementReward {
|
||||
gold?: number;
|
||||
boostType?: string;
|
||||
boostDurationMin?: number;
|
||||
bonusScrolls?: number;
|
||||
permanentGoldMultiplier?: number;
|
||||
permanentSpawnMultiplier?: number;
|
||||
title?: string;
|
||||
specialBackground?: string;
|
||||
}
|
||||
|
||||
interface Achievement {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
condition: AchievementCondition;
|
||||
reward: AchievementReward;
|
||||
isHidden: boolean;
|
||||
}
|
||||
|
||||
const ACHIEVEMENTS = achievementsData as Achievement[];
|
||||
|
||||
type CategoryKey = 'beginner' | 'intermediate' | 'advanced' | 'prestige';
|
||||
|
||||
const CATEGORY_TABS: { key: CategoryKey; label: string; icon: string }[] = [
|
||||
{ key: 'beginner', label: '입문', icon: '🌱' },
|
||||
{ key: 'intermediate', label: '중급', icon: '⚡' },
|
||||
{ key: 'advanced', label: '고급', icon: '🔥' },
|
||||
{ key: 'prestige', label: '프레스티지', icon: '✨' },
|
||||
];
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Progress calculation
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
function calcProgress(
|
||||
condition: AchievementCondition,
|
||||
state: {
|
||||
elements: Record<string, number>;
|
||||
gold: number;
|
||||
elementLevels: Record<string, number>;
|
||||
achievementStats: {
|
||||
fuseCount: number;
|
||||
enhanceCount: number;
|
||||
offlineClaimCount: number;
|
||||
prestigeCount: number;
|
||||
tiersUnlocked: number[];
|
||||
};
|
||||
}
|
||||
): { current: number; max: number } {
|
||||
const { elements, gold, elementLevels, achievementStats } = state;
|
||||
const ENHANCE_MAX_LEVEL = 5;
|
||||
|
||||
switch (condition.type) {
|
||||
case 'fuse_count':
|
||||
return { current: achievementStats.fuseCount, max: condition.value };
|
||||
case 'enhance_count':
|
||||
return { current: achievementStats.enhanceCount, max: condition.value };
|
||||
case 'offline_claim':
|
||||
return { current: achievementStats.offlineClaimCount, max: condition.value };
|
||||
case 'prestige_count':
|
||||
return { current: achievementStats.prestigeCount, max: condition.value };
|
||||
case 'element_count':
|
||||
return { current: elements[condition.target ?? ''] ?? 0, max: condition.value };
|
||||
case 'element_variety':
|
||||
return {
|
||||
current: Object.values(elements).filter((c) => c > 0).length,
|
||||
max: condition.value,
|
||||
};
|
||||
case 'gold_amount':
|
||||
return { current: gold, max: condition.value };
|
||||
case 'max_enhance_count': {
|
||||
const maxCount = Object.values(elementLevels).filter(
|
||||
(lv) => lv >= ENHANCE_MAX_LEVEL
|
||||
).length;
|
||||
return { current: maxCount, max: condition.value };
|
||||
}
|
||||
case 'total_elements': {
|
||||
const total = Object.values(elements).reduce((s, c) => s + c, 0);
|
||||
return { current: total, max: condition.value };
|
||||
}
|
||||
case 'single_element_count': {
|
||||
const maxCount = Math.max(0, ...Object.values(elements));
|
||||
return { current: maxCount, max: condition.value };
|
||||
}
|
||||
case 'tier_variety': {
|
||||
const targetTier = condition.tier ?? 0;
|
||||
const tierIds = (elementsData as Array<{ id: string; tier: number }>)
|
||||
.filter((e) => e.tier === targetTier)
|
||||
.map((e) => e.id);
|
||||
const discovered = tierIds.filter((id) => (elements[id] ?? 0) > 0).length;
|
||||
return { current: discovered, max: condition.value };
|
||||
}
|
||||
case 'all_enhance_min_level': {
|
||||
const discoveredIds = Object.entries(elements)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([id]) => id);
|
||||
if (discoveredIds.length === 0) return { current: 0, max: 1 };
|
||||
const meetCount = discoveredIds.filter(
|
||||
(id) => (elementLevels[id] ?? 0) >= condition.value
|
||||
).length;
|
||||
return { current: meetCount, max: discoveredIds.length };
|
||||
}
|
||||
case 'tier_unlock':
|
||||
return {
|
||||
current: achievementStats.tiersUnlocked.includes(condition.value) ? 1 : 0,
|
||||
max: 1,
|
||||
};
|
||||
case 'element_unlock':
|
||||
return {
|
||||
current: (elements[condition.target ?? ''] ?? 0) > 0 ? 1 : 0,
|
||||
max: 1,
|
||||
};
|
||||
default:
|
||||
return { current: 0, max: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
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(`👑 칭호: ${reward.title}`);
|
||||
if (reward.boostType) parts.push('⚡ 버프');
|
||||
return parts.join(' · ') || '보상 없음';
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Styles
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
const containerStyle = css`
|
||||
padding: 20px 20px 24px;
|
||||
background: ${adaptive.background};
|
||||
min-height: 100%;
|
||||
`;
|
||||
|
||||
const headerStyle = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const titleStyle = css`
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: ${adaptive.grey900};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const progressSummaryStyle = css`
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${adaptive.blue500};
|
||||
`;
|
||||
|
||||
const tabRowStyle = css`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
`;
|
||||
|
||||
const tabButtonStyle = (active: boolean) => css`
|
||||
flex-shrink: 0;
|
||||
padding: 7px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: ${active ? 700 : 500};
|
||||
border: none;
|
||||
background: ${active ? adaptive.blue500 : adaptive.greyBackground};
|
||||
color: ${active ? '#ffffff' : adaptive.grey600};
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
`;
|
||||
|
||||
const achievementListStyle = css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const achievementCardBaseStyle = `
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const achievementCardUnlockedStyle = css`
|
||||
${achievementCardBaseStyle}
|
||||
background: ${adaptive.background};
|
||||
border: 1.5px solid ${adaptive.grey200};
|
||||
`;
|
||||
|
||||
const achievementCardLockedStyle = css`
|
||||
${achievementCardBaseStyle}
|
||||
background: ${adaptive.greyBackground};
|
||||
border: 1px solid ${adaptive.grey100};
|
||||
opacity: 0.7;
|
||||
`;
|
||||
|
||||
const achievementCardHiddenStyle = css`
|
||||
${achievementCardBaseStyle}
|
||||
background: ${adaptive.greyBackground};
|
||||
border: 1px dashed ${adaptive.grey200};
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
const iconBoxStyle = (unlocked: boolean) => css`
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
background: ${unlocked
|
||||
? 'linear-gradient(135deg, #fff8e1, #ffe082)'
|
||||
: adaptive.grey200};
|
||||
border: 1.5px solid ${unlocked ? '#ffc107' : adaptive.grey300};
|
||||
`;
|
||||
|
||||
const achievementInfoStyle = css`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const achievementNameStyle = (unlocked: boolean) => css`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: ${unlocked ? adaptive.grey900 : adaptive.grey600};
|
||||
margin: 0 0 3px;
|
||||
`;
|
||||
|
||||
const achievementDescStyle = css`
|
||||
font-size: 12px;
|
||||
color: ${adaptive.grey500};
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
|
||||
const rewardRowStyle = css`
|
||||
font-size: 11px;
|
||||
color: #b8860b;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const progressBarWrapStyle = css`
|
||||
margin-top: 6px;
|
||||
`;
|
||||
|
||||
const progressRowStyle = css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const progressLabelStyle = css`
|
||||
font-size: 11px;
|
||||
color: ${adaptive.grey500};
|
||||
`;
|
||||
|
||||
const progressCountStyle = css`
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: ${adaptive.blue500};
|
||||
`;
|
||||
|
||||
const progressTrackStyle = css`
|
||||
height: 4px;
|
||||
background: ${adaptive.grey200};
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const progressFillStyle = (ratio: number) => css`
|
||||
height: 100%;
|
||||
width: ${Math.min(ratio * 100, 100).toFixed(1)}%;
|
||||
background: linear-gradient(90deg, #3182f6, #7c4dff);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
`;
|
||||
|
||||
const unlockedBadgePulse = keyframes`
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
100% { transform: scale(1); }
|
||||
`;
|
||||
|
||||
const checkBadgeStyle = css`
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
animation: ${unlockedBadgePulse} 1.5s ease-in-out infinite;
|
||||
`;
|
||||
|
||||
// ── 프레스티지 버튼 섹션 ──────────────────────────────────
|
||||
|
||||
const prestigeSectionStyle = css`
|
||||
margin-bottom: 20px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #1a0a00, #3d1a00);
|
||||
border: 1.5px solid #ff9800;
|
||||
padding: 18px 16px;
|
||||
`;
|
||||
|
||||
const prestigeTitleStyle = css`
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #ffd700;
|
||||
margin: 0 0 6px;
|
||||
`;
|
||||
|
||||
const prestigeDescStyle = css`
|
||||
font-size: 12px;
|
||||
color: #ffcc80;
|
||||
margin: 0 0 14px;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const prestigeButtonStyle = css`
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #ff6f00, #ffd700);
|
||||
color: #3d1a00;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.3px;
|
||||
box-shadow: 0 4px 16px rgba(255, 180, 0, 0.4);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
&:active { transform: scale(0.97); box-shadow: 0 2px 8px rgba(255, 180, 0, 0.3); }
|
||||
`;
|
||||
|
||||
const prestigeLockedStyle = css`
|
||||
background: ${adaptive.greyBackground};
|
||||
border: 1px dashed ${adaptive.grey300};
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: ${adaptive.grey500};
|
||||
`;
|
||||
|
||||
const prestigeCountBadgeStyle = css`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(255, 215, 0, 0.15);
|
||||
border: 1px solid rgba(255, 215, 0, 0.4);
|
||||
border-radius: 8px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #ffd700;
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// AchievementCard
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
interface AchievementCardProps {
|
||||
ach: Achievement;
|
||||
unlocked: boolean;
|
||||
progressState: {
|
||||
elements: Record<string, number>;
|
||||
gold: number;
|
||||
elementLevels: Record<string, number>;
|
||||
achievementStats: {
|
||||
fuseCount: number;
|
||||
enhanceCount: number;
|
||||
offlineClaimCount: number;
|
||||
prestigeCount: number;
|
||||
tiersUnlocked: number[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
beginner: '🌱',
|
||||
intermediate: '⚡',
|
||||
advanced: '🔥',
|
||||
prestige: '✨',
|
||||
};
|
||||
|
||||
function AchievementCard({ ach, unlocked, progressState }: AchievementCardProps) {
|
||||
const isHiddenLocked = ach.isHidden && !unlocked;
|
||||
|
||||
if (isHiddenLocked) {
|
||||
return (
|
||||
<div css={achievementCardHiddenStyle}>
|
||||
<div css={iconBoxStyle(false)}>❓</div>
|
||||
<div css={achievementInfoStyle}>
|
||||
<p css={achievementNameStyle(false)}>???</p>
|
||||
<p css={achievementDescStyle}>숨겨진 업적입니다</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { current, max } = calcProgress(ach.condition, progressState);
|
||||
const ratio = max > 0 ? current / max : 0;
|
||||
const icon = CATEGORY_ICONS[ach.category] ?? '🏅';
|
||||
|
||||
if (unlocked) {
|
||||
return (
|
||||
<div css={achievementCardUnlockedStyle}>
|
||||
<div css={iconBoxStyle(true)}>{icon}</div>
|
||||
<div css={achievementInfoStyle}>
|
||||
<p css={achievementNameStyle(true)}>{ach.name}</p>
|
||||
<p css={achievementDescStyle}>{ach.description}</p>
|
||||
<p css={rewardRowStyle}>{formatReward(ach.reward)}</p>
|
||||
</div>
|
||||
<span css={checkBadgeStyle}>✅</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showCount = max > 1;
|
||||
const displayCurrent = Math.min(current, max);
|
||||
|
||||
return (
|
||||
<div css={achievementCardLockedStyle}>
|
||||
<div css={iconBoxStyle(false)}>{icon}</div>
|
||||
<div css={achievementInfoStyle}>
|
||||
<p css={achievementNameStyle(false)}>{ach.name}</p>
|
||||
<p css={achievementDescStyle}>{ach.description}</p>
|
||||
<p css={rewardRowStyle}>{formatReward(ach.reward)}</p>
|
||||
{showCount && (
|
||||
<div css={progressBarWrapStyle}>
|
||||
<div css={progressRowStyle}>
|
||||
<span css={progressLabelStyle}>진행도</span>
|
||||
<span css={progressCountStyle}>
|
||||
{displayCurrent.toLocaleString()} / {max.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div css={progressTrackStyle}>
|
||||
<div css={progressFillStyle(ratio)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// AchievementsScreen
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
export function AchievementsScreen() {
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryKey>('beginner');
|
||||
const [showPrestigeModal, setShowPrestigeModal] = useState(false);
|
||||
|
||||
const elements = useGameStore((s) => s.elements);
|
||||
const gold = useGameStore((s) => s.gold);
|
||||
const elementLevels = useGameStore((s) => s.elementLevels);
|
||||
const unlockedAchievements = useGameStore((s) => s.unlockedAchievements);
|
||||
const achievementStats = useGameStore((s) => s.achievementStats);
|
||||
const prestigeCount = useGameStore((s) => s.prestigeCount);
|
||||
const prestigeTitle = useGameStore((s) => s.prestigeTitle);
|
||||
|
||||
const canPrestige =
|
||||
(elements['creation'] ?? 0) >= 1 && (elements['spirit'] ?? 0) >= 1;
|
||||
|
||||
const progressState = { elements, gold, elementLevels, achievementStats };
|
||||
|
||||
const filteredAchievements = ACHIEVEMENTS.filter(
|
||||
(ach) => ach.category === activeCategory
|
||||
);
|
||||
|
||||
const totalUnlocked = unlockedAchievements.length;
|
||||
const totalCount = ACHIEVEMENTS.length;
|
||||
|
||||
return (
|
||||
<div css={containerStyle}>
|
||||
<div css={headerStyle}>
|
||||
<h1 css={titleStyle}>업적</h1>
|
||||
<span css={progressSummaryStyle}>
|
||||
{totalUnlocked} / {totalCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 프레스티지 버튼 */}
|
||||
{activeCategory === 'prestige' && (
|
||||
canPrestige ? (
|
||||
<div css={prestigeSectionStyle}>
|
||||
{prestigeCount > 0 && (
|
||||
<div css={prestigeCountBadgeStyle}>
|
||||
✨ {prestigeCount}회 환생 달성 {prestigeTitle && `· ${prestigeTitle}`}
|
||||
</div>
|
||||
)}
|
||||
<p css={prestigeTitleStyle}>환생 준비 완료</p>
|
||||
<p css={prestigeDescStyle}>
|
||||
Creation과 Spirit 원소를 모두 보유하고 있습니다.
|
||||
환생하면 진행이 초기화되지만 영구 보너스를 획득합니다.
|
||||
</p>
|
||||
<button css={prestigeButtonStyle} onClick={() => setShowPrestigeModal(true)}>
|
||||
✨ 환생하기 (프레스티지)
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div css={prestigeLockedStyle}>
|
||||
🔒 Creation + Spirit 원소를 모두 보유하면 프레스티지가 가능합니다
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 카테고리 탭 */}
|
||||
<div css={tabRowStyle}>
|
||||
{CATEGORY_TABS.map((tab) => {
|
||||
const catAchs = ACHIEVEMENTS.filter((a) => a.category === tab.key);
|
||||
const catUnlocked = catAchs.filter((a) =>
|
||||
unlockedAchievements.includes(a.id)
|
||||
).length;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
css={tabButtonStyle(activeCategory === tab.key)}
|
||||
onClick={() => setActiveCategory(tab.key)}
|
||||
>
|
||||
{tab.icon} {tab.label} ({catUnlocked}/{catAchs.length})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 업적 목록 */}
|
||||
<div css={achievementListStyle}>
|
||||
{filteredAchievements.map((ach) => (
|
||||
<AchievementCard
|
||||
key={ach.id}
|
||||
ach={ach}
|
||||
unlocked={unlockedAchievements.includes(ach.id)}
|
||||
progressState={progressState}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 프레스티지 모달 */}
|
||||
{showPrestigeModal && (
|
||||
<PrestigeModal onClose={() => setShowPrestigeModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ const throttledStorage = createJSONStorage(() => ({
|
||||
removeItem: (name: string): void => localStorage.removeItem(name),
|
||||
}));
|
||||
|
||||
export type TabName = 'elements' | 'evolution' | 'fusion' | 'shop' | 'settings';
|
||||
export type TabName = 'elements' | 'evolution' | 'fusion' | 'shop' | 'achievements' | 'settings';
|
||||
export type Language = 'ko' | 'en';
|
||||
|
||||
const INITIAL_ELEMENTS: Record<string, number> = {
|
||||
|
||||
Reference in New Issue
Block a user