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:
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