perf: localStorage 저장 스로틀링 및 렌더링 최적화 (JSA-36)

- useGameStore: localStorage 저장을 최대 10초 간격으로 스로틀링
  (매 tick마다 저장하던 것을 개선)
- useGameStore: flushGameState() 내보내기 — beforeunload에서 즉시 저장
- useIdleTick: beforeunload 이벤트에 flushGameState 연결
- ElementsScreen: useGameStore 전체 구독 → 필요한 slice만 selector로 구독
- ElementsScreen: 원소 카드를 memo(ElementCard)로 추출
  (count/level 불변 시 리렌더 방지)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-01 00:36:40 +09:00
parent 56bc71a71a
commit e1cc6b2ea8
3 changed files with 1043 additions and 0 deletions

View File

@@ -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<string, number>
): 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<number, string> = {
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 (
<div css={overlayStyle} onClick={onClose}>
<div css={detailPanelStyle} onClick={(e) => e.stopPropagation()}>
<div css={detailHandleStyle} />
<div css={detailHeaderStyle}>
<div css={detailSpriteStyle}>
<CharacterSprite
elementId={el.id}
elementColor={el.color}
tier={el.tier}
size={72}
state="obtained"
/>
</div>
<div css={detailTitleBlockStyle}>
<p css={detailNameStyle}>{el.name}</p>
<span css={detailTierStyle}>
Tier {el.tier} · {el.rarity} · {el.nameEn}
</span>
</div>
<span css={detailCountBadgeStyle}>×{count.toLocaleString()}</span>
</div>
<div css={detailGridStyle}>
<div css={detailStatCardStyle}>
<div css={detailStatLabelStyle}> </div>
<div css={detailStatValueStyle}>+{spawnRate.toFixed(3)}/s</div>
</div>
<div css={detailStatCardStyle}>
<div css={detailStatLabelStyle}> </div>
<div css={detailStatValueStyle}>+{idleRate.toFixed(2)}/s</div>
</div>
<div css={detailStatCardStyle}>
<div css={detailStatLabelStyle}> </div>
<div css={detailStatValueStyle}>{el.idleIncomeRate}/s</div>
</div>
<div css={detailStatCardStyle}>
<div css={detailStatLabelStyle}> </div>
<div css={detailStatValueStyle}>{count.toLocaleString()}</div>
</div>
</div>
<div css={enhanceRowStyle}>
<div>
<div css={enhanceLabelStyle}> Lv.{level}</div>
<div css={enhanceDotRowStyle}>
{Array.from({ length: ENHANCE_MAX_LEVEL }, (_, i) => (
<span key={i} css={enhanceDotStyle(i < level)} />
))}
</div>
</div>
{isMaxLevel ? (
<span css={enhanceMaxStyle}>MAX</span>
) : (
<span css={enhanceCostStyle}>
💰{formatGold(nextCost!)}
</span>
)}
</div>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────
// 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 (
<div css={undiscoveredCardStyle}>
<div css={spriteWrapStyle}>
<CharacterSprite elementId={el.id} elementColor={el.color} tier={el.tier} size={56} state="undiscovered" />
</div>
<span css={undiscoveredNameStyle}>???</span>
</div>
);
}
if (state === 'locked') {
return (
<div css={lockedCardStyle}>
<span css={lockIconStyle}>🔒</span>
<div css={spriteWrapStyle}>
<CharacterSprite elementId={el.id} elementColor={el.color} tier={el.tier} size={56} state="locked" />
</div>
<span css={elementNameStyle}>{el.name}</span>
</div>
);
}
return (
<div css={obtainedCardStyle} onClick={() => onSelect(el)}>
<div css={spriteWrapStyle}>
<CharacterSprite elementId={el.id} elementColor={el.color} tier={el.tier} size={56} state="obtained" />
</div>
<span css={elementNameStyle}>{el.name}</span>
<span css={elementCountStyle}>×{count.toLocaleString()}</span>
<span css={spawnRateStyle}>+{spawnRate.toFixed(2)}/s</span>
</div>
);
});
// ────────────────────────────────────────────────────────────
// 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<ElementData | null>(null);
const { items: floatItems, add: addFloat } = useFloatingItems(1100);
const prevElements = useRef<Record<string, number>>({});
const prevGold = useRef<number>(-1);
const floatCount = useRef<number>(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 (
<div css={containerStyle}>
<div css={headerStyle}>
<h1 css={titleStyle}></h1>
<div css={goldBadgeStyle}>💰 {gold.toLocaleString()}</div>
</div>
{/* 합성 진행 상황 프로그레스 바 */}
<div css={progressBarWrapStyle}>
<div css={progressHeaderStyle}>
<span css={progressLabelStyle}> </span>
<span css={progressCountStyle}>
{obtainedElements.length} / {totalElements}
</span>
</div>
<div css={progressTrackStyle}>
<div css={progressFillStyle(unlockRatio)} />
</div>
</div>
{/* 방치 수입 요약 */}
<div css={idleInfoStyle}>
<div css={idleStatStyle}>
<span css={idleLabelStyle}> </span>
<span css={idleValueStyle}>+{totalSpawnPerSec.toFixed(2)}/s</span>
</div>
<div css={idleStatStyle}>
<span css={idleLabelStyle}> </span>
<span css={idleValueStyle}>+{totalGoldPerSec.toFixed(1)}/s</span>
</div>
<div css={idleStatStyle}>
<span css={idleLabelStyle}> </span>
<span css={idleValueStyle}>{unlockedElements.length}</span>
</div>
</div>
{/* 티어별 원소 그리드 */}
{tierGroups.map(({ tier, items }) => (
<div key={tier} css={tierSectionStyle}>
<div css={tierLabelStyle}>{TIER_LABELS[tier]}</div>
<div css={elementGridStyle}>
{items.map((el) => (
<ElementCard
key={el.id}
el={el}
state={getElementState(el.id, elements)}
count={elements[el.id] ?? 0}
level={elementLevels[el.id] ?? 0}
onSelect={setSelectedEl}
/>
))}
</div>
</div>
))}
{/* 원소 상세 정보 패널 */}
{selectedEl !== null && (
<DetailPanel
el={selectedEl}
count={elements[selectedEl.id] ?? 0}
level={elementLevels[selectedEl.id] ?? 0}
onClose={() => setSelectedEl(null)}
/>
)}
{/* 자동 수집 플로팅 이펙트 */}
<FloatingOverlay items={floatItems} />
</div>
);
}

24
src/hooks/useIdleTick.ts Normal file
View File

@@ -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
}, []);
}

352
src/store/useGameStore.ts Normal file
View File

@@ -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<typeof setTimeout> | 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<string, number> = {
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<string, number>): 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<string, number>;
gold: number;
offlineSec: number;
}
interface GameState {
activeTab: TabName;
setActiveTab: (tab: TabName) => void;
// 원소 인벤토리
elements: Record<string, number>;
gold: number;
addGold: (amount: number) => void;
addElement: (elementId: string, count?: number) => void;
// 원소 강화 레벨 (elementId -> level 0~5)
elementLevels: Record<string, number>;
enhance: (elementId: string) => EnhanceResult;
// 합성 액션
fuse: (slot1Id: string, slot2Id: string) => FuseResult;
// 방치형 시스템
lastTickAt: number;
spawnAccumulators: Record<string, number>;
pendingOfflineReward: OfflineReward | null;
tickIdle: (deltaSec: number) => void;
initOffline: () => void;
claimOfflineReward: () => void;
// 상점 버프 시스템 (boostId -> 만료 시각 ms)
activeBoosts: Record<string, number>;
activateBoost: (boostId: string, durationSec: number) => void;
}
type PersistedState = Pick<
GameState,
'activeTab' | 'elements' | 'gold' | 'elementLevels' | 'lastTickAt' | 'activeBoosts' | 'spawnAccumulators'
>;
export const useGameStore = create<GameState>()(
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<string, number> = {};
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<string, number> = {};
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<string, number> = {};
for (const [key, expiresAt] of Object.entries(state.activeBoosts)) {
if (expiresAt > now) validBoosts[key] = expiresAt;
}
state.activeBoosts = validBoosts;
},
}
)
);