From ff0ed541cdd69d0cb75c1fdab7f9b116154ee979 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 1 Apr 2026 22:44:09 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A0=88=EC=8A=A4=ED=8B=B0?= =?UTF-8?q?=EC=A7=80=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=B0=8F=20=EC=97=85?= =?UTF-8?q?=EC=A0=81(Achievement)=20=EC=83=81=ED=83=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=ED=98=84=20(JSA-47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PrestigeResult, AchievementStats 타입 추가 - performPrestige(): 프레스티지 시 골드/원소 초기화, 배율·타이틀 업데이트 - achieveUnlock(): 업적 조건 충족 여부 실시간 체크 및 보상 지급 - 업적 통계 (fuseCount, enhanceCount, tiersUnlocked, prestigeCount 등) 상태 추적 Co-Authored-By: Paperclip --- src/store/useGameStore.ts | 519 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 503 insertions(+), 16 deletions(-) diff --git a/src/store/useGameStore.ts b/src/store/useGameStore.ts index 682a246..2565e53 100644 --- a/src/store/useGameStore.ts +++ b/src/store/useGameStore.ts @@ -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; + gold: number; + elementLevels: Record; + 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; + gold: number; + elementLevels: Record; + 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; 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()( @@ -190,13 +393,40 @@ export const useGameStore = create()( } 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()( 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()( 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()( })); }, + // 프레스티지 시스템 + 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 = {}; let goldGained = 0; @@ -276,16 +649,24 @@ export const useGameStore = create()( 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()( 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()( 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()( 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()( 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()( 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 = ''; }, } )