feat: 프레스티지 시스템 및 업적(Achievement) 상태 관리 구현 (JSA-47)

- PrestigeResult, AchievementStats 타입 추가
- performPrestige(): 프레스티지 시 골드/원소 초기화, 배율·타이틀 업데이트
- achieveUnlock(): 업적 조건 충족 여부 실시간 체크 및 보상 지급
- 업적 통계 (fuseCount, enhanceCount, tiersUnlocked, prestigeCount 등) 상태 추적

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-01 22:44:09 +09:00
parent 466994da23
commit ff0ed541cd

View File

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