diff --git a/pages/index.tsx b/pages/index.tsx
index c2e0061..d5dead1 100644
--- a/pages/index.tsx
+++ b/pages/index.tsx
@@ -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 (
+
{activeTab === 'elements' &&
}
{activeTab === 'evolution' &&
}
{activeTab === 'fusion' &&
}
{activeTab === 'shop' &&
}
+ {activeTab === 'achievements' &&
}
{activeTab === 'settings' &&
}
diff --git a/src/components/AchievementToast.tsx b/src/components/AchievementToast.tsx
new file mode 100644
index 0000000..f711eb7
--- /dev/null
+++ b/src/components/AchievementToast.tsx
@@ -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
= {
+ 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([]);
+ const [current, setCurrent] = useState(null);
+ const [exiting, setExiting] = useState(false);
+ const [titlePopup, setTitlePopup] = useState(null);
+
+ const dismissTimer = useRef | 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).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 && (
+
+
+
{icon}
+
+
🏆 업적 달성
+
{current.achievement.name}
+ {rewardText &&
{rewardText}
}
+
+
+
+
+ )}
+
+ {titlePopup !== null && (
+ setTitlePopup(null)}>
+
+
👑
+
✨ 칭호 잠금해제 ✨
+
{titlePopup.reward.title}
+
{titlePopup.name}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/BottomTabBar.tsx b/src/components/BottomTabBar.tsx
index 84acef2..9466fad 100644
--- a/src/components/BottomTabBar.tsx
+++ b/src/components/BottomTabBar.tsx
@@ -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: '⚙️' },
];
diff --git a/src/components/PrestigeModal.tsx b/src/components/PrestigeModal.tsx
new file mode 100644
index 0000000..a89fc57
--- /dev/null
+++ b/src/components/PrestigeModal.tsx
@@ -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 (
+ <>
+
+
+ {particles.map((p) => (
+
+ {p.emoji}
+
+ ))}
+
+
+
+
{entry.titleEmoji}
+
✨ 칭호 잠금해제 ✨
+
{entry.title}
+
+
+
+ >
+ );
+}
+
+// ────────────────────────────────────────────────────────────
+// 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(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 ;
+ }
+
+ return (
+ <>
+ {showParticles && (
+ <>
+
+
+ {particles.map((p) => (
+
+ {p.emoji}
+
+ ))}
+
+ >
+ )}
+
+
+
e.stopPropagation()}>
+
+
✨ 프레스티지 (환생)
+
+ 환생 시 진행이 초기화되고 영구 보너스를 획득합니다
+
+
+ {/* 초기화 항목 */}
+
초기화될 항목
+
+
⚠️ 원소 인벤토리 → 초기값 (4종 각 5개)
+
⚠️ 골드 → 0 (스타터 골드로 교체)
+
⚠️ 강화 레벨 → 모두 0으로 리셋
+
⚠️ 상점 버프 → 초기화
+
+
+ {/* 획득할 프레스티지 보너스 */}
+
획득할 영구 보너스 ({nextEntry.count}회차)
+
+
+ 💰 골드 수입 배율
+ ×{nextEntry.goldMultiplier}
+
+
+ ⚡ 원소 스폰 배율
+ ×{nextEntry.spawnMultiplier}
+
+
+ 🪙 스타터 골드
+ {nextEntry.starterGold.toLocaleString()}G
+
+
+
+ {/* 칭호 잠금해제 미리보기 */}
+
잠금해제될 칭호
+
+
{nextEntry.titleEmoji}
+
+
{nextEntry.title}
+
{nextEntry.count}회 환생 달성 칭호
+
+
+
+ {/* 버튼 */}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/screens/AchievementsScreen.tsx b/src/components/screens/AchievementsScreen.tsx
new file mode 100644
index 0000000..4e4155c
--- /dev/null
+++ b/src/components/screens/AchievementsScreen.tsx
@@ -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;
+ gold: number;
+ elementLevels: Record;
+ 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;
+ gold: number;
+ elementLevels: Record;
+ achievementStats: {
+ fuseCount: number;
+ enhanceCount: number;
+ offlineClaimCount: number;
+ prestigeCount: number;
+ tiersUnlocked: number[];
+ };
+ };
+}
+
+const CATEGORY_ICONS: Record = {
+ beginner: '🌱',
+ intermediate: '⚡',
+ advanced: '🔥',
+ prestige: '✨',
+};
+
+function AchievementCard({ ach, unlocked, progressState }: AchievementCardProps) {
+ const isHiddenLocked = ach.isHidden && !unlocked;
+
+ if (isHiddenLocked) {
+ return (
+
+ );
+ }
+
+ const { current, max } = calcProgress(ach.condition, progressState);
+ const ratio = max > 0 ? current / max : 0;
+ const icon = CATEGORY_ICONS[ach.category] ?? '🏅';
+
+ if (unlocked) {
+ return (
+
+
{icon}
+
+
{ach.name}
+
{ach.description}
+
{formatReward(ach.reward)}
+
+
✅
+
+ );
+ }
+
+ const showCount = max > 1;
+ const displayCurrent = Math.min(current, max);
+
+ return (
+
+
{icon}
+
+
{ach.name}
+
{ach.description}
+
{formatReward(ach.reward)}
+ {showCount && (
+
+
+ 진행도
+
+ {displayCurrent.toLocaleString()} / {max.toLocaleString()}
+
+
+
+
+ )}
+
+
+ );
+}
+
+// ────────────────────────────────────────────────────────────
+// AchievementsScreen
+// ────────────────────────────────────────────────────────────
+
+export function AchievementsScreen() {
+ const [activeCategory, setActiveCategory] = useState('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 (
+
+
+
업적
+
+ {totalUnlocked} / {totalCount}
+
+
+
+ {/* 프레스티지 버튼 */}
+ {activeCategory === 'prestige' && (
+ canPrestige ? (
+
+ {prestigeCount > 0 && (
+
+ ✨ {prestigeCount}회 환생 달성 {prestigeTitle && `· ${prestigeTitle}`}
+
+ )}
+
환생 준비 완료
+
+ Creation과 Spirit 원소를 모두 보유하고 있습니다.
+ 환생하면 진행이 초기화되지만 영구 보너스를 획득합니다.
+
+
+
+ ) : (
+
+ 🔒 Creation + Spirit 원소를 모두 보유하면 프레스티지가 가능합니다
+
+ )
+ )}
+
+ {/* 카테고리 탭 */}
+
+ {CATEGORY_TABS.map((tab) => {
+ const catAchs = ACHIEVEMENTS.filter((a) => a.category === tab.key);
+ const catUnlocked = catAchs.filter((a) =>
+ unlockedAchievements.includes(a.id)
+ ).length;
+ return (
+
+ );
+ })}
+
+
+ {/* 업적 목록 */}
+
+ {filteredAchievements.map((ach) => (
+
+ ))}
+
+
+ {/* 프레스티지 모달 */}
+ {showPrestigeModal && (
+
setShowPrestigeModal(false)} />
+ )}
+
+ );
+}
diff --git a/src/store/useGameStore.ts b/src/store/useGameStore.ts
index 2565e53..462332c 100644
--- a/src/store/useGameStore.ts
+++ b/src/store/useGameStore.ts
@@ -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 = {