From 9800d067b940132d7dd659f3d5c98e9fccd29515 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 2 Apr 2026 04:19:19 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A0=88=EC=8A=A4=ED=8B=B0?= =?UTF-8?q?=EC=A7=80/=EC=97=85=EC=A0=81=20UI=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B2=8C=EC=9E=84?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?(JSA-55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AchievementToast, PrestigeModal, AchievementsScreen 컴포넌트 추가 - BottomTabBar에 업적 탭 연결 - useGameStore 프레스티지/업적 상태 로직 수정 - pages/index.tsx 라우팅 업데이트 Co-Authored-By: Paperclip --- pages/index.tsx | 4 + src/components/AchievementToast.tsx | 338 ++++++++++ src/components/BottomTabBar.tsx | 3 +- src/components/PrestigeModal.tsx | 508 +++++++++++++++ src/components/screens/AchievementsScreen.tsx | 581 ++++++++++++++++++ src/store/useGameStore.ts | 2 +- 6 files changed, 1434 insertions(+), 2 deletions(-) create mode 100644 src/components/AchievementToast.tsx create mode 100644 src/components/PrestigeModal.tsx create mode 100644 src/components/screens/AchievementsScreen.tsx 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 = {