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

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