feat: 프레스티지 시스템 및 업적(Achievement) 상태 관리 구현 (JSA-47)
- PrestigeResult, AchievementStats 타입 추가 - performPrestige(): 프레스티지 시 골드/원소 초기화, 배율·타이틀 업데이트 - achieveUnlock(): 업적 조건 충족 여부 실시간 체크 및 보상 지급 - 업적 통계 (fuseCount, enhanceCount, tiersUnlocked, prestigeCount 등) 상태 추적 Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -2,6 +2,8 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import recipesData from '../data/recipes.json';
|
||||
import elementsData from '../data/elements.json';
|
||||
import prestigeTableData from '../data/prestige.json';
|
||||
import achievementsData from '../data/achievements.json';
|
||||
|
||||
// localStorage 저장을 최대 10초에 1번으로 스로틀링
|
||||
// 매 tick(1초)마다 setState가 발생하는 방치형 게임 특성상 필요
|
||||
@@ -104,6 +106,178 @@ export interface OfflineReward {
|
||||
offlineSec: number;
|
||||
}
|
||||
|
||||
export interface PrestigeResult {
|
||||
success: boolean;
|
||||
newPrestigeCount?: number;
|
||||
goldMultiplier?: number;
|
||||
spawnMultiplier?: number;
|
||||
title?: string;
|
||||
error?: 'not_enough_elements';
|
||||
}
|
||||
|
||||
export interface AchievementStats {
|
||||
fuseCount: number;
|
||||
enhanceCount: number;
|
||||
offlineClaimCount: number;
|
||||
prestigeCount: number;
|
||||
tiersUnlocked: number[];
|
||||
}
|
||||
|
||||
// 프레스티지 테이블에서 현재 횟수에 맞는 항목 조회
|
||||
function getPrestigeEntry(count: number) {
|
||||
const table = prestigeTableData as Array<{
|
||||
count: number;
|
||||
goldMultiplier: number;
|
||||
spawnMultiplier: number;
|
||||
starterGold: number;
|
||||
title: string;
|
||||
titleEmoji: string;
|
||||
titleColor: string;
|
||||
}>;
|
||||
const entry = table.find((e) => e.count === count);
|
||||
// count가 테이블 최댓값을 초과하면 마지막 항목 사용
|
||||
if (!entry) return table[table.length - 1];
|
||||
return entry;
|
||||
}
|
||||
|
||||
interface AchievementCondition {
|
||||
type: string;
|
||||
value: number;
|
||||
target?: string;
|
||||
tier?: number;
|
||||
}
|
||||
|
||||
interface AchievementReward {
|
||||
gold?: number;
|
||||
boostType?: string;
|
||||
boostDurationMin?: number;
|
||||
bonusScrolls?: number;
|
||||
permanentGoldMultiplier?: number;
|
||||
permanentSpawnMultiplier?: number;
|
||||
title?: string;
|
||||
specialBackground?: string;
|
||||
}
|
||||
|
||||
interface Achievement {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
condition: AchievementCondition;
|
||||
reward: AchievementReward;
|
||||
isHidden: boolean;
|
||||
}
|
||||
|
||||
const ACHIEVEMENTS = achievementsData as Achievement[];
|
||||
|
||||
function checkAchievementCondition(
|
||||
condition: AchievementCondition,
|
||||
state: {
|
||||
elements: Record<string, number>;
|
||||
gold: number;
|
||||
elementLevels: Record<string, number>;
|
||||
achievementStats: AchievementStats;
|
||||
}
|
||||
): boolean {
|
||||
const { elements, gold, elementLevels, achievementStats } = state;
|
||||
|
||||
switch (condition.type) {
|
||||
case 'fuse_count':
|
||||
return achievementStats.fuseCount >= condition.value;
|
||||
|
||||
case 'element_count':
|
||||
return (elements[condition.target ?? ''] ?? 0) >= condition.value;
|
||||
|
||||
case 'tier_unlock':
|
||||
return achievementStats.tiersUnlocked.includes(condition.value);
|
||||
|
||||
case 'enhance_count':
|
||||
return achievementStats.enhanceCount >= condition.value;
|
||||
|
||||
case 'offline_claim':
|
||||
return achievementStats.offlineClaimCount >= condition.value;
|
||||
|
||||
case 'element_variety':
|
||||
return Object.values(elements).filter((c) => c > 0).length >= condition.value;
|
||||
|
||||
case 'gold_amount':
|
||||
return gold >= condition.value;
|
||||
|
||||
case 'max_enhance_count': {
|
||||
const maxLevelCount = Object.values(elementLevels).filter(
|
||||
(lv) => lv >= ENHANCE_MAX_LEVEL
|
||||
).length;
|
||||
return maxLevelCount >= condition.value;
|
||||
}
|
||||
|
||||
case 'total_elements':
|
||||
return Object.values(elements).reduce((sum, c) => sum + c, 0) >= condition.value;
|
||||
|
||||
case 'single_element_count':
|
||||
return Math.max(0, ...Object.values(elements)) >= condition.value;
|
||||
|
||||
case 'tier_variety': {
|
||||
const targetTier = condition.tier ?? 0;
|
||||
const tierElementIds = (elementsData as Array<{ id: string; tier: number }>)
|
||||
.filter((e) => e.tier === targetTier)
|
||||
.map((e) => e.id);
|
||||
const discoveredCount = tierElementIds.filter((id) => (elements[id] ?? 0) > 0).length;
|
||||
return discoveredCount >= condition.value;
|
||||
}
|
||||
|
||||
case 'all_enhance_min_level': {
|
||||
const discoveredIds = Object.entries(elements)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([id]) => id);
|
||||
if (discoveredIds.length === 0) return false;
|
||||
return discoveredIds.every((id) => (elementLevels[id] ?? 0) >= condition.value);
|
||||
}
|
||||
|
||||
case 'element_unlock':
|
||||
return (elements[condition.target ?? ''] ?? 0) > 0;
|
||||
|
||||
case 'prestige_count':
|
||||
return achievementStats.prestigeCount >= condition.value;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface AchievementUnlockResult {
|
||||
newlyUnlocked: Achievement[];
|
||||
goldBonus: number;
|
||||
permanentGoldMultiplierBonus: number;
|
||||
permanentSpawnMultiplierBonus: number;
|
||||
}
|
||||
|
||||
function collectNewAchievements(
|
||||
state: {
|
||||
elements: Record<string, number>;
|
||||
gold: number;
|
||||
elementLevels: Record<string, number>;
|
||||
achievementStats: AchievementStats;
|
||||
unlockedAchievements: string[];
|
||||
}
|
||||
): AchievementUnlockResult {
|
||||
const newlyUnlocked: Achievement[] = [];
|
||||
let goldBonus = 0;
|
||||
let permanentGoldMultiplierBonus = 0;
|
||||
let permanentSpawnMultiplierBonus = 0;
|
||||
|
||||
for (const ach of ACHIEVEMENTS) {
|
||||
if (state.unlockedAchievements.includes(ach.id)) continue;
|
||||
if (checkAchievementCondition(ach.condition, state)) {
|
||||
newlyUnlocked.push(ach);
|
||||
goldBonus += ach.reward.gold ?? 0;
|
||||
permanentGoldMultiplierBonus += ach.reward.permanentGoldMultiplier ?? 0;
|
||||
permanentSpawnMultiplierBonus += ach.reward.permanentSpawnMultiplier ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { newlyUnlocked, goldBonus, permanentGoldMultiplierBonus, permanentSpawnMultiplierBonus };
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
activeTab: TabName;
|
||||
setActiveTab: (tab: TabName) => void;
|
||||
@@ -133,6 +307,25 @@ interface GameState {
|
||||
activeBoosts: Record<string, number>;
|
||||
activateBoost: (boostId: string, durationSec: number) => void;
|
||||
|
||||
// 프레스티지 시스템
|
||||
prestigeCount: number;
|
||||
prestigeGoldMultiplier: number;
|
||||
prestigeSpawnMultiplier: number;
|
||||
prestigeTitle: string;
|
||||
prestige: () => PrestigeResult;
|
||||
|
||||
// 업적 시스템
|
||||
unlockedAchievements: string[];
|
||||
permanentGoldMultiplier: number;
|
||||
permanentSpawnMultiplier: number;
|
||||
achievementStats: AchievementStats;
|
||||
|
||||
// 튜토리얼 상태
|
||||
tutorialStep: number; // 0~5 (0=미시작, 5=완료)
|
||||
tutorialCompleted: boolean; // true면 튜토리얼 UI 완전히 숨김
|
||||
advanceTutorial: () => void;
|
||||
skipTutorial: () => void;
|
||||
|
||||
// 설정
|
||||
language: Language;
|
||||
bgmEnabled: boolean;
|
||||
@@ -152,6 +345,16 @@ type PersistedState = Pick<
|
||||
| 'spawnAccumulators'
|
||||
| 'language'
|
||||
| 'bgmEnabled'
|
||||
| 'prestigeCount'
|
||||
| 'prestigeGoldMultiplier'
|
||||
| 'prestigeSpawnMultiplier'
|
||||
| 'prestigeTitle'
|
||||
| 'unlockedAchievements'
|
||||
| 'permanentGoldMultiplier'
|
||||
| 'permanentSpawnMultiplier'
|
||||
| 'achievementStats'
|
||||
| 'tutorialStep'
|
||||
| 'tutorialCompleted'
|
||||
>;
|
||||
|
||||
export const useGameStore = create<GameState>()(
|
||||
@@ -190,13 +393,40 @@ export const useGameStore = create<GameState>()(
|
||||
}
|
||||
|
||||
const newLevel = currentLevel + 1;
|
||||
set((state) => ({
|
||||
gold: state.gold - cost,
|
||||
elementLevels: {
|
||||
set((state) => {
|
||||
const newElementLevels = {
|
||||
...state.elementLevels,
|
||||
[elementId]: newLevel,
|
||||
},
|
||||
}));
|
||||
};
|
||||
const newStats: AchievementStats = {
|
||||
...state.achievementStats,
|
||||
enhanceCount: state.achievementStats.enhanceCount + 1,
|
||||
};
|
||||
const checkState = {
|
||||
elements: state.elements,
|
||||
gold: state.gold - cost,
|
||||
elementLevels: newElementLevels,
|
||||
achievementStats: newStats,
|
||||
unlockedAchievements: state.unlockedAchievements,
|
||||
};
|
||||
const { newlyUnlocked, goldBonus, permanentGoldMultiplierBonus, permanentSpawnMultiplierBonus } =
|
||||
collectNewAchievements(checkState);
|
||||
for (const ach of newlyUnlocked) {
|
||||
window.dispatchEvent(new CustomEvent('newAchievement', { detail: ach }));
|
||||
}
|
||||
return {
|
||||
gold: state.gold - cost + goldBonus,
|
||||
elementLevels: newElementLevels,
|
||||
achievementStats: newStats,
|
||||
unlockedAchievements: [
|
||||
...state.unlockedAchievements,
|
||||
...newlyUnlocked.map((a) => a.id),
|
||||
],
|
||||
permanentGoldMultiplier: state.permanentGoldMultiplier + permanentGoldMultiplierBonus,
|
||||
permanentSpawnMultiplier:
|
||||
state.permanentSpawnMultiplier + permanentSpawnMultiplierBonus,
|
||||
};
|
||||
});
|
||||
|
||||
return { success: true, newLevel, cost };
|
||||
},
|
||||
@@ -221,6 +451,10 @@ export const useGameStore = create<GameState>()(
|
||||
return { success: false, error: 'insufficient_elements' };
|
||||
|
||||
const goldGained = recipe.tier * 10;
|
||||
const resultTier =
|
||||
(elementsData as Array<{ id: string; tier: number }>).find(
|
||||
(e) => e.id === recipe.result
|
||||
)?.tier ?? 0;
|
||||
|
||||
set((state) => {
|
||||
const next = { ...state.elements };
|
||||
@@ -229,7 +463,42 @@ export const useGameStore = create<GameState>()(
|
||||
next[slot2Id] = (next[slot2Id] ?? 0) - 1;
|
||||
}
|
||||
next[recipe.result] = (next[recipe.result] ?? 0) + 1;
|
||||
return { elements: next, gold: state.gold + goldGained };
|
||||
|
||||
const newTiersUnlocked = state.achievementStats.tiersUnlocked.includes(resultTier)
|
||||
? state.achievementStats.tiersUnlocked
|
||||
: [...state.achievementStats.tiersUnlocked, resultTier];
|
||||
|
||||
const newStats: AchievementStats = {
|
||||
...state.achievementStats,
|
||||
fuseCount: state.achievementStats.fuseCount + 1,
|
||||
tiersUnlocked: newTiersUnlocked,
|
||||
};
|
||||
|
||||
const checkState = {
|
||||
elements: next,
|
||||
gold: state.gold + goldGained,
|
||||
elementLevels: state.elementLevels,
|
||||
achievementStats: newStats,
|
||||
unlockedAchievements: state.unlockedAchievements,
|
||||
};
|
||||
const { newlyUnlocked, goldBonus, permanentGoldMultiplierBonus, permanentSpawnMultiplierBonus } =
|
||||
collectNewAchievements(checkState);
|
||||
for (const ach of newlyUnlocked) {
|
||||
window.dispatchEvent(new CustomEvent('newAchievement', { detail: ach }));
|
||||
}
|
||||
|
||||
return {
|
||||
elements: next,
|
||||
gold: state.gold + goldGained + goldBonus,
|
||||
achievementStats: newStats,
|
||||
unlockedAchievements: [
|
||||
...state.unlockedAchievements,
|
||||
...newlyUnlocked.map((a) => a.id),
|
||||
],
|
||||
permanentGoldMultiplier: state.permanentGoldMultiplier + permanentGoldMultiplierBonus,
|
||||
permanentSpawnMultiplier:
|
||||
state.permanentSpawnMultiplier + permanentSpawnMultiplierBonus,
|
||||
};
|
||||
});
|
||||
|
||||
return { success: true, resultId: recipe.result, goldGained };
|
||||
@@ -256,13 +525,117 @@ export const useGameStore = create<GameState>()(
|
||||
}));
|
||||
},
|
||||
|
||||
// 프레스티지 시스템
|
||||
prestigeCount: 0,
|
||||
prestigeGoldMultiplier: 1.0,
|
||||
prestigeSpawnMultiplier: 1.0,
|
||||
prestigeTitle: '',
|
||||
|
||||
prestige: () => {
|
||||
const state = get();
|
||||
const creationCount = state.elements['creation'] ?? 0;
|
||||
const spiritCount = state.elements['spirit'] ?? 0;
|
||||
|
||||
if (creationCount < 1 || spiritCount < 1) {
|
||||
return { success: false, error: 'not_enough_elements' };
|
||||
}
|
||||
|
||||
const newPrestigeCount = state.prestigeCount + 1;
|
||||
const entry = getPrestigeEntry(newPrestigeCount);
|
||||
|
||||
const newStats: AchievementStats = {
|
||||
...state.achievementStats,
|
||||
prestigeCount: newPrestigeCount,
|
||||
};
|
||||
|
||||
const checkState = {
|
||||
elements: { ...INITIAL_ELEMENTS },
|
||||
gold: entry.starterGold,
|
||||
elementLevels: {},
|
||||
achievementStats: newStats,
|
||||
unlockedAchievements: state.unlockedAchievements,
|
||||
};
|
||||
const { newlyUnlocked, goldBonus, permanentGoldMultiplierBonus, permanentSpawnMultiplierBonus } =
|
||||
collectNewAchievements(checkState);
|
||||
for (const ach of newlyUnlocked) {
|
||||
window.dispatchEvent(new CustomEvent('newAchievement', { detail: ach }));
|
||||
}
|
||||
|
||||
set({
|
||||
// 초기화 항목
|
||||
elements: { ...INITIAL_ELEMENTS },
|
||||
elementLevels: {},
|
||||
activeBoosts: {},
|
||||
// 골드는 스타터 골드 + 업적 보너스
|
||||
gold: entry.starterGold + goldBonus,
|
||||
// 유지/업데이트 항목
|
||||
prestigeCount: newPrestigeCount,
|
||||
prestigeGoldMultiplier: entry.goldMultiplier,
|
||||
prestigeSpawnMultiplier: entry.spawnMultiplier,
|
||||
prestigeTitle: `${entry.titleEmoji} ${entry.title}`,
|
||||
achievementStats: newStats,
|
||||
unlockedAchievements: [
|
||||
...state.unlockedAchievements,
|
||||
...newlyUnlocked.map((a) => a.id),
|
||||
],
|
||||
permanentGoldMultiplier:
|
||||
state.permanentGoldMultiplier + permanentGoldMultiplierBonus,
|
||||
permanentSpawnMultiplier:
|
||||
state.permanentSpawnMultiplier + permanentSpawnMultiplierBonus,
|
||||
spawnAccumulators: {},
|
||||
lastTickAt: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
newPrestigeCount,
|
||||
goldMultiplier: entry.goldMultiplier,
|
||||
spawnMultiplier: entry.spawnMultiplier,
|
||||
title: `${entry.titleEmoji} ${entry.title}`,
|
||||
};
|
||||
},
|
||||
|
||||
// 업적 시스템
|
||||
unlockedAchievements: [],
|
||||
permanentGoldMultiplier: 1.0,
|
||||
permanentSpawnMultiplier: 1.0,
|
||||
achievementStats: {
|
||||
fuseCount: 0,
|
||||
enhanceCount: 0,
|
||||
offlineClaimCount: 0,
|
||||
prestigeCount: 0,
|
||||
tiersUnlocked: [],
|
||||
},
|
||||
|
||||
// 튜토리얼 상태
|
||||
tutorialStep: 0,
|
||||
tutorialCompleted: false,
|
||||
advanceTutorial: () =>
|
||||
set((state) => {
|
||||
const nextStep = state.tutorialStep + 1;
|
||||
return {
|
||||
tutorialStep: nextStep,
|
||||
tutorialCompleted: nextStep >= 5,
|
||||
};
|
||||
}),
|
||||
skipTutorial: () => set({ tutorialStep: 5, tutorialCompleted: true }),
|
||||
|
||||
// 방치형 시스템
|
||||
lastTickAt: Date.now(),
|
||||
spawnAccumulators: {},
|
||||
pendingOfflineReward: null,
|
||||
|
||||
tickIdle: (deltaSec) => {
|
||||
const { elements, elementLevels, spawnAccumulators, activeBoosts } = get();
|
||||
const {
|
||||
elements,
|
||||
elementLevels,
|
||||
spawnAccumulators,
|
||||
activeBoosts,
|
||||
prestigeGoldMultiplier,
|
||||
prestigeSpawnMultiplier,
|
||||
permanentGoldMultiplier,
|
||||
permanentSpawnMultiplier,
|
||||
} = get();
|
||||
const newAccumulators = { ...spawnAccumulators };
|
||||
const spawnedElements: Record<string, number> = {};
|
||||
let goldGained = 0;
|
||||
@@ -276,16 +649,24 @@ export const useGameStore = create<GameState>()(
|
||||
const boostId = el.id + '_boost';
|
||||
const boostMultiplier = (activeBoosts[boostId] ?? 0) > now ? 2.0 : 1.0;
|
||||
|
||||
// 원소 자동 생성
|
||||
const rate = calcSpawnRate(el.id, level) * boostMultiplier;
|
||||
// 원소 자동 생성 (프레스티지 + 영구 스폰 배율 적용)
|
||||
const rate =
|
||||
calcSpawnRate(el.id, level) *
|
||||
boostMultiplier *
|
||||
prestigeSpawnMultiplier *
|
||||
permanentSpawnMultiplier;
|
||||
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;
|
||||
// 골드 자동 수입 (프레스티지 + 영구 골드 배율 적용)
|
||||
goldGained +=
|
||||
calcEffectiveIdleRate(el.id, level) *
|
||||
deltaSec *
|
||||
prestigeGoldMultiplier *
|
||||
permanentGoldMultiplier;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
@@ -293,17 +674,53 @@ export const useGameStore = create<GameState>()(
|
||||
for (const [id, count] of Object.entries(spawnedElements)) {
|
||||
newElements[id] = (newElements[id] ?? 0) + count;
|
||||
}
|
||||
const goldToAdd = Math.floor(goldGained);
|
||||
|
||||
// tickIdle에서도 gold_amount, element_count 등 업적 체크
|
||||
const checkState = {
|
||||
elements: newElements,
|
||||
gold: state.gold + goldToAdd,
|
||||
elementLevels: state.elementLevels,
|
||||
achievementStats: state.achievementStats,
|
||||
unlockedAchievements: state.unlockedAchievements,
|
||||
};
|
||||
const {
|
||||
newlyUnlocked,
|
||||
goldBonus,
|
||||
permanentGoldMultiplierBonus,
|
||||
permanentSpawnMultiplierBonus,
|
||||
} = collectNewAchievements(checkState);
|
||||
for (const ach of newlyUnlocked) {
|
||||
window.dispatchEvent(new CustomEvent('newAchievement', { detail: ach }));
|
||||
}
|
||||
|
||||
return {
|
||||
elements: newElements,
|
||||
gold: state.gold + Math.floor(goldGained),
|
||||
gold: state.gold + goldToAdd + goldBonus,
|
||||
spawnAccumulators: newAccumulators,
|
||||
lastTickAt: Date.now(),
|
||||
unlockedAchievements: [
|
||||
...state.unlockedAchievements,
|
||||
...newlyUnlocked.map((a) => a.id),
|
||||
],
|
||||
permanentGoldMultiplier:
|
||||
state.permanentGoldMultiplier + permanentGoldMultiplierBonus,
|
||||
permanentSpawnMultiplier:
|
||||
state.permanentSpawnMultiplier + permanentSpawnMultiplierBonus,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
initOffline: () => {
|
||||
const { lastTickAt, elements, elementLevels } = get();
|
||||
const {
|
||||
lastTickAt,
|
||||
elements,
|
||||
elementLevels,
|
||||
prestigeSpawnMultiplier,
|
||||
permanentSpawnMultiplier,
|
||||
prestigeGoldMultiplier,
|
||||
permanentGoldMultiplier,
|
||||
} = get();
|
||||
const now = Date.now();
|
||||
const offlineSec = Math.min((now - lastTickAt) / 1000, MAX_OFFLINE_SECONDS);
|
||||
|
||||
@@ -319,10 +736,19 @@ export const useGameStore = create<GameState>()(
|
||||
if (!isElementUnlocked(el.id, elements)) continue;
|
||||
const level = elementLevels[el.id] ?? 0;
|
||||
|
||||
const spawned = Math.floor(calcSpawnRate(el.id, level) * offlineSec);
|
||||
const spawned = Math.floor(
|
||||
calcSpawnRate(el.id, level) *
|
||||
prestigeSpawnMultiplier *
|
||||
permanentSpawnMultiplier *
|
||||
offlineSec
|
||||
);
|
||||
if (spawned > 0) rewardElements[el.id] = spawned;
|
||||
|
||||
rewardGold += calcEffectiveIdleRate(el.id, level) * offlineSec;
|
||||
rewardGold +=
|
||||
calcEffectiveIdleRate(el.id, level) *
|
||||
offlineSec *
|
||||
prestigeGoldMultiplier *
|
||||
permanentGoldMultiplier;
|
||||
}
|
||||
|
||||
set({
|
||||
@@ -344,10 +770,40 @@ export const useGameStore = create<GameState>()(
|
||||
for (const [id, count] of Object.entries(pendingOfflineReward.elements)) {
|
||||
newElements[id] = (newElements[id] ?? 0) + count;
|
||||
}
|
||||
return {
|
||||
const newStats: AchievementStats = {
|
||||
...state.achievementStats,
|
||||
offlineClaimCount: state.achievementStats.offlineClaimCount + 1,
|
||||
};
|
||||
const checkState = {
|
||||
elements: newElements,
|
||||
gold: state.gold + pendingOfflineReward.gold,
|
||||
elementLevels: state.elementLevels,
|
||||
achievementStats: newStats,
|
||||
unlockedAchievements: state.unlockedAchievements,
|
||||
};
|
||||
const {
|
||||
newlyUnlocked,
|
||||
goldBonus,
|
||||
permanentGoldMultiplierBonus,
|
||||
permanentSpawnMultiplierBonus,
|
||||
} = collectNewAchievements(checkState);
|
||||
for (const ach of newlyUnlocked) {
|
||||
window.dispatchEvent(new CustomEvent('newAchievement', { detail: ach }));
|
||||
}
|
||||
|
||||
return {
|
||||
elements: newElements,
|
||||
gold: state.gold + pendingOfflineReward.gold + goldBonus,
|
||||
pendingOfflineReward: null,
|
||||
achievementStats: newStats,
|
||||
unlockedAchievements: [
|
||||
...state.unlockedAchievements,
|
||||
...newlyUnlocked.map((a) => a.id),
|
||||
],
|
||||
permanentGoldMultiplier:
|
||||
state.permanentGoldMultiplier + permanentGoldMultiplierBonus,
|
||||
permanentSpawnMultiplier:
|
||||
state.permanentSpawnMultiplier + permanentSpawnMultiplierBonus,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -365,6 +821,16 @@ export const useGameStore = create<GameState>()(
|
||||
spawnAccumulators: state.spawnAccumulators,
|
||||
language: state.language,
|
||||
bgmEnabled: state.bgmEnabled,
|
||||
prestigeCount: state.prestigeCount,
|
||||
prestigeGoldMultiplier: state.prestigeGoldMultiplier,
|
||||
prestigeSpawnMultiplier: state.prestigeSpawnMultiplier,
|
||||
prestigeTitle: state.prestigeTitle,
|
||||
unlockedAchievements: state.unlockedAchievements,
|
||||
permanentGoldMultiplier: state.permanentGoldMultiplier,
|
||||
permanentSpawnMultiplier: state.permanentSpawnMultiplier,
|
||||
achievementStats: state.achievementStats,
|
||||
tutorialStep: state.tutorialStep,
|
||||
tutorialCompleted: state.tutorialCompleted,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// 앱 재시작 시 localStorage에서 복원된 만료 부스트를 자동 정리
|
||||
@@ -375,6 +841,27 @@ export const useGameStore = create<GameState>()(
|
||||
if (expiresAt > now) validBoosts[key] = expiresAt;
|
||||
}
|
||||
state.activeBoosts = validBoosts;
|
||||
|
||||
// achievementStats 누락 필드 기본값 보정 (구버전 저장 데이터 호환)
|
||||
if (!state.achievementStats) {
|
||||
state.achievementStats = {
|
||||
fuseCount: 0,
|
||||
enhanceCount: 0,
|
||||
offlineClaimCount: 0,
|
||||
prestigeCount: 0,
|
||||
tiersUnlocked: [],
|
||||
};
|
||||
}
|
||||
if (!Array.isArray(state.achievementStats.tiersUnlocked)) {
|
||||
state.achievementStats.tiersUnlocked = [];
|
||||
}
|
||||
if (!state.unlockedAchievements) state.unlockedAchievements = [];
|
||||
if (!state.permanentGoldMultiplier) state.permanentGoldMultiplier = 1.0;
|
||||
if (!state.permanentSpawnMultiplier) state.permanentSpawnMultiplier = 1.0;
|
||||
if (!state.prestigeCount) state.prestigeCount = 0;
|
||||
if (!state.prestigeGoldMultiplier) state.prestigeGoldMultiplier = 1.0;
|
||||
if (!state.prestigeSpawnMultiplier) state.prestigeSpawnMultiplier = 1.0;
|
||||
if (!state.prestigeTitle) state.prestigeTitle = '';
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user