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:
667
src/components/screens/ElementsScreen.tsx
Normal file
667
src/components/screens/ElementsScreen.tsx
Normal 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
24
src/hooks/useIdleTick.ts
Normal 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
352
src/store/useGameStore.ts
Normal 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;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user