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 (
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}
+
+
+
+
생산 속도
@@ -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;