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

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

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

View File

@@ -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} />

View File

@@ -0,0 +1,338 @@
import { css, keyframes } from '@emotion/react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { adaptive } from '@toss/tds-colors';
// ────────────────────────────────────────────────────────────
// Types
// ────────────────────────────────────────────────────────────
interface AchievementReward {
gold?: number;
permanentGoldMultiplier?: number;
permanentSpawnMultiplier?: number;
title?: string;
boostType?: string;
}
interface Achievement {
id: string;
name: string;
description: string;
category: string;
reward: AchievementReward;
isHidden: boolean;
}
// ────────────────────────────────────────────────────────────
// Animations
// ────────────────────────────────────────────────────────────
const slideUp = keyframes`
0% { transform: translateY(100px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
`;
const slideDown = keyframes`
0% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(100px); opacity: 0; }
`;
const titleReveal = keyframes`
0% { transform: scale(0.6) translateY(30px); opacity: 0; }
60% { transform: scale(1.08) translateY(-6px); opacity: 1; }
100% { transform: scale(1) translateY(0); opacity: 1; }
`;
const starSparkle = keyframes`
0% { transform: scale(0) rotate(0deg); opacity: 0; }
50% { transform: scale(1.3) rotate(20deg); opacity: 1; }
100% { transform: scale(1) rotate(0deg); opacity: 1; }
`;
const fadePop = keyframes`
0% { opacity: 0; }
100% { opacity: 1; }
`;
// ────────────────────────────────────────────────────────────
// Toast styles
// ────────────────────────────────────────────────────────────
const toastContainerStyle = (exiting: boolean) => css`
position: fixed;
bottom: 84px;
left: 16px;
right: 16px;
z-index: 600;
animation: ${exiting ? slideDown : slideUp} 0.35s ease-out forwards;
`;
const toastCardStyle = css`
background: linear-gradient(135deg, #1a0a00, #3d1a00);
border: 1.5px solid #ffd700;
border-radius: 16px;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 8px 32px rgba(255, 180, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.4);
`;
const toastIconStyle = css`
font-size: 28px;
flex-shrink: 0;
animation: ${starSparkle} 0.5s ease-out forwards;
`;
const toastContentStyle = css`
flex: 1;
min-width: 0;
`;
const toastHeadingStyle = css`
font-size: 11px;
color: #ffcc80;
font-weight: 600;
letter-spacing: 0.8px;
text-transform: uppercase;
margin-bottom: 2px;
`;
const toastNameStyle = css`
font-size: 15px;
font-weight: 700;
color: #ffd700;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const toastRewardStyle = css`
font-size: 12px;
color: #ffcc80;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const toastProgressBarStyle = css`
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(255, 215, 0, 0.15);
border-radius: 0 0 16px 16px;
overflow: hidden;
`;
const TOAST_DURATION_MS = 3000;
const toastProgressFillStyle = css`
height: 100%;
background: linear-gradient(90deg, #ffd700, #ff9800);
border-radius: 0 0 16px 16px;
animation: shrink linear ${TOAST_DURATION_MS}ms forwards;
@keyframes shrink {
from { width: 100%; }
to { width: 0%; }
}
`;
// ────────────────────────────────────────────────────────────
// Title popup (full-screen for title-granting achievements)
// ────────────────────────────────────────────────────────────
const titleOverlayStyle = css`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.92);
z-index: 700;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
animation: ${fadePop} 0.3s ease-out;
`;
const titleCardStyle = css`
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
animation: ${titleReveal} 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
`;
const titleGlowEmoji = css`
font-size: 72px;
`;
const titlePopupHeading = css`
font-size: 13px;
color: #ffcc80;
letter-spacing: 2px;
text-transform: uppercase;
`;
const titlePopupName = css`
font-size: 26px;
font-weight: 800;
color: #ffd700;
text-align: center;
text-shadow: 0 0 30px rgba(255, 215, 0, 0.8);
padding: 0 24px;
`;
const titlePopupSub = css`
font-size: 13px;
color: #b39ddb;
text-align: center;
`;
const titlePopupDismissStyle = css`
margin-top: 32px;
padding: 12px 36px;
border-radius: 24px;
background: rgba(255, 215, 0, 0.12);
border: 1px solid rgba(255, 215, 0, 0.4);
color: #ffd700;
font-size: 15px;
font-weight: 600;
cursor: pointer;
`;
// ────────────────────────────────────────────────────────────
// Helper
// ────────────────────────────────────────────────────────────
const CATEGORY_ICON: Record<string, string> = {
beginner: '🌱',
intermediate: '⚡',
advanced: '🔥',
prestige: '✨',
};
function formatReward(reward: AchievementReward): string {
const parts: string[] = [];
if (reward.gold) parts.push(`💰 +${reward.gold.toLocaleString()}G`);
if (reward.permanentGoldMultiplier)
parts.push(`🏅 골드 +${(reward.permanentGoldMultiplier * 100).toFixed(0)}%`);
if (reward.permanentSpawnMultiplier)
parts.push(`🏅 스폰 +${(reward.permanentSpawnMultiplier * 100).toFixed(0)}%`);
if (reward.title) parts.push(`👑 칭호 획득`);
return parts.join(' · ');
}
// ────────────────────────────────────────────────────────────
// AchievementToast component
// ────────────────────────────────────────────────────────────
interface ToastItem {
id: string;
achievement: Achievement;
}
export function AchievementToast() {
const [queue, setQueue] = useState<ToastItem[]>([]);
const [current, setCurrent] = useState<ToastItem | null>(null);
const [exiting, setExiting] = useState(false);
const [titlePopup, setTitlePopup] = useState<Achievement | null>(null);
const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearDismissTimer = useCallback(() => {
if (dismissTimer.current !== null) {
clearTimeout(dismissTimer.current);
dismissTimer.current = null;
}
}, []);
const dismissCurrent = useCallback(() => {
clearDismissTimer();
setExiting(true);
setTimeout(() => {
setExiting(false);
setCurrent(null);
}, 350);
}, [clearDismissTimer]);
// Show next toast when current is done
useEffect(() => {
if (current === null && queue.length > 0) {
const [next, ...rest] = queue;
setQueue(rest);
setCurrent(next);
}
}, [current, queue]);
// Auto-dismiss after TOAST_DURATION_MS
useEffect(() => {
if (current === null) return;
clearDismissTimer();
// If this achievement has a title reward, show full-screen popup
if (current.achievement.reward.title) {
dismissTimer.current = setTimeout(() => {
dismissCurrent();
setTitlePopup(current.achievement);
}, TOAST_DURATION_MS);
} else {
dismissTimer.current = setTimeout(dismissCurrent, TOAST_DURATION_MS);
}
return clearDismissTimer;
}, [current, dismissCurrent, clearDismissTimer]);
// Listen to game store custom events
useEffect(() => {
const handler = (e: Event) => {
const ach = (e as CustomEvent<Achievement>).detail;
const item: ToastItem = {
id: `${ach.id}-${Date.now()}`,
achievement: ach,
};
setQueue((prev) => [...prev, item]);
};
window.addEventListener('newAchievement', handler);
return () => window.removeEventListener('newAchievement', handler);
}, []);
const icon = current ? (CATEGORY_ICON[current.achievement.category] ?? '🏅') : '';
const rewardText = current ? formatReward(current.achievement.reward) : '';
return (
<>
{current !== null && (
<div css={toastContainerStyle(exiting)} onClick={dismissCurrent}>
<div css={toastCardStyle} style={{ position: 'relative' }}>
<div css={toastIconStyle}>{icon}</div>
<div css={toastContentStyle}>
<div css={toastHeadingStyle}>🏆 </div>
<div css={toastNameStyle}>{current.achievement.name}</div>
{rewardText && <div css={toastRewardStyle}>{rewardText}</div>}
</div>
<div css={toastProgressBarStyle}>
<div key={current.id} css={toastProgressFillStyle} />
</div>
</div>
</div>
)}
{titlePopup !== null && (
<div css={titleOverlayStyle} onClick={() => setTitlePopup(null)}>
<div css={titleCardStyle}>
<div css={titleGlowEmoji}>👑</div>
<div css={titlePopupHeading}> </div>
<div css={titlePopupName}>{titlePopup.reward.title}</div>
<div css={titlePopupSub}>{titlePopup.name}</div>
</div>
<button css={titlePopupDismissStyle} onClick={() => setTitlePopup(null)}>
</button>
</div>
)}
</>
);
}

View File

@@ -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: '⚙️' },
];

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

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

View File

@@ -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> = {