From 3676d8b12c265cd36e92bf836c2e8e00fea95d76 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 2 May 2026 07:34:10 +0900 Subject: [PATCH] feat: integrate Phase 0 fun systems across screens and game store Wires the new scaffolding (intro, discovery, ad slot, sfx/haptic, sprite art) into existing systems and adds combo + lucky proc to the fusion loop. - useGameStore: add combo meter (8s window, x1.5/x2/x3 caps), lucky proc (1% tier-up + gold bonus), sfx/haptic enabled toggles, welcome-gift selection, screen polish state for new visual systems. - App.tsx: mount IntroSplash + AdBanner; listen for legendaryImpact event and apply 0.42s screen-shake keyframes. - FusionScreen: render combo bar + lucky badge + DiscoveryHero overlay; trigger SFX, haptic, legendary impact event on fuse results. - SettingsScreen: add SFX and haptic toggles wired to store flags. - TutorialOverlay: integrate welcome-gift step at tutorial completion. - CharacterSprite, ElementsScreen, EvolutionScreen, ShopScreen, OfflineRewardModal: adopt new sprite art and apply visual polish to match the discovery / scene aesthetic. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 44 +- src/components/CharacterSprite.tsx | 444 ++++++- src/components/OfflineRewardModal.tsx | 89 +- src/components/screens/ElementsScreen.tsx | 1219 ++++++++++++++++++- src/components/screens/EvolutionScreen.tsx | 77 +- src/components/screens/FusionScreen.tsx | 193 ++- src/components/screens/SettingsScreen.tsx | 23 +- src/components/screens/ShopScreen.tsx | 294 ++++- src/components/tutorial/TutorialOverlay.tsx | 13 +- src/store/useGameStore.ts | 947 +++++++++++++- 10 files changed, 3189 insertions(+), 154 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index bee594d..89ffca5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,10 @@ -import { css } from '@emotion/react'; -import { useEffect } from 'react'; +import { css, keyframes } from '@emotion/react'; +import { useCallback, useEffect, useState } from 'react'; import { BottomTabBar } from './components/BottomTabBar'; import { OfflineRewardModal } from './components/OfflineRewardModal'; import { AchievementToast } from './components/AchievementToast'; +import { AdBanner } from './components/AdBanner'; +import { IntroSplash } from './components/IntroSplash'; import { ElementsScreen } from './components/screens/ElementsScreen'; import { EvolutionScreen } from './components/screens/EvolutionScreen'; import { FusionScreen } from './components/screens/FusionScreen'; @@ -14,7 +16,16 @@ import { useIdleTick } from './hooks/useIdleTick'; import { useGameStore } from './store/useGameStore'; import { trackGameEvent } from './platform/analytics'; -const rootStyle = css` +const legendaryShake = keyframes` + 0%, 100% { transform: translate3d(0, 0, 0); filter: none; } + 15% { transform: translate3d(-6px, 2px, 0); filter: drop-shadow(0 0 18px rgba(255, 215, 0, 0.7)); } + 30% { transform: translate3d(5px, -3px, 0); } + 45% { transform: translate3d(-4px, -2px, 0); } + 60% { transform: translate3d(4px, 3px, 0); } + 75% { transform: translate3d(-2px, 1px, 0); } +`; + +const rootStyle = (shaking: boolean) => css` width: 100%; height: 100vh; display: flex; @@ -26,18 +37,26 @@ const rootStyle = css` -apple-system, BlinkMacSystemFont, sans-serif; + animation: ${shaking ? css`${legendaryShake} 0.42s ease-out` : 'none'}; `; const contentStyle = css` flex: 1; overflow-y: auto; - padding-bottom: 72px; + padding-bottom: 106px; `; export function App() { const { activeTab, setActiveTab, elements, gold, elementLevels } = useGameStore(); + const [shaking, setShaking] = useState(false); + const [showIntro, setShowIntro] = useState(() => localStorage.getItem('firstspark-intro-seen') !== '1'); useIdleTick(); + const handleIntroDone = useCallback(() => { + localStorage.setItem('firstspark-intro-seen', '1'); + setShowIntro(false); + }, []); + useEffect(() => { const ownedCount = Object.values(elements).filter((c) => c > 0).length; const totalLevel = Object.values(elementLevels).reduce((sum, lv) => sum + lv, 0); @@ -50,8 +69,22 @@ export function App() { }); }, []); + useEffect(() => { + const handler = () => { + setShaking(true); + window.setTimeout(() => setShaking(false), 440); + }; + window.addEventListener('legendaryImpact', handler); + return () => window.removeEventListener('legendaryImpact', handler); + }, []); + return ( -
+
+ {showIntro && ( + + )}
@@ -62,6 +95,7 @@ export function App() { {activeTab === 'achievements' && } {activeTab === 'settings' && }
+
diff --git a/src/components/CharacterSprite.tsx b/src/components/CharacterSprite.tsx index 541f309..89351fd 100644 --- a/src/components/CharacterSprite.tsx +++ b/src/components/CharacterSprite.tsx @@ -1,10 +1,11 @@ /** * CharacterSprite * - * docs/character-design-guide.md 에 정의된 카와이 치비 스타일을 SVG로 구현. - * 각 원소의 색상·Tier·파티클 유형에 따라 고유한 캐릭터를 생성합니다. + * 원소별 속성 문양과 티어 프레임을 가진 SVG 스프라이트. + * 같은 둥근 캐릭터처럼 보이지 않도록 색, 문양, 실루엣, 오라가 함께 달라집니다. */ -import { Fragment, useMemo, type ReactElement } from 'react'; +import { css, keyframes } from '@emotion/react'; +import { Fragment, useId, useMemo, type ReactElement } from 'react'; import { CHARACTER_VISUAL, DEFAULT_VISUAL, @@ -12,6 +13,112 @@ import { type ParticleType, type PatternType, } from '../data/characterVisual'; +import earthAsset from '../assets/elements/earth.png'; +import fireAsset from '../assets/elements/fire.png'; +import waterAsset from '../assets/elements/water.png'; +import windAsset from '../assets/elements/wind.png'; + +type RasterMotion = 'fire' | 'water' | 'wind' | 'earth'; + +const RASTER_SPRITES: Record = { + fire: { src: fireAsset, motion: 'fire' }, + water: { src: waterAsset, motion: 'water' }, + wind: { src: windAsset, motion: 'wind' }, + earth: { src: earthAsset, motion: 'earth' }, +}; + +const fireFlicker = keyframes` + 0%, 100% { transform: translateY(0) scale(1) rotate(0deg); filter: brightness(1) saturate(1.04); } + 18% { transform: translateY(-2px) scale(1.035, 0.985) rotate(-1.4deg); filter: brightness(1.12) saturate(1.18); } + 36% { transform: translateY(1px) scale(0.985, 1.035) rotate(1.1deg); filter: brightness(0.96) saturate(1.08); } + 62% { transform: translateY(-3px) scale(1.045, 0.975) rotate(0.8deg); filter: brightness(1.18) saturate(1.22); } + 82% { transform: translateY(-1px) scale(1.01, 1.02) rotate(-0.6deg); filter: brightness(1.04) saturate(1.12); } +`; + +const waterRipple = keyframes` + 0%, 100% { transform: translateY(0) scale(1) rotate(0deg); filter: brightness(1); } + 30% { transform: translateY(-2px) scale(1.02, 0.99) rotate(0.8deg); filter: brightness(1.08); } + 58% { transform: translateY(1px) scale(0.99, 1.02) rotate(-0.7deg); filter: brightness(0.98); } + 78% { transform: translateY(-1px) scale(1.015, 1) rotate(0.4deg); filter: brightness(1.04); } +`; + +const windFlow = keyframes` + 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); opacity: 0.96; } + 25% { transform: translate(2px, -2px) scale(1.02) rotate(1.6deg); opacity: 1; } + 52% { transform: translate(-2px, 1px) scale(0.99, 1.02) rotate(-1.2deg); opacity: 0.92; } + 76% { transform: translate(1px, -1px) scale(1.01) rotate(0.8deg); opacity: 0.98; } +`; + +const earthGrow = keyframes` + 0%, 100% { transform: translateY(0) scale(1) rotate(0deg); filter: brightness(1); } + 38% { transform: translateY(-1px) scale(1.01, 1.015) rotate(-0.4deg); filter: brightness(1.05); } + 72% { transform: translateY(1px) scale(0.995, 1.005) rotate(0.5deg); filter: brightness(0.98); } +`; + +const rasterFloat = keyframes` + 0%, 100% { transform: translateY(0) scale(1); } + 50% { transform: translateY(-3px) scale(1.02); } +`; + +const glowPulse = keyframes` + 0%, 100% { opacity: 0.08; transform: scale(0.94); } + 50% { opacity: 0.18; transform: scale(1.04); } +`; + +const rasterWrapStyle = (size: number, color: string, tier: number) => css` + position: relative; + width: ${size}px; + height: ${size}px; + display: inline-block; + border-radius: 999px; + filter: drop-shadow(0 7px 10px rgba(15, 23, 42, 0.16)); + animation: ${rasterFloat} 2.2s ease-in-out infinite; + + &::before { + content: ''; + position: absolute; + inset: ${Math.max(1, size * 0.04)}px; + border-radius: 999px; + background: ${color}; + opacity: ${tier >= 3 ? 0.16 : 0.08}; + filter: blur(${Math.max(6, size * 0.14)}px); + animation: ${glowPulse} 2.3s ease-in-out infinite; + } + + &::after { + content: ''; + position: absolute; + inset: ${Math.max(1, size * 0.03)}px; + border-radius: 999px; + border: ${Math.max(1, size * 0.025)}px solid rgba(255, 255, 255, 0.6); + box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08); + pointer-events: none; + } +`; + +function getRasterMotionStyle(motion: RasterMotion) { + switch (motion) { + case 'fire': + return css`${fireFlicker} 0.82s ease-in-out infinite`; + case 'water': + return css`${waterRipple} 1.7s ease-in-out infinite`; + case 'wind': + return css`${windFlow} 1.35s ease-in-out infinite`; + case 'earth': + return css`${earthGrow} 2.2s ease-in-out infinite`; + } +} + +const rasterImageStyle = (size: number, motion: RasterMotion) => css` + position: absolute; + inset: 0; + width: ${size}px; + height: ${size}px; + object-fit: contain; + transform-origin: 50% 62%; + animation: ${getRasterMotionStyle(motion)}; + will-change: transform, filter, opacity; +`; // ─── 색상 유틸 ─────────────────────────────────────────────── @@ -33,16 +140,44 @@ function rgbToHex(r: number, g: number, b: number): string { ); } -function lighten(hex: string, amt: number): string { - const [r, g, b] = hexToRgb(hex); - return rgbToHex(r + amt, g + amt, b + amt); -} - function darken(hex: string, amt: number): string { const [r, g, b] = hexToRgb(hex); return rgbToHex(r - amt, g - amt, b - amt); } +function mix(hexA: string, hexB: string, ratio: number): string { + const [ar, ag, ab] = hexToRgb(hexA); + const [br, bg, bb] = hexToRgb(hexB); + return rgbToHex( + ar + (br - ar) * ratio, + ag + (bg - ag) * ratio, + ab + (bb - ab) * ratio + ); +} + +function luminance(hex: string): number { + const [r, g, b] = hexToRgb(hex).map((v) => v / 255); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function readableBase(hex: string): string { + if (luminance(hex) > 0.82) return mix(hex, '#3182F6', 0.28); + if (luminance(hex) < 0.08) return mix(hex, '#7C4DFF', 0.32); + return hex; +} + +const TIER_FRAME: Record = { + 1: { stroke: '#94A3B8', accent: '#CBD5E1', aura: 0.08 }, + 2: { stroke: '#22C55E', accent: '#86EFAC', aura: 0.12, dash: '4 5' }, + 3: { stroke: '#3B82F6', accent: '#93C5FD', aura: 0.18, dash: '7 4' }, + 4: { stroke: '#A855F7', accent: '#D8B4FE', aura: 0.24 }, + 5: { stroke: '#F59E0B', accent: '#FDE68A', aura: 0.32 }, +}; + +function getTierFrame(tier: number) { + return TIER_FRAME[Math.min(5, Math.max(1, tier))] ?? TIER_FRAME[1]; +} + // ─── 파티클 위치 ────────────────────────────────────────────── interface ParticlePos { @@ -309,6 +444,141 @@ function renderBodyPattern( } } +function renderElementCrest( + particleType: ParticleType, + color: string, + accentColor: string, + cx: number, + cy: number, + tier: number +): ReactElement { + const crestY = cy + 5; + const opacity = tier >= 4 ? 0.28 : 0.22; + + switch (particleType) { + case 'flame': + return ( + + ); + case 'water': + return ( + + ); + case 'wind': + return ( + + + + + ); + case 'earth': + case 'leaf': + return ( + + + + + ); + case 'crystal': + return ( + + ); + case 'spark': + return ( + + ); + case 'smoke': + return ( + + + + + + ); + case 'rainbow': + return ( + + + + + + ); + case 'star': + default: + return ( + + ); + } +} + // ─── 눈 렌더링 ──────────────────────────────────────────────── function renderEyes( @@ -382,29 +652,36 @@ export function CharacterSprite({ size = 120, state = 'obtained', }: CharacterSpriteProps) { + const instanceId = useId().replace(/:/g, ''); const config = CHARACTER_VISUAL[elementId] ?? DEFAULT_VISUAL; const [bodyRx, bodyRy] = config.bodyShape ?? [33, 31]; + const frame = getTierFrame(tier); const colors = useMemo(() => { if (state === 'undiscovered') { return { - body1: '#C8C8C8', - body2: '#A0A0A0', - body3: '#808080', - outline: '#606060', - pattern: '#888888', - glow: '#AAAAAA', + body1: '#E5E7EB', + body2: '#B8C0CC', + body3: '#7C8798', + outline: '#667085', + pattern: '#98A2B3', + glow: '#CBD5E1', + crest: '#F8FAFC', + shine: '#FFFFFF', }; } const isLocked = state === 'locked'; - const base = isLocked ? '#9E9E9E' : elementColor; + const base = isLocked ? '#9E9E9E' : readableBase(elementColor); + const glowBase = isLocked ? '#CCCCCC' : readableBase(config.glowColor); return { - body1: lighten(base, 45), - body2: base, - body3: darken(base, 20), - outline: darken(base, 35), - pattern: darken(base, 15), - glow: isLocked ? '#CCCCCC' : config.glowColor, + body1: mix(base, '#FFFFFF', 0.58), + body2: mix(base, '#FFFFFF', 0.12), + body3: mix(base, '#111827', 0.22), + outline: mix(base, '#0F172A', 0.48), + pattern: mix(base, '#FFFFFF', 0.18), + glow: glowBase, + crest: mix(base, '#FFFFFF', 0.66), + shine: mix(base, '#FFFFFF', 0.82), }; }, [elementColor, state, config.glowColor]); @@ -414,9 +691,21 @@ export function CharacterSprite({ const showRing = showDetails && tier >= 4; const showRainbow = showDetails && tier >= 5; - const uid = `cs-${elementId}`; + const uid = `cs-${elementId}-${instanceId}`; const CX = 60; const CY = 64; + const coreScale = tier >= 5 ? 1.06 : tier >= 4 ? 1.03 : 1; + const rx = bodyRx * coreScale; + const ry = bodyRy * coreScale; + const rasterSprite = RASTER_SPRITES[elementId]; + + if (state === 'obtained' && rasterSprite) { + return ( + + ); + } return ( + + + + {/* 몸체 그라디언트 */} @@ -434,6 +727,12 @@ export function CharacterSprite({ + + + + + + {/* 글로우 필터 (T3+) */} {showGlow && ( @@ -465,16 +764,25 @@ export function CharacterSprite({ )} - {/* ① 배경 글로우 헤일로 (T3+) */} - {showGlow && ( + + + {/* ① 배경 오라 */} + {showDetails && ( = 5 ? 0.2 : tier >= 4 ? 0.15 : 0.1} - /> + opacity={frame.aura} + > + + )} {/* ② 에너지 링 (T4) */} @@ -482,22 +790,23 @@ export function CharacterSprite({ @@ -510,11 +819,11 @@ export function CharacterSprite({ = 4 ? 3 : tier >= 2 ? 2.4 : 1.8} + strokeDasharray={tier < 4 ? frame.dash : undefined} + opacity={state === 'locked' ? 0.52 : 0.9} + filter={`url(#${uid}-shadow)`} + /> + )} + + {/* ⑥ 몸체 */} - {/* ⑥ 몸체 패턴 */} + {/* ⑦ 속성 문양과 몸체 패턴 */} + {showDetails && + renderElementCrest(config.particleType, colors.pattern, colors.crest, CX, CY, tier)} {showDetails && renderBodyPattern(config.patternType, colors.pattern, CX, CY)} - {/* ⑦ 눈 */} + + + {/* ⑧ 눈 */} {state !== 'undiscovered' && renderEyes( showDetails ? config.eyeStyle : 'steady', CX, CY, - bodyRy, + ry, colors.outline, )} - {/* ⑧ 잠금 눈 가리개 */} + {/* ⑨ 잠금 눈 가리개 */} {state === 'locked' && ( )} - {/* ⑨ 미소 */} + {/* ⑩ 미소 */} {state !== 'undiscovered' && ( )} - {/* ⑩ 미발견 ? */} + {/* ⑪ 고티어 장식 */} + {showDetails && tier >= 4 && ( + + = 5 ? 3.2 : 2.4} fill={frame.accent} opacity="0.9" /> + = 5 ? 3.2 : 2.4} fill={frame.accent} opacity="0.9" /> + = 5 ? 3.5 : 2.6} fill={frame.accent} opacity="0.86" /> + + )} + + {/* ⑫ 미발견 ? */} {state === 'undiscovered' && ( = 3600) { @@ -33,7 +36,7 @@ const overlayStyle = css` display: flex; align-items: center; justify-content: center; - z-index: 1000; + z-index: 2000; padding: 20px; `; @@ -192,27 +195,88 @@ const claimButtonStyle = css` transparent ); animation: ${shimmer} 2.8s ease-in-out infinite; + pointer-events: none; } &:active { transform: scale(0.97); } + + &:disabled { + cursor: wait; + opacity: 0.72; + } +`; + +const doubleButtonStyle = css` + width: 100%; + padding: 13px; + background: #101828; + color: white; + border: none; + border-radius: 14px; + font-size: 14px; + font-weight: 800; + cursor: pointer; + margin-bottom: 10px; + + &:active { + transform: scale(0.97); + } + + &:disabled { + cursor: wait; + opacity: 0.72; + } `; export function OfflineRewardModal() { const pendingOfflineReward = useGameStore((s) => s.pendingOfflineReward); const claimOfflineReward = useGameStore((s) => s.claimOfflineReward); const set = useGameStore.setState; + const [isClaiming, setIsClaiming] = useState(false); if (!pendingOfflineReward) return null; - const handleClaim = () => { - trackGameEvent('offline_reward_claimed', { - offline_sec: pendingOfflineReward.offlineSec, - gold_reward: pendingOfflineReward.gold, - element_types_count: Object.values(pendingOfflineReward.elements).filter((c) => c > 0).length, - }); - claimOfflineReward(); + const trackClaim = (reward: OfflineReward, multiplier: number) => { + try { + trackGameEvent('offline_reward_claimed', { + offline_sec: reward.offlineSec, + gold_reward: reward.gold * multiplier, + multiplier, + element_types_count: Object.values(reward.elements).filter((c) => c > 0).length, + }); + } catch (error) { + console.warn('Failed to track offline reward claim', error); + } + }; + + const claimReward = (reward: OfflineReward, multiplier = 1) => { + claimOfflineReward(multiplier); + trackClaim(reward, multiplier); + }; + + const handleClaim = (multiplier = 1) => { + if (isClaiming) return; + setIsClaiming(true); + claimReward(pendingOfflineReward, multiplier); + }; + + const handleDoubleClaim = async () => { + if (isClaiming) return; + setIsClaiming(true); + + try { + const result = await showRewardedAd(); + if (result.rewarded) { + claimReward(pendingOfflineReward, 2); + return; + } + } catch (error) { + console.warn('Failed to show rewarded ad', error); + } + + setIsClaiming(false); }; const elementRewards = Object.entries(pendingOfflineReward.elements) @@ -260,8 +324,13 @@ export function OfflineRewardModal() {

아직 수집된 원소가 없습니다.

)} - + )} +
diff --git a/src/components/screens/ElementsScreen.tsx b/src/components/screens/ElementsScreen.tsx index 81f0fb0..4692afd 100644 --- a/src/components/screens/ElementsScreen.tsx +++ b/src/components/screens/ElementsScreen.tsx @@ -1,32 +1,30 @@ -import { css } from '@emotion/react'; +import { css, keyframes } from '@emotion/react'; import { memo, useEffect, useRef, useState } from 'react'; import { adaptive } from '../../styles/adaptive'; import { FloatingOverlay } from '../FloatingOverlay'; import { useFloatingItems } from '../../hooks/useFloatingItems'; import elementsData from '../../data/elements.json'; +import recipesData from '../../data/recipes.json'; import { CharacterSprite } from '../CharacterSprite'; import { + calcResonanceBonuses, + calcTierAutomationStatus, + countDiscoveredByTier, calcEffectiveIdleRate, calcEnhanceCost, calcSpawnRate, + DAILY_MISSIONS, ENHANCE_MAX_LEVEL, + getDailyMissionProgress, + getDiscoveredElementIds, isElementUnlocked, + TIER_AUTOMATION_GOALS, useGameStore, } from '../../store/useGameStore'; // 원소 상태 분류 type ElementState = 'obtained' | 'locked' | 'undiscovered'; -function getElementState( - elementId: string, - elements: Record -): ElementState { - const count = elements[elementId] ?? 0; - if (count > 0) return 'obtained'; - if (isElementUnlocked(elementId, elements)) return 'locked'; - return 'undiscovered'; -} - // 골드 금액 표시 포맷 function formatGold(amount: number): string { if (amount >= 1_000_000) return `${(amount / 1_000_000).toFixed(1)}M`; @@ -34,6 +32,27 @@ function formatGold(amount: number): string { return String(amount); } +function formatSeconds(seconds: number): string { + if (!Number.isFinite(seconds)) return '-'; + if (seconds < 1) return '<1초'; + if (seconds < 60) return `${Math.ceil(seconds)}초`; + return `${Math.ceil(seconds / 60)}분`; +} + +function formatBoostTime(ms: number): string { + return formatSeconds(Math.max(0, Math.ceil(ms / 1000))); +} + +function formatRemaining(ms: number): string { + const seconds = Math.max(0, Math.ceil(ms / 1000)); + if (seconds >= 3600) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return `${h}시간 ${m}분`; + } + return formatSeconds(seconds); +} + // ──────────────────────────────────────────────────────────── // Styles // ──────────────────────────────────────────────────────────── @@ -113,6 +132,483 @@ const progressFillStyle = (ratio: number) => css` transition: width 0.3s ease; `; +const dailyPanelStyle = css` + background: #ffffff; + border: 1px solid ${adaptive.grey200}; + border-radius: 16px; + padding: 14px; + margin-bottom: 16px; + box-shadow: 0 8px 20px rgba(49, 64, 84, 0.06); +`; + +const dailyHeaderStyle = css` + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 12px; +`; + +const dailyTitleStyle = css` + font-size: 14px; + font-weight: 900; + color: ${adaptive.grey900}; +`; + +const dailyStreakStyle = css` + border-radius: 999px; + background: #fff3cd; + color: #8a5a00; + padding: 5px 9px; + font-size: 11px; + font-weight: 900; + white-space: nowrap; +`; + +const dailyBonusRowStyle = css` + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: center; + background: linear-gradient(135deg, #f0f7ff, #f8fbff); + border-radius: 12px; + padding: 10px 12px; + margin-bottom: 10px; +`; + +const dailyBonusTextStyle = css` + min-width: 0; +`; + +const dailyBonusNameStyle = css` + font-size: 13px; + font-weight: 900; + color: ${adaptive.grey900}; +`; + +const dailyBonusDescStyle = css` + margin-top: 2px; + font-size: 11px; + font-weight: 700; + color: ${adaptive.grey500}; +`; + +const compactButtonStyle = (active: boolean) => css` + border: none; + border-radius: 10px; + background: ${active ? adaptive.blue500 : adaptive.grey200}; + color: ${active ? '#ffffff' : adaptive.grey500}; + padding: 8px 10px; + font-size: 12px; + font-weight: 900; + cursor: ${active ? 'pointer' : 'default'}; + white-space: nowrap; +`; + +const missionListStyle = css` + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + + @media (max-width: 520px) { + grid-template-columns: 1fr; + } +`; + +const missionCardStyle = (done: boolean, claimed: boolean) => css` + border-radius: 12px; + border: 1px solid ${claimed ? '#8bd5a2' : done ? adaptive.blue500 : adaptive.grey200}; + background: ${claimed ? '#f0fff4' : done ? '#f0f7ff' : adaptive.greyBackground}; + padding: 10px; +`; + +const missionNameStyle = css` + font-size: 12px; + font-weight: 900; + color: ${adaptive.grey900}; + margin-bottom: 3px; +`; + +const missionDescStyle = css` + font-size: 10px; + line-height: 1.35; + font-weight: 700; + color: ${adaptive.grey500}; +`; + +const missionProgressStyle = css` + height: 5px; + border-radius: 999px; + background: ${adaptive.grey200}; + overflow: hidden; + margin: 8px 0 7px; +`; + +const missionProgressFillStyle = (ratio: number) => css` + height: 100%; + width: ${(Math.min(1, Math.max(0, ratio)) * 100).toFixed(1)}%; + border-radius: 999px; + background: linear-gradient(90deg, #3182f6, #22c55e); +`; + +const missionFooterStyle = css` + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + font-size: 10px; + font-weight: 900; + color: #8a5a00; +`; + +const stormPanelStyle = (active: boolean) => css` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-radius: 16px; + padding: 12px 14px; + margin-bottom: 16px; + color: #ffffff; + background: ${active + ? 'linear-gradient(135deg, #0f172a, #1d4ed8)' + : 'linear-gradient(135deg, #334155, #64748b)'}; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16); +`; + +const stormTitleStyle = css` + font-size: 13px; + font-weight: 900; +`; + +const stormDescStyle = css` + margin-top: 3px; + font-size: 11px; + font-weight: 700; + color: rgba(255, 255, 255, 0.72); +`; + +const stormBadgeStyle = css` + flex-shrink: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.16); + padding: 7px 10px; + font-size: 11px; + font-weight: 900; + white-space: nowrap; +`; + +const tierRewardPanelStyle = css` + background: #ffffff; + border: 1px solid ${adaptive.grey200}; + border-radius: 14px; + padding: 12px; + margin-bottom: 16px; + box-shadow: 0 8px 20px rgba(49, 64, 84, 0.06); +`; + +const tierRewardHeaderStyle = css` + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin-bottom: 10px; +`; + +const tierRewardTitleStyle = css` + font-size: 13px; + font-weight: 800; + color: ${adaptive.grey900}; +`; + +const tierRewardBadgeStyle = css` + flex-shrink: 0; + border-radius: 999px; + background: ${adaptive.blue500}; + color: #ffffff; + padding: 5px 9px; + font-size: 11px; + font-weight: 800; +`; + +const tierRewardTrackStyle = css` + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + + @media (max-width: 420px) { + grid-template-columns: repeat(2, 1fr); + } +`; + +const tierRewardStepStyle = (unlocked: boolean) => css` + border-radius: 10px; + padding: 9px; + background: ${unlocked ? '#eef8f1' : adaptive.greyBackground}; + border: 1px solid ${unlocked ? '#67c587' : adaptive.grey200}; +`; + +const tierRewardStepNameStyle = css` + font-size: 11px; + font-weight: 900; + color: ${adaptive.grey900}; + margin-bottom: 5px; +`; + +const tierRewardStepTextStyle = css` + font-size: 10px; + line-height: 1.35; + font-weight: 700; + color: ${adaptive.grey500}; +`; + +const tierRewardProgressStyle = css` + height: 5px; + border-radius: 999px; + background: ${adaptive.grey200}; + overflow: hidden; + margin-top: 7px; +`; + +const tierRewardProgressFillStyle = (ratio: number, unlocked: boolean) => css` + height: 100%; + width: ${(Math.min(1, Math.max(0, ratio)) * 100).toFixed(1)}%; + border-radius: 999px; + background: ${unlocked ? '#22c55e' : adaptive.blue500}; +`; + +const scenePulse = keyframes` + 0%, 100% { transform: scale(1); opacity: 0.75; } + 50% { transform: scale(1.08); opacity: 1; } +`; + +const driftMote = keyframes` + 0% { transform: translateY(18px) scale(0.7); opacity: 0; } + 20% { opacity: 0.85; } + 100% { transform: translateY(-84px) scale(1); opacity: 0; } +`; + +const laneFlow = keyframes` + 0% { background-position: 0 0; } + 100% { background-position: 44px 0; } +`; + +const sceneStyle = css` + position: relative; + overflow: hidden; + background: + radial-gradient(circle at 18% 22%, rgba(255, 176, 64, 0.18), transparent 26%), + radial-gradient(circle at 82% 18%, rgba(49, 130, 246, 0.16), transparent 26%), + linear-gradient(135deg, #ffffff, #f3f7fb 62%, #fff8ed); + border: 1px solid ${adaptive.grey200}; + border-radius: 16px; + padding: 16px; + margin-bottom: 16px; + box-shadow: 0 12px 28px rgba(49, 64, 84, 0.08); +`; + +const sceneHeaderStyle = css` + position: relative; + z-index: 1; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 14px; +`; + +const sceneTitleStyle = css` + font-size: 15px; + font-weight: 800; + color: ${adaptive.grey900}; +`; + +const sceneSubtitleStyle = css` + margin-top: 3px; + font-size: 11px; + font-weight: 600; + color: ${adaptive.grey500}; +`; + +const sceneStatusStyle = css` + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + border-radius: 999px; + background: rgba(25, 25, 25, 0.72); + color: #ffffff; + padding: 6px 10px; + font-size: 11px; + font-weight: 700; +`; + +const statusDotStyle = css` + width: 7px; + height: 7px; + border-radius: 50%; + background: #30d158; + box-shadow: 0 0 0 4px rgba(48, 209, 88, 0.18); + animation: ${scenePulse} 1.4s ease-in-out infinite; +`; + +const sceneBodyStyle = css` + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) 116px; + gap: 12px; + + @media (max-width: 420px) { + grid-template-columns: 1fr; + } +`; + +const producerGridStyle = css` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +`; + +const producerLaneStyle = (color: string, active: boolean, ready = false) => css` + position: relative; + overflow: hidden; + min-height: 98px; + background: rgba(255, 255, 255, 0.76); + border: 1px solid ${ready ? '#22c55e' : active ? color : adaptive.grey200}; + border-radius: 12px; + padding: 10px; + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 8px; + align-items: center; + cursor: ${ready ? 'pointer' : 'default'}; + box-shadow: ${ready ? '0 0 0 3px rgba(34, 197, 94, 0.14)' : 'none'}; + + &::after { + content: ''; + position: absolute; + left: 8px; + right: 8px; + bottom: 8px; + height: 4px; + border-radius: 999px; + background-image: linear-gradient( + 90deg, + ${color} 0 18px, + rgba(255, 255, 255, 0.25) 18px 28px + ); + background-size: 44px 4px; + opacity: ${ready ? 0.85 : active ? 0.55 : 0.2}; + animation: ${laneFlow} 1.1s linear infinite; + } + + &:active { + transform: ${ready ? 'scale(0.98)' : 'none'}; + } +`; + +const producerSpriteStyle = (active: boolean) => css` + width: 42px; + height: 42px; + filter: drop-shadow(0 5px 8px rgba(0, 0, 0, 0.1)); + animation: ${active ? `${scenePulse} 1.8s ease-in-out infinite` : 'none'}; +`; + +const producerInfoStyle = css` + min-width: 0; +`; + +const producerNameStyle = css` + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + font-size: 12px; + font-weight: 800; + color: ${adaptive.grey900}; +`; + +const producerRateStyle = css` + margin-top: 3px; + font-size: 10px; + font-weight: 700; + color: ${adaptive.grey500}; +`; + +const producerTrackStyle = css` + height: 6px; + background: ${adaptive.grey200}; + border-radius: 999px; + overflow: hidden; + margin-top: 8px; +`; + +const producerFillStyle = (ratio: number, color: string) => css` + height: 100%; + width: ${(Math.min(1, Math.max(0, ratio)) * 100).toFixed(1)}%; + background: ${color}; + border-radius: 999px; + transition: width 0.25s ease; +`; + +const producerNextStyle = css` + margin-top: 5px; + font-size: 10px; + font-weight: 700; + color: ${adaptive.blue500}; +`; + +const activityPanelStyle = css` + min-height: 100%; + background: rgba(255, 255, 255, 0.68); + border: 1px solid rgba(255, 255, 255, 0.78); + border-radius: 12px; + padding: 10px; +`; + +const activityTitleStyle = css` + font-size: 11px; + font-weight: 800; + color: ${adaptive.grey700}; + margin-bottom: 8px; +`; + +const activityListStyle = css` + display: flex; + flex-direction: column; + gap: 6px; +`; + +const activityItemStyle = css` + font-size: 10px; + line-height: 1.3; + font-weight: 700; + color: ${adaptive.grey700}; + background: rgba(255, 255, 255, 0.72); + border-radius: 8px; + padding: 6px 7px; +`; + +const activityEmptyStyle = css` + font-size: 10px; + line-height: 1.4; + color: ${adaptive.grey400}; +`; + +const moteStyle = (left: number, delay: number, color: string) => css` + position: absolute; + left: ${left}%; + bottom: 4px; + width: 7px; + height: 7px; + border-radius: 50%; + background: ${color}; + opacity: 0; + filter: blur(0.1px); + animation: ${driftMote} 3.2s ${delay}s ease-in-out infinite; +`; + const idleInfoStyle = css` background: ${adaptive.greyBackground}; border-radius: 12px; @@ -139,6 +635,68 @@ const idleValueStyle = css` color: ${adaptive.blue500}; `; +const boostPanelStyle = css` + background: #0f172a; + border-radius: 14px; + padding: 12px; + margin-bottom: 20px; + color: #ffffff; +`; + +const boostPanelHeaderStyle = css` + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +`; + +const boostPanelTitleStyle = css` + font-size: 13px; + font-weight: 900; +`; + +const boostPanelCountStyle = css` + font-size: 11px; + font-weight: 800; + color: rgba(255, 255, 255, 0.62); +`; + +const boostListStyle = css` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 8px; +`; + +const boostChipStyle = css` + display: flex; + flex-direction: column; + gap: 4px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 9px 10px; +`; + +const boostChipNameStyle = css` + font-size: 12px; + line-height: 1.25; + font-weight: 900; +`; + +const boostChipDescStyle = css` + font-size: 10px; + line-height: 1.35; + font-weight: 700; + color: rgba(255, 255, 255, 0.62); +`; + +const boostEmptyStyle = css` + color: rgba(255, 255, 255, 0.56); + font-size: 12px; + font-weight: 700; +`; + const tierSectionStyle = css` margin-bottom: 20px; `; @@ -148,30 +706,36 @@ const tierLabelStyle = css` font-weight: 600; color: ${adaptive.grey500}; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0; margin-bottom: 10px; `; const elementGridStyle = css` display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 10px; + grid-template-columns: repeat(auto-fill, minmax(74px, 1fr)); + gap: 12px; `; const elementCardBaseStyle = ` display: flex; flex-direction: column; align-items: center; - border-radius: 14px; - padding: 12px 6px 10px; + border-radius: 999px; + padding: 10px 6px 12px; gap: 5px; position: relative; `; const obtainedCardStyle = css` ${elementCardBaseStyle} - background: ${adaptive.background}; - border: 1.5px solid ${adaptive.grey200}; + min-height: 104px; + justify-content: center; + background: + radial-gradient(circle at 50% 34%, rgba(255, 255, 255, 0.95), rgba(246, 248, 251, 0.82) 58%, rgba(232, 238, 247, 0.88)); + border: 1px solid rgba(49, 130, 246, 0.18); + box-shadow: + inset 0 1px 8px rgba(255, 255, 255, 0.9), + 0 8px 18px rgba(49, 64, 84, 0.08); cursor: pointer; &:active { transform: scale(0.96); @@ -197,8 +761,8 @@ const undiscoveredCardStyle = css` `; const spriteWrapStyle = css` - width: 56px; - height: 56px; + width: 52px; + height: 52px; flex-shrink: 0; `; @@ -234,6 +798,113 @@ const spawnRateStyle = css` color: ${adaptive.grey400}; `; +const discoveryPanelStyle = css` + background: #101828; + border-radius: 16px; + padding: 14px; + color: #ffffff; + margin-bottom: 20px; +`; + +const discoveryHeaderStyle = css` + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 10px; +`; + +const discoveryTitleStyle = css` + font-size: 14px; + font-weight: 800; +`; + +const discoveryCountStyle = css` + font-size: 11px; + font-weight: 700; + color: rgba(255, 255, 255, 0.65); +`; + +const recipeTargetGridStyle = css` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const recipeTargetStyle = css` + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 10px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 10px 12px; +`; + +const recipeFormulaStyle = css` + min-width: 0; + display: flex; + align-items: center; + gap: 7px; + font-size: 13px; + font-weight: 800; +`; + +const recipeHintStyle = css` + margin-top: 3px; + color: rgba(255, 255, 255, 0.62); + font-size: 10px; + font-weight: 600; +`; + +const recipeRewardStyle = css` + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + font-weight: 800; + color: #ffd166; +`; + +const collectionHeaderStyle = css` + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 12px; + margin-bottom: 12px; +`; + +const collectionTitleStyle = css` + font-size: 15px; + font-weight: 800; + color: ${adaptive.grey900}; +`; + +const resonanceBadgeStyle = css` + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + color: ${adaptive.grey500}; + font-size: 10px; + font-weight: 700; +`; + +const resonanceValueStyle = css` + color: ${adaptive.blue500}; + font-size: 12px; + font-weight: 900; +`; + +const emptyTargetStyle = css` + color: rgba(255, 255, 255, 0.62); + font-size: 12px; + line-height: 1.5; + font-weight: 600; +`; + // ──────────────────────────────────────────────────────────── // 상세 정보 패널 // ──────────────────────────────────────────────────────────── @@ -363,6 +1034,54 @@ const detailCountBadgeStyle = css` color: ${adaptive.blue500}; `; +const loreBoxStyle = css` + background: #111827; + color: #ffffff; + border-radius: 14px; + padding: 12px 14px; + margin-bottom: 12px; +`; + +const loreTitleStyle = css` + font-size: 12px; + font-weight: 900; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 5px; +`; + +const loreTextStyle = css` + font-size: 13px; + line-height: 1.5; + font-weight: 700; +`; + +const genealogyStyle = css` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 12px; +`; + +const genealogyCardStyle = css` + background: ${adaptive.greyBackground}; + border-radius: 12px; + padding: 10px 12px; +`; + +const genealogyTitleStyle = css` + font-size: 11px; + font-weight: 900; + color: ${adaptive.grey500}; + margin-bottom: 6px; +`; + +const genealogyTextStyle = css` + font-size: 12px; + line-height: 1.45; + font-weight: 800; + color: ${adaptive.grey900}; +`; + // ──────────────────────────────────────────────────────────── // Tier labels // ──────────────────────────────────────────────────────────── @@ -381,6 +1100,38 @@ const TIER_LABELS: Record = { type ElementData = (typeof elementsData)[number]; +interface ActivityEntry { + id: number; + text: string; +} + +interface ActiveBoostSummary { + id: string; + name: string; + desc: string; + remainingMs: number; +} + +function getBoostSummary(boostId: string, remainingMs: number): ActiveBoostSummary | null { + if (boostId === 'global_spawn_boost') { + return { id: boostId, name: '전체 생산 x2', desc: '모든 자동 생산 속도 적용 중', remainingMs }; + } + if (boostId === 'gold_boost') { + return { id: boostId, name: '골드 수입 x2', desc: '모든 골드 수입 적용 중', remainingMs }; + } + if (boostId.startsWith('tier_') && boostId.endsWith('_boost')) { + const tier = boostId.replace('tier_', '').replace('_boost', ''); + return { id: boostId, name: `Tier ${tier} 생산 x2`, desc: `Tier ${tier} 원소 생산 속도 적용 중`, remainingMs }; + } + if (boostId.endsWith('_boost')) { + const elementId = boostId.replace('_boost', ''); + const el = elementsData.find((item) => item.id === elementId); + if (!el) return null; + return { id: boostId, name: `${el.name} 생산 x2`, desc: `${el.emoji} ${el.name} 원소 생산 속도 적용 중`, remainingMs }; + } + return null; +} + interface DetailPanelProps { el: ElementData; count: number; @@ -393,6 +1144,20 @@ function DetailPanel({ el, count, level, onClose }: DetailPanelProps) { const idleRate = calcEffectiveIdleRate(el.id, level); const isMaxLevel = level >= ENHANCE_MAX_LEVEL; const nextCost = isMaxLevel ? null : calcEnhanceCost(level); + const parentRecipe = recipesData.find((recipe) => recipe.result === el.id); + const childRecipes = recipesData.filter((recipe) => recipe.ingredients.includes(el.id)); + const parentText = parentRecipe + ? parentRecipe.ingredients + .map((id) => elementsData.find((item) => item.id === id)?.name ?? id) + .join(' + ') + : '태초 원소'; + const childText = + childRecipes.length > 0 + ? childRecipes + .slice(0, 3) + .map((recipe) => elementsData.find((item) => item.id === recipe.result)?.name ?? recipe.result) + .join(' · ') + : '최종 계열'; return (
@@ -418,6 +1183,22 @@ function DetailPanel({ el, count, level, onClose }: DetailPanelProps) { ×{count.toLocaleString()}
+
+
도감 기록
+
{el.description}
+
+ +
+
+
탄생 계보
+
{parentText}
+
+
+
다음 가능성
+
{childText}
+
+
+
생산 속도
@@ -469,10 +1250,19 @@ interface ElementCardProps { count: number; level: number; onSelect: (el: ElementData) => void; + isAutomated?: boolean; isTutorialTarget?: boolean; } -const ElementCard = memo(function ElementCard({ el, state, count, level, onSelect, isTutorialTarget }: ElementCardProps) { +const ElementCard = memo(function ElementCard({ + el, + state, + count, + level, + onSelect, + isAutomated = true, + isTutorialTarget, +}: ElementCardProps) { const spawnRate = calcSpawnRate(el.id, level); if (state === 'undiscovered') { @@ -509,7 +1299,7 @@ const ElementCard = memo(function ElementCard({ el, state, count, level, onSelec
{el.name} ×{count.toLocaleString()} - +{spawnRate.toFixed(2)}/s + {isAutomated ? `+${spawnRate.toFixed(2)}/s` : '티어 대기'}
); }); @@ -522,18 +1312,33 @@ export function ElementsScreen() { // 전체 store 구독 대신 필요한 slice만 selector로 구독 (불필요한 리렌더 방지) const gold = useGameStore((s) => s.gold); const elements = useGameStore((s) => s.elements); + const discoveredElements = useGameStore((s) => s.discoveredElements); const elementLevels = useGameStore((s) => s.elementLevels); + const spawnAccumulators = useGameStore((s) => s.spawnAccumulators); + const activeBoosts = useGameStore((s) => s.activeBoosts); + const prestigeGoldMultiplier = useGameStore((s) => s.prestigeGoldMultiplier); + const prestigeSpawnMultiplier = useGameStore((s) => s.prestigeSpawnMultiplier); + const permanentGoldMultiplier = useGameStore((s) => s.permanentGoldMultiplier); + const permanentSpawnMultiplier = useGameStore((s) => s.permanentSpawnMultiplier); + const dailyProgress = useGameStore((s) => s.dailyProgress); + const activeStorm = useGameStore((s) => s.activeStorm); + const claimDailyBonus = useGameStore((s) => s.claimDailyBonus); + const claimDailyMission = useGameStore((s) => s.claimDailyMission); + const harvestElement = useGameStore((s) => s.harvestElement); const [selectedEl, setSelectedEl] = useState(null); + const [activityLog, setActivityLog] = useState([]); const { items: floatItems, add: addFloat } = useFloatingItems(1100); const prevElements = useRef>({}); const prevGold = useRef(-1); const floatCount = useRef(0); + const activitySeq = useRef(0); // 자동 수집 플로팅 이펙트: 원소 수 또는 골드 증가 감지 useEffect(() => { const W = window.innerWidth; const H = window.innerHeight; let showed = 0; + const newActivities: ActivityEntry[] = []; // 원소 증가 감지 (idle tick에서 발생) for (const el of elementsData) { @@ -551,6 +1356,10 @@ export function ElementsScreen() { }); showed++; } + newActivities.push({ + id: activitySeq.current++, + text: `${el.emoji} ${el.name} +${curr - prev}`, + }); } } prevElements.current = { ...elements }; @@ -569,39 +1378,107 @@ export function ElementsScreen() { fontSize: 13, }); } + newActivities.push({ + id: activitySeq.current++, + text: `💰 골드 +${formatGold(gained)}`, + }); } prevGold.current = gold; + + if (newActivities.length > 0) { + setActivityLog((prev) => [...newActivities.reverse(), ...prev].slice(0, 5)); + } }, [elements, gold]); // eslint-disable-line react-hooks/exhaustive-deps const totalElements = elementsData.length; + const discoveredElementIds = getDiscoveredElementIds(discoveredElements, elements); + const tierAutomation = calcTierAutomationStatus(discoveredElementIds); - const obtainedElements = elementsData.filter( - (el) => (elements[el.id] ?? 0) > 0 + const discoveredElementList = elementsData.filter((el) => + discoveredElementIds.includes(el.id) ); - const unlockedElements = elementsData.filter((el) => - isElementUnlocked(el.id, elements) + const autoProducerElements = discoveredElementList.filter( + (el) => el.tier <= tierAutomation.unlockedTier ); - const totalSpawnPerSec = obtainedElements.reduce( - (sum, el) => sum + calcSpawnRate(el.id, elementLevels[el.id] ?? 0), + const craftableRecipes = recipesData.filter( + (recipe) => !discoveredElementIds.includes(recipe.result) && isElementUnlocked(recipe.result, elements) + ); + + const resonance = calcResonanceBonuses(elements); + const now = Date.now(); + const effectiveSpawnRate = (el: ElementData) => { + const boostId = el.id + '_boost'; + const boostMultiplier = (activeBoosts[boostId] ?? 0) > now ? 2.0 : 1.0; + const globalSpawnMultiplier = (activeBoosts.global_spawn_boost ?? 0) > now ? 2.0 : 1.0; + const tierBoostMultiplier = (activeBoosts[`tier_${el.tier}_boost`] ?? 0) > now ? 2.0 : 1.0; + const stormMultiplier = + activeStorm && activeStorm.expiresAt > now && activeStorm.elementId === el.id ? 5.0 : 1.0; + return ( + calcSpawnRate(el.id, elementLevels[el.id] ?? 0) * + boostMultiplier * + globalSpawnMultiplier * + tierBoostMultiplier * + stormMultiplier * + prestigeSpawnMultiplier * + permanentSpawnMultiplier * + resonance.spawnMultiplier + ); + }; + const goldBoostMultiplier = (activeBoosts.gold_boost ?? 0) > now ? 2.0 : 1.0; + const effectiveGoldRate = (el: ElementData) => + calcEffectiveIdleRate(el.id, elementLevels[el.id] ?? 0) * + goldBoostMultiplier * + prestigeGoldMultiplier * + permanentGoldMultiplier * + resonance.goldMultiplier; + + const totalSpawnPerSec = autoProducerElements.reduce( + (sum, el) => sum + effectiveSpawnRate(el), 0 ); - const totalGoldPerSec = obtainedElements.reduce( - (sum, el) => sum + calcEffectiveIdleRate(el.id, elementLevels[el.id] ?? 0), + const totalGoldPerSec = autoProducerElements.reduce( + (sum, el) => sum + effectiveGoldRate(el), 0 ); + const activeProducers = autoProducerElements + .slice() + .sort((a, b) => { + const countDiff = (elements[b.id] ?? 0) - (elements[a.id] ?? 0); + if (countDiff !== 0) return countDiff; + return b.tier - a.tier; + }) + .slice(0, 4); + + const sceneStatus = ['채집 중', '응축 중', '정제 중'][Math.floor(Date.now() / 1000) % 3]; + const moteColors = activeProducers.length > 0 + ? activeProducers.map((el) => el.color) + : ['#3182F6', '#FFB020', '#00C853']; + const activeBoostSummaries = Object.entries(activeBoosts) + .map(([boostId, expiresAt]) => getBoostSummary(boostId, expiresAt - now)) + .filter((summary): summary is ActiveBoostSummary => summary !== null && summary.remainingMs > 0) + .sort((a, b) => a.remainingMs - b.remainingMs); + const tierGroups = [1, 2, 3, 4, 5].map((tier) => ({ tier, - items: elementsData.filter((el) => el.tier === tier), - })); + items: discoveredElementList.filter((el) => el.tier === tier), + })).filter(({ items }) => items.length > 0); // 첫 번째 obtained 원소 ID (튜토리얼 스포트라이트용) - const firstObtainedId = obtainedElements[0]?.id ?? null; + const firstObtainedId = discoveredElementList[0]?.id ?? null; - const unlockRatio = obtainedElements.length / totalElements; + const unlockRatio = discoveredElementList.length / totalElements; + const dailyBonusGold = 100 + Math.min(6, dailyProgress.streak) * 25; + const stormElement = activeStorm + ? elementsData.find((el) => el.id === activeStorm.elementId) + : null; + const stormActive = Boolean(activeStorm && activeStorm.expiresAt > now); + const stormRemainingMs = activeStorm + ? (stormActive ? activeStorm.expiresAt - now : activeStorm.nextAt - now) + : 0; return (
@@ -615,7 +1492,7 @@ export function ElementsScreen() {
원소 해금 진행 - {obtainedElements.length} / {totalElements} + {discoveredElementList.length} / {totalElements}
@@ -623,6 +1500,200 @@ export function ElementsScreen() {
+
+
+
오늘의 창조 루틴
+
연속 {dailyProgress.streak}일
+
+
+
+
데일리 보너스
+
+ 오늘 접속 보상 💰{dailyBonusGold.toLocaleString()}G +
+
+ +
+
+ {DAILY_MISSIONS.map((mission) => { + const current = getDailyMissionProgress(mission, dailyProgress); + const done = current >= mission.target; + const claimed = dailyProgress.claimedMissionIds.includes(mission.id); + return ( +
+
{mission.title}
+
{mission.description}
+
+
+
+
+ + {Math.min(current, mission.target).toLocaleString()}/{mission.target.toLocaleString()} + {' · '} + 💰{mission.rewardGold} + + +
+
+ ); + })} +
+
+ + {activeStorm && stormElement && ( +
+
+
+ {stormActive ? 'Element Storm 진행 중' : '다음 Element Storm'} +
+
+ {stormElement.emoji} {stormElement.name} + {stormActive ? ' 생산 속도 x5' : ' 폭풍 예보'} +
+
+
+ {stormActive ? '남은 시간 ' : '시작까지 '} + {formatRemaining(stormRemainingMs)} +
+
+ )} + +
+
+
자동 생산 티어 보상
+
T{tierAutomation.unlockedTier} 생산 중
+
+
+ {TIER_AUTOMATION_GOALS.map((goal) => { + const current = countDiscoveredByTier(discoveredElementIds, goal.requiredTier); + const unlocked = tierAutomation.unlockedTier >= goal.tier; + const ratio = current / goal.requiredCount; + + return ( +
+
+ {unlocked ? '해금됨' : `목표 T${goal.tier}`} +
+
+ T{goal.requiredTier} 발견 {Math.min(current, goal.requiredCount)}/{goal.requiredCount} + {' · '} + 보상 {goal.rewardGold}G +
+
+
+
+
+ ); + })} +
+
+ +
+ {moteColors.slice(0, 4).map((color, i) => ( + + ))} +
+
+
원소 작업장
+
+ T{tierAutomation.unlockedTier} 이하 발견 원소 {autoProducerElements.length}종이 생성됩니다 +
+
+
+ + {sceneStatus} +
+
+ +
+
+ {activeProducers.map((el) => { + const rate = effectiveSpawnRate(el); + const progress = spawnAccumulators[el.id] ?? 0; + const nextSec = rate > 0 ? (1 - progress) / rate : Infinity; + const active = rate > 0; + const harvestReady = active && progress >= 0.75; + + return ( +
{ + if (!harvestReady) return; + const result = harvestElement(el.id); + if (!result.success) return; + addFloat({ + text: `${el.emoji} +${result.count} 수확`, + x: window.innerWidth * 0.5, + y: window.innerHeight * 0.28, + color: el.color, + fontSize: 16, + }); + setActivityLog((prev) => [ + { + id: activitySeq.current++, + text: `${el.emoji} ${el.name} 직접 수확 +${result.count}`, + }, + ...prev, + ].slice(0, 5)); + }} + > +
+ 0 ? 'obtained' : 'locked'} + /> +
+
+
+ {el.name} + ×{formatGold(elements[el.id] ?? 0)} +
+
+{rate.toFixed(2)}/s
+
+
+
+
+ {harvestReady ? '탭해서 보너스 수확' : `다음 생성 ${formatSeconds(nextSec)}`} +
+
+
+ ); + })} +
+ + +
+
+ {/* 방치 수입 요약 */}
@@ -635,11 +1706,82 @@ export function ElementsScreen() {
발견 가능 - {unlockedElements.length}종 + {craftableRecipes.length}종
- {/* 티어별 원소 그리드 */} +
+
+
상점 효과 적용 현황
+
{activeBoostSummaries.length}개 활성
+
+ {activeBoostSummaries.length > 0 ? ( +
+ {activeBoostSummaries.map((boost) => ( +
+
+ {boost.name} · {formatBoostTime(boost.remainingMs)} +
+
{boost.desc}
+
+ ))} +
+ ) : ( +
상점에서 구매한 시간제 효과가 여기에 표시됩니다.
+ )} +
+ +
+
+
다음 발견 목표
+
{craftableRecipes.length}개 조합 가능
+
+ {craftableRecipes.length > 0 ? ( +
+ {craftableRecipes.slice(0, 4).map((recipe) => { + const left = elementsData.find((el) => el.id === recipe.ingredients[0]); + const right = elementsData.find((el) => el.id === recipe.ingredients[1]); + const result = elementsData.find((el) => el.id === recipe.result); + if (!left || !right || !result) return null; + + return ( +
+
+
+ {left.emoji} + + + {right.emoji} + + {result.emoji} + {result.name} +
+
+ 합성하면 작업장에 새 생산원이 추가됩니다 +
+
+
+{recipe.tier * 10}G
+
+ ); + })} +
+ ) : ( +
+ 보유 원소를 더 모으거나 합성 화면에서 새 조합을 찾아보세요. +
+ )} +
+ +
+
보유 원소 궤도
+
+ 상위 원소 공명 + + 생산 x{resonance.spawnMultiplier.toFixed(2)} · 골드 x{resonance.goldMultiplier.toFixed(2)} + +
+
+ + {/* 티어별 보유 원소 */} {tierGroups.map(({ tier, items }) => (
{TIER_LABELS[tier]}
@@ -648,10 +1790,11 @@ export function ElementsScreen() { ))} diff --git a/src/components/screens/EvolutionScreen.tsx b/src/components/screens/EvolutionScreen.tsx index 086d095..afed9f2 100644 --- a/src/components/screens/EvolutionScreen.tsx +++ b/src/components/screens/EvolutionScreen.tsx @@ -9,6 +9,7 @@ import { } from '../../store/useGameStore'; import { FloatingOverlay } from '../FloatingOverlay'; import { trackGameEvent } from '../../platform/analytics'; +import { showRewardedAd } from '../../platform/ads'; import { useFloatingItems } from '../../hooks/useFloatingItems'; const containerStyle = css` @@ -144,6 +145,27 @@ const enhanceButtonStyle = (canEnhance: boolean) => css` } `; +const fundingButtonStyle = (enabled: boolean) => css` + width: 100%; + padding: 9px; + margin-top: 8px; + background: ${enabled ? '#101828' : '#f0f0f0'}; + color: ${enabled ? '#ffffff' : '#a0a0a0'}; + border: none; + border-radius: 10px; + font-size: 12px; + font-weight: 800; + cursor: ${enabled ? 'pointer' : 'default'}; +`; + +const fundingLimitStyle = css` + margin-top: 6px; + font-size: 10px; + font-weight: 700; + color: #8b8b8b; + text-align: center; +`; + const emptyStyle = css` text-align: center; padding: 60px 20px; @@ -153,8 +175,16 @@ const emptyStyle = css` const LEVEL_LABELS = ['기본', '강화 I', '강화 II', '강화 III', '강화 IV', '강화 V (최대)']; export function EvolutionScreen() { - const { gold, elements, elementLevels, enhance } = useGameStore(); + const { + gold, + elements, + elementLevels, + enhance, + adRewardProgress, + claimAdGoldFunding, + } = useGameStore(); const [flashedId, setFlashedId] = useState(null); + const [fundingId, setFundingId] = useState(null); const { items: floatItems, add: addFloat } = useFloatingItems(1000); const ownedElements = elementsData.filter((el) => (elements[el.id] ?? 0) > 0); @@ -191,6 +221,33 @@ export function EvolutionScreen() { } }; + const handleGoldFunding = async (elementId: string, cost: number) => { + if (fundingId || adRewardProgress.goldFundingUses >= 5) return; + const missingGold = Math.max(0, cost - useGameStore.getState().gold); + if (missingGold <= 0) return; + + setFundingId(elementId); + try { + const adResult = await showRewardedAd(); + if (adResult.rewarded && claimAdGoldFunding(missingGold)) { + addFloat({ + text: `+${missingGold} 💰`, + x: window.innerWidth / 2 - 28, + y: window.innerHeight * 0.38, + color: '#F7C12A', + fontSize: 15, + }); + trackGameEvent('rewarded_ad_claimed', { + placement: 'enhance_gold_funding', + reward_gold: missingGold, + element_id: elementId, + }); + } + } finally { + setFundingId(null); + } + }; + if (ownedElements.length === 0) { return (
@@ -260,6 +317,24 @@ export function EvolutionScreen() { ? `🔒 골드 부족 (${cost - gold}G 필요)` : `🔒 강화 불가`} + {!isMax && !canEnhance && gold < cost && ( + <> + +
+ 오늘 골드 보충 {adRewardProgress.goldFundingUses}/5 +
+ + )}
); diff --git a/src/components/screens/FusionScreen.tsx b/src/components/screens/FusionScreen.tsx index 6bc255e..114bd2b 100644 --- a/src/components/screens/FusionScreen.tsx +++ b/src/components/screens/FusionScreen.tsx @@ -6,6 +6,9 @@ import { useGameStore } from '../../store/useGameStore'; import { FloatingOverlay } from '../FloatingOverlay'; import { useFloatingItems } from '../../hooks/useFloatingItems'; import { trackGameEvent } from '../../platform/analytics'; +import { showRewardedAd } from '../../platform/ads'; +import { DiscoveryHero } from '../DiscoveryHero'; +import { playRaritySfx, vibrate } from '../../lib/sfx'; // TDS 색상 팔레트 const tds = { @@ -189,6 +192,23 @@ const previewBannerStyle = css` color: ${tds.blue}; `; +const comboBarStyle = (active: boolean) => css` + background: ${active ? 'linear-gradient(135deg, #101828, #27364f)' : tds.gray100}; + color: ${active ? '#ffffff' : tds.gray500}; + border-radius: 12px; + padding: 10px 12px; + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + font-weight: 800; +`; + +const comboValueStyle = css` + color: #ffd166; +`; + const fuseButtonStyle = (canFuse: boolean) => css` width: 100%; padding: 14px; @@ -271,6 +291,30 @@ const errorBannerStyle = css` margin-bottom: 10px; `; +const assistBannerStyle = css` + background: ${tds.goldLight}; + border: 1px solid ${tds.gold}; + border-radius: 10px; + padding: 10px 12px; + font-size: 12px; + color: ${tds.gray900}; + font-weight: 800; + text-align: center; + margin-bottom: 10px; +`; + +const assistButtonStyle = (enabled: boolean) => css` + margin-top: 8px; + border: none; + border-radius: 9px; + background: ${enabled ? '#101828' : tds.gray200}; + color: ${enabled ? tds.white : tds.gray500}; + padding: 8px 10px; + font-size: 12px; + font-weight: 900; + cursor: ${enabled ? 'pointer' : 'default'}; +`; + // ─── 인벤토리 섹션 ────────────────────────────────────────────────────────── const inventorySectionStyle = css` @@ -389,7 +433,17 @@ const shineBadgeStyle = css` // ─── 컴포넌트 ──────────────────────────────────────────────────────────────── export function FusionScreen() { - const { elements, fuse } = useGameStore(); + const { + elements, + discoveredElements, + comboCount, + comboExpiresAt, + sfxEnabled, + hapticEnabled, + adRewardProgress, + claimAdFusionAssist, + fuse, + } = useGameStore(); const [slot1, setSlot1] = useState(null); const [slot2, setSlot2] = useState(null); const [selectingSlot, setSelectingSlot] = useState<1 | 2 | null>(null); @@ -400,8 +454,22 @@ export function FusionScreen() { message?: string; } | null>(null); const [isFusing, setIsFusing] = useState(false); + const [assistLoading, setAssistLoading] = useState(false); + const [assistMessage, setAssistMessage] = useState(null); const [fuseRarity, setFuseRarity] = useState('common'); + const [hero, setHero] = useState<{ + elementId: string; + name: string; + color: string; + tier: number; + rarity: string; + discoveredCount: number; + isLucky: boolean; + } | null>(null); const { items: floatItems, add: addFloat } = useFloatingItems(1100); + const comboRemainingSec = Math.max(0, Math.ceil((comboExpiresAt - Date.now()) / 1000)); + const comboMultiplier = comboCount >= 10 ? 3 : comboCount >= 6 ? 2 : comboCount >= 3 ? 1.5 : 1; + const comboActive = comboRemainingSec > 0 && comboCount > 0; // 보유 중인 원소 목록 (보유량 > 0) const ownedElements = elementsData.filter((el) => (elements[el.id] ?? 0) > 0); @@ -416,10 +484,49 @@ export function FusionScreen() { }; const matchingRecipe = getMatchingRecipe(); + const sameSelectedElement = slot1 !== null && slot1 === slot2; const canFuse = !!matchingRecipe && - (elements[slot1 ?? ''] ?? 0) > 0 && - (elements[slot2 ?? ''] ?? 0) > 0; + (sameSelectedElement + ? (elements[slot1 ?? ''] ?? 0) >= 2 + : (elements[slot1 ?? ''] ?? 0) > 0 && (elements[slot2 ?? ''] ?? 0) > 0); + + const findAssistRecipe = () => + recipesData.find((recipe) => { + const [first, second] = recipe.ingredients; + const same = first === second; + if (same) return (elements[first] ?? 0) >= 2; + return (elements[first] ?? 0) > 0 && (elements[second] ?? 0) > 0; + }); + + const handleFusionAssist = async () => { + if (assistLoading || adRewardProgress.fusionAssistUses >= 5) return; + const recipe = findAssistRecipe(); + if (!recipe) { + setAssistMessage('추천할 수 있는 조합이 아직 없습니다. 원소를 더 모아보세요.'); + return; + } + + setAssistLoading(true); + try { + const adResult = await showRewardedAd(); + if (adResult.rewarded && claimAdFusionAssist()) { + setSlot1(recipe.ingredients[0]); + setSlot2(recipe.ingredients[1]); + setSelectingSlot(null); + const resultEl = elementMap[recipe.result]; + setAssistMessage(`${resultEl?.emoji ?? '✨'} ${resultEl?.name ?? '새 원소'} 조합을 슬롯에 넣었습니다.`); + setLastResult(null); + trackGameEvent('rewarded_ad_claimed', { + placement: 'fusion_assist', + recipe_id: recipe.id, + result_id: recipe.result, + }); + } + } finally { + setAssistLoading(false); + } + }; const handleFuse = () => { if (!slot1 || !slot2) return; @@ -430,21 +537,59 @@ export function FusionScreen() { setFuseRarity(rarity); setIsFusing(true); setTimeout(() => setIsFusing(false), 750); + playRaritySfx(rarity, sfxEnabled); + vibrate(rarity === 'legendary' ? [80, 40, 80] : 50, hapticEnabled); + if (rarity === 'legendary') { + window.dispatchEvent(new CustomEvent('legendaryImpact')); + } // 골드 획득 플로팅 텍스트 const cx = window.innerWidth / 2 - 30; const cy = window.innerHeight * 0.45; addFloat({ text: `+${result.goldGained} 💰`, x: cx + (Math.random() - 0.5) * 60, y: cy, color: tds.gold, fontSize: 14 }); + if (result.comboMultiplier && result.comboMultiplier > 1) { + addFloat({ + text: `${result.comboCount} COMBO x${result.comboMultiplier}`, + x: cx + (Math.random() - 0.5) * 80, + y: cy - 28, + color: '#FFD166', + fontSize: 15, + }); + } + if (result.isLucky) { + addFloat({ + text: '🍀 LUCKY!', + x: cx + (Math.random() - 0.5) * 80, + y: cy - 54, + color: '#22C55E', + fontSize: 16, + }); + } trackGameEvent('fusion_completed', { result_id: result.resultId, result_name: resultEl?.name ?? '', result_tier: rarity, gold_gained: result.goldGained ?? 0, + combo_count: result.comboCount ?? 1, + combo_multiplier: result.comboMultiplier ?? 1, + lucky: result.isLucky ?? false, ingredient_1: slot1, ingredient_2: slot2, }); + if (result.isNewDiscovery && resultEl) { + setHero({ + elementId: result.resultId, + name: resultEl.name, + color: resultEl.color, + tier: resultEl.tier, + rarity, + discoveredCount: discoveredElements.length + 1, + isLucky: result.isLucky ?? false, + }); + } + setLastResult({ type: 'success', resultId: result.resultId, @@ -461,6 +606,7 @@ export function FusionScreen() { ? '⚠️ 알 수 없는 조합입니다' : '⚠️ 원소가 부족합니다', }); + vibrate(35, hapticEnabled); } // 3초 후 피드백 초기화 setTimeout(() => setLastResult(null), 3000); @@ -497,6 +643,12 @@ export function FusionScreen() { {/* ── 합성 패널 ── */}
+
+ 합성 콤보 + + {comboActive ? `${comboCount} COMBO · x${comboMultiplier} · ${comboRemainingSec}s` : '8초 안에 연속 합성'} + +
{/* 슬롯 */}
- {elementMap[lastResult.resultId]?.name} 합성 성공! + {lastResult.resultId && elementMap[lastResult.resultId]?.name} 합성 성공!
+{lastResult.goldGained} Gold 획득
- NEW + + {lastResult.resultId && elementMap[lastResult.resultId]?.rarity === 'legendary' ? 'LEGEND' : 'NEW'} +
)} {lastResult?.type === 'error' && ( -
{lastResult.message}
+
+
{lastResult.message}
+ +
)} + {assistMessage &&
{assistMessage}
} {/* 합성 버튼 + 파티클 버스트 */}
@@ -657,6 +825,19 @@ export function FusionScreen() {
)}
+ + {hero && ( + setHero(null)} + /> + )}
); } diff --git a/src/components/screens/SettingsScreen.tsx b/src/components/screens/SettingsScreen.tsx index 96a1322..580a5ed 100644 --- a/src/components/screens/SettingsScreen.tsx +++ b/src/components/screens/SettingsScreen.tsx @@ -215,7 +215,18 @@ function Toggle({ enabled, onToggle }: { enabled: boolean; onToggle: () => void type ResetStep = 'idle' | 'confirm1' | 'confirm2'; export function SettingsScreen() { - const { language, bgmEnabled, lastTickAt, setLanguage, setBgmEnabled, resetGame } = useGameStore(); + const { + language, + bgmEnabled, + sfxEnabled, + hapticEnabled, + lastTickAt, + setLanguage, + setBgmEnabled, + setSfxEnabled, + setHapticEnabled, + resetGame, + } = useGameStore(); const [resetStep, setResetStep] = useState('idle'); const lastSavedDate = new Date(lastTickAt); @@ -262,12 +273,20 @@ export function SettingsScreen() { {/* 사운드 설정 */}
-

사운드

+

사운드와 반응

BGM setBgmEnabled(!bgmEnabled)} />
+
+ 효과음 + setSfxEnabled(!sfxEnabled)} /> +
+
+ 햅틱 진동 + setHapticEnabled(!hapticEnabled)} /> +
diff --git a/src/components/screens/ShopScreen.tsx b/src/components/screens/ShopScreen.tsx index e30589d..704c8a6 100644 --- a/src/components/screens/ShopScreen.tsx +++ b/src/components/screens/ShopScreen.tsx @@ -4,9 +4,12 @@ import { adaptive } from '../../styles/adaptive'; import { rarityGradient } from '../../styles/gameColors'; import { useGameStore, isElementUnlocked } from '../../store/useGameStore'; import { trackGameEvent } from '../../platform/analytics'; +import { showRewardedAd } from '../../platform/ads'; import elementsData from '../../data/elements.json'; +import recipesData from '../../data/recipes.json'; const BOOST_DURATION_SEC = 30; +const ADVANCED_BOOST_DURATION_SEC = 45; const containerStyle = css` padding: 24px 20px; @@ -34,6 +37,88 @@ const goldRowStyle = css` color: #5a3200; `; +const rewardSectionStyle = css` + background: #101828; + color: #ffffff; + border-radius: 18px; + padding: 16px; + margin-bottom: 18px; + box-shadow: 0 12px 26px rgba(15, 23, 42, 0.18); +`; + +const rewardHeaderStyle = css` + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 12px; +`; + +const rewardTitleStyle = css` + font-size: 15px; + font-weight: 900; +`; + +const rewardLimitStyle = css` + font-size: 11px; + font-weight: 800; + color: rgba(255, 255, 255, 0.58); + white-space: nowrap; +`; + +const rewardGridStyle = css` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + + @media (max-width: 520px) { + grid-template-columns: 1fr; + } +`; + +const rewardCardStyle = css` + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 14px; + padding: 12px; +`; + +const rewardNameStyle = css` + font-size: 13px; + font-weight: 900; + margin-bottom: 4px; +`; + +const rewardDescStyle = css` + font-size: 11px; + line-height: 1.4; + font-weight: 700; + color: rgba(255, 255, 255, 0.64); + margin-bottom: 10px; +`; + +const rewardButtonStyle = (enabled: boolean) => css` + width: 100%; + border: none; + border-radius: 10px; + background: ${enabled ? '#ffffff' : 'rgba(255, 255, 255, 0.14)'}; + color: ${enabled ? '#101828' : 'rgba(255, 255, 255, 0.52)'}; + padding: 10px 12px; + font-size: 12px; + font-weight: 900; + cursor: ${enabled ? 'pointer' : 'default'}; +`; + +const rewardResultStyle = css` + margin-top: 10px; + border-radius: 10px; + background: rgba(34, 197, 94, 0.15); + color: #bbf7d0; + padding: 8px 10px; + font-size: 11px; + font-weight: 900; +`; + const shopGridStyle = css` display: flex; flex-direction: column; @@ -179,28 +264,95 @@ const SHOP_ITEMS = [ price: 50, rarity: 'uncommon', }, + { + id: 'wind_boost', + name: '바람 강화석', + desc: `바람 원소 획득량 2배 (${BOOST_DURATION_SEC}초)`, + icon: '🌪️', + price: 50, + rarity: 'uncommon', + }, + { + id: 'earth_boost', + name: '대지 강화석', + desc: `흙 원소 획득량 2배 (${BOOST_DURATION_SEC}초)`, + icon: '🌱', + price: 50, + rarity: 'uncommon', + }, + { + id: 'starter_bundle', + name: '기본 원소 묶음', + desc: 'Tier 1 원소를 각각 +3 획득', + icon: '🧪', + price: 90, + rarity: 'uncommon', + }, { id: 'fusion_scroll', name: '합성 두루마리', - desc: '랜덤 원소 합성 시도', + desc: '현재 만들 수 있는 미발견 원소 1개 획득', icon: '📜', - price: 80, + price: 160, rarity: 'rare', }, + { + id: 'global_spawn_boost', + name: '정령 가속 코어', + desc: `전체 원소 생산량 2배 (${ADVANCED_BOOST_DURATION_SEC}초)`, + icon: '⚙️', + price: 180, + rarity: 'rare', + }, + { + id: 'gold_boost', + name: '황금 향로', + desc: `골드 수입 2배 (${ADVANCED_BOOST_DURATION_SEC}초)`, + icon: '🏺', + price: 200, + rarity: 'rare', + }, + { + id: 'tier_2_boost', + name: '2티어 공명석', + desc: `Tier 2 원소 생산량 2배 (${ADVANCED_BOOST_DURATION_SEC}초)`, + icon: '🔷', + price: 260, + rarity: 'epic', + }, + { + id: 'tier_3_boost', + name: '3티어 공명석', + desc: `Tier 3 원소 생산량 2배 (${ADVANCED_BOOST_DURATION_SEC}초)`, + icon: '💠', + price: 520, + rarity: 'epic', + }, { id: 'gold_bag', name: '골드 주머니', - desc: '+50 골드 즉시 획득', + desc: '+120 골드 즉시 획득', icon: '👝', - price: 100, + price: 90, rarity: 'epic', }, ]; export function ShopScreen() { - const { gold, addGold, addElement, activeBoosts, activateBoost } = useGameStore(); + const { + gold, + addGold, + addElement, + activeBoosts, + activateBoost, + adRewardProgress, + claimAdFreeBoost, + claimAdDailyGacha, + } = useGameStore(); const [boughtId, setBoughtId] = useState(null); const [noGoldId, setNoGoldId] = useState(null); + const [rewardLoadingId, setRewardLoadingId] = useState(null); + const [rewardResult, setRewardResult] = useState(null); const [, setTick] = useState(0); // 버프 타이머 갱신 (1초마다 re-render) @@ -215,6 +367,44 @@ export function ShopScreen() { return Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)); }; + const handleAdFreeBoost = async () => { + if (rewardLoadingId || adRewardProgress.freeBoostUses >= 3) return; + setRewardLoadingId('free_boost'); + try { + const result = await showRewardedAd(); + if (result.rewarded && claimAdFreeBoost()) { + setRewardResult('전체 생산 x2 부스트 30분이 적용되었습니다.'); + trackGameEvent('rewarded_ad_claimed', { + placement: 'shop_free_boost', + reward: 'global_spawn_boost_30m', + }); + } + } finally { + setRewardLoadingId(null); + } + }; + + const handleAdDailyGacha = async () => { + if (rewardLoadingId || adRewardProgress.dailyGachaClaimed) return; + setRewardLoadingId('daily_gacha'); + try { + const adResult = await showRewardedAd(); + if (adResult.rewarded) { + const gachaResult = claimAdDailyGacha(); + if (gachaResult.success && gachaResult.elementId) { + const el = elementsData.find((item) => item.id === gachaResult.elementId); + setRewardResult(`${el?.emoji ?? '✨'} ${el?.name ?? gachaResult.elementId} 원소를 획득했습니다.`); + trackGameEvent('rewarded_ad_claimed', { + placement: 'shop_daily_gacha', + reward: gachaResult.elementId, + }); + } + } + } finally { + setRewardLoadingId(null); + } + }; + const handleBuy = (item: (typeof SHOP_ITEMS)[0]) => { // 스토어에서 최신 골드 값을 직접 읽어 스테일 클로저로 인한 음수 골드 버그 방지 const currentGold = useGameStore.getState().gold; @@ -226,19 +416,43 @@ export function ShopScreen() { addGold(-item.price); if (item.id === 'gold_bag') { - addGold(50); + addGold(120); + } else if (item.id === 'starter_bundle') { + addElement('fire', 3); + addElement('water', 3); + addElement('wind', 3); + addElement('earth', 3); } else if (item.id === 'fusion_scroll') { - // 미보유 원소 우선 제공, 없으면 보유 중 랜덤 + // 현재 재료로 만들 수 있는 미발견 원소를 우선 제공 const currentElements = useGameStore.getState().elements; - const unlockedIds = elementsData - .filter((el) => isElementUnlocked(el.id, currentElements)) + const discoveredElements = useGameStore.getState().discoveredElements; + const craftableIds = recipesData + .filter( + (recipe) => + !discoveredElements.includes(recipe.result) && + isElementUnlocked(recipe.result, currentElements) + ) + .map((recipe) => recipe.result); + const fallbackIds = elementsData + .filter((el) => discoveredElements.includes(el.id)) .map((el) => el.id); - const unownedIds = unlockedIds.filter((id) => (currentElements[id] ?? 0) === 0); - const pool = unownedIds.length > 0 ? unownedIds : unlockedIds; + const pool = craftableIds.length > 0 ? craftableIds : fallbackIds; if (pool.length > 0) { addElement(pool[Math.floor(Math.random() * pool.length)]); } - } else if (item.id === 'fire_boost' || item.id === 'water_boost') { + } else if ( + item.id === 'global_spawn_boost' || + item.id === 'gold_boost' || + item.id === 'tier_2_boost' || + item.id === 'tier_3_boost' + ) { + activateBoost(item.id, ADVANCED_BOOST_DURATION_SEC); + } else if ( + item.id === 'fire_boost' || + item.id === 'water_boost' || + item.id === 'wind_boost' || + item.id === 'earth_boost' + ) { activateBoost(item.id, BOOST_DURATION_SEC); } @@ -260,11 +474,65 @@ export function ShopScreen() { 💰 보유 골드: {gold.toLocaleString()} + +
+
+
무료 보상
+
+ 부스트 {adRewardProgress.freeBoostUses}/3 · 가챠 {adRewardProgress.dailyGachaClaimed ? '완료' : '가능'} +
+
+
+
+
⚡ 30분 생산 부스트
+
+ 광고를 보고 전체 자동 생산 속도를 30분 동안 2배로 올립니다. +
+ +
+
+
🎁 일일 원소 가챠
+
+ 광고를 보고 만들 수 있는 미발견 원소 또는 상위 원소 1개를 획득합니다. +
+ +
+
+ {rewardResult &&
{rewardResult}
} +
+
{SHOP_ITEMS.map((item) => { const remaining = getRemainingSeconds(item.id); const isActive = remaining > 0; - const progress = (remaining / BOOST_DURATION_SEC) * 100; + const duration = + item.id === 'global_spawn_boost' || + item.id === 'gold_boost' || + item.id === 'tier_2_boost' || + item.id === 'tier_3_boost' + ? ADVANCED_BOOST_DURATION_SEC + : BOOST_DURATION_SEC; + const progress = (remaining / duration) * 100; return (
diff --git a/src/components/tutorial/TutorialOverlay.tsx b/src/components/tutorial/TutorialOverlay.tsx index 4c4d9e1..6fb0d1f 100644 --- a/src/components/tutorial/TutorialOverlay.tsx +++ b/src/components/tutorial/TutorialOverlay.tsx @@ -112,8 +112,14 @@ const completionBadgeStyle = css` `; export function TutorialOverlay() { - const { tutorialStep, tutorialCompleted, advanceTutorial, skipTutorial, setActiveTab } = - useGameStore(); + const { + tutorialStep, + tutorialCompleted, + pendingOfflineReward, + advanceTutorial, + skipTutorial, + setActiveTab, + } = useGameStore(); const [targetRect, setTargetRect] = useState(null); const [showCompletionBadge, setShowCompletionBadge] = useState(false); @@ -170,8 +176,9 @@ export function TutorialOverlay() { // 완전히 완료된 경우 렌더링하지 않음 if (tutorialCompleted && !showCompletionBadge) return null; + if (pendingOfflineReward) return null; if (tutorialCompleted && showCompletionBadge) { - return
🎉 튜토리얼 완료!
; + return
🎁 환영 선물 지급! T2 원소 + 120G
; } // Step 0: 환영 카드 diff --git a/src/store/useGameStore.ts b/src/store/useGameStore.ts index 462332c..c12da97 100644 --- a/src/store/useGameStore.ts +++ b/src/store/useGameStore.ts @@ -52,6 +52,7 @@ const INITIAL_ELEMENTS: Record = { wind: 5, earth: 5, }; +const INITIAL_DISCOVERED_ELEMENT_IDS = Object.keys(INITIAL_ELEMENTS); export const ENHANCE_MAX_LEVEL = 5; const ENHANCE_BASE_COST = 50; @@ -59,6 +60,392 @@ const ENHANCE_COST_MULTIPLIER = 1.5; const ENHANCE_RATE_MULTIPLIER = 1.2; const SPAWN_ENHANCE_MULTIPLIER = 1.2; const MAX_OFFLINE_SECONDS = 86400; // 24시간 +const MAX_RESONANCE_SPAWN_BONUS = 2.0; +const MAX_RESONANCE_GOLD_BONUS = 3.0; +const TIER_SPAWN_RATE_MULTIPLIER: Record = { + 1: 1.0, + 2: 0.12, + 3: 0.025, + 4: 0.004, + 5: 0.0007, +}; +const COMBO_WINDOW_MS = 8_000; +const LUCKY_FUSION_RATE = 0.01; +const STORM_INTERVAL_MS = 6 * 60 * 60 * 1000; +const STORM_DURATION_MS = 5 * 60 * 1000; +const TAP_HARVEST_THRESHOLD = 0.75; +const TAP_HARVEST_BONUS = 1.5; + +export type DailyMissionType = 'fuse' | 'collect' | 'discover'; + +export interface DailyMissionDefinition { + id: string; + type: DailyMissionType; + title: string; + description: string; + target: number; + rewardGold: number; + rewardBoostId?: string; + rewardBoostDurationSec?: number; +} + +export interface DailyProgress { + dateKey: string; + fuseCount: number; + elementsCollected: number; + newDiscoveries: number; + goldEarned: number; + claimedMissionIds: string[]; + bonusClaimed: boolean; + streak: number; + lastBonusDateKey: string | null; +} + +export interface ActiveStorm { + elementId: string; + startedAt: number; + expiresAt: number; + nextAt: number; +} + +export interface TapHarvestResult { + success: boolean; + elementId?: string; + count?: number; + goldGained?: number; + error?: 'not_ready' | 'not_found'; +} + +export interface DailyBonusResult { + success: boolean; + goldGained?: number; + streak?: number; + error?: 'already_claimed'; +} + +export interface AdRewardProgress { + dateKey: string; + freeBoostUses: number; + dailyGachaClaimed: boolean; + goldFundingUses: number; + fusionAssistUses: number; +} + +export interface DailyGachaResult { + success: boolean; + elementId?: string; + error?: 'already_claimed' | 'no_reward'; +} + +export const DAILY_MISSIONS: DailyMissionDefinition[] = [ + { + id: 'daily_fuse_5', + type: 'fuse', + title: '연금술 점화', + description: '합성을 5회 완료', + target: 5, + rewardGold: 120, + }, + { + id: 'daily_collect_60', + type: 'collect', + title: '작업장 순찰', + description: '원소를 60개 수집', + target: 60, + rewardGold: 160, + rewardBoostId: 'global_spawn_boost', + rewardBoostDurationSec: 120, + }, + { + id: 'daily_discover_1', + type: 'discover', + title: '새 발견 기록', + description: '새 원소 1종 발견', + target: 1, + rewardGold: 220, + }, +]; + +export interface TierAutomationGoal { + tier: number; + requiredTier: number; + requiredCount: number; + rewardGold: number; + label: string; +} + +export const TIER_AUTOMATION_GOALS: TierAutomationGoal[] = [ + { + tier: 2, + requiredTier: 2, + requiredCount: 1, + rewardGold: 50, + label: 'Tier 2 자동 생산 해금', + }, + { + tier: 3, + requiredTier: 2, + requiredCount: 5, + rewardGold: 180, + label: 'Tier 3 자동 생산 해금', + }, + { + tier: 4, + requiredTier: 3, + requiredCount: 6, + rewardGold: 600, + label: 'Tier 4 자동 생산 해금', + }, + { + tier: 5, + requiredTier: 4, + requiredCount: 4, + rewardGold: 1800, + label: 'Tier 5 자동 생산 해금', + }, +]; + +function uniq(ids: string[]): string[] { + return Array.from(new Set(ids)); +} + +function getDateKey(time = Date.now()): string { + const date = new Date(time); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function getPreviousDateKey(dateKey: string): string { + const [year, month, day] = dateKey.split('-').map(Number); + const date = new Date(year, month - 1, day); + date.setDate(date.getDate() - 1); + return getDateKey(date.getTime()); +} + +function createDailyProgress(dateKey = getDateKey(), previous?: DailyProgress): DailyProgress { + const streak = + previous?.lastBonusDateKey === getPreviousDateKey(dateKey) ? previous.streak : 0; + + return { + dateKey, + fuseCount: 0, + elementsCollected: 0, + newDiscoveries: 0, + goldEarned: 0, + claimedMissionIds: [], + bonusClaimed: previous?.lastBonusDateKey === dateKey && previous.bonusClaimed, + streak, + lastBonusDateKey: previous?.lastBonusDateKey ?? null, + }; +} + +function ensureDailyProgress(progress: DailyProgress | undefined): DailyProgress { + const today = getDateKey(); + if (!progress || progress.dateKey !== today) { + return createDailyProgress(today, progress); + } + return progress; +} + +function addDailyProgress( + progress: DailyProgress, + patch: Partial> +): DailyProgress { + const current = ensureDailyProgress(progress); + return { + ...current, + fuseCount: current.fuseCount + (patch.fuseCount ?? 0), + elementsCollected: current.elementsCollected + (patch.elementsCollected ?? 0), + newDiscoveries: current.newDiscoveries + (patch.newDiscoveries ?? 0), + goldEarned: current.goldEarned + (patch.goldEarned ?? 0), + }; +} + +function createAdRewardProgress(dateKey = getDateKey()): AdRewardProgress { + return { + dateKey, + freeBoostUses: 0, + dailyGachaClaimed: false, + goldFundingUses: 0, + fusionAssistUses: 0, + }; +} + +function ensureAdRewardProgress(progress: AdRewardProgress | undefined): AdRewardProgress { + const today = getDateKey(); + if (!progress || progress.dateKey !== today) return createAdRewardProgress(today); + return { + dateKey: progress.dateKey, + freeBoostUses: progress.freeBoostUses ?? 0, + dailyGachaClaimed: progress.dailyGachaClaimed ?? false, + goldFundingUses: progress.goldFundingUses ?? 0, + fusionAssistUses: progress.fusionAssistUses ?? 0, + }; +} + +export function getDailyMissionProgress( + mission: DailyMissionDefinition, + progress: DailyProgress +): number { + switch (mission.type) { + case 'fuse': + return progress.fuseCount; + case 'collect': + return progress.elementsCollected; + case 'discover': + return progress.newDiscoveries; + } +} + +function pickStormElementId(discoveredElementIds: string[]): string { + const candidates = elementsData.filter((el) => discoveredElementIds.includes(el.id)); + const pool = candidates.length > 0 ? candidates : elementsData.filter((el) => el.tier === 1); + return pool[Math.floor(Math.random() * pool.length)]?.id ?? 'fire'; +} + +function pickDailyGachaElementId( + elements: Record, + discoveredElements: string[] +): string | null { + const discoveredElementIds = getDiscoveredElementIds(discoveredElements, elements); + const craftableIds = recipesData + .filter( + (recipe) => + !discoveredElementIds.includes(recipe.result) && + isElementUnlocked(recipe.result, elements) + ) + .map((recipe) => recipe.result); + if (craftableIds.length > 0) { + return craftableIds[Math.floor(Math.random() * craftableIds.length)]; + } + + const maxTier = Math.max( + 1, + ...elementsData + .filter((el) => discoveredElementIds.includes(el.id)) + .map((el) => el.tier) + ); + const targetTier = Math.min(5, maxTier + 1); + const nextTierIds = elementsData + .filter((el) => el.tier === targetTier && !discoveredElementIds.includes(el.id)) + .map((el) => el.id); + if (nextTierIds.length > 0) { + return nextTierIds[Math.floor(Math.random() * nextTierIds.length)]; + } + + const fallbackIds = discoveredElementIds.filter((id) => (elements[id] ?? 0) > 0); + if (fallbackIds.length === 0) return null; + return fallbackIds[Math.floor(Math.random() * fallbackIds.length)]; +} + +function refreshStorm( + storm: ActiveStorm | null, + now: number, + discoveredElementIds: string[] +): ActiveStorm { + if (storm && storm.expiresAt > now) return storm; + const nextAt = storm?.nextAt ?? now + STORM_INTERVAL_MS; + if (now < nextAt) { + return { + elementId: storm?.elementId ?? pickStormElementId(discoveredElementIds), + startedAt: storm?.startedAt ?? nextAt, + expiresAt: storm?.expiresAt ?? nextAt, + nextAt, + }; + } + + return { + elementId: pickStormElementId(discoveredElementIds), + startedAt: now, + expiresAt: now + STORM_DURATION_MS, + nextAt: now + STORM_INTERVAL_MS, + }; +} + +function getDiscoveredFromElements(elements: Record): string[] { + return Object.entries(elements) + .filter(([, count]) => count > 0) + .map(([id]) => id); +} + +export function getDiscoveredElementIds( + discoveredElements: string[] | undefined, + elements: Record +): string[] { + return uniq([ + ...INITIAL_DISCOVERED_ELEMENT_IDS, + ...(discoveredElements ?? []), + ...getDiscoveredFromElements(elements), + ]); +} + +export function countDiscoveredByTier(discoveredElementIds: string[], tier: number): number { + return elementsData.filter( + (el) => el.tier === tier && discoveredElementIds.includes(el.id) + ).length; +} + +export function calcTierAutomationStatus(discoveredElementIds: string[]): { + unlockedTier: number; + nextGoal: TierAutomationGoal | null; +} { + let unlockedTier = 1; + + for (const goal of TIER_AUTOMATION_GOALS) { + if (countDiscoveredByTier(discoveredElementIds, goal.requiredTier) >= goal.requiredCount) { + unlockedTier = Math.max(unlockedTier, goal.tier); + } + } + + return { + unlockedTier, + nextGoal: TIER_AUTOMATION_GOALS.find((goal) => goal.tier > unlockedTier) ?? null, + }; +} + +function collectTierAutomationRewards( + discoveredElementIds: string[], + claimedTierRewards: number[] +): { newlyClaimedTiers: number[]; goldBonus: number } { + const newlyClaimedTiers: number[] = []; + let goldBonus = 0; + + for (const goal of TIER_AUTOMATION_GOALS) { + if (claimedTierRewards.includes(goal.tier)) continue; + if (countDiscoveredByTier(discoveredElementIds, goal.requiredTier) >= goal.requiredCount) { + newlyClaimedTiers.push(goal.tier); + goldBonus += goal.rewardGold; + } + } + + return { newlyClaimedTiers, goldBonus }; +} + +function calcComboMultiplier(comboCount: number): number { + if (comboCount >= 10) return 3; + if (comboCount >= 6) return 2; + if (comboCount >= 3) return 1.5; + return 1; +} + +function pickRandomTier2ElementId(): string { + const pool = elementsData.filter((el) => el.tier === 2); + return pool[Math.floor(Math.random() * pool.length)]?.id ?? 'steam'; +} + +function pickLuckyResultId(baseResultId: string, discoveredElements: string[]): string { + const baseTier = + (elementsData as Array<{ id: string; tier: number }>).find((el) => el.id === baseResultId) + ?.tier ?? 1; + const targetTier = baseTier + 1; + const candidates = elementsData.filter( + (el) => el.tier === targetTier && !discoveredElements.includes(el.id) + ); + if (candidates.length === 0) return baseResultId; + return candidates[Math.floor(Math.random() * candidates.length)].id; +} export function calcEnhanceCost(level: number): number { return Math.floor(ENHANCE_BASE_COST * Math.pow(ENHANCE_COST_MULTIPLIER, level)); @@ -72,7 +459,28 @@ export function calcEffectiveIdleRate(elementId: string, level: number): number export function calcSpawnRate(elementId: string, level: number): number { const el = elementsData.find((e) => e.id === elementId); const baseSpawnSpeed = el?.baseSpawnSpeed ?? 5.0; - return (1 / baseSpawnSpeed) * Math.pow(SPAWN_ENHANCE_MULTIPLIER, level); + const tierMultiplier = TIER_SPAWN_RATE_MULTIPLIER[el?.tier ?? 1] ?? 1.0; + return (1 / baseSpawnSpeed) * tierMultiplier * Math.pow(SPAWN_ENHANCE_MULTIPLIER, level); +} + +export function calcResonanceBonuses(elements: Record): { + spawnMultiplier: number; + goldMultiplier: number; + power: number; +} { + let power = 0; + + for (const el of elementsData) { + const count = elements[el.id] ?? 0; + if (count <= 0 || el.tier <= 1) continue; + power += (el.tier - 1) * Math.sqrt(count); + } + + return { + spawnMultiplier: 1 + Math.min(MAX_RESONANCE_SPAWN_BONUS, power * 0.015), + goldMultiplier: 1 + Math.min(MAX_RESONANCE_GOLD_BONUS, power * 0.02), + power, + }; } export function isElementUnlocked(elementId: string, elements: Record): boolean { @@ -81,7 +489,13 @@ export function isElementUnlocked(elementId: string, elements: Record (elements[id] ?? 0) > 0); + const requiredCounts = ids.reduce>((acc, id) => { + acc[id] = (acc[id] ?? 0) + 1; + return acc; + }, {}); + return Object.entries(requiredCounts).every( + ([id, required]) => (elements[id] ?? 0) >= required + ); } return false; } @@ -90,6 +504,11 @@ export interface FuseResult { success: boolean; resultId?: string; goldGained?: number; + comboCount?: number; + comboMultiplier?: number; + isLucky?: boolean; + isNewDiscovery?: boolean; + rarity?: string; error?: 'no_recipe' | 'insufficient_elements'; } @@ -284,6 +703,7 @@ interface GameState { // 원소 인벤토리 elements: Record; + discoveredElements: string[]; gold: number; addGold: (amount: number) => void; addElement: (elementId: string, count?: number) => void; @@ -293,6 +713,8 @@ interface GameState { enhance: (elementId: string) => EnhanceResult; // 합성 액션 + comboCount: number; + comboExpiresAt: number; fuse: (slot1Id: string, slot2Id: string) => FuseResult; // 방치형 시스템 @@ -301,11 +723,24 @@ interface GameState { pendingOfflineReward: OfflineReward | null; tickIdle: (deltaSec: number) => void; initOffline: () => void; - claimOfflineReward: () => void; + claimOfflineReward: (multiplier?: number) => void; + harvestElement: (elementId: string) => TapHarvestResult; // 상점 버프 시스템 (boostId -> 만료 시각 ms) activeBoosts: Record; activateBoost: (boostId: string, durationSec: number) => void; + claimedTierRewards: number[]; + + // v1.1 리텐션 시스템 + dailyProgress: DailyProgress; + activeStorm: ActiveStorm | null; + adRewardProgress: AdRewardProgress; + claimDailyBonus: () => DailyBonusResult; + claimDailyMission: (missionId: string) => boolean; + claimAdFreeBoost: () => boolean; + claimAdDailyGacha: () => DailyGachaResult; + claimAdGoldFunding: (amount: number) => boolean; + claimAdFusionAssist: () => boolean; // 프레스티지 시스템 prestigeCount: number; @@ -323,14 +758,19 @@ interface GameState { // 튜토리얼 상태 tutorialStep: number; // 0~5 (0=미시작, 5=완료) tutorialCompleted: boolean; // true면 튜토리얼 UI 완전히 숨김 + welcomeGiftClaimed: boolean; advanceTutorial: () => void; skipTutorial: () => void; // 설정 language: Language; bgmEnabled: boolean; + sfxEnabled: boolean; + hapticEnabled: boolean; setLanguage: (lang: Language) => void; setBgmEnabled: (enabled: boolean) => void; + setSfxEnabled: (enabled: boolean) => void; + setHapticEnabled: (enabled: boolean) => void; resetGame: () => void; } @@ -338,10 +778,17 @@ type PersistedState = Pick< GameState, | 'activeTab' | 'elements' + | 'discoveredElements' | 'gold' | 'elementLevels' + | 'comboCount' + | 'comboExpiresAt' | 'lastTickAt' | 'activeBoosts' + | 'claimedTierRewards' + | 'dailyProgress' + | 'activeStorm' + | 'adRewardProgress' | 'spawnAccumulators' | 'language' | 'bgmEnabled' @@ -355,6 +802,9 @@ type PersistedState = Pick< | 'achievementStats' | 'tutorialStep' | 'tutorialCompleted' + | 'welcomeGiftClaimed' + | 'sfxEnabled' + | 'hapticEnabled' >; export const useGameStore = create()( @@ -364,18 +814,56 @@ export const useGameStore = create()( setActiveTab: (tab) => set({ activeTab: tab }), elements: { ...INITIAL_ELEMENTS }, + discoveredElements: [...INITIAL_DISCOVERED_ELEMENT_IDS], gold: 0, activeBoosts: {}, + claimedTierRewards: [], + dailyProgress: createDailyProgress(), + activeStorm: null, + adRewardProgress: createAdRewardProgress(), + comboCount: 0, + comboExpiresAt: 0, - addGold: (amount) => set((state) => ({ gold: state.gold + amount })), + addGold: (amount) => + set((state) => ({ + gold: state.gold + amount, + dailyProgress: + amount > 0 + ? addDailyProgress(state.dailyProgress, { goldEarned: amount }) + : ensureDailyProgress(state.dailyProgress), + })), addElement: (elementId, count = 1) => - set((state) => ({ - elements: { + set((state) => { + const elements = { ...state.elements, [elementId]: (state.elements[elementId] ?? 0) + count, - }, - })), + }; + const wasDiscovered = getDiscoveredElementIds( + state.discoveredElements, + state.elements + ).includes(elementId); + const discoveredElements = getDiscoveredElementIds( + [...state.discoveredElements, elementId], + elements + ); + const { newlyClaimedTiers, goldBonus } = collectTierAutomationRewards( + discoveredElements, + state.claimedTierRewards + ); + + return { + elements, + discoveredElements, + gold: state.gold + goldBonus, + dailyProgress: addDailyProgress(state.dailyProgress, { + elementsCollected: Math.max(0, count), + newDiscoveries: wasDiscovered ? 0 : 1, + goldEarned: goldBonus, + }), + claimedTierRewards: [...state.claimedTierRewards, ...newlyClaimedTiers], + }; + }), elementLevels: {}, @@ -432,7 +920,7 @@ export const useGameStore = create()( }, fuse: (slot1Id, slot2Id) => { - const { elements } = get(); + const { elements, discoveredElements, comboCount, comboExpiresAt } = get(); const recipe = recipesData.find( (r) => @@ -450,11 +938,22 @@ export const useGameStore = create()( if (!sameElement && (slot1Count < 1 || slot2Count < 1)) return { success: false, error: 'insufficient_elements' }; - const goldGained = recipe.tier * 10; + const now = Date.now(); + const nextComboCount = comboExpiresAt > now ? comboCount + 1 : 1; + const comboMultiplier = calcComboMultiplier(nextComboCount); + const isLucky = Math.random() < LUCKY_FUSION_RATE; + const actualResultId = isLucky + ? pickLuckyResultId(recipe.result, getDiscoveredElementIds(discoveredElements, elements)) + : recipe.result; + const actualResult = elementsData.find((el) => el.id === actualResultId); + const goldGained = Math.floor(recipe.tier * 10 * comboMultiplier); const resultTier = (elementsData as Array<{ id: string; tier: number }>).find( - (e) => e.id === recipe.result + (e) => e.id === actualResultId )?.tier ?? 0; + const wasDiscovered = getDiscoveredElementIds(discoveredElements, elements).includes( + actualResultId + ); set((state) => { const next = { ...state.elements }; @@ -462,7 +961,13 @@ export const useGameStore = create()( if (!sameElement) { next[slot2Id] = (next[slot2Id] ?? 0) - 1; } - next[recipe.result] = (next[recipe.result] ?? 0) + 1; + next[actualResultId] = (next[actualResultId] ?? 0) + 1; + const discoveredElements = getDiscoveredElementIds( + [...state.discoveredElements, actualResultId], + next + ); + const { newlyClaimedTiers, goldBonus: tierRewardGold } = + collectTierAutomationRewards(discoveredElements, state.claimedTierRewards); const newTiersUnlocked = state.achievementStats.tiersUnlocked.includes(resultTier) ? state.achievementStats.tiersUnlocked @@ -473,10 +978,11 @@ export const useGameStore = create()( fuseCount: state.achievementStats.fuseCount + 1, tiersUnlocked: newTiersUnlocked, }; + const nextGold = state.gold + goldGained + tierRewardGold; const checkState = { elements: next, - gold: state.gold + goldGained, + gold: nextGold, elementLevels: state.elementLevels, achievementStats: newStats, unlockedAchievements: state.unlockedAchievements, @@ -489,7 +995,17 @@ export const useGameStore = create()( return { elements: next, - gold: state.gold + goldGained + goldBonus, + discoveredElements, + gold: nextGold + goldBonus, + dailyProgress: addDailyProgress(state.dailyProgress, { + fuseCount: 1, + elementsCollected: 1, + newDiscoveries: wasDiscovered ? 0 : 1, + goldEarned: goldGained + tierRewardGold + goldBonus, + }), + claimedTierRewards: [...state.claimedTierRewards, ...newlyClaimedTiers], + comboCount: nextComboCount, + comboExpiresAt: now + COMBO_WINDOW_MS, achievementStats: newStats, unlockedAchievements: [ ...state.unlockedAchievements, @@ -501,14 +1017,27 @@ export const useGameStore = create()( }; }); - return { success: true, resultId: recipe.result, goldGained }; + return { + success: true, + resultId: actualResultId, + goldGained, + comboCount: nextComboCount, + comboMultiplier, + isLucky: actualResultId !== recipe.result, + isNewDiscovery: !wasDiscovered, + rarity: actualResult?.rarity, + }; }, // 설정 language: 'ko', bgmEnabled: false, + sfxEnabled: true, + hapticEnabled: true, setLanguage: (lang) => set({ language: lang }), setBgmEnabled: (enabled) => set({ bgmEnabled: enabled }), + setSfxEnabled: (enabled) => set({ sfxEnabled: enabled }), + setHapticEnabled: (enabled) => set({ hapticEnabled: enabled }), resetGame: () => { flushGameState(); localStorage.removeItem('archetype-game-state'); @@ -525,6 +1054,187 @@ export const useGameStore = create()( })); }, + claimDailyBonus: () => { + const progress = ensureDailyProgress(get().dailyProgress); + if (progress.bonusClaimed) return { success: false, error: 'already_claimed' }; + + const nextStreak = progress.streak + 1; + const goldGained = 100 + Math.min(6, nextStreak - 1) * 25; + set((state) => { + const current = ensureDailyProgress(state.dailyProgress); + return { + gold: state.gold + goldGained, + dailyProgress: { + ...current, + bonusClaimed: true, + streak: nextStreak, + lastBonusDateKey: current.dateKey, + goldEarned: current.goldEarned + goldGained, + }, + }; + }); + + return { success: true, goldGained, streak: nextStreak }; + }, + + claimDailyMission: (missionId) => { + const mission = DAILY_MISSIONS.find((item) => item.id === missionId); + if (!mission) return false; + + const progress = ensureDailyProgress(get().dailyProgress); + if (progress.claimedMissionIds.includes(missionId)) return false; + if (getDailyMissionProgress(mission, progress) < mission.target) return false; + + set((state) => { + const current = ensureDailyProgress(state.dailyProgress); + const activeBoosts = { ...state.activeBoosts }; + if (mission.rewardBoostId && mission.rewardBoostDurationSec) { + activeBoosts[mission.rewardBoostId] = + Date.now() + mission.rewardBoostDurationSec * 1000; + } + + return { + gold: state.gold + mission.rewardGold, + activeBoosts, + dailyProgress: { + ...current, + goldEarned: current.goldEarned + mission.rewardGold, + claimedMissionIds: [...current.claimedMissionIds, missionId], + }, + }; + }); + + return true; + }, + + claimAdFreeBoost: () => { + const progress = ensureAdRewardProgress(get().adRewardProgress); + if (progress.freeBoostUses >= 3) return false; + + set((state) => { + const current = ensureAdRewardProgress(state.adRewardProgress); + if (current.freeBoostUses >= 3) return {}; + + const now = Date.now(); + const currentExpiresAt = state.activeBoosts.global_spawn_boost ?? 0; + const baseTime = Math.max(now, currentExpiresAt); + + return { + activeBoosts: { + ...state.activeBoosts, + global_spawn_boost: baseTime + 30 * 60 * 1000, + }, + adRewardProgress: { + ...current, + freeBoostUses: current.freeBoostUses + 1, + }, + }; + }); + + return true; + }, + + claimAdDailyGacha: () => { + const progress = ensureAdRewardProgress(get().adRewardProgress); + if (progress.dailyGachaClaimed) return { success: false, error: 'already_claimed' }; + + let result: DailyGachaResult = { success: false, error: 'no_reward' }; + set((state) => { + const current = ensureAdRewardProgress(state.adRewardProgress); + if (current.dailyGachaClaimed) { + result = { success: false, error: 'already_claimed' }; + return {}; + } + + const elementId = pickDailyGachaElementId(state.elements, state.discoveredElements); + if (!elementId) { + result = { success: false, error: 'no_reward' }; + return {}; + } + + const wasDiscovered = getDiscoveredElementIds( + state.discoveredElements, + state.elements + ).includes(elementId); + const elements = { + ...state.elements, + [elementId]: (state.elements[elementId] ?? 0) + 1, + }; + const discoveredElements = getDiscoveredElementIds( + [...state.discoveredElements, elementId], + elements + ); + const { newlyClaimedTiers, goldBonus } = collectTierAutomationRewards( + discoveredElements, + state.claimedTierRewards + ); + + result = { success: true, elementId }; + + return { + elements, + discoveredElements, + gold: state.gold + goldBonus, + claimedTierRewards: [...state.claimedTierRewards, ...newlyClaimedTiers], + dailyProgress: addDailyProgress(state.dailyProgress, { + elementsCollected: 1, + newDiscoveries: wasDiscovered ? 0 : 1, + goldEarned: goldBonus, + }), + adRewardProgress: { + ...current, + dailyGachaClaimed: true, + }, + }; + }); + + return result; + }, + + claimAdGoldFunding: (amount) => { + const goldGained = Math.max(0, Math.ceil(amount)); + if (goldGained <= 0) return false; + const progress = ensureAdRewardProgress(get().adRewardProgress); + if (progress.goldFundingUses >= 5) return false; + + set((state) => { + const current = ensureAdRewardProgress(state.adRewardProgress); + if (current.goldFundingUses >= 5) return {}; + + return { + gold: state.gold + goldGained, + dailyProgress: addDailyProgress(state.dailyProgress, { + goldEarned: goldGained, + }), + adRewardProgress: { + ...current, + goldFundingUses: current.goldFundingUses + 1, + }, + }; + }); + + return true; + }, + + claimAdFusionAssist: () => { + const progress = ensureAdRewardProgress(get().adRewardProgress); + if (progress.fusionAssistUses >= 5) return false; + + set((state) => { + const current = ensureAdRewardProgress(state.adRewardProgress); + if (current.fusionAssistUses >= 5) return {}; + + return { + adRewardProgress: { + ...current, + fusionAssistUses: current.fusionAssistUses + 1, + }, + }; + }); + + return true; + }, + // 프레스티지 시스템 prestigeCount: 0, prestigeGoldMultiplier: 1.0, @@ -610,15 +1320,45 @@ export const useGameStore = create()( // 튜토리얼 상태 tutorialStep: 0, tutorialCompleted: false, + welcomeGiftClaimed: false, advanceTutorial: () => set((state) => { const nextStep = state.tutorialStep + 1; + if (nextStep >= 5 && !state.welcomeGiftClaimed) { + const giftId = pickRandomTier2ElementId(); + const elements = { + ...state.elements, + [giftId]: (state.elements[giftId] ?? 0) + 1, + }; + const discoveredElements = getDiscoveredElementIds( + [...state.discoveredElements, giftId], + elements + ); + const { newlyClaimedTiers, goldBonus } = collectTierAutomationRewards( + discoveredElements, + state.claimedTierRewards + ); + return { + elements, + discoveredElements, + gold: state.gold + goldBonus + 120, + claimedTierRewards: [...state.claimedTierRewards, ...newlyClaimedTiers], + tutorialStep: nextStep, + tutorialCompleted: true, + welcomeGiftClaimed: true, + }; + } return { tutorialStep: nextStep, tutorialCompleted: nextStep >= 5, }; }), - skipTutorial: () => set({ tutorialStep: 5, tutorialCompleted: true }), + skipTutorial: () => + set((state) => ({ + tutorialStep: 5, + tutorialCompleted: true, + welcomeGiftClaimed: state.welcomeGiftClaimed, + })), // 방치형 시스템 lastTickAt: Date.now(), @@ -628,6 +1368,7 @@ export const useGameStore = create()( tickIdle: (deltaSec) => { const { elements, + discoveredElements, elementLevels, spawnAccumulators, activeBoosts, @@ -635,26 +1376,48 @@ export const useGameStore = create()( prestigeSpawnMultiplier, permanentGoldMultiplier, permanentSpawnMultiplier, + tutorialCompleted, + tutorialStep, + activeStorm, } = get(); + if (!tutorialCompleted && tutorialStep <= 2) { + set({ lastTickAt: Date.now() }); + return; + } const newAccumulators = { ...spawnAccumulators }; const spawnedElements: Record = {}; let goldGained = 0; const now = Date.now(); + const discoveredElementIds = getDiscoveredElementIds(discoveredElements, elements); + const automationStatus = calcTierAutomationStatus(discoveredElementIds); + const resonance = calcResonanceBonuses(elements); + const currentStorm = refreshStorm(activeStorm, now, discoveredElementIds); + const globalSpawnMultiplier = (activeBoosts.global_spawn_boost ?? 0) > now ? 2.0 : 1.0; + const goldBoostMultiplier = (activeBoosts.gold_boost ?? 0) > now ? 2.0 : 1.0; for (const el of elementsData) { - if (!isElementUnlocked(el.id, elements)) continue; + if (!discoveredElementIds.includes(el.id) || el.tier > automationStatus.unlockedTier) { + continue; + } const level = elementLevels[el.id] ?? 0; // 버프 배수 계산 (해당 원소 강화석이 활성 중이면 2배) const boostId = el.id + '_boost'; const boostMultiplier = (activeBoosts[boostId] ?? 0) > now ? 2.0 : 1.0; + const tierBoostMultiplier = (activeBoosts[`tier_${el.tier}_boost`] ?? 0) > now ? 2.0 : 1.0; + const stormMultiplier = + currentStorm.expiresAt > now && currentStorm.elementId === el.id ? 5.0 : 1.0; // 원소 자동 생성 (프레스티지 + 영구 스폰 배율 적용) const rate = calcSpawnRate(el.id, level) * boostMultiplier * + tierBoostMultiplier * + stormMultiplier * + globalSpawnMultiplier * prestigeSpawnMultiplier * - permanentSpawnMultiplier; + permanentSpawnMultiplier * + resonance.spawnMultiplier; const prev = newAccumulators[el.id] ?? 0; const next = prev + rate * deltaSec; const spawned = Math.floor(next); @@ -665,8 +1428,10 @@ export const useGameStore = create()( goldGained += calcEffectiveIdleRate(el.id, level) * deltaSec * + goldBoostMultiplier * prestigeGoldMultiplier * - permanentGoldMultiplier; + permanentGoldMultiplier * + resonance.goldMultiplier; } set((state) => { @@ -674,7 +1439,12 @@ export const useGameStore = create()( for (const [id, count] of Object.entries(spawnedElements)) { newElements[id] = (newElements[id] ?? 0) + count; } + const newDiscoveredElements = getDiscoveredElementIds( + state.discoveredElements, + newElements + ); const goldToAdd = Math.floor(goldGained); + const collectedCount = Object.values(spawnedElements).reduce((sum, count) => sum + count, 0); // tickIdle에서도 gold_amount, element_count 등 업적 체크 const checkState = { @@ -696,9 +1466,15 @@ export const useGameStore = create()( return { elements: newElements, + discoveredElements: newDiscoveredElements, gold: state.gold + goldToAdd + goldBonus, spawnAccumulators: newAccumulators, lastTickAt: Date.now(), + activeStorm: currentStorm, + dailyProgress: addDailyProgress(state.dailyProgress, { + elementsCollected: collectedCount, + goldEarned: goldToAdd + goldBonus, + }), unlockedAchievements: [ ...state.unlockedAchievements, ...newlyUnlocked.map((a) => a.id), @@ -715,6 +1491,7 @@ export const useGameStore = create()( const { lastTickAt, elements, + discoveredElements, elementLevels, prestigeSpawnMultiplier, permanentSpawnMultiplier, @@ -731,15 +1508,30 @@ export const useGameStore = create()( const rewardElements: Record = {}; let rewardGold = 0; + const discoveredElementIds = getDiscoveredElementIds(discoveredElements, elements); + const automationStatus = calcTierAutomationStatus(discoveredElementIds); + const resonance = calcResonanceBonuses(elements); + const activeBoosts = get().activeBoosts; + const nowForBoosts = Date.now(); + const globalSpawnMultiplier = + (activeBoosts.global_spawn_boost ?? 0) > nowForBoosts ? 2.0 : 1.0; + const goldBoostMultiplier = (activeBoosts.gold_boost ?? 0) > nowForBoosts ? 2.0 : 1.0; for (const el of elementsData) { - if (!isElementUnlocked(el.id, elements)) continue; + if (!discoveredElementIds.includes(el.id) || el.tier > automationStatus.unlockedTier) { + continue; + } const level = elementLevels[el.id] ?? 0; + const tierBoostMultiplier = + (activeBoosts[`tier_${el.tier}_boost`] ?? 0) > nowForBoosts ? 2.0 : 1.0; const spawned = Math.floor( calcSpawnRate(el.id, level) * + tierBoostMultiplier * + globalSpawnMultiplier * prestigeSpawnMultiplier * permanentSpawnMultiplier * + resonance.spawnMultiplier * offlineSec ); if (spawned > 0) rewardElements[el.id] = spawned; @@ -747,8 +1539,10 @@ export const useGameStore = create()( rewardGold += calcEffectiveIdleRate(el.id, level) * offlineSec * + goldBoostMultiplier * prestigeGoldMultiplier * - permanentGoldMultiplier; + permanentGoldMultiplier * + resonance.goldMultiplier; } set({ @@ -761,22 +1555,29 @@ export const useGameStore = create()( }); }, - claimOfflineReward: () => { - const { pendingOfflineReward } = get(); - if (!pendingOfflineReward) return; + claimOfflineReward: (multiplier = 1) => { + const safeMultiplier = Math.max(1, Math.floor(multiplier)); set((state) => { + const pendingOfflineReward = state.pendingOfflineReward; + if (!pendingOfflineReward) return {}; + const newElements = { ...state.elements }; for (const [id, count] of Object.entries(pendingOfflineReward.elements)) { - newElements[id] = (newElements[id] ?? 0) + count; + newElements[id] = (newElements[id] ?? 0) + count * safeMultiplier; } + const rewardGold = pendingOfflineReward.gold * safeMultiplier; + const newDiscoveredElements = getDiscoveredElementIds( + state.discoveredElements, + newElements + ); const newStats: AchievementStats = { ...state.achievementStats, offlineClaimCount: state.achievementStats.offlineClaimCount + 1, }; const checkState = { elements: newElements, - gold: state.gold + pendingOfflineReward.gold, + gold: state.gold + rewardGold, elementLevels: state.elementLevels, achievementStats: newStats, unlockedAchievements: state.unlockedAchievements, @@ -790,12 +1591,21 @@ export const useGameStore = create()( for (const ach of newlyUnlocked) { window.dispatchEvent(new CustomEvent('newAchievement', { detail: ach })); } + const collectedCount = Object.values(pendingOfflineReward.elements).reduce( + (sum, count) => sum + count * safeMultiplier, + 0 + ); return { elements: newElements, - gold: state.gold + pendingOfflineReward.gold + goldBonus, + discoveredElements: newDiscoveredElements, + gold: state.gold + rewardGold + goldBonus, pendingOfflineReward: null, achievementStats: newStats, + dailyProgress: addDailyProgress(state.dailyProgress, { + elementsCollected: collectedCount, + goldEarned: rewardGold + goldBonus, + }), unlockedAchievements: [ ...state.unlockedAchievements, ...newlyUnlocked.map((a) => a.id), @@ -807,6 +1617,69 @@ export const useGameStore = create()( }; }); }, + + harvestElement: (elementId) => { + let result: TapHarvestResult = { success: false, error: 'not_ready' }; + + set((state) => { + const el = elementsData.find((item) => item.id === elementId); + if (!el) { + result = { success: false, error: 'not_found' }; + return {}; + } + + const discoveredElementIds = getDiscoveredElementIds( + state.discoveredElements, + state.elements + ); + const automationStatus = calcTierAutomationStatus(discoveredElementIds); + const progress = state.spawnAccumulators[elementId] ?? 0; + if ( + !discoveredElementIds.includes(elementId) || + el.tier > automationStatus.unlockedTier || + progress < TAP_HARVEST_THRESHOLD + ) { + result = { success: false, error: 'not_ready' }; + return {}; + } + + const now = Date.now(); + const level = state.elementLevels[elementId] ?? 0; + const goldBoostMultiplier = (state.activeBoosts.gold_boost ?? 0) > now ? 2.0 : 1.0; + const resonance = calcResonanceBonuses(state.elements); + const elementGain = Math.ceil(TAP_HARVEST_BONUS); + const goldGained = Math.ceil( + calcEffectiveIdleRate(elementId, level) * + 5 * + TAP_HARVEST_BONUS * + goldBoostMultiplier * + state.prestigeGoldMultiplier * + state.permanentGoldMultiplier * + resonance.goldMultiplier + ); + const elements = { + ...state.elements, + [elementId]: (state.elements[elementId] ?? 0) + elementGain, + }; + const spawnAccumulators = { + ...state.spawnAccumulators, + [elementId]: 0, + }; + result = { success: true, elementId, count: elementGain, goldGained }; + + return { + elements, + spawnAccumulators, + gold: state.gold + goldGained, + dailyProgress: addDailyProgress(state.dailyProgress, { + elementsCollected: elementGain, + goldEarned: goldGained, + }), + }; + }); + + return result; + }, }), { name: 'archetype-game-state', @@ -814,10 +1687,17 @@ export const useGameStore = create()( partialize: (state): PersistedState => ({ activeTab: state.activeTab, elements: state.elements, + discoveredElements: state.discoveredElements, gold: state.gold, elementLevels: state.elementLevels, + comboCount: state.comboCount, + comboExpiresAt: state.comboExpiresAt, lastTickAt: state.lastTickAt, activeBoosts: state.activeBoosts, + claimedTierRewards: state.claimedTierRewards, + dailyProgress: state.dailyProgress, + activeStorm: state.activeStorm, + adRewardProgress: state.adRewardProgress, spawnAccumulators: state.spawnAccumulators, language: state.language, bgmEnabled: state.bgmEnabled, @@ -831,6 +1711,9 @@ export const useGameStore = create()( achievementStats: state.achievementStats, tutorialStep: state.tutorialStep, tutorialCompleted: state.tutorialCompleted, + welcomeGiftClaimed: state.welcomeGiftClaimed, + sfxEnabled: state.sfxEnabled, + hapticEnabled: state.hapticEnabled, }), onRehydrateStorage: () => (state) => { // 앱 재시작 시 localStorage에서 복원된 만료 부스트를 자동 정리 @@ -856,6 +1739,16 @@ export const useGameStore = create()( state.achievementStats.tiersUnlocked = []; } if (!state.unlockedAchievements) state.unlockedAchievements = []; + state.discoveredElements = getDiscoveredElementIds(state.discoveredElements, state.elements); + if (!state.claimedTierRewards) state.claimedTierRewards = []; + state.dailyProgress = ensureDailyProgress(state.dailyProgress); + if (!state.activeStorm) state.activeStorm = null; + state.adRewardProgress = ensureAdRewardProgress(state.adRewardProgress); + if (!state.comboCount) state.comboCount = 0; + if (!state.comboExpiresAt) state.comboExpiresAt = 0; + if (!state.welcomeGiftClaimed) state.welcomeGiftClaimed = false; + if (state.sfxEnabled === undefined) state.sfxEnabled = true; + if (state.hapticEnabled === undefined) state.hapticEnabled = true; if (!state.permanentGoldMultiplier) state.permanentGoldMultiplier = 1.0; if (!state.permanentSpawnMultiplier) state.permanentSpawnMultiplier = 1.0; if (!state.prestigeCount) state.prestigeCount = 0;