From e1cc6b2ea87d520b1b42b5aade8268cfc1f6c3fd Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 1 Apr 2026 00:36:40 +0900 Subject: [PATCH] =?UTF-8?q?perf:=20localStorage=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=ED=8B=80=EB=A7=81=20=EB=B0=8F=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EC=B5=9C=EC=A0=81=ED=99=94=20(JSA-36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useGameStore: localStorage 저장을 최대 10초 간격으로 스로틀링 (매 tick마다 저장하던 것을 개선) - useGameStore: flushGameState() 내보내기 — beforeunload에서 즉시 저장 - useIdleTick: beforeunload 이벤트에 flushGameState 연결 - ElementsScreen: useGameStore 전체 구독 → 필요한 slice만 selector로 구독 - ElementsScreen: 원소 카드를 memo(ElementCard)로 추출 (count/level 불변 시 리렌더 방지) Co-Authored-By: Paperclip --- src/components/screens/ElementsScreen.tsx | 667 ++++++++++++++++++++++ src/hooks/useIdleTick.ts | 24 + src/store/useGameStore.ts | 352 ++++++++++++ 3 files changed, 1043 insertions(+) create mode 100644 src/components/screens/ElementsScreen.tsx create mode 100644 src/hooks/useIdleTick.ts create mode 100644 src/store/useGameStore.ts diff --git a/src/components/screens/ElementsScreen.tsx b/src/components/screens/ElementsScreen.tsx new file mode 100644 index 0000000..25a0219 --- /dev/null +++ b/src/components/screens/ElementsScreen.tsx @@ -0,0 +1,667 @@ +import { css } from '@emotion/react'; +import { memo, useEffect, useRef, useState } from 'react'; +import { adaptive } from '@toss/tds-colors'; +import { FloatingOverlay } from '../FloatingOverlay'; +import { useFloatingItems } from '../../hooks/useFloatingItems'; +import elementsData from '../../data/elements.json'; +import { CharacterSprite } from '../CharacterSprite'; +import { + calcEffectiveIdleRate, + calcEnhanceCost, + calcSpawnRate, + ENHANCE_MAX_LEVEL, + isElementUnlocked, + 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`; + if (amount >= 1_000) return `${(amount / 1_000).toFixed(1)}K`; + return String(amount); +} + +// ──────────────────────────────────────────────────────────── +// Styles +// ──────────────────────────────────────────────────────────── + +const containerStyle = css` + padding: 20px; + background: ${adaptive.background}; + min-height: 100%; +`; + +const headerStyle = css` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +`; + +const titleStyle = css` + font-size: 20px; + font-weight: 700; + color: ${adaptive.grey900}; + margin: 0; +`; + +const goldBadgeStyle = css` + display: flex; + align-items: center; + gap: 4px; + background: linear-gradient(135deg, #ffd700, #ffaa00); + padding: 6px 12px; + border-radius: 16px; + font-size: 14px; + font-weight: 700; + color: #5a3200; +`; + +const progressBarWrapStyle = css` + background: ${adaptive.greyBackground}; + border-radius: 10px; + padding: 10px 14px; + margin-bottom: 16px; + display: flex; + flex-direction: column; + gap: 6px; +`; + +const progressHeaderStyle = css` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const progressLabelStyle = css` + font-size: 12px; + color: ${adaptive.grey500}; + font-weight: 500; +`; + +const progressCountStyle = css` + font-size: 13px; + font-weight: 700; + color: ${adaptive.blue500}; +`; + +const progressTrackStyle = css` + height: 6px; + background: ${adaptive.grey200}; + border-radius: 3px; + overflow: hidden; +`; + +const progressFillStyle = (ratio: number) => css` + height: 100%; + width: ${(ratio * 100).toFixed(1)}%; + background: linear-gradient(90deg, #3182f6, #7c4dff); + border-radius: 3px; + transition: width 0.3s ease; +`; + +const idleInfoStyle = css` + background: ${adaptive.greyBackground}; + border-radius: 12px; + padding: 12px 16px; + margin-bottom: 20px; + display: flex; + gap: 16px; +`; + +const idleStatStyle = css` + display: flex; + flex-direction: column; + gap: 2px; +`; + +const idleLabelStyle = css` + font-size: 11px; + color: ${adaptive.grey500}; +`; + +const idleValueStyle = css` + font-size: 13px; + font-weight: 700; + color: ${adaptive.blue500}; +`; + +const tierSectionStyle = css` + margin-bottom: 20px; +`; + +const tierLabelStyle = css` + font-size: 12px; + font-weight: 600; + color: ${adaptive.grey500}; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; +`; + +const elementGridStyle = css` + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; +`; + +const elementCardBaseStyle = ` + display: flex; + flex-direction: column; + align-items: center; + border-radius: 14px; + padding: 12px 6px 10px; + gap: 5px; + position: relative; +`; + +const obtainedCardStyle = css` + ${elementCardBaseStyle} + background: ${adaptive.background}; + border: 1.5px solid ${adaptive.grey200}; + cursor: pointer; + &:active { + transform: scale(0.96); + } +`; + +const lockedCardStyle = css` + ${elementCardBaseStyle} + background: ${adaptive.greyBackground}; + border: 1.5px solid ${adaptive.grey200}; + opacity: 0.75; + cursor: default; +`; + +const undiscoveredCardStyle = css` + ${elementCardBaseStyle} + background: ${adaptive.greyBackground}; + border: 1.5px dashed ${adaptive.grey300}; + opacity: 0.4; + cursor: default; + justify-content: center; + min-height: 80px; +`; + +const spriteWrapStyle = css` + width: 56px; + height: 56px; + flex-shrink: 0; +`; + +const elementNameStyle = css` + font-size: 10px; + font-weight: 600; + color: ${adaptive.grey900}; + text-align: center; + line-height: 1.2; +`; + +const undiscoveredNameStyle = css` + font-size: 10px; + color: ${adaptive.grey400}; + text-align: center; +`; + +const elementCountStyle = css` + font-size: 11px; + color: ${adaptive.blue500}; + font-weight: 700; +`; + +const lockIconStyle = css` + position: absolute; + top: 4px; + right: 4px; + font-size: 10px; +`; + +const spawnRateStyle = css` + font-size: 9px; + color: ${adaptive.grey400}; +`; + +// ──────────────────────────────────────────────────────────── +// 상세 정보 패널 +// ──────────────────────────────────────────────────────────── + +const overlayStyle = css` + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 100; + display: flex; + align-items: flex-end; +`; + +const detailPanelStyle = css` + width: 100%; + background: ${adaptive.background}; + border-radius: 20px 20px 0 0; + padding: 20px; + padding-bottom: 32px; +`; + +const detailHandleStyle = css` + width: 36px; + height: 4px; + background: ${adaptive.grey300}; + border-radius: 2px; + margin: 0 auto 16px; +`; + +const detailHeaderStyle = css` + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +`; + +const detailSpriteStyle = css` + width: 72px; + height: 72px; + flex-shrink: 0; +`; + +const detailTitleBlockStyle = css` + flex: 1; +`; + +const detailNameStyle = css` + font-size: 18px; + font-weight: 700; + color: ${adaptive.grey900}; + margin: 0 0 2px; +`; + +const detailTierStyle = css` + font-size: 12px; + color: ${adaptive.grey500}; +`; + +const detailGridStyle = css` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 16px; +`; + +const detailStatCardStyle = css` + background: ${adaptive.greyBackground}; + border-radius: 12px; + padding: 10px 12px; +`; + +const detailStatLabelStyle = css` + font-size: 11px; + color: ${adaptive.grey500}; + margin-bottom: 4px; +`; + +const detailStatValueStyle = css` + font-size: 15px; + font-weight: 700; + color: ${adaptive.grey900}; +`; + +const enhanceRowStyle = css` + display: flex; + align-items: center; + justify-content: space-between; + background: ${adaptive.greyBackground}; + border-radius: 12px; + padding: 12px 16px; +`; + +const enhanceLabelStyle = css` + font-size: 13px; + font-weight: 600; + color: ${adaptive.grey700}; + margin-bottom: 6px; +`; + +const enhanceDotRowStyle = css` + display: flex; + gap: 5px; + align-items: center; +`; + +const enhanceDotStyle = (filled: boolean) => css` + width: 10px; + height: 10px; + border-radius: 50%; + background: ${filled ? adaptive.blue500 : adaptive.grey300}; +`; + +const enhanceCostStyle = css` + font-size: 12px; + font-weight: 600; + color: ${adaptive.blue500}; +`; + +const enhanceMaxStyle = css` + font-size: 12px; + color: ${adaptive.grey400}; +`; + +const detailCountBadgeStyle = css` + font-size: 14px; + font-weight: 700; + color: ${adaptive.blue500}; +`; + +// ──────────────────────────────────────────────────────────── +// Tier labels +// ──────────────────────────────────────────────────────────── + +const TIER_LABELS: Record = { + 1: 'Tier 1 — 기본 원소', + 2: 'Tier 2 — 2차 원소', + 3: 'Tier 3 — 희귀 원소', + 4: 'Tier 4 — 에픽 원소', + 5: 'Tier 5 — 전설 원소', +}; + +// ──────────────────────────────────────────────────────────── +// DetailPanel 컴포넌트 +// ──────────────────────────────────────────────────────────── + +type ElementData = (typeof elementsData)[number]; + +interface DetailPanelProps { + el: ElementData; + count: number; + level: number; + onClose: () => void; +} + +function DetailPanel({ el, count, level, onClose }: DetailPanelProps) { + const spawnRate = calcSpawnRate(el.id, level); + const idleRate = calcEffectiveIdleRate(el.id, level); + const isMaxLevel = level >= ENHANCE_MAX_LEVEL; + const nextCost = isMaxLevel ? null : calcEnhanceCost(level); + + return ( +
+
e.stopPropagation()}> +
+ +
+
+ +
+
+

{el.name}

+ + Tier {el.tier} · {el.rarity} · {el.nameEn} + +
+ ×{count.toLocaleString()} +
+ +
+
+
생산 속도
+
+{spawnRate.toFixed(3)}/s
+
+
+
골드 수입
+
+{idleRate.toFixed(2)}/s
+
+
+
기본 수입률
+
{el.idleIncomeRate}/s
+
+
+
보유 수량
+
{count.toLocaleString()}
+
+
+ +
+
+
강화 레벨 Lv.{level}
+
+ {Array.from({ length: ENHANCE_MAX_LEVEL }, (_, i) => ( + + ))} +
+
+ {isMaxLevel ? ( + MAX + ) : ( + + 다음 강화 💰{formatGold(nextCost!)} + + )} +
+
+
+ ); +} + +// ──────────────────────────────────────────────────────────── +// ElementCard (메모이제이션 — count/level 동일하면 리렌더 안 함) +// ──────────────────────────────────────────────────────────── + +interface ElementCardProps { + el: ElementData; + state: ElementState; + count: number; + level: number; + onSelect: (el: ElementData) => void; +} + +const ElementCard = memo(function ElementCard({ el, state, count, level, onSelect }: ElementCardProps) { + const spawnRate = calcSpawnRate(el.id, level); + + if (state === 'undiscovered') { + return ( +
+
+ +
+ ??? +
+ ); + } + + if (state === 'locked') { + return ( +
+ 🔒 +
+ +
+ {el.name} +
+ ); + } + + return ( +
onSelect(el)}> +
+ +
+ {el.name} + ×{count.toLocaleString()} + +{spawnRate.toFixed(2)}/s +
+ ); +}); + +// ──────────────────────────────────────────────────────────── +// ElementsScreen (메인) +// ──────────────────────────────────────────────────────────── + +export function ElementsScreen() { + // 전체 store 구독 대신 필요한 slice만 selector로 구독 (불필요한 리렌더 방지) + const gold = useGameStore((s) => s.gold); + const elements = useGameStore((s) => s.elements); + const elementLevels = useGameStore((s) => s.elementLevels); + const [selectedEl, setSelectedEl] = useState(null); + const { items: floatItems, add: addFloat } = useFloatingItems(1100); + const prevElements = useRef>({}); + const prevGold = useRef(-1); + const floatCount = useRef(0); + + // 자동 수집 플로팅 이펙트: 원소 수 또는 골드 증가 감지 + useEffect(() => { + const W = window.innerWidth; + const H = window.innerHeight; + let showed = 0; + + // 원소 증가 감지 (idle tick에서 발생) + for (const el of elementsData) { + const prev = prevElements.current[el.id] ?? -1; + const curr = elements[el.id] ?? 0; + if (prev >= 0 && curr > prev) { + // 최대 2개까지만 표시 (너무 많으면 지저분) + if (showed < 2) { + addFloat({ + text: `${el.emoji} +${curr - prev}`, + x: W * 0.15 + Math.random() * W * 0.7, + y: H * 0.2 + Math.random() * H * 0.4, + color: el.color, + fontSize: 16, + }); + showed++; + } + } + } + prevElements.current = { ...elements }; + + // 골드 증가 감지 + if (prevGold.current >= 0 && gold > prevGold.current) { + const gained = gold - prevGold.current; + // 매 3번 tick마다 1번만 표시 (너무 잦지 않게) + floatCount.current = (floatCount.current + 1) % 3; + if (floatCount.current === 0) { + addFloat({ + text: `+${gained} 💰`, + x: W * 0.3 + Math.random() * W * 0.4, + y: H * 0.15 + Math.random() * H * 0.15, + color: '#F7C12A', + fontSize: 13, + }); + } + } + prevGold.current = gold; + }, [elements, gold]); // eslint-disable-line react-hooks/exhaustive-deps + + const totalElements = elementsData.length; + + const obtainedElements = elementsData.filter( + (el) => (elements[el.id] ?? 0) > 0 + ); + + const unlockedElements = elementsData.filter((el) => + isElementUnlocked(el.id, elements) + ); + + const totalSpawnPerSec = obtainedElements.reduce( + (sum, el) => sum + calcSpawnRate(el.id, elementLevels[el.id] ?? 0), + 0 + ); + + const totalGoldPerSec = obtainedElements.reduce( + (sum, el) => sum + calcEffectiveIdleRate(el.id, elementLevels[el.id] ?? 0), + 0 + ); + + const tierGroups = [1, 2, 3, 4, 5].map((tier) => ({ + tier, + items: elementsData.filter((el) => el.tier === tier), + })); + + const unlockRatio = obtainedElements.length / totalElements; + + return ( +
+
+

원소

+
💰 {gold.toLocaleString()}
+
+ + {/* 합성 진행 상황 프로그레스 바 */} +
+
+ 원소 해금 진행 + + {obtainedElements.length} / {totalElements} + +
+
+
+
+
+ + {/* 방치 수입 요약 */} +
+
+ 원소 수집 + +{totalSpawnPerSec.toFixed(2)}/s +
+
+ 골드 수입 + +{totalGoldPerSec.toFixed(1)}/s +
+
+ 발견 가능 + {unlockedElements.length}종 +
+
+ + {/* 티어별 원소 그리드 */} + {tierGroups.map(({ tier, items }) => ( +
+
{TIER_LABELS[tier]}
+
+ {items.map((el) => ( + + ))} +
+
+ ))} + + {/* 원소 상세 정보 패널 */} + {selectedEl !== null && ( + setSelectedEl(null)} + /> + )} + + {/* 자동 수집 플로팅 이펙트 */} + +
+ ); +} diff --git a/src/hooks/useIdleTick.ts b/src/hooks/useIdleTick.ts new file mode 100644 index 0000000..04529ab --- /dev/null +++ b/src/hooks/useIdleTick.ts @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; +import { useGameStore, flushGameState } from '../store/useGameStore'; + +export function useIdleTick() { + const tickIdle = useGameStore((s) => s.tickIdle); + const initOffline = useGameStore((s) => s.initOffline); + + useEffect(() => { + initOffline(); + + const interval = setInterval(() => { + tickIdle(1); + }, 1000); + + // 탭 전환/앱 종료 시 즉시 저장 (스로틀 우회) + window.addEventListener('beforeunload', flushGameState); + + return () => { + clearInterval(interval); + window.removeEventListener('beforeunload', flushGameState); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} diff --git a/src/store/useGameStore.ts b/src/store/useGameStore.ts new file mode 100644 index 0000000..f9357ef --- /dev/null +++ b/src/store/useGameStore.ts @@ -0,0 +1,352 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import recipesData from '../data/recipes.json'; +import elementsData from '../data/elements.json'; + +// localStorage 저장을 최대 10초에 1번으로 스로틀링 +// 매 tick(1초)마다 setState가 발생하는 방치형 게임 특성상 필요 +const STORAGE_SAVE_INTERVAL_MS = 10_000; + +let _pendingStorageKey: string | null = null; +let _pendingStorageValue: string | null = null; +let _storageTimer: ReturnType | null = null; + +function _doFlush() { + if (_pendingStorageKey !== null && _pendingStorageValue !== null) { + localStorage.setItem(_pendingStorageKey, _pendingStorageValue); + _pendingStorageKey = null; + _pendingStorageValue = null; + } + _storageTimer = null; +} + +/** 탭 전환/앱 종료 직전 즉시 저장 (beforeunload에서 호출) */ +export function flushGameState(): void { + if (_storageTimer !== null) { + clearTimeout(_storageTimer); + _storageTimer = null; + } + _doFlush(); +} + +const throttledStorage = createJSONStorage(() => ({ + getItem: (name: string): string | null => localStorage.getItem(name), + setItem: (name: string, value: string): void => { + _pendingStorageKey = name; + _pendingStorageValue = value; + if (_storageTimer === null) { + _storageTimer = setTimeout(_doFlush, STORAGE_SAVE_INTERVAL_MS); + } + }, + removeItem: (name: string): void => localStorage.removeItem(name), +})); + +export type TabName = 'elements' | 'evolution' | 'fusion' | 'shop'; + +const INITIAL_ELEMENTS: Record = { + fire: 5, + water: 5, + wind: 5, + earth: 5, +}; + +export const ENHANCE_MAX_LEVEL = 5; +const ENHANCE_BASE_COST = 50; +const ENHANCE_COST_MULTIPLIER = 1.5; +const ENHANCE_RATE_MULTIPLIER = 1.2; +const SPAWN_ENHANCE_MULTIPLIER = 1.2; +const MAX_OFFLINE_SECONDS = 86400; // 24시간 + +export function calcEnhanceCost(level: number): number { + return Math.floor(ENHANCE_BASE_COST * Math.pow(ENHANCE_COST_MULTIPLIER, level)); +} + +export function calcEffectiveIdleRate(elementId: string, level: number): number { + const base = elementsData.find((e) => e.id === elementId)?.idleIncomeRate ?? 1.0; + return base * Math.pow(ENHANCE_RATE_MULTIPLIER, level); +} + +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); +} + +export function isElementUnlocked(elementId: string, elements: Record): boolean { + const el = elementsData.find((e) => e.id === elementId); + if (!el) return false; + if (el.unlockCondition === 'initial') return true; + if (el.unlockCondition.startsWith('recipe:')) { + const ids = el.unlockCondition.replace('recipe:', '').split('+'); + return ids.every((id) => (elements[id] ?? 0) > 0); + } + return false; +} + +export interface FuseResult { + success: boolean; + resultId?: string; + goldGained?: number; + error?: 'no_recipe' | 'insufficient_elements'; +} + +export interface EnhanceResult { + success: boolean; + newLevel?: number; + cost?: number; + error?: 'max_level' | 'insufficient_gold'; +} + +export interface OfflineReward { + elements: Record; + gold: number; + offlineSec: number; +} + +interface GameState { + activeTab: TabName; + setActiveTab: (tab: TabName) => void; + + // 원소 인벤토리 + elements: Record; + gold: number; + addGold: (amount: number) => void; + addElement: (elementId: string, count?: number) => void; + + // 원소 강화 레벨 (elementId -> level 0~5) + elementLevels: Record; + enhance: (elementId: string) => EnhanceResult; + + // 합성 액션 + fuse: (slot1Id: string, slot2Id: string) => FuseResult; + + // 방치형 시스템 + lastTickAt: number; + spawnAccumulators: Record; + pendingOfflineReward: OfflineReward | null; + tickIdle: (deltaSec: number) => void; + initOffline: () => void; + claimOfflineReward: () => void; + + // 상점 버프 시스템 (boostId -> 만료 시각 ms) + activeBoosts: Record; + activateBoost: (boostId: string, durationSec: number) => void; +} + +type PersistedState = Pick< + GameState, + 'activeTab' | 'elements' | 'gold' | 'elementLevels' | 'lastTickAt' | 'activeBoosts' | 'spawnAccumulators' +>; + +export const useGameStore = create()( + persist( + (set, get) => ({ + activeTab: 'elements', + setActiveTab: (tab) => set({ activeTab: tab }), + + elements: { ...INITIAL_ELEMENTS }, + gold: 0, + activeBoosts: {}, + + addGold: (amount) => set((state) => ({ gold: state.gold + amount })), + + addElement: (elementId, count = 1) => + set((state) => ({ + elements: { + ...state.elements, + [elementId]: (state.elements[elementId] ?? 0) + count, + }, + })), + + elementLevels: {}, + + enhance: (elementId) => { + const { elementLevels, gold } = get(); + const currentLevel = elementLevels[elementId] ?? 0; + + if (currentLevel >= ENHANCE_MAX_LEVEL) { + return { success: false, error: 'max_level' }; + } + + const cost = calcEnhanceCost(currentLevel); + if (gold < cost) { + return { success: false, error: 'insufficient_gold' }; + } + + const newLevel = currentLevel + 1; + set((state) => ({ + gold: state.gold - cost, + elementLevels: { + ...state.elementLevels, + [elementId]: newLevel, + }, + })); + + return { success: true, newLevel, cost }; + }, + + fuse: (slot1Id, slot2Id) => { + const { elements } = get(); + + const recipe = recipesData.find( + (r) => + (r.ingredients[0] === slot1Id && r.ingredients[1] === slot2Id) || + (r.ingredients[0] === slot2Id && r.ingredients[1] === slot1Id) + ); + + if (!recipe) return { success: false, error: 'no_recipe' }; + + const slot1Count = elements[slot1Id] ?? 0; + const slot2Count = elements[slot2Id] ?? 0; + const sameElement = slot1Id === slot2Id; + + if (sameElement && slot1Count < 2) return { success: false, error: 'insufficient_elements' }; + if (!sameElement && (slot1Count < 1 || slot2Count < 1)) + return { success: false, error: 'insufficient_elements' }; + + const goldGained = recipe.tier * 10; + + set((state) => { + const next = { ...state.elements }; + next[slot1Id] = (next[slot1Id] ?? 0) - 1; + if (!sameElement) { + next[slot2Id] = (next[slot2Id] ?? 0) - 1; + } + next[recipe.result] = (next[recipe.result] ?? 0) + 1; + return { elements: next, gold: state.gold + goldGained }; + }); + + return { success: true, resultId: recipe.result, goldGained }; + }, + + // 상점 버프 시스템 + activateBoost: (boostId, durationSec) => { + set((state) => ({ + activeBoosts: { + ...state.activeBoosts, + [boostId]: Date.now() + durationSec * 1000, + }, + })); + }, + + // 방치형 시스템 + lastTickAt: Date.now(), + spawnAccumulators: {}, + pendingOfflineReward: null, + + tickIdle: (deltaSec) => { + const { elements, elementLevels, spawnAccumulators, activeBoosts } = get(); + const newAccumulators = { ...spawnAccumulators }; + const spawnedElements: Record = {}; + let goldGained = 0; + const now = Date.now(); + + for (const el of elementsData) { + if (!isElementUnlocked(el.id, elements)) continue; + const level = elementLevels[el.id] ?? 0; + + // 버프 배수 계산 (해당 원소 강화석이 활성 중이면 2배) + const boostId = el.id + '_boost'; + const boostMultiplier = (activeBoosts[boostId] ?? 0) > now ? 2.0 : 1.0; + + // 원소 자동 생성 + const rate = calcSpawnRate(el.id, level) * boostMultiplier; + const prev = newAccumulators[el.id] ?? 0; + const next = prev + rate * deltaSec; + const spawned = Math.floor(next); + newAccumulators[el.id] = next - spawned; + if (spawned > 0) spawnedElements[el.id] = spawned; + + // 골드 자동 수입 + goldGained += calcEffectiveIdleRate(el.id, level) * deltaSec; + } + + set((state) => { + const newElements = { ...state.elements }; + for (const [id, count] of Object.entries(spawnedElements)) { + newElements[id] = (newElements[id] ?? 0) + count; + } + return { + elements: newElements, + gold: state.gold + Math.floor(goldGained), + spawnAccumulators: newAccumulators, + lastTickAt: Date.now(), + }; + }); + }, + + initOffline: () => { + const { lastTickAt, elements, elementLevels } = get(); + const now = Date.now(); + const offlineSec = Math.min((now - lastTickAt) / 1000, MAX_OFFLINE_SECONDS); + + if (offlineSec < 10) { + set({ lastTickAt: now }); + return; + } + + const rewardElements: Record = {}; + let rewardGold = 0; + + for (const el of elementsData) { + if (!isElementUnlocked(el.id, elements)) continue; + const level = elementLevels[el.id] ?? 0; + + const spawned = Math.floor(calcSpawnRate(el.id, level) * offlineSec); + if (spawned > 0) rewardElements[el.id] = spawned; + + rewardGold += calcEffectiveIdleRate(el.id, level) * offlineSec; + } + + set({ + lastTickAt: now, + pendingOfflineReward: { + elements: rewardElements, + gold: Math.floor(rewardGold), + offlineSec: Math.floor(offlineSec), + }, + }); + }, + + claimOfflineReward: () => { + const { pendingOfflineReward } = get(); + if (!pendingOfflineReward) return; + + set((state) => { + const newElements = { ...state.elements }; + for (const [id, count] of Object.entries(pendingOfflineReward.elements)) { + newElements[id] = (newElements[id] ?? 0) + count; + } + return { + elements: newElements, + gold: state.gold + pendingOfflineReward.gold, + pendingOfflineReward: null, + }; + }); + }, + }), + { + name: 'archetype-game-state', + storage: throttledStorage, + partialize: (state): PersistedState => ({ + activeTab: state.activeTab, + elements: state.elements, + gold: state.gold, + elementLevels: state.elementLevels, + lastTickAt: state.lastTickAt, + activeBoosts: state.activeBoosts, + spawnAccumulators: state.spawnAccumulators, + }), + onRehydrateStorage: () => (state) => { + // 앱 재시작 시 localStorage에서 복원된 만료 부스트를 자동 정리 + if (!state) return; + const now = Date.now(); + const validBoosts: Record = {}; + for (const [key, expiresAt] of Object.entries(state.activeBoosts)) { + if (expiresAt > now) validBoosts[key] = expiresAt; + } + state.activeBoosts = validBoosts; + }, + } + ) +);