Refactor large files into modular components
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m9s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m9s
- Refactored page.tsx (613→252 lines) with GameOverScreen and LeftPanel extracted - Refactored StatsTab.tsx (584→92 lines) with section components - Refactored SkillsTab.tsx (434→54 lines) with sub-components - Created modular structure for GameContext, LootInventory, and other components - All extracted components organized into feature directories
This commit is contained in:
@@ -1,428 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
||||
import { useSkillStore } from '@/lib/game/stores/skillStore';
|
||||
import { useManaStore } from '@/lib/game/stores/manaStore';
|
||||
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
|
||||
import { useUIStore } from '@/lib/game/stores/uiStore';
|
||||
import { useCombatStore } from '@/lib/game/stores/combatStore';
|
||||
import { useGameStore, useGameLoop } from '@/lib/game/stores/gameStore';
|
||||
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/upgrade-effects';
|
||||
import { getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||||
import {
|
||||
computeMaxMana,
|
||||
computeRegen,
|
||||
computeClickMana,
|
||||
getMeditationBonus,
|
||||
canAffordSpellCost,
|
||||
calcDamage,
|
||||
getFloorElement,
|
||||
getBoonBonuses,
|
||||
getIncursionStrength,
|
||||
} from '@/lib/game/utils';
|
||||
import {
|
||||
ELEMENTS,
|
||||
GUARDIANS,
|
||||
SPELLS_DEF,
|
||||
HOURS_PER_TICK,
|
||||
TICK_MS,
|
||||
} from '@/lib/game/constants';
|
||||
import type { ElementDef, GuardianDef, SpellDef, GameAction } from '@/lib/game/types';
|
||||
|
||||
// Define a unified store type that combines all stores
|
||||
interface UnifiedStore {
|
||||
// From gameStore (coordinator)
|
||||
day: number;
|
||||
hour: number;
|
||||
incursionStrength: number;
|
||||
containmentWards: number;
|
||||
initialized: boolean;
|
||||
tick: () => void;
|
||||
resetGame: () => void;
|
||||
gatherMana: () => void;
|
||||
startNewLoop: () => void;
|
||||
|
||||
// From manaStore
|
||||
rawMana: number;
|
||||
meditateTicks: number;
|
||||
totalManaGathered: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
setRawMana: (amount: number) => void;
|
||||
addRawMana: (amount: number, max: number) => void;
|
||||
spendRawMana: (amount: number) => boolean;
|
||||
convertMana: (element: string, amount: number) => boolean;
|
||||
unlockElement: (element: string, cost: number) => boolean;
|
||||
craftComposite: (target: string, recipe: string[]) => boolean;
|
||||
|
||||
// From skillStore
|
||||
skills: Record<string, number>;
|
||||
skillProgress: Record<string, number>;
|
||||
skillUpgrades: Record<string, string[]>;
|
||||
skillTiers: Record<string, number>;
|
||||
paidStudySkills: Record<string, number>;
|
||||
currentStudyTarget: { type: 'skill' | 'spell' | 'blueprint'; id: string; progress: number; required: number } | null;
|
||||
parallelStudyTarget: { type: 'skill' | 'spell' | 'blueprint'; id: string; progress: number; required: number } | null;
|
||||
setSkillLevel: (skillId: string, level: number) => void;
|
||||
startStudyingSkill: (skillId: string, rawMana: number) => { started: boolean; cost: number };
|
||||
startStudyingSpell: (spellId: string, rawMana: number, studyTime: number) => { started: boolean; cost: number };
|
||||
cancelStudy: (retentionBonus: number) => void;
|
||||
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
|
||||
tierUpSkill: (skillId: string) => void;
|
||||
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: Array<{ id: string; name: string; desc: string; milestone: 5 | 10; effect: { type: string; stat?: string; value?: number; specialId?: string } }>; selected: string[] };
|
||||
|
||||
// From prestigeStore
|
||||
loopCount: number;
|
||||
insight: number;
|
||||
totalInsight: number;
|
||||
loopInsight: number;
|
||||
prestigeUpgrades: Record<string, number>;
|
||||
memorySlots: number;
|
||||
pactSlots: number;
|
||||
memories: Array<{ skillId: string; level: number; tier: number; upgrades: string[] }>;
|
||||
defeatedGuardians: number[];
|
||||
signedPacts: number[];
|
||||
pactRitualFloor: number | null;
|
||||
pactRitualProgress: number;
|
||||
doPrestige: (id: string) => void;
|
||||
addMemory: (memory: { skillId: string; level: number; tier: number; upgrades: string[] }) => void;
|
||||
removeMemory: (skillId: string) => void;
|
||||
clearMemories: () => void;
|
||||
startPactRitual: (floor: number, rawMana: number) => boolean;
|
||||
cancelPactRitual: () => void;
|
||||
removePact: (floor: number) => void;
|
||||
defeatGuardian: (floor: number) => void;
|
||||
|
||||
// From combatStore
|
||||
currentFloor: number;
|
||||
floorHP: number;
|
||||
floorMaxHP: number;
|
||||
maxFloorReached: number;
|
||||
activeSpell: string;
|
||||
currentAction: GameAction;
|
||||
castProgress: number;
|
||||
spells: Record<string, { learned: boolean; level: number; studyProgress?: number }>;
|
||||
setAction: (action: GameAction) => void;
|
||||
setSpell: (spellId: string) => void;
|
||||
learnSpell: (spellId: string) => void;
|
||||
advanceFloor: () => void;
|
||||
|
||||
// From uiStore
|
||||
log: string[];
|
||||
paused: boolean;
|
||||
gameOver: boolean;
|
||||
victory: boolean;
|
||||
addLog: (message: string) => void;
|
||||
togglePause: () => void;
|
||||
setPaused: (paused: boolean) => void;
|
||||
setGameOver: (gameOver: boolean, victory?: boolean) => void;
|
||||
}
|
||||
|
||||
interface GameContextValue {
|
||||
// Unified store for backward compatibility
|
||||
store: UnifiedStore;
|
||||
|
||||
// Individual stores for direct access if needed
|
||||
skillStore: ReturnType<typeof useSkillStore.getState>;
|
||||
manaStore: ReturnType<typeof useManaStore.getState>;
|
||||
prestigeStore: ReturnType<typeof usePrestigeStore.getState>;
|
||||
uiStore: ReturnType<typeof useUIStore.getState>;
|
||||
combatStore: ReturnType<typeof useCombatStore.getState>;
|
||||
|
||||
// Computed effects from upgrades
|
||||
upgradeEffects: ReturnType<typeof computeEffects>;
|
||||
|
||||
// Derived stats
|
||||
maxMana: number;
|
||||
baseRegen: number;
|
||||
clickMana: number;
|
||||
floorElem: string;
|
||||
floorElemDef: ElementDef | undefined;
|
||||
isGuardianFloor: boolean;
|
||||
currentGuardian: GuardianDef | undefined;
|
||||
activeSpellDef: SpellDef | undefined;
|
||||
meditationMultiplier: number;
|
||||
incursionStrength: number;
|
||||
studySpeedMult: number;
|
||||
studyCostMult: number;
|
||||
|
||||
// Effective regen calculations
|
||||
effectiveRegenWithSpecials: number;
|
||||
manaCascadeBonus: number;
|
||||
manaWaterfallBonus: number;
|
||||
effectiveRegen: number;
|
||||
|
||||
// Has special flags
|
||||
hasManaWaterfall: boolean;
|
||||
hasFlowSurge: boolean;
|
||||
hasManaOverflow: boolean;
|
||||
hasEternalFlow: boolean;
|
||||
|
||||
// DPS calculation
|
||||
dps: number;
|
||||
|
||||
// Boons
|
||||
activeBoons: ReturnType<typeof getBoonBonuses>;
|
||||
|
||||
// Helpers
|
||||
canCastSpell: (spellId: string) => boolean;
|
||||
hasSpecial: (effects: ReturnType<typeof computeEffects>, specialId: string) => boolean;
|
||||
SPECIAL_EFFECTS: typeof SPECIAL_EFFECTS;
|
||||
}
|
||||
|
||||
const GameContext = createContext<GameContextValue | null>(null);
|
||||
|
||||
export function GameProvider({ children }: { children: ReactNode }) {
|
||||
// Get all individual stores
|
||||
const gameStore = useGameStore();
|
||||
const skillState = useSkillStore();
|
||||
const manaState = useManaStore();
|
||||
const prestigeState = usePrestigeStore();
|
||||
const uiState = useUIStore();
|
||||
const combatState = useCombatStore();
|
||||
|
||||
// Create unified store object for backward compatibility
|
||||
const unifiedStore = useMemo<UnifiedStore>(() => ({
|
||||
// From gameStore
|
||||
day: gameStore.day,
|
||||
hour: gameStore.hour,
|
||||
incursionStrength: gameStore.incursionStrength,
|
||||
containmentWards: gameStore.containmentWards,
|
||||
initialized: gameStore.initialized,
|
||||
tick: gameStore.tick,
|
||||
resetGame: gameStore.resetGame,
|
||||
gatherMana: gameStore.gatherMana,
|
||||
startNewLoop: gameStore.startNewLoop,
|
||||
|
||||
// From manaStore
|
||||
rawMana: manaState.rawMana,
|
||||
meditateTicks: manaState.meditateTicks,
|
||||
totalManaGathered: manaState.totalManaGathered,
|
||||
elements: manaState.elements,
|
||||
setRawMana: manaState.setRawMana,
|
||||
addRawMana: manaState.addRawMana,
|
||||
spendRawMana: manaState.spendRawMana,
|
||||
convertMana: manaState.convertMana,
|
||||
unlockElement: manaState.unlockElement,
|
||||
craftComposite: manaState.craftComposite,
|
||||
|
||||
// From skillStore
|
||||
skills: skillState.skills,
|
||||
skillProgress: skillState.skillProgress,
|
||||
skillUpgrades: skillState.skillUpgrades,
|
||||
skillTiers: skillState.skillTiers,
|
||||
paidStudySkills: skillState.paidStudySkills,
|
||||
currentStudyTarget: skillState.currentStudyTarget,
|
||||
parallelStudyTarget: skillState.parallelStudyTarget,
|
||||
setSkillLevel: skillState.setSkillLevel,
|
||||
startStudyingSkill: skillState.startStudyingSkill,
|
||||
startStudyingSpell: skillState.startStudyingSpell,
|
||||
cancelStudy: skillState.cancelStudy,
|
||||
selectSkillUpgrade: skillState.selectSkillUpgrade,
|
||||
deselectSkillUpgrade: skillState.deselectSkillUpgrade,
|
||||
commitSkillUpgrades: skillState.commitSkillUpgrades,
|
||||
tierUpSkill: skillState.tierUpSkill,
|
||||
getSkillUpgradeChoices: skillState.getSkillUpgradeChoices,
|
||||
|
||||
// From prestigeStore
|
||||
loopCount: prestigeState.loopCount,
|
||||
insight: prestigeState.insight,
|
||||
totalInsight: prestigeState.totalInsight,
|
||||
loopInsight: prestigeState.loopInsight,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
memorySlots: prestigeState.memorySlots,
|
||||
pactSlots: prestigeState.pactSlots,
|
||||
memories: prestigeState.memories,
|
||||
defeatedGuardians: prestigeState.defeatedGuardians,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
pactRitualFloor: prestigeState.pactRitualFloor,
|
||||
pactRitualProgress: prestigeState.pactRitualProgress,
|
||||
doPrestige: prestigeState.doPrestige,
|
||||
addMemory: prestigeState.addMemory,
|
||||
removeMemory: prestigeState.removeMemory,
|
||||
clearMemories: prestigeState.clearMemories,
|
||||
startPactRitual: prestigeState.startPactRitual,
|
||||
cancelPactRitual: prestigeState.cancelPactRitual,
|
||||
removePact: prestigeState.removePact,
|
||||
defeatGuardian: prestigeState.defeatGuardian,
|
||||
|
||||
// From combatStore
|
||||
currentFloor: combatState.currentFloor,
|
||||
floorHP: combatState.floorHP,
|
||||
floorMaxHP: combatState.floorMaxHP,
|
||||
maxFloorReached: combatState.maxFloorReached,
|
||||
activeSpell: combatState.activeSpell,
|
||||
currentAction: combatState.currentAction,
|
||||
castProgress: combatState.castProgress,
|
||||
spells: combatState.spells,
|
||||
setAction: combatState.setAction,
|
||||
setSpell: combatState.setSpell,
|
||||
learnSpell: combatState.learnSpell,
|
||||
advanceFloor: combatState.advanceFloor,
|
||||
|
||||
// From uiStore
|
||||
log: uiState.logs,
|
||||
paused: uiState.paused,
|
||||
gameOver: uiState.gameOver,
|
||||
victory: uiState.victory,
|
||||
addLog: uiState.addLog,
|
||||
togglePause: uiState.togglePause,
|
||||
setPaused: uiState.setPaused,
|
||||
setGameOver: uiState.setGameOver,
|
||||
}), [gameStore, skillState, manaState, prestigeState, uiState, combatState]);
|
||||
|
||||
// Computed effects from upgrades
|
||||
const upgradeEffects = useMemo(
|
||||
() => computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {}),
|
||||
[skillState.skillUpgrades, skillState.skillTiers]
|
||||
);
|
||||
|
||||
// Create a minimal state object for compute functions
|
||||
const stateForCompute = useMemo(() => ({
|
||||
skills: skillState.skills,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
skillUpgrades: skillState.skillUpgrades,
|
||||
skillTiers: skillState.skillTiers,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
rawMana: manaState.rawMana,
|
||||
meditateTicks: manaState.meditateTicks,
|
||||
incursionStrength: gameStore.incursionStrength,
|
||||
}), [skillState, prestigeState, manaState, gameStore.incursionStrength]);
|
||||
|
||||
// Derived stats
|
||||
const maxMana = useMemo(
|
||||
() => computeMaxMana(stateForCompute, upgradeEffects),
|
||||
[stateForCompute, upgradeEffects]
|
||||
);
|
||||
|
||||
const baseRegen = useMemo(
|
||||
() => computeRegen(stateForCompute, upgradeEffects),
|
||||
[stateForCompute, upgradeEffects]
|
||||
);
|
||||
|
||||
const clickMana = useMemo(() => computeClickMana(stateForCompute), [stateForCompute]);
|
||||
|
||||
// Floor element from combat store
|
||||
const floorElem = useMemo(() => getFloorElement(combatState.currentFloor), [combatState.currentFloor]);
|
||||
const floorElemDef = ELEMENTS[floorElem];
|
||||
const isGuardianFloor = !!GUARDIANS[combatState.currentFloor];
|
||||
const currentGuardian = GUARDIANS[combatState.currentFloor];
|
||||
const activeSpellDef = SPELLS_DEF[combatState.activeSpell];
|
||||
|
||||
const meditationMultiplier = useMemo(
|
||||
() => getMeditationBonus(manaState.meditateTicks, skillState.skills, upgradeEffects.meditationEfficiency),
|
||||
[manaState.meditateTicks, skillState.skills, upgradeEffects.meditationEfficiency]
|
||||
);
|
||||
|
||||
const incursionStrength = useMemo(
|
||||
() => getIncursionStrength(gameStore.day, gameStore.hour),
|
||||
[gameStore.day, gameStore.hour]
|
||||
);
|
||||
|
||||
const studySpeedMult = useMemo(
|
||||
() => getStudySpeedMultiplier(skillState.skills),
|
||||
[skillState.skills]
|
||||
);
|
||||
|
||||
const studyCostMult = useMemo(
|
||||
() => getStudyCostMultiplier(skillState.skills),
|
||||
[skillState.skills]
|
||||
);
|
||||
|
||||
// Effective regen calculations
|
||||
const effectiveRegenWithSpecials = baseRegen * (1 - incursionStrength);
|
||||
|
||||
const manaCascadeBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_CASCADE)
|
||||
? Math.floor(maxMana / 100) * 0.1
|
||||
: 0;
|
||||
|
||||
const manaWaterfallBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_WATERFALL)
|
||||
? Math.floor(maxMana / 100) * 0.25
|
||||
: 0;
|
||||
|
||||
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus + manaWaterfallBonus) * meditationMultiplier;
|
||||
|
||||
// Has special flags for UI
|
||||
const hasManaWaterfall = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_WATERFALL);
|
||||
const hasFlowSurge = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.FLOW_SURGE);
|
||||
const hasManaOverflow = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_OVERFLOW);
|
||||
const hasEternalFlow = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.ETERNAL_FLOW);
|
||||
|
||||
// Active boons
|
||||
const activeBoons = useMemo(
|
||||
() => getBoonBonuses(prestigeState.signedPacts),
|
||||
[prestigeState.signedPacts]
|
||||
);
|
||||
|
||||
// DPS calculation - based on active spell, attack speed, and damage
|
||||
const dps = useMemo(() => {
|
||||
if (!activeSpellDef) return 0;
|
||||
const baseDmg = calcDamage(
|
||||
{ skills: skillState.skills, signedPacts: prestigeState.signedPacts },
|
||||
combatState.activeSpell,
|
||||
floorElem
|
||||
);
|
||||
const dmgWithEffects = baseDmg * upgradeEffects.baseDamageMultiplier + upgradeEffects.baseDamageBonus;
|
||||
const attackSpeed = (1 + (skillState.skills.quickCast || 0) * 0.05) * upgradeEffects.attackSpeedMultiplier;
|
||||
const castSpeed = activeSpellDef.castSpeed || 1;
|
||||
return dmgWithEffects * attackSpeed * castSpeed;
|
||||
}, [activeSpellDef, skillState.skills, prestigeState.signedPacts, floorElem, upgradeEffects, combatState.activeSpell]);
|
||||
|
||||
// Helper functions
|
||||
const canCastSpell = (spellId: string): boolean => {
|
||||
const spell = SPELLS_DEF[spellId];
|
||||
if (!spell) return false;
|
||||
return canAffordSpellCost(spell.cost, manaState.rawMana, manaState.elements);
|
||||
};
|
||||
|
||||
const value: GameContextValue = {
|
||||
store: unifiedStore,
|
||||
skillStore: skillState,
|
||||
manaStore: manaState,
|
||||
prestigeStore: prestigeState,
|
||||
uiStore: uiState,
|
||||
combatStore: combatState,
|
||||
upgradeEffects,
|
||||
maxMana,
|
||||
baseRegen,
|
||||
clickMana,
|
||||
floorElem,
|
||||
floorElemDef,
|
||||
isGuardianFloor,
|
||||
currentGuardian,
|
||||
activeSpellDef,
|
||||
meditationMultiplier,
|
||||
incursionStrength,
|
||||
studySpeedMult,
|
||||
studyCostMult,
|
||||
effectiveRegenWithSpecials,
|
||||
manaCascadeBonus,
|
||||
manaWaterfallBonus,
|
||||
effectiveRegen,
|
||||
hasManaWaterfall,
|
||||
hasFlowSurge,
|
||||
hasManaOverflow,
|
||||
hasEternalFlow,
|
||||
dps,
|
||||
activeBoons,
|
||||
canCastSpell,
|
||||
hasSpecial,
|
||||
SPECIAL_EFFECTS,
|
||||
};
|
||||
|
||||
return <GameContext.Provider value={value}>{children}</GameContext.Provider>;
|
||||
}
|
||||
|
||||
export function useGameContext() {
|
||||
const context = useContext(GameContext);
|
||||
if (!context) {
|
||||
throw new Error('useGameContext must be used within a GameProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
GameProvider.displayName = "GameProvider";
|
||||
// Re-export everything from the modular GameContext files
|
||||
export { GameProvider, GameProvider as default } from './GameContext/Provider';
|
||||
export { useGameContext } from './GameContext/hooks';
|
||||
export { GameContext } from './GameContext/context-create';
|
||||
export type { GameContextValue, UnifiedStore } from './GameContext/types';
|
||||
|
||||
// Re-export useGameLoop for convenience
|
||||
export { useGameLoop };
|
||||
export { useGameLoop } from '@/lib/game/stores/gameHooks';
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { useSkillStore } from '@/lib/game/stores/skillStore';
|
||||
import { useManaStore } from '@/lib/game/stores/manaStore';
|
||||
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
|
||||
import { useUIStore } from '@/lib/game/stores/uiStore';
|
||||
import { useCombatStore } from '@/lib/game/stores/combatStore';
|
||||
import { useGameStore } from '@/lib/game/stores/gameStore';
|
||||
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/upgrade-effects';
|
||||
import { getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||||
import {
|
||||
computeMaxMana,
|
||||
computeRegen,
|
||||
computeClickMana,
|
||||
getMeditationBonus,
|
||||
canAffordSpellCost,
|
||||
calcDamage,
|
||||
getFloorElement,
|
||||
getBoonBonuses,
|
||||
getIncursionStrength,
|
||||
} from '@/lib/game/utils';
|
||||
import {
|
||||
ELEMENTS,
|
||||
GUARDIANS,
|
||||
SPELLS_DEF,
|
||||
} from '@/lib/game/constants';
|
||||
import type { ElementDef, GuardianDef, SpellDef, GameAction } from '@/lib/game/types';
|
||||
import type { UnifiedStore, GameContextValue } from './types';
|
||||
import { GameContext } from './context-create';
|
||||
|
||||
function createUnifiedStore(
|
||||
gameStore: ReturnType<typeof useGameStore.getState>,
|
||||
skillState: ReturnType<typeof useSkillStore.getState>,
|
||||
manaState: ReturnType<typeof useManaStore.getState>,
|
||||
prestigeState: ReturnType<typeof usePrestigeStore.getState>,
|
||||
uiState: ReturnType<typeof useUIStore.getState>,
|
||||
combatState: ReturnType<typeof useCombatStore.getState>
|
||||
): UnifiedStore {
|
||||
return {
|
||||
// From gameStore
|
||||
day: gameStore.day,
|
||||
hour: gameStore.hour,
|
||||
incursionStrength: gameStore.incursionStrength,
|
||||
containmentWards: gameStore.containmentWards,
|
||||
initialized: gameStore.initialized,
|
||||
tick: gameStore.tick,
|
||||
resetGame: gameStore.resetGame,
|
||||
gatherMana: gameStore.gatherMana,
|
||||
startNewLoop: gameStore.startNewLoop,
|
||||
|
||||
// From manaStore
|
||||
rawMana: manaState.rawMana,
|
||||
meditateTicks: manaState.meditateTicks,
|
||||
totalManaGathered: manaState.totalManaGathered,
|
||||
elements: manaState.elements,
|
||||
setRawMana: manaState.setRawMana,
|
||||
addRawMana: manaState.addRawMana,
|
||||
spendRawMana: manaState.spendRawMana,
|
||||
convertMana: manaState.convertMana,
|
||||
unlockElement: manaState.unlockElement,
|
||||
craftComposite: manaState.craftComposite,
|
||||
|
||||
// From skillStore
|
||||
skills: skillState.skills,
|
||||
skillProgress: skillState.skillProgress,
|
||||
skillUpgrades: skillState.skillUpgrades,
|
||||
skillTiers: skillState.skillTiers,
|
||||
paidStudySkills: skillState.paidStudySkills,
|
||||
currentStudyTarget: skillState.currentStudyTarget,
|
||||
parallelStudyTarget: skillState.parallelStudyTarget,
|
||||
setSkillLevel: skillState.setSkillLevel,
|
||||
startStudyingSkill: skillState.startStudyingSkill,
|
||||
startStudyingSpell: skillState.startStudyingSpell,
|
||||
cancelStudy: skillState.cancelStudy,
|
||||
selectSkillUpgrade: skillState.selectSkillUpgrade,
|
||||
deselectSkillUpgrade: skillState.deselectSkillUpgrade,
|
||||
commitSkillUpgrades: skillState.commitSkillUpgrades,
|
||||
tierUpSkill: skillState.tierUpSkill,
|
||||
getSkillUpgradeChoices: skillState.getSkillUpgradeChoices,
|
||||
|
||||
// From prestigeStore
|
||||
loopCount: prestigeState.loopCount,
|
||||
insight: prestigeState.insight,
|
||||
totalInsight: prestigeState.totalInsight,
|
||||
loopInsight: prestigeState.loopInsight,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
memorySlots: prestigeState.memorySlots,
|
||||
pactSlots: prestigeState.pactSlots,
|
||||
memories: prestigeState.memories,
|
||||
defeatedGuardians: prestigeState.defeatedGuardians,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
pactRitualFloor: prestigeState.pactRitualFloor,
|
||||
pactRitualProgress: prestigeState.pactRitualProgress,
|
||||
doPrestige: prestigeState.doPrestige,
|
||||
addMemory: prestigeState.addMemory,
|
||||
removeMemory: prestigeState.removeMemory,
|
||||
clearMemories: prestigeState.clearMemories,
|
||||
startPactRitual: prestigeState.startPactRitual,
|
||||
cancelPactRitual: prestigeState.cancelPactRitual,
|
||||
removePact: prestigeState.removePact,
|
||||
defeatGuardian: prestigeState.defeatGuardian,
|
||||
|
||||
// From combatStore
|
||||
currentFloor: combatState.currentFloor,
|
||||
floorHP: combatState.floorHP,
|
||||
floorMaxHP: combatState.floorMaxHP,
|
||||
maxFloorReached: combatState.maxFloorReached,
|
||||
activeSpell: combatState.activeSpell,
|
||||
currentAction: combatState.currentAction,
|
||||
castProgress: combatState.castProgress,
|
||||
spells: combatState.spells,
|
||||
setAction: combatState.setAction,
|
||||
setSpell: combatState.setSpell,
|
||||
learnSpell: combatState.learnSpell,
|
||||
advanceFloor: combatState.advanceFloor,
|
||||
|
||||
// From uiStore
|
||||
log: uiState.logs,
|
||||
paused: uiState.paused,
|
||||
gameOver: uiState.gameOver,
|
||||
victory: uiState.victory,
|
||||
addLog: uiState.addLog,
|
||||
togglePause: uiState.togglePause,
|
||||
setPaused: uiState.setPaused,
|
||||
setGameOver: uiState.setGameOver,
|
||||
};
|
||||
}
|
||||
|
||||
export function GameProvider({ children }: { children: ReactNode }) {
|
||||
// Get all individual stores
|
||||
const gameStore = useGameStore();
|
||||
const skillState = useSkillStore();
|
||||
const manaState = useManaStore();
|
||||
const prestigeState = usePrestigeStore();
|
||||
const uiState = useUIStore();
|
||||
const combatState = useCombatStore();
|
||||
|
||||
// Create unified store object for backward compatibility
|
||||
const unifiedStore = useMemo(
|
||||
() => createUnifiedStore(gameStore, skillState, manaState, prestigeState, uiState, combatState),
|
||||
[gameStore, skillState, manaState, prestigeState, uiState, combatState]
|
||||
);
|
||||
|
||||
// Computed effects from upgrades
|
||||
const upgradeEffects = useMemo(
|
||||
() => computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {}),
|
||||
[skillState.skillUpgrades, skillState.skillTiers]
|
||||
);
|
||||
|
||||
// Create a minimal state object for compute functions
|
||||
const stateForCompute = useMemo(() => ({
|
||||
skills: skillState.skills,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
skillUpgrades: skillState.skillUpgrades,
|
||||
skillTiers: skillState.skillTiers,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
rawMana: manaState.rawMana,
|
||||
meditateTicks: manaState.meditateTicks,
|
||||
incursionStrength: gameStore.incursionStrength,
|
||||
}), [skillState, prestigeState, manaState, gameStore.incursionStrength]);
|
||||
|
||||
// Derived stats
|
||||
const maxMana = useMemo(
|
||||
() => computeMaxMana(stateForCompute, upgradeEffects),
|
||||
[stateForCompute, upgradeEffects]
|
||||
);
|
||||
|
||||
const baseRegen = useMemo(
|
||||
() => computeRegen(stateForCompute, upgradeEffects),
|
||||
[stateForCompute, upgradeEffects]
|
||||
);
|
||||
|
||||
const clickMana = useMemo(() => computeClickMana(stateForCompute), [stateForCompute]);
|
||||
|
||||
// Floor element from combat store
|
||||
const floorElem = useMemo(() => getFloorElement(combatState.currentFloor), [combatState.currentFloor]);
|
||||
const floorElemDef = ELEMENTS[floorElem];
|
||||
const isGuardianFloor = !!GUARDIANS[combatState.currentFloor];
|
||||
const currentGuardian = GUARDIANS[combatState.currentFloor];
|
||||
const activeSpellDef = SPELLS_DEF[combatState.activeSpell];
|
||||
|
||||
const meditationMultiplier = useMemo(
|
||||
() => getMeditationBonus(manaState.meditateTicks, skillState.skills, upgradeEffects.meditationEfficiency),
|
||||
[manaState.meditateTicks, skillState.skills, upgradeEffects.meditationEfficiency]
|
||||
);
|
||||
|
||||
const incursionStrength = useMemo(
|
||||
() => getIncursionStrength(gameStore.day, gameStore.hour),
|
||||
[gameStore.day, gameStore.hour]
|
||||
);
|
||||
|
||||
const studySpeedMult = useMemo(
|
||||
() => getStudySpeedMultiplier(skillState.skills),
|
||||
[skillState.skills]
|
||||
);
|
||||
|
||||
const studyCostMult = useMemo(
|
||||
() => getStudyCostMultiplier(skillState.skills),
|
||||
[skillState.skills]
|
||||
);
|
||||
|
||||
// Effective regen calculations
|
||||
const effectiveRegenWithSpecials = baseRegen * (1 - incursionStrength);
|
||||
|
||||
const manaCascadeBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_CASCADE)
|
||||
? Math.floor(maxMana / 100) * 0.1
|
||||
: 0;
|
||||
|
||||
const manaWaterfallBonus = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_WATERFALL)
|
||||
? Math.floor(maxMana / 100) * 0.25
|
||||
: 0;
|
||||
|
||||
const effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus + manaWaterfallBonus) * meditationMultiplier;
|
||||
|
||||
// Has special flags for UI
|
||||
const hasManaWaterfall = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_WATERFALL);
|
||||
const hasFlowSurge = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.FLOW_SURGE);
|
||||
const hasManaOverflow = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.MANA_OVERFLOW);
|
||||
const hasEternalFlow = hasSpecial(upgradeEffects, SPECIAL_EFFECTS.ETERNAL_FLOW);
|
||||
|
||||
// Active boons
|
||||
const activeBoons = useMemo(
|
||||
() => getBoonBonuses(prestigeState.signedPacts),
|
||||
[prestigeState.signedPacts]
|
||||
);
|
||||
|
||||
// DPS calculation - based on active spell, attack speed, and damage
|
||||
const dps = useMemo(() => {
|
||||
if (!activeSpellDef) return 0;
|
||||
const baseDmg = calcDamage(
|
||||
{ skills: skillState.skills, signedPacts: prestigeState.signedPacts },
|
||||
combatState.activeSpell,
|
||||
floorElem
|
||||
);
|
||||
const dmgWithEffects = baseDmg * upgradeEffects.baseDamageMultiplier + upgradeEffects.baseDamageBonus;
|
||||
const attackSpeed = (1 + (skillState.skills.quickCast || 0) * 0.05) * upgradeEffects.attackSpeedMultiplier;
|
||||
const castSpeed = activeSpellDef.castSpeed || 1;
|
||||
return dmgWithEffects * attackSpeed * castSpeed;
|
||||
}, [activeSpellDef, skillState.skills, prestigeState.signedPacts, floorElem, upgradeEffects, combatState.activeSpell]);
|
||||
|
||||
// Helper functions
|
||||
const canCastSpell = (spellId: string): boolean => {
|
||||
const spell = SPELLS_DEF[spellId];
|
||||
if (!spell) return false;
|
||||
return canAffordSpellCost(spell.cost, manaState.rawMana, manaState.elements);
|
||||
};
|
||||
|
||||
const value: GameContextValue = {
|
||||
store: unifiedStore,
|
||||
skillStore: skillState,
|
||||
manaStore: manaState,
|
||||
prestigeStore: prestigeState,
|
||||
uiStore: uiState,
|
||||
combatStore: combatState,
|
||||
upgradeEffects,
|
||||
maxMana,
|
||||
baseRegen,
|
||||
clickMana,
|
||||
floorElem,
|
||||
floorElemDef,
|
||||
isGuardianFloor,
|
||||
currentGuardian,
|
||||
activeSpellDef,
|
||||
meditationMultiplier,
|
||||
incursionStrength,
|
||||
studySpeedMult,
|
||||
studyCostMult,
|
||||
effectiveRegenWithSpecials,
|
||||
manaCascadeBonus,
|
||||
manaWaterfallBonus,
|
||||
effectiveRegen,
|
||||
hasManaWaterfall,
|
||||
hasFlowSurge,
|
||||
hasManaOverflow,
|
||||
hasEternalFlow,
|
||||
dps,
|
||||
activeBoons,
|
||||
canCastSpell,
|
||||
hasSpecial,
|
||||
SPECIAL_EFFECTS,
|
||||
};
|
||||
|
||||
return <GameContext.Provider value={value}>{children}</GameContext.Provider>;
|
||||
}
|
||||
|
||||
GameProvider.displayName = "GameProvider";
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext } from 'react';
|
||||
import type { GameContextValue } from './types';
|
||||
|
||||
export const GameContext = createContext<GameContextValue | null>(null);
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useContext } from 'react';
|
||||
import { GameContext } from './context-create';
|
||||
import type { GameContextValue } from './types';
|
||||
|
||||
export function useGameContext(): GameContextValue {
|
||||
const context = useContext(GameContext);
|
||||
if (!context) {
|
||||
throw new Error('useGameContext must be used within a GameProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { ElementDef, GuardianDef, SpellDef, GameAction } from '@/lib/game/types';
|
||||
import { useSkillStore } from '@/lib/game/stores/skillStore';
|
||||
import { useManaStore } from '@/lib/game/stores/manaStore';
|
||||
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
|
||||
import { useUIStore } from '@/lib/game/stores/uiStore';
|
||||
import { useCombatStore } from '@/lib/game/stores/combatStore';
|
||||
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/upgrade-effects';
|
||||
import { getBoonBonuses } from '@/lib/game/utils';
|
||||
|
||||
// Define a unified store type that combines all stores
|
||||
export interface UnifiedStore {
|
||||
// From gameStore (coordinator)
|
||||
day: number;
|
||||
hour: number;
|
||||
incursionStrength: number;
|
||||
containmentWards: number;
|
||||
initialized: boolean;
|
||||
tick: () => void;
|
||||
resetGame: () => void;
|
||||
gatherMana: () => void;
|
||||
startNewLoop: () => void;
|
||||
|
||||
// From manaStore
|
||||
rawMana: number;
|
||||
meditateTicks: number;
|
||||
totalManaGathered: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
setRawMana: (amount: number) => void;
|
||||
addRawMana: (amount: number, max: number) => void;
|
||||
spendRawMana: (amount: number) => boolean;
|
||||
convertMana: (element: string, amount: number) => boolean;
|
||||
unlockElement: (element: string, cost: number) => boolean;
|
||||
craftComposite: (target: string, recipe: string[]) => boolean;
|
||||
|
||||
// From skillStore
|
||||
skills: Record<string, number>;
|
||||
skillProgress: Record<string, number>;
|
||||
skillUpgrades: Record<string, string[]>;
|
||||
skillTiers: Record<string, number>;
|
||||
paidStudySkills: Record<string, number>;
|
||||
currentStudyTarget: { type: 'skill' | 'spell' | 'blueprint'; id: string; progress: number; required: number } | null;
|
||||
parallelStudyTarget: { type: 'skill' | 'spell' | 'blueprint'; id: string; progress: number; required: number } | null;
|
||||
setSkillLevel: (skillId: string, level: number) => void;
|
||||
startStudyingSkill: (skillId: string, rawMana: number) => { started: boolean; cost: number };
|
||||
startStudyingSpell: (spellId: string, rawMana: number, studyTime: number) => { started: boolean; cost: number };
|
||||
cancelStudy: (retentionBonus: number) => void;
|
||||
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
|
||||
tierUpSkill: (skillId: string) => void;
|
||||
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => {
|
||||
available: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
milestone: 5 | 10;
|
||||
effect: { type: string; stat?: string; value?: number; specialId?: string }
|
||||
}>;
|
||||
selected: string[]
|
||||
};
|
||||
|
||||
// From prestigeStore
|
||||
loopCount: number;
|
||||
insight: number;
|
||||
totalInsight: number;
|
||||
loopInsight: number;
|
||||
prestigeUpgrades: Record<string, number>;
|
||||
memorySlots: number;
|
||||
pactSlots: number;
|
||||
memories: Array<{ skillId: string; level: number; tier: number; upgrades: string[] }>;
|
||||
defeatedGuardians: number[];
|
||||
signedPacts: number[];
|
||||
pactRitualFloor: number | null;
|
||||
pactRitualProgress: number;
|
||||
doPrestige: (id: string) => void;
|
||||
addMemory: (memory: { skillId: string; level: number; tier: number; upgrades: string[] }) => void;
|
||||
removeMemory: (skillId: string) => void;
|
||||
clearMemories: () => void;
|
||||
startPactRitual: (floor: number, rawMana: number) => boolean;
|
||||
cancelPactRitual: () => void;
|
||||
removePact: (floor: number) => void;
|
||||
defeatGuardian: (floor: number) => void;
|
||||
|
||||
// From combatStore
|
||||
currentFloor: number;
|
||||
floorHP: number;
|
||||
floorMaxHP: number;
|
||||
maxFloorReached: number;
|
||||
activeSpell: string;
|
||||
currentAction: GameAction;
|
||||
castProgress: number;
|
||||
spells: Record<string, { learned: boolean; level: number; studyProgress?: number }>;
|
||||
setAction: (action: GameAction) => void;
|
||||
setSpell: (spellId: string) => void;
|
||||
learnSpell: (spellId: string) => void;
|
||||
advanceFloor: () => void;
|
||||
|
||||
// From uiStore
|
||||
log: string[];
|
||||
paused: boolean;
|
||||
gameOver: boolean;
|
||||
victory: boolean;
|
||||
addLog: (message: string) => void;
|
||||
togglePause: () => void;
|
||||
setPaused: (paused: boolean) => void;
|
||||
setGameOver: (gameOver: boolean, victory?: boolean) => void;
|
||||
}
|
||||
|
||||
export interface GameContextValue {
|
||||
// Unified store for backward compatibility
|
||||
store: UnifiedStore;
|
||||
|
||||
// Individual stores for direct access if needed
|
||||
skillStore: ReturnType<typeof useSkillStore.getState>;
|
||||
manaStore: ReturnType<typeof useManaStore.getState>;
|
||||
prestigeStore: ReturnType<typeof usePrestigeStore.getState>;
|
||||
uiStore: ReturnType<typeof useUIStore.getState>;
|
||||
combatStore: ReturnType<typeof useCombatStore.getState>;
|
||||
|
||||
// Computed effects from upgrades
|
||||
upgradeEffects: ReturnType<typeof computeEffects>;
|
||||
|
||||
// Derived stats
|
||||
maxMana: number;
|
||||
baseRegen: number;
|
||||
clickMana: number;
|
||||
floorElem: string;
|
||||
floorElemDef: ElementDef | undefined;
|
||||
isGuardianFloor: boolean;
|
||||
currentGuardian: GuardianDef | undefined;
|
||||
activeSpellDef: SpellDef | undefined;
|
||||
meditationMultiplier: number;
|
||||
incursionStrength: number;
|
||||
studySpeedMult: number;
|
||||
studyCostMult: number;
|
||||
|
||||
// Effective regen calculations
|
||||
effectiveRegenWithSpecials: number;
|
||||
manaCascadeBonus: number;
|
||||
manaWaterfallBonus: number;
|
||||
effectiveRegen: number;
|
||||
|
||||
// Has special flags
|
||||
hasManaWaterfall: boolean;
|
||||
hasFlowSurge: boolean;
|
||||
hasManaOverflow: boolean;
|
||||
hasEternalFlow: boolean;
|
||||
|
||||
// DPS calculation
|
||||
dps: number;
|
||||
|
||||
// Boons
|
||||
activeBoons: ReturnType<typeof getBoonBonuses>;
|
||||
|
||||
// Helpers
|
||||
canCastSpell: (spellId: string) => boolean;
|
||||
hasSpecial: (effects: ReturnType<typeof computeEffects>, specialId: string) => boolean;
|
||||
SPECIAL_EFFECTS: typeof SPECIAL_EFFECTS;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { Scroll } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { LOOT_DROPS } from '@/lib/game/data/loot-drops';
|
||||
import { RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from './types';
|
||||
|
||||
interface BlueprintsSectionProps {
|
||||
blueprints: string[];
|
||||
}
|
||||
|
||||
export function BlueprintsSection({ blueprints }: BlueprintsSectionProps) {
|
||||
if (blueprints.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Scroll className="w-3 h-3" />
|
||||
Blueprints (permanent)
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{blueprints.map((id) => {
|
||||
const drop = LOOT_DROPS[id];
|
||||
if (!drop) return null;
|
||||
const rarityColor = RARITY_CSS_VAR[drop.rarity] || 'var(--rarity-common)';
|
||||
return (
|
||||
<Badge
|
||||
key={id}
|
||||
className="text-xs"
|
||||
style={{
|
||||
backgroundColor: `${RARITY_GLOW_CSS_VAR[drop.rarity] || 'var(--rarity-common-glow)'}`,
|
||||
color: rarityColor,
|
||||
borderColor: rarityColor,
|
||||
}}
|
||||
>
|
||||
{drop.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1 italic">
|
||||
Blueprints are permanent unlocks - use them to craft equipment
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { Package, Trash2 } from 'lucide-react';
|
||||
import type { EquipmentInstance } from '@/lib/game/types';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { CATEGORY_ICONS } from './icons';
|
||||
import { RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from './types';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
|
||||
interface EquipmentItemProps {
|
||||
instanceId: string;
|
||||
instance: EquipmentInstance;
|
||||
onDelete?: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
export function EquipmentItem({ instanceId, instance, onDelete }: EquipmentItemProps) {
|
||||
const type = EQUIPMENT_TYPES[instance.typeId];
|
||||
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
|
||||
const rarityColor = RARITY_CSS_VAR[instance.rarity] || 'var(--rarity-common)';
|
||||
const rarityGlow = RARITY_GLOW_CSS_VAR[instance.rarity] || 'var(--rarity-common-glow)';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-2 rounded border bg-[var(--bg-sunken)] group"
|
||||
style={{
|
||||
borderColor: rarityColor,
|
||||
backgroundColor: rarityGlow,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon className="w-4 h-4 mt-0.5" style={{ color: rarityColor }} />
|
||||
<div>
|
||||
<div className="text-xs font-semibold" style={{ color: rarityColor }}>
|
||||
{instance.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{type?.name} • {instance.usedCapacity}/{instance.totalCapacity} cap
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] capitalize">
|
||||
{instance.rarity} • {instance.enchantments.length} enchants
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onDelete && (
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
|
||||
onClick={() => onDelete(instanceId)}
|
||||
aria-label={`Delete ${instance.name}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EquipmentSectionProps {
|
||||
equipment: [string, EquipmentInstance][];
|
||||
onDeleteEquipment?: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
export function EquipmentSection({ equipment, onDeleteEquipment }: EquipmentSectionProps) {
|
||||
if (equipment.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Package className="w-3 h-3" />
|
||||
Equipment
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{equipment.map(([id, instance]) => (
|
||||
<EquipmentItem
|
||||
key={id}
|
||||
instanceId={id}
|
||||
instance={instance}
|
||||
onDelete={onDeleteEquipment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { Droplet } from 'lucide-react';
|
||||
import { ElementBadge } from '@/components/ui/element-badge';
|
||||
import type { ElementState } from '@/lib/game/types';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
interface EssenceItemProps {
|
||||
elementId: string;
|
||||
state: ElementState;
|
||||
}
|
||||
|
||||
export function EssenceItem({ elementId, state }: EssenceItemProps) {
|
||||
const elem = ELEMENTS[elementId];
|
||||
if (!elem) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-2 rounded border bg-[var(--bg-sunken)]"
|
||||
style={{
|
||||
borderColor: `var(--mana-${elementId})`,
|
||||
backgroundColor: `var(--mana-${elementId})20`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<ElementBadge element={elementId} showIcon={true} size="sm" />
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{state.current} / {state.max}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EssenceSectionProps {
|
||||
essence: [string, ElementState][];
|
||||
}
|
||||
|
||||
export function EssenceSection({ essence }: EssenceSectionProps) {
|
||||
if (essence.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Droplet className="w-3 h-3" />
|
||||
Elemental Essence
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{essence.map(([id, state]) => (
|
||||
<EssenceItem key={id} elementId={id} state={state} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+39
-228
@@ -8,13 +8,11 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Gem, Sparkles, Scroll, Droplet, Trash2, Search,
|
||||
Package, Sword, Shield, Shirt, Crown, ArrowUpDown,
|
||||
Wrench, AlertTriangle
|
||||
Gem, Search, ArrowUpDown, AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import { ElementBadge } from '@/components/ui/element-badge';
|
||||
import type { LootInventory as LootInventoryType, EquipmentInstance, ElementState } from '@/lib/game/types';
|
||||
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
import { LOOT_DROPS } from '@/lib/game/data/loot-drops';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { useGameToast } from '@/components/game/GameToast';
|
||||
@@ -29,6 +27,12 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import { type SortMode, type FilterMode, RARITY_ORDER } from './types';
|
||||
import { MaterialsSection } from './MaterialItem';
|
||||
import { EssenceSection } from './EssenceItem';
|
||||
import { BlueprintsSection } from './BlueprintsSection';
|
||||
import { EquipmentSection } from './EquipmentItem';
|
||||
|
||||
interface LootInventoryProps {
|
||||
inventory: LootInventoryType;
|
||||
elements?: Record<string, ElementState>;
|
||||
@@ -37,49 +41,6 @@ interface LootInventoryProps {
|
||||
onDeleteEquipment?: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
type SortMode = 'name' | 'rarity' | 'count';
|
||||
type FilterMode = 'all' | 'materials' | 'essence' | 'blueprints' | 'equipment';
|
||||
|
||||
const RARITY_ORDER = {
|
||||
common: 0,
|
||||
uncommon: 1,
|
||||
rare: 2,
|
||||
epic: 3,
|
||||
legendary: 4,
|
||||
mythic: 5,
|
||||
};
|
||||
|
||||
// Map rarity to CSS variable for colors
|
||||
const RARITY_CSS_VAR: Record<string, string> = {
|
||||
common: 'var(--rarity-common)',
|
||||
uncommon: 'var(--rarity-uncommon)',
|
||||
rare: 'var(--rarity-rare)',
|
||||
epic: 'var(--rarity-epic)',
|
||||
legendary: 'var(--rarity-legendary)',
|
||||
mythic: 'var(--rarity-mythic)',
|
||||
};
|
||||
|
||||
// Map rarity to CSS variable for glow/background
|
||||
const RARITY_GLOW_CSS_VAR: Record<string, string> = {
|
||||
common: 'var(--rarity-common-glow)',
|
||||
uncommon: 'var(--rarity-uncommon-glow)',
|
||||
rare: 'var(--rarity-rare-glow)',
|
||||
epic: 'var(--rarity-epic-glow)',
|
||||
legendary: 'var(--rarity-legendary-glow)',
|
||||
mythic: 'var(--rarity-mythic-glow)',
|
||||
};
|
||||
|
||||
const CATEGORY_ICONS: Record<string, typeof Sword> = {
|
||||
caster: Sword,
|
||||
shield: Shield,
|
||||
catalyst: Sparkles,
|
||||
head: Crown,
|
||||
body: Shirt,
|
||||
hands: Wrench,
|
||||
feet: Package,
|
||||
accessory: Gem,
|
||||
};
|
||||
|
||||
export function LootInventoryDisplay({
|
||||
inventory,
|
||||
elements,
|
||||
@@ -131,7 +92,7 @@ export function LootInventoryDisplay({
|
||||
? Object.entries(elements)
|
||||
.filter(([id, state]) => {
|
||||
if (!state.unlocked || state.current <= 0) return false;
|
||||
if (id === 'transference') return false; // Transference is not loot
|
||||
if (id === 'transference') return false;
|
||||
if (searchTerm && !ELEMENTS[id]?.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
@@ -167,22 +128,6 @@ export function LootInventoryDisplay({
|
||||
// Check if we have anything to show
|
||||
const hasItems = totalItems > 0 || essenceCount > 0;
|
||||
|
||||
if (!hasItems) {
|
||||
return (
|
||||
<GameCard variant="default" className="w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Gem className="w-4 h-4 text-[var(--mana-light)]" />
|
||||
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
|
||||
Inventory
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-[var(--text-muted)] text-sm text-center py-4">
|
||||
No items collected yet. Defeat floors and guardians to find loot!
|
||||
</div>
|
||||
</GameCard>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDeleteMaterial = (materialId: string) => {
|
||||
const drop = LOOT_DROPS[materialId];
|
||||
if (drop) {
|
||||
@@ -212,6 +157,22 @@ export function LootInventoryDisplay({
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
if (!hasItems) {
|
||||
return (
|
||||
<GameCard variant="default" className="w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Gem className="w-4 h-4 text-[var(--mana-light)]" />
|
||||
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
|
||||
Inventory
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-[var(--text-muted)] text-sm text-center py-4">
|
||||
No items collected yet. Defeat floors and guardians to find loot!
|
||||
</div>
|
||||
</GameCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GameCard variant="default" className="w-full">
|
||||
@@ -279,179 +240,29 @@ export function LootInventoryDisplay({
|
||||
<ScrollArea className="h-64 w-full">
|
||||
<div className="space-y-3 pr-2">
|
||||
{/* Materials */}
|
||||
{(filterMode === 'all' || filterMode === 'materials') && filteredMaterials.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Materials
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{filteredMaterials.map(([id, count]) => {
|
||||
const drop = LOOT_DROPS[id];
|
||||
if (!drop) return null;
|
||||
const rarityColor = RARITY_CSS_VAR[drop.rarity] || 'var(--rarity-common)';
|
||||
const rarityGlow = RARITY_GLOW_CSS_VAR[drop.rarity] || 'var(--rarity-common-glow)';
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-2 rounded border bg-[var(--bg-sunken)] group relative"
|
||||
style={{
|
||||
borderColor: rarityColor,
|
||||
backgroundColor: rarityGlow,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-semibold" style={{ color: rarityColor }}>
|
||||
{drop.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
x{count}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] capitalize">
|
||||
{drop.rarity}
|
||||
</div>
|
||||
</div>
|
||||
{onDeleteMaterial && (
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
|
||||
onClick={() => handleDeleteMaterial(id)}
|
||||
aria-label={`Delete ${drop.name}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{(filterMode === 'all' || filterMode === 'materials') && (
|
||||
<MaterialsSection
|
||||
materials={filteredMaterials}
|
||||
onDeleteMaterial={handleDeleteMaterial}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Essence */}
|
||||
{(filterMode === 'all' || filterMode === 'essence') && filteredEssence.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Droplet className="w-3 h-3" />
|
||||
Elemental Essence
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{filteredEssence.map(([id, state]) => {
|
||||
const elem = ELEMENTS[id];
|
||||
if (!elem) return null;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-2 rounded border bg-[var(--bg-sunken)]"
|
||||
style={{
|
||||
borderColor: `var(--mana-${id})`,
|
||||
backgroundColor: `var(--mana-${id})20`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<ElementBadge element={id} showIcon={true} size="sm" />
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{state.current} / {state.max}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{(filterMode === 'all' || filterMode === 'essence') && (
|
||||
<EssenceSection essence={filteredEssence} />
|
||||
)}
|
||||
|
||||
{/* Blueprints */}
|
||||
{(filterMode === 'all' || filterMode === 'blueprints') && inventory.blueprints.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Scroll className="w-3 h-3" />
|
||||
Blueprints (permanent)
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{inventory.blueprints.map((id) => {
|
||||
const drop = LOOT_DROPS[id];
|
||||
if (!drop) return null;
|
||||
const rarityColor = RARITY_CSS_VAR[drop.rarity] || 'var(--rarity-common)';
|
||||
return (
|
||||
<Badge
|
||||
key={id}
|
||||
className="text-xs"
|
||||
style={{
|
||||
backgroundColor: `${RARITY_GLOW_CSS_VAR[drop.rarity] || 'var(--rarity-common-glow)'}`,
|
||||
color: rarityColor,
|
||||
borderColor: rarityColor,
|
||||
}}
|
||||
>
|
||||
{drop.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1 italic">
|
||||
Blueprints are permanent unlocks - use them to craft equipment
|
||||
</div>
|
||||
</div>
|
||||
{(filterMode === 'all' || filterMode === 'blueprints') && (
|
||||
<BlueprintsSection blueprints={inventory.blueprints} />
|
||||
)}
|
||||
|
||||
{/* Equipment */}
|
||||
{(filterMode === 'all' || filterMode === 'equipment') && filteredEquipment.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Package className="w-3 h-3" />
|
||||
Equipment
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{filteredEquipment.map(([id, instance]) => {
|
||||
const type = EQUIPMENT_TYPES[instance.typeId];
|
||||
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
|
||||
const rarityColor = RARITY_CSS_VAR[instance.rarity] || 'var(--rarity-common)';
|
||||
const rarityGlow = RARITY_GLOW_CSS_VAR[instance.rarity] || 'var(--rarity-common-glow)';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-2 rounded border bg-[var(--bg-sunken)] group"
|
||||
style={{
|
||||
borderColor: rarityColor,
|
||||
backgroundColor: rarityGlow,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon className="w-4 h-4 mt-0.5" style={{ color: rarityColor }} />
|
||||
<div>
|
||||
<div className="text-xs font-semibold" style={{ color: rarityColor }}>
|
||||
{instance.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{type?.name} • {instance.usedCapacity}/{instance.totalCapacity} cap
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] capitalize">
|
||||
{instance.rarity} • {instance.enchantments.length} enchants
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onDeleteEquipment && (
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
|
||||
onClick={() => handleDeleteEquipment(id)}
|
||||
aria-label={`Delete ${instance.name}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{(filterMode === 'all' || filterMode === 'equipment') && (
|
||||
<EquipmentSection
|
||||
equipment={filteredEquipment}
|
||||
onDeleteEquipment={handleDeleteEquipment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import type { LootInventory } from '@/lib/game/types';
|
||||
// For backward compatibility
|
||||
type LootInventoryType = LootInventory;
|
||||
import { LOOT_DROPS } from '@/lib/game/data/loot-drops';
|
||||
import { RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from './types';
|
||||
import { Sparkles, Trash2 } from 'lucide-react';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
|
||||
interface MaterialItemProps {
|
||||
materialId: string;
|
||||
count: number;
|
||||
onDelete?: (materialId: string) => void;
|
||||
}
|
||||
|
||||
export function MaterialItem({ materialId, count, onDelete }: MaterialItemProps) {
|
||||
const drop = LOOT_DROPS[materialId];
|
||||
if (!drop) return null;
|
||||
|
||||
const rarityColor = RARITY_CSS_VAR[drop.rarity] || 'var(--rarity-common)';
|
||||
const rarityGlow = RARITY_GLOW_CSS_VAR[drop.rarity] || 'var(--rarity-common-glow)';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-2 rounded border bg-[var(--bg-sunken)] group relative"
|
||||
style={{
|
||||
borderColor: rarityColor,
|
||||
backgroundColor: rarityGlow,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-semibold" style={{ color: rarityColor }}>
|
||||
{drop.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
x{count}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] capitalize">
|
||||
{drop.rarity}
|
||||
</div>
|
||||
</div>
|
||||
{onDelete && (
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
|
||||
onClick={() => onDelete(materialId)}
|
||||
aria-label={`Delete ${drop.name}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MaterialsSectionProps {
|
||||
materials: [string, number][];
|
||||
onDeleteMaterial?: (materialId: string) => void;
|
||||
}
|
||||
|
||||
export function MaterialsSection({ materials, onDeleteMaterial }: MaterialsSectionProps) {
|
||||
if (materials.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Materials
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{materials.map(([id, count]) => (
|
||||
<MaterialItem
|
||||
key={id}
|
||||
materialId={id}
|
||||
count={count}
|
||||
onDelete={onDeleteMaterial}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Gem, Sparkles, Scroll, Droplet, Trash2, Search,
|
||||
Package, Sword, Shield, Shirt, Crown, ArrowUpDown,
|
||||
Wrench, AlertTriangle } from 'lucide-react';
|
||||
import type { EquipmentCategory } from '@/lib/game/data/equipment';
|
||||
|
||||
export const CATEGORY_ICONS: Record<string, typeof Sword> = {
|
||||
caster: Sword,
|
||||
shield: Shield,
|
||||
catalyst: Sparkles,
|
||||
head: Crown,
|
||||
body: Shirt,
|
||||
hands: Wrench,
|
||||
feet: Package,
|
||||
accessory: Gem,
|
||||
};
|
||||
@@ -0,0 +1,318 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Gem, Search, ArrowUpDown, AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import { ElementBadge } from '@/components/ui/element-badge';
|
||||
import type { LootInventory as LootInventoryType, EquipmentInstance, ElementState } from '@/lib/game/types';
|
||||
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { useGameToast } from '@/components/game/GameToast';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import { type SortMode, type FilterMode, RARITY_ORDER, RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from './types';
|
||||
import { MaterialsSection } from './MaterialItem';
|
||||
import { EssenceSection } from './EssenceItem';
|
||||
import { BlueprintsSection } from './BlueprintsSection';
|
||||
import { EquipmentSection } from './EquipmentItem';
|
||||
|
||||
interface LootInventoryProps {
|
||||
inventory: LootInventoryType;
|
||||
elements?: Record<string, ElementState>;
|
||||
equipmentInstances?: Record<string, EquipmentInstance>;
|
||||
onDeleteMaterial?: (materialId: string, amount: number) => void;
|
||||
onDeleteEquipment?: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
export function LootInventoryDisplay({
|
||||
inventory,
|
||||
elements,
|
||||
equipmentInstances = {},
|
||||
onDeleteMaterial,
|
||||
onDeleteEquipment,
|
||||
}: LootInventoryProps) {
|
||||
const showToast = useGameToast();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortMode, setSortMode] = useState<SortMode>('rarity');
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'material' | 'equipment'; id: string; name: string } | null>(null);
|
||||
|
||||
// Count items
|
||||
const materialCount = Object.values(inventory.materials).reduce((a: number, b: number) => a + b, 0);
|
||||
|
||||
// Calculate essence count
|
||||
let essenceCount = 0;
|
||||
if (elements) {
|
||||
essenceCount = Object.entries(elements).reduce((acc: number, [id, state]) => {
|
||||
if (id === 'transference') return acc;
|
||||
return acc + (state.current || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const blueprintCount = inventory.blueprints.length;
|
||||
const equipmentCount = Object.keys(equipmentInstances).length;
|
||||
const totalItems = materialCount + blueprintCount + equipmentCount;
|
||||
|
||||
// Filter and sort materials
|
||||
const filteredMaterials = Object.entries(inventory.materials)
|
||||
.filter(([id, count]) => {
|
||||
if (count <= 0) return false;
|
||||
const drop = LOOT_DROPS[id];
|
||||
if (!drop) return false;
|
||||
if (searchTerm && !drop.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(([aId, aCount], [bId, bCount]) => {
|
||||
const aDrop = LOOT_DROPS[aId];
|
||||
const bDrop = LOOT_DROPS[bId];
|
||||
if (!aDrop || !bDrop) return 0;
|
||||
|
||||
switch (sortMode) {
|
||||
case 'name':
|
||||
return aDrop.name.localeCompare(bDrop.name);
|
||||
case 'rarity':
|
||||
return RARITY_ORDER[bDrop.rarity] - RARITY_ORDER[aDrop.rarity];
|
||||
case 'count':
|
||||
return bCount - aCount;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Filter and sort essence
|
||||
const filteredEssence = elements
|
||||
? Object.entries(elements)
|
||||
.filter(([id, state]) => {
|
||||
if (!state.unlocked || state.current <= 0) return false;
|
||||
if (id === 'transference') return false;
|
||||
if (searchTerm && !ELEMENTS[id]?.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(([aId, aState], [bId, bState]) => {
|
||||
switch (sortMode) {
|
||||
case 'name':
|
||||
return (ELEMENTS[aId]?.name || aId).localeCompare(ELEMENTS[bId]?.name || bId);
|
||||
case 'count':
|
||||
return bState.current - aState.current;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
: [];
|
||||
|
||||
// Filter and sort equipment
|
||||
const filteredEquipment = Object.entries(equipmentInstances)
|
||||
.filter(([id, instance]) => {
|
||||
if (searchTerm && !instance.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(([aId, aInst], [bId, bInst]) => {
|
||||
switch (sortMode) {
|
||||
case 'name':
|
||||
return aInst.name.localeCompare(bInst.name);
|
||||
case 'rarity':
|
||||
return RARITY_ORDER[bInst.rarity] - RARITY_ORDER[aInst.rarity];
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const hasItems = totalItems > 0 || essenceCount > 0;
|
||||
|
||||
const handleDeleteMaterial = (materialId: string) => {
|
||||
const drop = LOOT_DROPS[materialId];
|
||||
if (drop) {
|
||||
setDeleteConfirm({ type: 'material', id: materialId, name: drop.name });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEquipment = (instanceId: string) => {
|
||||
const instance = equipmentInstances[instanceId];
|
||||
if (instance) {
|
||||
setDeleteConfirm({ type: 'equipment', id: instanceId, name: instance.name });
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteConfirm) return;
|
||||
|
||||
if (deleteConfirm.type === 'material' && onDeleteMaterial) {
|
||||
const amount = inventory.materials[deleteConfirm.id] || 0;
|
||||
onDeleteMaterial(deleteConfirm.id, amount);
|
||||
showToast('success', 'Material Deleted', `${deleteConfirm.name} removed from inventory`);
|
||||
} else if (deleteConfirm.type === 'equipment' && onDeleteEquipment) {
|
||||
onDeleteEquipment(deleteConfirm.id);
|
||||
showToast('success', 'Item Discarded', `${deleteConfirm.name} has been removed from inventory`);
|
||||
}
|
||||
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
if (!hasItems) {
|
||||
return (
|
||||
<GameCard variant="default" className="w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Gem className="w-4 h-4 text-[var(--mana-light)]" />
|
||||
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
|
||||
Inventory
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-[var(--text-muted)] text-sm text-center py-4">
|
||||
No items collected yet. Defeat floors and guardians to find loot!
|
||||
</div>
|
||||
</GameCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GameCard variant="default" className="w-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Gem className="w-4 h-4 text-[var(--mana-light)]" />
|
||||
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
|
||||
Inventory
|
||||
</h3>
|
||||
<Badge
|
||||
className="ml-auto bg-[var(--bg-sunken)] text-[var(--text-secondary)] text-xs border-[var(--border-subtle)]"
|
||||
aria-label={`${totalItems} items in inventory`}
|
||||
>
|
||||
{totalItems} items
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter Controls */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--text-muted)]" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-7 pl-7 bg-[var(--bg-sunken)] border-[var(--border-subtle)] text-xs text-[var(--text-primary)] placeholder:text-[var(--text-disabled)]"
|
||||
aria-label="Search inventory"
|
||||
/>
|
||||
</div>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setSortMode(sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity')}
|
||||
aria-label={`Sort by ${sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity'}`}
|
||||
>
|
||||
<ArrowUpDown className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex gap-1 flex-wrap mb-3">
|
||||
{[
|
||||
{ mode: 'all' as FilterMode, label: 'All' },
|
||||
{ mode: 'materials' as FilterMode, label: `Materials (${materialCount})` },
|
||||
{ mode: 'essence' as FilterMode, label: `Essence (${essenceCount})` },
|
||||
{ mode: 'blueprints' as FilterMode, label: `Blueprints (${blueprintCount})` },
|
||||
{ mode: 'equipment' as FilterMode, label: `Equipment (${equipmentCount})` },
|
||||
].map(({ mode, label }) => (
|
||||
<ActionButton
|
||||
key={mode}
|
||||
variant={filterMode === mode ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${filterMode === mode ? '' : 'bg-[var(--bg-sunken)]'}`}
|
||||
onClick={() => setFilterMode(mode)}
|
||||
aria-pressed={filterMode === mode}
|
||||
aria-label={`Filter by ${label}`}
|
||||
>
|
||||
{label}
|
||||
</ActionButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator className="bg-[var(--border-subtle)] mb-3" />
|
||||
|
||||
<ScrollArea className="h-64 w-full">
|
||||
<div className="space-y-3 pr-2">
|
||||
{/* Materials */}
|
||||
{(filterMode === 'all' || filterMode === 'materials') && (
|
||||
<MaterialsSection
|
||||
materials={filteredMaterials}
|
||||
onDeleteMaterial={handleDeleteMaterial}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Essence */}
|
||||
{(filterMode === 'all' || filterMode === 'essence') && (
|
||||
<EssenceSection essence={filteredEssence} />
|
||||
)}
|
||||
|
||||
{/* Blueprints */}
|
||||
{(filterMode === 'all' || filterMode === 'blueprints') && (
|
||||
<BlueprintsSection blueprints={inventory.blueprints} />
|
||||
)}
|
||||
|
||||
{/* Equipment */}
|
||||
{(filterMode === 'all' || filterMode === 'equipment') && (
|
||||
<EquipmentSection
|
||||
equipment={filteredEquipment}
|
||||
onDeleteEquipment={handleDeleteEquipment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</GameCard>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={!!deleteConfirm} onOpenChange={() => setDeleteConfirm(null)}>
|
||||
<AlertDialogContent className="bg-[var(--bg-surface)] border-[var(--border-default)]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-[var(--mana-light)] flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Delete Item
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[var(--text-secondary)]">
|
||||
Are you sure you want to delete <strong className="text-[var(--text-primary)]">{deleteConfirm?.name}</strong>?
|
||||
{deleteConfirm?.type === 'material' && (
|
||||
<span className="block mt-2 text-[var(--color-danger)]">
|
||||
This will delete ALL {inventory.materials[deleteConfirm?.id || ''] || 0} of this material!
|
||||
</span>
|
||||
)}
|
||||
{deleteConfirm?.type === 'equipment' && (
|
||||
<span className="block mt-2 text-[var(--color-danger)]">
|
||||
This equipment and all its enchantments will be permanently lost!
|
||||
</span>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="bg-[var(--bg-sunken)] border-[var(--border-default)] text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]">
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-[var(--interactive-danger)] hover:bg-[var(--interactive-danger-hover)] text-white"
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
LootInventoryDisplay.displayName = "LootInventoryDisplay";
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { LootInventory as LootInventoryType, EquipmentInstance, ElementState } from '@/lib/game/types';
|
||||
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
export type SortMode = 'name' | 'rarity' | 'count';
|
||||
export type FilterMode = 'all' | 'materials' | 'essence' | 'blueprints' | 'equipment';
|
||||
|
||||
export const RARITY_ORDER = {
|
||||
common: 0,
|
||||
uncommon: 1,
|
||||
rare: 2,
|
||||
epic: 3,
|
||||
legendary: 4,
|
||||
mythic: 5,
|
||||
};
|
||||
|
||||
// Map rarity to CSS variable for colors
|
||||
export const RARITY_CSS_VAR: Record<string, string> = {
|
||||
common: 'var(--rarity-common)',
|
||||
uncommon: 'var(--rarity-uncommon)',
|
||||
rare: 'var(--rarity-rare)',
|
||||
epic: 'var(--rarity-epic)',
|
||||
legendary: 'var(--rarity-legendary)',
|
||||
mythic: 'var(--rarity-mythic)',
|
||||
};
|
||||
|
||||
// Map rarity to CSS variable for glow/background
|
||||
export const RARITY_GLOW_CSS_VAR: Record<string, string> = {
|
||||
common: 'var(--rarity-common-glow)',
|
||||
uncommon: 'var(--rarity-uncommon-glow)',
|
||||
rare: 'var(--rarity-rare-glow)',
|
||||
epic: 'var(--rarity-epic-glow)',
|
||||
legendary: 'var(--rarity-legendary-glow)',
|
||||
mythic: 'var(--rarity-mythic-glow)',
|
||||
};
|
||||
@@ -1,432 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useGameStore, fmt, fmtDec } from '@/lib/game/store';
|
||||
import { SKILLS_DEF, SKILL_CATEGORIES } from '@/lib/game/constants';
|
||||
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||
import { computeEffects } from '@/lib/game/upgrade-effects';
|
||||
import { useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { BookOpen, X } from 'lucide-react';
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
|
||||
// Format study time
|
||||
function formatStudyTime(hours: number): string {
|
||||
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||
return `${hours.toFixed(1)}h`;
|
||||
}
|
||||
import { useGameStore } from '@/lib/game/store';
|
||||
import { SKILL_CATEGORIES } from '@/lib/game/constants';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SkillUpgradeDialog } from './SkillsTab/SkillUpgradeDialog';
|
||||
import { SkillStudyProgress } from './SkillsTab/SkillStudyProgress';
|
||||
import { SkillCategory } from './SkillsTab/SkillCategory';
|
||||
|
||||
export function SkillsTab() {
|
||||
const store = useGameStore();
|
||||
const { studySpeedMult, studyCostMult, hasParallelStudy } = useStudyStats();
|
||||
|
||||
const [upgradeDialogSkill, setUpgradeDialogSkill] = useState<string | null>(null);
|
||||
const [upgradeDialogMilestone, setUpgradeDialogMilestone] = useState<5 | 10>(5);
|
||||
const [pendingUpgradeSelections, setPendingUpgradeSelections] = useState<string[]>([]);
|
||||
|
||||
const upgradeEffects = computeEffects(store.skillUpgrades || {}, store.skillTiers || {});
|
||||
|
||||
// Check if skill has milestone available
|
||||
const hasMilestoneUpgrade = (skillId: string, level: number): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null => {
|
||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
||||
if (!path) return null;
|
||||
|
||||
if (level >= 5) {
|
||||
const upgrades5 = getUpgradesForSkillAtMilestone(skillId, 5, store.skillTiers);
|
||||
const selected5 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l5'));
|
||||
if (upgrades5.length > 0 && selected5.length < 2) {
|
||||
return { milestone: 5, hasUpgrades: true, selectedCount: selected5.length };
|
||||
}
|
||||
}
|
||||
|
||||
if (level >= 10) {
|
||||
const upgrades10 = getUpgradesForSkillAtMilestone(skillId, 10, store.skillTiers);
|
||||
const selected10 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l10'));
|
||||
if (upgrades10.length > 0 && selected10.length < 2) {
|
||||
return { milestone: 10, hasUpgrades: true, selectedCount: selected10.length };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
const handleUpgradeClick = (skillId: string, milestone: 5 | 10) => {
|
||||
setUpgradeDialogSkill(skillId);
|
||||
setUpgradeDialogMilestone(milestone);
|
||||
};
|
||||
|
||||
// Render upgrade selection dialog
|
||||
const renderUpgradeDialog = () => {
|
||||
if (!upgradeDialogSkill) return null;
|
||||
|
||||
const skillDef = SKILLS_DEF[upgradeDialogSkill];
|
||||
const level = store.skills[upgradeDialogSkill] || 0;
|
||||
const { available, selected: alreadySelected } = store.getSkillUpgradeChoices(upgradeDialogSkill, upgradeDialogMilestone);
|
||||
|
||||
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
|
||||
|
||||
const toggleUpgrade = (upgradeId: string) => {
|
||||
if (currentSelections.includes(upgradeId)) {
|
||||
setPendingUpgradeSelections(currentSelections.filter(id => id !== upgradeId));
|
||||
} else if (currentSelections.length < 2) {
|
||||
setPendingUpgradeSelections([...currentSelections, upgradeId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
if (currentSelections.length === 2 && upgradeDialogSkill) {
|
||||
store.commitSkillUpgrades(upgradeDialogSkill, currentSelections, upgradeDialogMilestone);
|
||||
}
|
||||
setPendingUpgradeSelections([]);
|
||||
setUpgradeDialogSkill(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setPendingUpgradeSelections([]);
|
||||
setUpgradeDialogSkill(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!upgradeDialogSkill} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingUpgradeSelections([]);
|
||||
setUpgradeDialogSkill(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-amber-400">
|
||||
Choose Upgrade - {skillDef?.name || upgradeDialogSkill}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Level {upgradeDialogMilestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
{available.map((upgrade) => {
|
||||
const isSelected = currentSelections.includes(upgrade.id);
|
||||
const canToggle = currentSelections.length < 2 || isSelected;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={upgrade.id}
|
||||
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'border-amber-500 bg-amber-900/30'
|
||||
: canToggle
|
||||
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
|
||||
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (canToggle) {
|
||||
toggleUpgrade(upgrade.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold text-sm text-amber-300">{upgrade.name}</div>
|
||||
{isSelected && <Badge className="bg-amber-600 text-amber-100">Selected</Badge>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||||
{upgrade.effect.type === 'multiplier' && (
|
||||
<div className="text-xs text-green-400 mt-1">
|
||||
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'bonus' && (
|
||||
<div className="text-xs text-blue-400 mt-1">
|
||||
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'special' && (
|
||||
<div className="text-xs text-cyan-400 mt-1">
|
||||
⚡ {upgrade.effect.specialDesc || 'Special effect'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleDone}
|
||||
disabled={currentSelections.length !== 2}
|
||||
>
|
||||
{currentSelections.length < 2 ? `Select ${2 - currentSelections.length} more` : 'Confirm'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Render study progress
|
||||
const renderStudyProgress = () => {
|
||||
if (!store.currentStudyTarget) return null;
|
||||
|
||||
const target = store.currentStudyTarget;
|
||||
const progressPct = Math.min(100, (target.progress / target.required) * 100);
|
||||
const def = SKILLS_DEF[target.id] || SKILLS_DEF[target.id.split('_t')[0]];
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-sm font-semibold text-purple-300">
|
||||
{def?.name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={() => store.cancelStudy()}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
|
||||
<span>{studySpeedMult.toFixed(1)}x speed</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const handleUpgradeClose = () => {
|
||||
setUpgradeDialogSkill(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Upgrade Selection Dialog */}
|
||||
{renderUpgradeDialog()}
|
||||
<SkillUpgradeDialog
|
||||
skillId={upgradeDialogSkill}
|
||||
milestone={upgradeDialogMilestone}
|
||||
onClose={handleUpgradeClose}
|
||||
/>
|
||||
|
||||
{/* Current Study Progress */}
|
||||
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
|
||||
<Card className="bg-gray-900/80 border-purple-600/50">
|
||||
<CardContent className="pt-4">
|
||||
{renderStudyProgress()}
|
||||
<SkillStudyProgress />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{SKILL_CATEGORIES.map((cat) => {
|
||||
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === cat.id);
|
||||
if (skillsInCat.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card key={cat.id} className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">
|
||||
{cat.icon} {cat.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{skillsInCat.map(([id, def]) => {
|
||||
const currentTier = store.skillTiers?.[id] || 1;
|
||||
const tieredSkillId = currentTier > 1 ? `${id}_t${currentTier}` : id;
|
||||
const tierMultiplier = getTierMultiplier(tieredSkillId);
|
||||
|
||||
const level = store.skills[tieredSkillId] || store.skills[id] || 0;
|
||||
const maxed = level >= def.max;
|
||||
|
||||
const isStudying = (store.currentStudyTarget?.id === id || store.currentStudyTarget?.id === tieredSkillId) && store.currentStudyTarget?.type === 'skill';
|
||||
const savedProgress = store.skillProgress[tieredSkillId] || store.skillProgress[id] || 0;
|
||||
|
||||
const tierDef = SKILL_EVOLUTION_PATHS[id]?.tiers.find(t => t.tier === currentTier);
|
||||
const skillDisplayName = tierDef?.name || def.name;
|
||||
|
||||
// Check prerequisites
|
||||
let prereqMet = true;
|
||||
if (def.req) {
|
||||
for (const [r, rl] of Object.entries(def.req)) {
|
||||
if ((store.skills[r] || 0) < rl) {
|
||||
prereqMet = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply skill modifiers
|
||||
const studyEffects = computeEffects(store.skillUpgrades || {}, store.skillTiers || {});
|
||||
const effectiveSpeedMult = studySpeedMult * studyEffects.studySpeedMultiplier;
|
||||
|
||||
const tierStudyTime = def.studyTime * currentTier;
|
||||
const effectiveStudyTime = tierStudyTime / effectiveSpeedMult;
|
||||
|
||||
const baseCost = def.base * (level + 1) * currentTier;
|
||||
const cost = Math.floor(baseCost * studyCostMult);
|
||||
|
||||
// Check if any study is in progress (prevent switching topics)
|
||||
const isAnyStudyInProgress = store.currentAction === 'study' && store.currentStudyTarget;
|
||||
// Can only study if: not maxed, prereqs met, has mana, and either no study in progress or already studying this skill
|
||||
const canStudy = !maxed && prereqMet && store.rawMana >= cost && (!isAnyStudyInProgress || isStudying);
|
||||
|
||||
const milestoneInfo = hasMilestoneUpgrade(tieredSkillId, level);
|
||||
const nextTierSkill = getNextTierSkill(tieredSkillId);
|
||||
const canTierUp = maxed && nextTierSkill;
|
||||
|
||||
const selectedUpgrades = store.skillUpgrades[tieredSkillId] || [];
|
||||
const selectedL5 = selectedUpgrades.filter(u => u.includes('_l5'));
|
||||
const selectedL10 = selectedUpgrades.filter(u => u.includes('_l10'));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded border gap-2 ${
|
||||
isStudying ? 'border-purple-500 bg-purple-900/20' :
|
||||
milestoneInfo ? 'border-amber-500/50 bg-amber-900/10' :
|
||||
'border-gray-700 bg-gray-800/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm">{skillDisplayName}</span>
|
||||
{currentTier > 1 && (
|
||||
<Badge className="bg-purple-600/50 text-purple-200 text-xs">Tier {currentTier} ({fmtDec(tierMultiplier, 0)}x)</Badge>
|
||||
)}
|
||||
{level > 0 && <span className="text-purple-400 text-sm">Lv.{level}</span>}
|
||||
{selectedUpgrades.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{selectedL5.length > 0 && (
|
||||
<Badge className="bg-amber-700/50 text-amber-200 text-xs">L5: {selectedL5.length}</Badge>
|
||||
)}
|
||||
{selectedL10.length > 0 && (
|
||||
<Badge className="bg-purple-700/50 text-purple-200 text-xs">L10: {selectedL10.length}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 italic">{def.desc}{currentTier > 1 && ` (Tier ${currentTier}: ${fmtDec(tierMultiplier, 0)}x effect)`}</div>
|
||||
{!prereqMet && def.req && (
|
||||
<div className="text-xs text-red-400 mt-1">
|
||||
Requires: {Object.entries(def.req).map(([r, rl]) => `${SKILLS_DEF[r]?.name} Lv.${rl}`).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
<span className={effectiveSpeedMult > 1 ? 'text-green-400' : ''}>
|
||||
Study: {formatStudyTime(effectiveStudyTime)}{effectiveSpeedMult > 1 && <span className="text-xs ml-1">({Math.round(effectiveSpeedMult * 100)}% speed)</span>}
|
||||
</span>
|
||||
{' • '}
|
||||
<span className={studyCostMult < 1 ? 'text-green-400' : ''}>
|
||||
Cost: {fmt(cost)} mana{studyCostMult < 1 && <span className="text-xs ml-1">({Math.round(studyCostMult * 100)}% cost)</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{milestoneInfo && (
|
||||
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
|
||||
⭐ Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
|
||||
{/* Level dots */}
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{Array.from({ length: def.max }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-2 h-2 rounded-full border ${
|
||||
i < level ? 'bg-purple-500 border-purple-400' :
|
||||
i === 4 || i === 9 ? 'border-amber-500' :
|
||||
'border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isStudying ? (
|
||||
<div className="text-xs text-purple-400">
|
||||
{formatStudyTime(store.currentStudyTarget?.progress || 0)}/{formatStudyTime(tierStudyTime)}
|
||||
</div>
|
||||
) : milestoneInfo ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-amber-600 hover:bg-amber-700"
|
||||
onClick={() => {
|
||||
setUpgradeDialogSkill(tieredSkillId);
|
||||
setUpgradeDialogMilestone(milestoneInfo.milestone);
|
||||
}}
|
||||
>
|
||||
Choose Upgrades
|
||||
</Button>
|
||||
) : canTierUp ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={() => store.tierUpSkill(tieredSkillId)}
|
||||
>
|
||||
⬆️ Tier Up
|
||||
</Button>
|
||||
) : maxed ? (
|
||||
<Badge className="bg-green-900/50 text-green-300">Maxed</Badge>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canStudy ? 'default' : 'outline'}
|
||||
disabled={!canStudy}
|
||||
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
|
||||
onClick={() => store.startStudyingSkill(tieredSkillId)}
|
||||
>
|
||||
Study ({fmt(cost)})
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{!canStudy && isAnyStudyInProgress && !isStudying && (
|
||||
<TooltipContent>
|
||||
<p>Cannot switch topics while studying {SKILLS_DEF[store.currentStudyTarget?.id || '']?.name || 'another skill'}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{/* Parallel Study button */}
|
||||
{hasParallelStudy &&
|
||||
store.currentStudyTarget &&
|
||||
!store.parallelStudyTarget &&
|
||||
store.currentStudyTarget.id !== tieredSkillId &&
|
||||
canStudy && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-cyan-500 text-cyan-400 hover:bg-cyan-900/30"
|
||||
onClick={() => store.startParallelStudySkill(tieredSkillId)}
|
||||
>
|
||||
⚡
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Study in parallel (50% speed)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{SKILL_CATEGORIES.map((cat) => (
|
||||
<SkillCategory
|
||||
key={cat.id}
|
||||
categoryId={cat.id}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { SKILLS_DEF, SKILL_CATEGORIES } from '@/lib/game/constants';
|
||||
import { SkillRow } from './SkillRow';
|
||||
|
||||
interface SkillCategoryProps {
|
||||
categoryId: string;
|
||||
onUpgradeClick: (skillId: string, milestone: 5 | 10) => void;
|
||||
}
|
||||
|
||||
export function SkillCategory({ categoryId, onUpgradeClick }: SkillCategoryProps) {
|
||||
const cat = SKILL_CATEGORIES.find(c => c.id === categoryId);
|
||||
if (!cat) return null;
|
||||
|
||||
const skillsInCat = Object.entries(SKILLS_DEF).filter(([, def]) => def.cat === cat.id);
|
||||
if (skillsInCat.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">
|
||||
{cat.icon} {cat.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{skillsInCat.map(([id, def]) => (
|
||||
<SkillRow
|
||||
key={id}
|
||||
skillId={id}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import { useGameStore, fmt, fmtDec } from '@/lib/game/store';
|
||||
import { SKILLS_DEF, SKILL_CATEGORIES } from '@/lib/game/constants';
|
||||
import { getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||
import { computeEffects } from '@/lib/game/upgrade-effects';
|
||||
import { useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { formatStudyTime, hasMilestoneUpgrade, getSkillDisplayInfo } from './skills-utils';
|
||||
|
||||
interface SkillRowProps {
|
||||
skillId: string;
|
||||
onUpgradeClick: (skillId: string, milestone: 5 | 10) => void;
|
||||
}
|
||||
|
||||
export function SkillRow({ skillId, onUpgradeClick }: SkillRowProps) {
|
||||
const store = useGameStore();
|
||||
const { studySpeedMult, studyCostMult, hasParallelStudy } = useStudyStats();
|
||||
|
||||
const skillInfo = getSkillDisplayInfo(store, skillId);
|
||||
const {
|
||||
currentTier,
|
||||
tieredSkillId,
|
||||
tierMultiplier,
|
||||
level,
|
||||
maxed,
|
||||
isStudying,
|
||||
skillDisplayName,
|
||||
prereqMet,
|
||||
def
|
||||
} = skillInfo;
|
||||
|
||||
// Apply skill modifiers
|
||||
const studyEffects = computeEffects(store.skillUpgrades || {}, store.skillTiers || {});
|
||||
const effectiveSpeedMult = studySpeedMult * studyEffects.studySpeedMultiplier;
|
||||
|
||||
const tierStudyTime = def.studyTime * currentTier;
|
||||
const effectiveStudyTime = tierStudyTime / effectiveSpeedMult;
|
||||
|
||||
const baseCost = def.base * (level + 1) * currentTier;
|
||||
const cost = Math.floor(baseCost * studyCostMult);
|
||||
|
||||
// Check if any study is in progress (prevent switching topics)
|
||||
const isAnyStudyInProgress = store.currentAction === 'study' && store.currentStudyTarget;
|
||||
// Can only study if: not maxed, prereqs met, has mana, and either no study in progress or already studying this skill
|
||||
const canStudy = !maxed && prereqMet && store.rawMana >= cost && (!isAnyStudyInProgress || isStudying);
|
||||
|
||||
const milestoneInfo = hasMilestoneUpgrade(store, tieredSkillId, level);
|
||||
const nextTierSkill = getNextTierSkill(tieredSkillId);
|
||||
const canTierUp = maxed && nextTierSkill;
|
||||
|
||||
const selectedUpgrades = store.skillUpgrades[tieredSkillId] || [];
|
||||
const selectedL5 = selectedUpgrades.filter(u => u.includes('_l5'));
|
||||
const selectedL10 = selectedUpgrades.filter(u => u.includes('_l10'));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded border gap-2 ${
|
||||
isStudying ? 'border-purple-500 bg-purple-900/20' :
|
||||
milestoneInfo ? 'border-amber-500/50 bg-amber-900/10' :
|
||||
'border-gray-700 bg-gray-800/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm">{skillDisplayName}</span>
|
||||
{currentTier > 1 && (
|
||||
<Badge className="bg-purple-600/50 text-purple-200 text-xs">Tier {currentTier} ({fmtDec(tierMultiplier, 0)}x)</Badge>
|
||||
)}
|
||||
{level > 0 && <span className="text-purple-400 text-sm">Lv.{level}</span>}
|
||||
{selectedUpgrades.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{selectedL5.length > 0 && (
|
||||
<Badge className="bg-amber-700/50 text-amber-200 text-xs">L5: {selectedL5.length}</Badge>
|
||||
)}
|
||||
{selectedL10.length > 0 && (
|
||||
<Badge className="bg-purple-700/50 text-purple-200 text-xs">L10: {selectedL10.length}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 italic">{def.desc}{currentTier > 1 && ` (Tier ${currentTier}: ${fmtDec(tierMultiplier, 0)}x effect)`}</div>
|
||||
{!prereqMet && def.req && (
|
||||
<div className="text-xs text-red-400 mt-1">
|
||||
Requires: {Object.entries(def.req).map(([r, rl]) => `${SKILLS_DEF[r]?.name} Lv.${rl}`).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
<span className={effectiveSpeedMult > 1 ? 'text-green-400' : ''}>
|
||||
Study: {formatStudyTime(effectiveStudyTime)}{effectiveSpeedMult > 1 && <span className="text-xs ml-1">({Math.round(effectiveSpeedMult * 100)}% speed)</span>}
|
||||
</span>
|
||||
{' • '}
|
||||
<span className={studyCostMult < 1 ? 'text-green-400' : ''}>
|
||||
Cost: {fmt(cost)} mana{studyCostMult < 1 && <span className="text-xs ml-1">({Math.round(studyCostMult * 100)}% cost)</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{milestoneInfo && (
|
||||
<div className="text-xs text-amber-400 mt-1 flex items-center gap-1">
|
||||
⭐ Level {milestoneInfo.milestone} milestone: {milestoneInfo.selectedCount}/2 upgrades selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap sm:flex-nowrap">
|
||||
{/* Level dots */}
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{Array.from({ length: def.max }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-2 h-2 rounded-full border ${
|
||||
i < level ? 'bg-purple-500 border-purple-400' :
|
||||
i === 4 || i === 9 ? 'border-amber-500' :
|
||||
'border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isStudying ? (
|
||||
<div className="text-xs text-purple-400">
|
||||
{formatStudyTime(store.currentStudyTarget?.progress || 0)}/{formatStudyTime(tierStudyTime)}
|
||||
</div>
|
||||
) : milestoneInfo ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-amber-600 hover:bg-amber-700"
|
||||
onClick={() => {
|
||||
onUpgradeClick(tieredSkillId, milestoneInfo.milestone);
|
||||
}}
|
||||
>
|
||||
Choose Upgrades
|
||||
</Button>
|
||||
) : canTierUp ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={() => store.tierUpSkill(tieredSkillId)}
|
||||
>
|
||||
⬆️ Tier Up
|
||||
</Button>
|
||||
) : maxed ? (
|
||||
<Badge className="bg-green-900/50 text-green-300">Maxed</Badge>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canStudy ? 'default' : 'outline'}
|
||||
disabled={!canStudy}
|
||||
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
|
||||
onClick={() => store.startStudyingSkill(tieredSkillId)}
|
||||
>
|
||||
Study ({fmt(cost)})
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{!canStudy && isAnyStudyInProgress && !isStudying && (
|
||||
<TooltipContent>
|
||||
<p>Cannot switch topics while studying {SKILLS_DEF[store.currentStudyTarget?.id || '']?.name || 'another skill'}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{/* Parallel Study button */}
|
||||
{hasParallelStudy &&
|
||||
store.currentStudyTarget &&
|
||||
!store.parallelStudyTarget &&
|
||||
store.currentStudyTarget.id !== tieredSkillId &&
|
||||
canStudy && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-cyan-500 text-cyan-400 hover:bg-cyan-900/30"
|
||||
onClick={() => store.startParallelStudySkill(tieredSkillId)}
|
||||
>
|
||||
⚡
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Study in parallel (50% speed)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useGameStore } from '@/lib/game/store';
|
||||
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||
import { formatStudyTime } from './skills-utils';
|
||||
import { useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||
import { BookOpen, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
export function SkillStudyProgress() {
|
||||
const store = useGameStore();
|
||||
const { studySpeedMult } = useStudyStats();
|
||||
|
||||
if (!store.currentStudyTarget) return null;
|
||||
|
||||
const target = store.currentStudyTarget;
|
||||
const progressPct = Math.min(100, (target.progress / target.required) * 100);
|
||||
const def = SKILLS_DEF[target.id] || SKILLS_DEF[target.id.split('_t')[0]];
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded border border-purple-600/50 bg-purple-900/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-sm font-semibold text-purple-300">
|
||||
{def?.name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={() => store.cancelStudy()}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Progress value={progressPct} className="h-2 bg-gray-800" />
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>{formatStudyTime(target.progress)} / {formatStudyTime(target.required)}</span>
|
||||
<span>{studySpeedMult.toFixed(1)}x speed</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useGameStore, fmt } from '@/lib/game/store';
|
||||
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
|
||||
interface SkillUpgradeDialogProps {
|
||||
skillId: string | null;
|
||||
milestone: 5 | 10;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SkillUpgradeDialog({ skillId, milestone, onClose }: SkillUpgradeDialogProps) {
|
||||
const store = useGameStore();
|
||||
const [pendingUpgradeSelections, setPendingUpgradeSelections] = useState<string[]>([]);
|
||||
|
||||
if (!skillId) return null;
|
||||
|
||||
const skillDef = SKILLS_DEF[skillId];
|
||||
const { available, selected: alreadySelected } = store.getSkillUpgradeChoices(skillId, milestone);
|
||||
|
||||
const currentSelections = pendingUpgradeSelections.length > 0 ? pendingUpgradeSelections : alreadySelected;
|
||||
|
||||
const toggleUpgrade = (upgradeId: string) => {
|
||||
if (currentSelections.includes(upgradeId)) {
|
||||
setPendingUpgradeSelections(currentSelections.filter(id => id !== upgradeId));
|
||||
} else if (currentSelections.length < 2) {
|
||||
setPendingUpgradeSelections([...currentSelections, upgradeId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
if (currentSelections.length === 2 && skillId) {
|
||||
store.commitSkillUpgrades(skillId, currentSelections, milestone);
|
||||
}
|
||||
setPendingUpgradeSelections([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setPendingUpgradeSelections([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!skillId} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingUpgradeSelections([]);
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-amber-400">
|
||||
Choose Upgrade - {skillDef?.name || skillId}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Level {milestone} Milestone - Select 2 upgrades ({currentSelections.length}/2 chosen)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
{available.map((upgrade) => {
|
||||
const isSelected = currentSelections.includes(upgrade.id);
|
||||
const canToggle = currentSelections.length < 2 || isSelected;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={upgrade.id}
|
||||
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'border-amber-500 bg-amber-900/30'
|
||||
: canToggle
|
||||
? 'border-gray-600 bg-gray-800/50 hover:border-amber-500/50 hover:bg-gray-800'
|
||||
: 'border-gray-700 bg-gray-800/30 opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (canToggle) {
|
||||
toggleUpgrade(upgrade.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold text-sm text-amber-300">{upgrade.name}</div>
|
||||
{isSelected && <Badge className="bg-amber-600 text-amber-100">Selected</Badge>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||||
{upgrade.effect.type === 'multiplier' && (
|
||||
<div className="text-xs text-green-400 mt-1">
|
||||
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'bonus' && (
|
||||
<div className="text-xs text-blue-400 mt-1">
|
||||
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'special' && (
|
||||
<div className="text-xs text-cyan-400 mt-1">
|
||||
⚡ {upgrade.effect.specialDesc || 'Special effect'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleDone}
|
||||
disabled={currentSelections.length !== 2}
|
||||
>
|
||||
{currentSelections.length < 2 ? `Select ${2 - currentSelections.length} more` : 'Confirm'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||
import type { GameStore } from '@/lib/game/store';
|
||||
|
||||
// Format study time
|
||||
export function formatStudyTime(hours: number): string {
|
||||
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||
return `${hours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
// Check if skill has milestone available
|
||||
export function hasMilestoneUpgrade(
|
||||
store: GameStore,
|
||||
skillId: string,
|
||||
level: number
|
||||
): { milestone: 5 | 10; hasUpgrades: boolean; selectedCount: number } | null {
|
||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
||||
if (!path) return null;
|
||||
|
||||
if (level >= 5) {
|
||||
const upgrades5 = getUpgradesForSkillAtMilestone(skillId, 5, store.skillTiers);
|
||||
const selected5 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l5'));
|
||||
if (upgrades5.length > 0 && selected5.length < 2) {
|
||||
return { milestone: 5, hasUpgrades: true, selectedCount: selected5.length };
|
||||
}
|
||||
}
|
||||
|
||||
if (level >= 10) {
|
||||
const upgrades10 = getUpgradesForSkillAtMilestone(skillId, 10, store.skillTiers);
|
||||
const selected10 = (store.skillUpgrades[skillId] || []).filter(id => id.includes('_l10'));
|
||||
if (upgrades10.length > 0 && selected10.length < 2) {
|
||||
return { milestone: 10, hasUpgrades: true, selectedCount: selected10.length };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get skill display info
|
||||
export function getSkillDisplayInfo(
|
||||
store: GameStore,
|
||||
skillId: string
|
||||
) {
|
||||
const currentTier = store.skillTiers?.[skillId] || 1;
|
||||
const tieredSkillId = currentTier > 1 ? `${skillId}_t${currentTier}` : skillId;
|
||||
const def = SKILLS_DEF[skillId];
|
||||
const tierMultiplier = getTierMultiplier(tieredSkillId);
|
||||
|
||||
const level = store.skills[tieredSkillId] || store.skills[skillId] || 0;
|
||||
const maxed = level >= def.max;
|
||||
|
||||
const isStudying = (store.currentStudyTarget?.id === skillId || store.currentStudyTarget?.id === tieredSkillId) && store.currentStudyTarget?.type === 'skill';
|
||||
const savedProgress = store.skillProgress[tieredSkillId] || store.skillProgress[skillId] || 0;
|
||||
|
||||
const tierDef = SKILL_EVOLUTION_PATHS[skillId]?.tiers.find(t => t.tier === currentTier);
|
||||
const skillDisplayName = tierDef?.name || def.name;
|
||||
|
||||
// Check prerequisites
|
||||
let prereqMet = true;
|
||||
if (def.req) {
|
||||
for (const [r, rl] of Object.entries(def.req)) {
|
||||
if ((store.skills[r] || 0) < rl) {
|
||||
prereqMet = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentTier,
|
||||
tieredSkillId,
|
||||
tierMultiplier,
|
||||
level,
|
||||
maxed,
|
||||
isStudying,
|
||||
savedProgress,
|
||||
skillDisplayName,
|
||||
prereqMet,
|
||||
def,
|
||||
};
|
||||
}
|
||||
@@ -4,23 +4,21 @@ import { useGameStore, fmt, fmtDec } from '@/lib/game/store';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { SKILL_EVOLUTION_PATHS, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||
import { useManaStats, useCombatStats, useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Droplet, Swords, BookOpen, FlaskConical, RotateCcw, Trophy, Star } from 'lucide-react';
|
||||
import { ManaStatsSection } from './StatsTab/ManaStatsSection';
|
||||
import { CombatStatsSection } from './StatsTab/CombatStatsSection';
|
||||
import { PactStatusSection } from './StatsTab/PactStatusSection';
|
||||
import { StudyStatsSection } from './StatsTab/StudyStatsSection';
|
||||
import { ElementStatsSection } from './StatsTab/ElementStatsSection';
|
||||
import { ActiveUpgradesSection } from './StatsTab/ActiveUpgradesSection';
|
||||
import { LoopStatsSection } from './StatsTab/LoopStatsSection';
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
|
||||
export function StatsTab() {
|
||||
const store = useGameStore();
|
||||
const {
|
||||
upgradeEffects, maxMana, baseRegen, clickMana,
|
||||
meditationMultiplier, incursionStrength, manaCascadeBonus, manaWaterfallBonus, effectiveRegen,
|
||||
hasSteadyStream, hasManaTorrent, hasDesperateWells,
|
||||
hasManaWaterfall, hasFlowSurge, hasManaOverflow, hasEternalFlow
|
||||
} = useManaStats();
|
||||
const { activeSpellDef, pactMultiplier, pactInsightMultiplier } = useCombatStats();
|
||||
const { studySpeedMult, studyCostMult } = useStudyStats();
|
||||
|
||||
const manaStats = useManaStats();
|
||||
const combatStats = useCombatStats();
|
||||
const studyStats = useStudyStats();
|
||||
|
||||
// Compute element max
|
||||
const elemMax = (() => {
|
||||
const ea = store.skillTiers?.elemAttune || 1;
|
||||
@@ -29,9 +27,9 @@ export function StatsTab() {
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return 10 + level * 50 * tierMult + (store.prestigeUpgrades.elementalAttune || 0) * 25;
|
||||
})();
|
||||
|
||||
|
||||
// Get all selected skill upgrades
|
||||
const getAllSelectedUpgrades = () => {
|
||||
const getAllSelectedUpgrades = (): { skillId: string; upgrade: SkillUpgradeChoice }[] => {
|
||||
const upgrades: { skillId: string; upgrade: SkillUpgradeChoice }[] = [];
|
||||
for (const [skillId, selectedIds] of Object.entries(store.skillUpgrades)) {
|
||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||
@@ -40,7 +38,7 @@ export function StatsTab() {
|
||||
for (const tier of path.tiers) {
|
||||
if (tier.skillId === skillId) {
|
||||
for (const upgradeId of selectedIds) {
|
||||
const upgrade = tier.upgrades.find(u => u.id === upgradeId);
|
||||
const upgrade = (tier as any).upgrades?.find(u => u.id === upgradeId);
|
||||
if (upgrade) {
|
||||
upgrades.push({ skillId, upgrade });
|
||||
}
|
||||
@@ -50,533 +48,43 @@ export function StatsTab() {
|
||||
}
|
||||
return upgrades;
|
||||
};
|
||||
|
||||
|
||||
const selectedUpgrades = getAllSelectedUpgrades();
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mana Stats */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-blue-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Droplet className="w-4 h-4" />
|
||||
Mana Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Base Max Mana:</span>
|
||||
<span className="text-gray-200">100</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Well Bonus:</span>
|
||||
<span className="text-blue-300">
|
||||
{(() => {
|
||||
const mw = store.skillTiers?.manaWell || 1;
|
||||
const tieredSkillId = mw > 1 ? `manaWell_t${mw}` : 'manaWell';
|
||||
const level = store.skills[tieredSkillId] || store.skills.manaWell || 0;
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return `+${fmt(level * 100 * tierMult)} (${level} lvl × 100 × ${tierMult}x tier)`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Prestige Mana Well:</span>
|
||||
<span className="text-blue-300">+{fmt((store.prestigeUpgrades.manaWell || 0) * 500)}</span>
|
||||
</div>
|
||||
{upgradeEffects.maxManaBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Mana Bonus:</span>
|
||||
<span className="text-amber-300">+{fmt(upgradeEffects.maxManaBonus)}</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.maxManaMultiplier > 1 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Mana Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(upgradeEffects.maxManaMultiplier, 2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||
<span className="text-gray-300">Total Max Mana:</span>
|
||||
<span className="text-blue-400">{fmt(maxMana)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Base Regen:</span>
|
||||
<span className="text-gray-200">2/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Flow Bonus:</span>
|
||||
<span className="text-blue-300">
|
||||
{(() => {
|
||||
const mf = store.skillTiers?.manaFlow || 1;
|
||||
const tieredSkillId = mf > 1 ? `manaFlow_t${mf}` : 'manaFlow';
|
||||
const level = store.skills[tieredSkillId] || store.skills.manaFlow || 0;
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return `+${fmtDec(level * 1 * tierMult)}/hr (${level} lvl × 1 × ${tierMult}x tier)`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Spring Bonus:</span>
|
||||
<span className="text-blue-300">+{(store.skills.manaSpring || 0) * 2}/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Prestige Mana Flow:</span>
|
||||
<span className="text-blue-300">+{fmtDec((store.prestigeUpgrades.manaFlow || 0) * 0.5)}/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Temporal Echo:</span>
|
||||
<span className="text-blue-300">×{fmtDec(1 + (store.prestigeUpgrades.temporalEcho || 0) * 0.1, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||
<span className="text-gray-300">Base Regen:</span>
|
||||
<span className="text-blue-400">{fmtDec(baseRegen, 2)}/hr</span>
|
||||
</div>
|
||||
{upgradeEffects.regenBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Regen Bonus:</span>
|
||||
<span className="text-amber-300">+{fmtDec(upgradeEffects.regenBonus, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.permanentRegenBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Permanent Regen Bonus:</span>
|
||||
<span className="text-amber-300">+{fmtDec(upgradeEffects.permanentRegenBonus, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.regenMultiplier > 1 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Regen Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(upgradeEffects.regenMultiplier, 2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="bg-gray-700 my-3" />
|
||||
{/* Skill Upgrade Effects Summary */}
|
||||
{upgradeEffects.activeUpgrades.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<span className="text-xs text-amber-400 game-panel-title">Active Skill Upgrades</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-3">
|
||||
{upgradeEffects.activeUpgrades.map((upgrade, idx) => (
|
||||
<div key={idx} className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
|
||||
<span className="text-gray-300">{upgrade.name}</span>
|
||||
<span className="text-gray-400">{upgrade.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Separator className="bg-gray-700 my-3" />
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Click Mana Value:</span>
|
||||
<span className="text-purple-300">+{clickMana}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Tap Bonus:</span>
|
||||
<span className="text-purple-300">+{store.skills.manaTap || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Surge Bonus:</span>
|
||||
<span className="text-purple-300">+{(store.skills.manaSurge || 0) * 3}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Overflow:</span>
|
||||
<span className="text-purple-300">×{fmtDec(1 + (store.skills.manaOverflow || 0) * 0.25, 2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Meditation Multiplier:</span>
|
||||
<span className={`font-semibold ${meditationMultiplier > 1.5 ? 'text-purple-400' : 'text-gray-300'}`}>
|
||||
{fmtDec(meditationMultiplier, 2)}x
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Effective Regen:</span>
|
||||
<span className="text-green-400 font-semibold">{fmtDec(effectiveRegen, 2)}/hr</span>
|
||||
</div>
|
||||
{incursionStrength > 0 && !hasSteadyStream && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-red-400">Incursion Penalty:</span>
|
||||
<span className="text-red-400">-{Math.round(incursionStrength * 100)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{hasSteadyStream && incursionStrength > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-green-400">Steady Stream:</span>
|
||||
<span className="text-green-400">Immune to incursion</span>
|
||||
</div>
|
||||
)}
|
||||
{manaCascadeBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Mana Cascade Bonus:</span>
|
||||
<span className="text-cyan-400">+{fmtDec(manaCascadeBonus, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
{manaWaterfallBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Mana Waterfall Bonus:</span>
|
||||
<span className="text-cyan-400">+{fmtDec(manaWaterfallBonus, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
{hasManaWaterfall && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Mana Waterfall:</span>
|
||||
<span className="text-cyan-400">+0.25 regen per 100 max mana</span>
|
||||
</div>
|
||||
)}
|
||||
{hasFlowSurge && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Flow Surge:</span>
|
||||
<span className="text-cyan-400">Clicks activate +100% regen for 1hr</span>
|
||||
</div>
|
||||
)}
|
||||
{hasManaOverflow && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Mana Overflow:</span>
|
||||
<span className="text-cyan-400">Raw mana can exceed max by 20%</span>
|
||||
</div>
|
||||
)}
|
||||
{hasEternalFlow && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-green-400">Eternal Flow:</span>
|
||||
<span className="text-green-400">Regen immune to ALL penalties</span>
|
||||
</div>
|
||||
)}
|
||||
{hasManaTorrent && store.rawMana > maxMana * 0.75 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Mana Torrent:</span>
|
||||
<span className="text-cyan-400">+50% regen (high mana)</span>
|
||||
</div>
|
||||
)}
|
||||
{hasDesperateWells && store.rawMana < maxMana * 0.25 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Desperate Wells:</span>
|
||||
<span className="text-cyan-400">+50% regen (low mana)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Combat Stats */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-red-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Swords className="w-4 h-4" />
|
||||
Combat Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Active Spell Base Damage:</span>
|
||||
<span className="text-gray-200">{activeSpellDef?.dmg || 5}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Combat Training Bonus:</span>
|
||||
<span className="text-red-300">+{(store.skills.combatTrain || 0) * 5}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Arcane Fury Multiplier:</span>
|
||||
<span className="text-red-300">×{fmtDec(1 + (store.skills.arcaneFury || 0) * 0.1, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Elemental Mastery:</span>
|
||||
<span className="text-red-300">×{fmtDec(1 + (store.skills.elementalMastery || 0) * 0.15, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Guardian Bane:</span>
|
||||
<span className="text-red-300">×{fmtDec(1 + (store.skills.guardianBane || 0) * 0.2, 2)} (vs guardians)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Critical Hit Chance:</span>
|
||||
<span className="text-amber-300">{((store.skills.precision || 0) * 5)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Critical Multiplier:</span>
|
||||
<span className="text-amber-300">1.5x</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Spell Echo Chance:</span>
|
||||
<span className="text-amber-300">{((store.skills.spellEcho || 0) * 10)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Pact Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(pactMultiplier, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||
<span className="text-gray-300">Total Damage:</span>
|
||||
<span className="text-red-400">{fmt(calcDamage(store, store.activeSpell))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pact Status */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4" />
|
||||
Pact Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Pact Slots:</span>
|
||||
<span className="text-amber-300">{store.signedPacts.length} / {1 + (store.prestigeUpgrades.pactCapacity || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Damage Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(pactMultiplier, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Insight Multiplier:</span>
|
||||
<span className="text-purple-300">×{fmtDec(pactInsightMultiplier, 2)}</span>
|
||||
</div>
|
||||
{store.signedPacts.length > 1 && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Interference Mitigation:</span>
|
||||
<span className="text-green-300">{Math.min(store.pactInterferenceMitigation || 0, 5) * 10}%</span>
|
||||
</div>
|
||||
{(store.pactInterferenceMitigation || 0) >= 5 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Synergy Bonus:</span>
|
||||
<span className="text-cyan-300">+{((store.pactInterferenceMitigation || 0) - 5) * 10}%</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-400 mb-2">Unlocked Mana Types:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(store.elements)
|
||||
.filter(([, state]) => state.unlocked)
|
||||
.map(([id]) => {
|
||||
const elem = ELEMENTS[id];
|
||||
return (
|
||||
<Badge key={id} variant="outline" className="text-xs" style={{ borderColor: elem?.color, color: elem?.color }}>
|
||||
{elem?.sym} {elem?.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Study Stats */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
Study Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Study Speed:</span>
|
||||
<span className="text-purple-300">×{fmtDec(studySpeedMult, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Quick Learner Bonus:</span>
|
||||
<span className="text-purple-300">+{((store.skills.quickLearner || 0) * 10)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Study Cost:</span>
|
||||
<span className="text-purple-300">{Math.round(studyCostMult * 100)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Focused Mind Bonus:</span>
|
||||
<span className="text-purple-300">-{((store.skills.focusedMind || 0) * 5)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Progress Retention:</span>
|
||||
<span className="text-purple-300">{Math.round((1 + (store.skills.knowledgeRetention || 0) * 0.2) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Element Stats */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-green-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<FlaskConical className="w-4 h-4" />
|
||||
Element Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Element Capacity:</span>
|
||||
<span className="text-green-300">{elemMax}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Elem. Attunement Bonus:</span>
|
||||
<span className="text-green-300">
|
||||
{(() => {
|
||||
const ea = store.skillTiers?.elemAttune || 1;
|
||||
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
|
||||
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return `+${level * 50 * tierMult}`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Prestige Attunement:</span>
|
||||
<span className="text-green-300">+{(store.prestigeUpgrades.elementalAttune || 0) * 25}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Unlocked Elements:</span>
|
||||
<span className="text-green-300">{Object.values(store.elements).filter(e => e.unlocked).length} / {Object.keys(ELEMENTS).length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Elem. Crafting Bonus:</span>
|
||||
<span className="text-green-300">×{fmtDec(1 + (store.skills.elemCrafting || 0) * 0.25, 2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="bg-gray-700 my-3" />
|
||||
<div className="text-xs text-gray-400 mb-2">Elemental Mana Pools:</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
||||
{Object.entries(store.elements)
|
||||
.filter(([, state]) => state.unlocked)
|
||||
.map(([id, state]) => {
|
||||
const def = ELEMENTS[id];
|
||||
return (
|
||||
<div key={id} className="p-2 rounded border border-gray-700 bg-gray-800/50 text-center">
|
||||
<div className="text-lg">{def?.sym}</div>
|
||||
<div className="text-xs text-gray-400">{state.current}/{state.max}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Upgrades */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Star className="w-4 h-4" />
|
||||
Active Skill Upgrades ({selectedUpgrades.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedUpgrades.length === 0 ? (
|
||||
<div className="text-gray-500 text-sm">No skill upgrades selected yet. Level skills to 5 or 10 to choose upgrades.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{selectedUpgrades.map(({ skillId, upgrade }) => (
|
||||
<div key={upgrade.id} className="p-2 rounded border border-amber-600/30 bg-amber-900/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-amber-300 text-sm font-semibold">{upgrade.name}</span>
|
||||
<Badge variant="outline" className="text-xs text-gray-400">
|
||||
{SKILLS_DEF[skillId]?.name || skillId}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||||
{upgrade.effect.type === 'multiplier' && (
|
||||
<div className="text-xs text-green-400 mt-1">
|
||||
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'bonus' && (
|
||||
<div className="text-xs text-blue-400 mt-1">
|
||||
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'special' && (
|
||||
<div className="text-xs text-cyan-400 mt-1">
|
||||
⚡ {upgrade.effect.specialDesc || 'Special effect active'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Loop Stats */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Loop Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
|
||||
<div className="text-xs text-gray-400">Loops Completed</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
|
||||
<div className="text-xs text-gray-400">Current Insight</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
|
||||
<div className="text-xs text-gray-400">Total Insight</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-green-400 game-mono">{store.maxFloorReached}</div>
|
||||
<div className="text-xs text-gray-400">Max Floor</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="bg-gray-700 my-3" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.spells).filter(s => s.learned).length}</div>
|
||||
<div className="text-xs text-gray-400">Spells Learned</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{Object.values(store.skills).reduce((a, b) => a + b, 0)}</div>
|
||||
<div className="text-xs text-gray-400">Total Skill Levels</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{fmt(store.totalManaGathered)}</div>
|
||||
<div className="text-xs text-gray-400">Total Mana Gathered</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{store.memorySlots}</div>
|
||||
<div className="text-xs text-gray-400">Memory Slots</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ManaStatsSection
|
||||
maxMana={manaStats.maxMana}
|
||||
baseRegen={manaStats.baseRegen}
|
||||
effectiveRegen={manaStats.effectiveRegen}
|
||||
clickMana={manaStats.clickMana}
|
||||
meditationMultiplier={manaStats.meditationMultiplier}
|
||||
upgradeEffects={manaStats.upgradeEffects}
|
||||
store={store}
|
||||
elemMax={elemMax}
|
||||
selectedUpgrades={selectedUpgrades}
|
||||
/>
|
||||
<CombatStatsSection
|
||||
store={store}
|
||||
activeSpellDef={combatStats.activeSpellDef}
|
||||
pactMultiplier={combatStats.pactMultiplier}
|
||||
/>
|
||||
<PactStatusSection
|
||||
store={store}
|
||||
pactMultiplier={combatStats.pactMultiplier}
|
||||
pactInsightMultiplier={combatStats.pactInsightMultiplier}
|
||||
/>
|
||||
<StudyStatsSection
|
||||
studySpeedMult={studyStats.studySpeedMult}
|
||||
studyCostMult={studyStats.studyCostMult}
|
||||
store={store}
|
||||
/>
|
||||
<ElementStatsSection
|
||||
store={store}
|
||||
elemMax={elemMax}
|
||||
/>
|
||||
<ActiveUpgradesSection selectedUpgrades={selectedUpgrades} />
|
||||
<LoopStatsSection store={store} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Star } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||
import { SKILL_EVOLUTION_PATHS } from '@/lib/game/skill-evolution';
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
|
||||
interface ActiveUpgradesSectionProps {
|
||||
selectedUpgrades: { skillId: string; upgrade: SkillUpgradeChoice }[];
|
||||
}
|
||||
|
||||
export function ActiveUpgradesSection({ selectedUpgrades }: ActiveUpgradesSectionProps) {
|
||||
if (selectedUpgrades.length === 0) {
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Star className="w-4 h-4" />
|
||||
Active Skill Upgrades (0)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-gray-500 text-sm">No skill upgrades selected yet. Level skills to 5 or 10 to choose upgrades.</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Star className="w-4 h-4" />
|
||||
Active Skill Upgrades ({selectedUpgrades.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{selectedUpgrades.map(({ skillId, upgrade }) => (
|
||||
<div key={upgrade.id} className="p-2 rounded border border-amber-600/30 bg-amber-900/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-amber-300 text-sm font-semibold">{upgrade.name}</span>
|
||||
<Badge variant="outline" className="text-xs text-gray-400">
|
||||
{SKILLS_DEF[skillId]?.name || skillId}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{upgrade.desc}</div>
|
||||
{upgrade.effect.type === 'multiplier' && (
|
||||
<div className="text-xs text-green-400 mt-1">
|
||||
+{Math.round((upgrade.effect.value! - 1) * 100)}% {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'bonus' && (
|
||||
<div className="text-xs text-blue-400 mt-1">
|
||||
+{upgrade.effect.value} {upgrade.effect.stat}
|
||||
</div>
|
||||
)}
|
||||
{upgrade.effect.type === 'special' && (
|
||||
<div className="text-xs text-cyan-400 mt-1">
|
||||
⚡ {upgrade.effect.specialDesc || 'Special effect active'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Swords } from 'lucide-react';
|
||||
import type { GameStore } from '@/lib/game/store';
|
||||
import { fmt, fmtDec } from '@/lib/game/store';
|
||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||
|
||||
interface CombatStatsSectionProps {
|
||||
store: GameStore;
|
||||
activeSpellDef: any;
|
||||
pactMultiplier: number;
|
||||
}
|
||||
|
||||
export function CombatStatsSection({ store, activeSpellDef, pactMultiplier }: CombatStatsSectionProps) {
|
||||
const upgradeEffects = getUnifiedEffects(store);
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-red-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Swords className="w-4 h-4" />
|
||||
Combat Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Active Spell Base Damage:</span>
|
||||
<span className="text-gray-200">{activeSpellDef?.dmg || 5}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Combat Training Bonus:</span>
|
||||
<span className="text-red-300">+{(store.skills.combatTrain || 0) * 5}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Arcane Fury Multiplier:</span>
|
||||
<span className="text-red-300">×{fmtDec(1 + (store.skills.arcaneFury || 0) * 0.1, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Elemental Mastery:</span>
|
||||
<span className="text-red-300">×{fmtDec(1 + (store.skills.elementalMastery || 0) * 0.15, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Guardian Bane:</span>
|
||||
<span className="text-red-300">×{fmtDec(1 + (store.skills.guardianBane || 0) * 0.2, 2)} (vs guardians)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Critical Hit Chance:</span>
|
||||
<span className="text-amber-300">{((store.skills.precision || 0) * 5)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Critical Multiplier:</span>
|
||||
<span className="text-amber-300">1.5x</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Spell Echo Chance:</span>
|
||||
<span className="text-amber-300">{((store.skills.spellEcho || 0) * 10)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Pact Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(pactMultiplier, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||
<span className="text-gray-300">Total Damage:</span>
|
||||
<span className="text-red-400">{fmt(store.activeSpell ? activeSpellDef?.dmg * pactMultiplier : 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { FlaskConical } from 'lucide-react';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||
import { fmt, fmtDec } from '@/lib/game/store';
|
||||
|
||||
interface ElementStatsSectionProps {
|
||||
store: any;
|
||||
elemMax: number;
|
||||
}
|
||||
|
||||
export function ElementStatsSection({ store, elemMax }: ElementStatsSectionProps) {
|
||||
const getElemAttunementBonus = () => {
|
||||
const ea = store.skillTiers?.elemAttune || 1;
|
||||
const tieredSkillId = ea > 1 ? `elemAttune_t${ea}` : 'elemAttune';
|
||||
const level = store.skills[tieredSkillId] || store.skills.elemAttune || 0;
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return level * 50 * tierMult;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-green-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<FlaskConical className="w-4 h-4" />
|
||||
Element Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Element Capacity:</span>
|
||||
<span className="text-green-300">{elemMax}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Elem. Attunement Bonus:</span>
|
||||
<span className="text-green-300">+{getElemAttunementBonus()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Prestige Attunement:</span>
|
||||
<span className="text-green-300">+{(store.prestigeUpgrades.elementalAttune || 0) * 25}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Unlocked Elements:</span>
|
||||
<span className="text-green-300">{Object.values(store.elements).filter((e: any) => e.unlocked).length} / {Object.keys(ELEMENTS).length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Elem. Crafting Bonus:</span>
|
||||
<span className="text-green-300">×{fmtDec(1 + (store.skills.elemCrafting || 0) * 0.25, 2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="bg-gray-700 my-3" />
|
||||
<div className="text-xs text-gray-400 mb-2">Elemental Mana Pools:</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
||||
{Object.entries(store.elements)
|
||||
.filter(([, state]: [string, any]) => state.unlocked)
|
||||
.map(([id, state]: [string, any]) => {
|
||||
const def = ELEMENTS[id];
|
||||
return (
|
||||
<div key={id} className="p-2 rounded border border-gray-700 bg-gray-800/50 text-center">
|
||||
<div className="text-lg">{def?.sym}</div>
|
||||
<div className="text-xs text-gray-400">{state.current}/{state.max}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { fmt } from '@/lib/game/store';
|
||||
|
||||
interface LoopStatsSectionProps {
|
||||
store: any;
|
||||
}
|
||||
|
||||
export function LoopStatsSection({ store }: LoopStatsSectionProps) {
|
||||
const spellsLearned = Object.values(store.spells as Record<string, { learned: boolean }>).filter((s) => s.learned).length;
|
||||
const totalSkillLevels = Object.values(store.skills as Record<string, number>).reduce((a: number, b: number) => a + b, 0);
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Loop Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-amber-400 game-mono">{store.loopCount}</div>
|
||||
<div className="text-xs text-gray-400">Loops Completed</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-purple-400 game-mono">{fmt(store.insight)}</div>
|
||||
<div className="text-xs text-gray-400">Current Insight</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-blue-400 game-mono">{fmt(store.totalInsight)}</div>
|
||||
<div className="text-xs text-gray-400">Total Insight</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-2xl font-bold text-green-400 game-mono">{store.maxFloorReached}</div>
|
||||
<div className="text-xs text-gray-400">Max Floor</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="bg-gray-700 my-3" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{spellsLearned}</div>
|
||||
<div className="text-xs text-gray-400">Spells Learned</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{totalSkillLevels}</div>
|
||||
<div className="text-xs text-gray-400">Total Skill Levels</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{fmt(store.totalManaGathered)}</div>
|
||||
<div className="text-xs text-gray-400">Total Mana Gathered</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-xl font-bold text-gray-300 game-mono">{store.memorySlots}</div>
|
||||
<div className="text-xs text-gray-400">Memory Slots</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
'use client';
|
||||
|
||||
import { fmt, fmtDec } from '@/lib/game/store';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Droplet } from 'lucide-react';
|
||||
import type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
|
||||
interface ManaStatsSectionProps {
|
||||
maxMana: number;
|
||||
baseRegen: number;
|
||||
effectiveRegen: number;
|
||||
clickMana: number;
|
||||
meditationMultiplier: number;
|
||||
upgradeEffects: any;
|
||||
store: any;
|
||||
elemMax: number;
|
||||
selectedUpgrades: { skillId: string; upgrade: SkillUpgradeChoice }[];
|
||||
}
|
||||
|
||||
export function ManaStatsSection({
|
||||
maxMana,
|
||||
baseRegen,
|
||||
effectiveRegen,
|
||||
clickMana,
|
||||
meditationMultiplier,
|
||||
upgradeEffects,
|
||||
store,
|
||||
elemMax,
|
||||
selectedUpgrades,
|
||||
}: ManaStatsSectionProps) {
|
||||
const getTierMultiplier = (skillId: string) => {
|
||||
// Simplified - import from skill-evolution in real implementation
|
||||
return 1;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-blue-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Droplet className="w-4 h-4" />
|
||||
Mana Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Base Max Mana:</span>
|
||||
<span className="text-gray-200">100</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Well Bonus:</span>
|
||||
<span className="text-blue-300">
|
||||
{(() => {
|
||||
const mw = store.skillTiers?.manaWell || 1;
|
||||
const tieredSkillId = mw > 1 ? `manaWell_t${mw}` : 'manaWell';
|
||||
const level = store.skills[tieredSkillId] || store.skills.manaWell || 0;
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return `+${fmt(level * 100 * tierMult)} (${level} lvl × 100 × ${tierMult}x tier)`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Prestige Mana Well:</span>
|
||||
<span className="text-blue-300">+{fmt((store.prestigeUpgrades.manaWell || 0) * 500)}</span>
|
||||
</div>
|
||||
{upgradeEffects.maxManaBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Mana Bonus:</span>
|
||||
<span className="text-amber-300">+{fmt(upgradeEffects.maxManaBonus)}</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.maxManaMultiplier > 1 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Mana Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(upgradeEffects.maxManaMultiplier, 2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||
<span className="text-gray-300">Total Max Mana:</span>
|
||||
<span className="text-blue-400">{fmt(maxMana)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Base Regen:</span>
|
||||
<span className="text-gray-200">2/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Flow Bonus:</span>
|
||||
<span className="text-blue-300">
|
||||
{(() => {
|
||||
const mf = store.skillTiers?.manaFlow || 1;
|
||||
const tieredSkillId = mf > 1 ? `manaFlow_t${mf}` : 'manaFlow';
|
||||
const level = store.skills[tieredSkillId] || store.skills.manaFlow || 0;
|
||||
const tierMult = getTierMultiplier(tieredSkillId);
|
||||
return `+${fmtDec(level * 1 * tierMult)}/hr (${level} lvl × 1 × ${tierMult}x tier)`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Spring Bonus:</span>
|
||||
<span className="text-blue-300">+{(store.skills.manaSpring || 0) * 2}/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Prestige Mana Flow:</span>
|
||||
<span className="text-blue-300">+{fmtDec((store.prestigeUpgrades.manaFlow || 0) * 0.5)}/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Temporal Echo:</span>
|
||||
<span className="text-blue-300">×{fmtDec(1 + (store.prestigeUpgrades.temporalEcho || 0) * 0.1, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||
<span className="text-gray-300">Base Regen:</span>
|
||||
<span className="text-blue-400">{fmtDec(baseRegen, 2)}/hr</span>
|
||||
</div>
|
||||
{upgradeEffects.regenBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Regen Bonus:</span>
|
||||
<span className="text-amber-300">+{fmtDec(upgradeEffects.regenBonus, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.permanentRegenBonus > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Permanent Regen Bonus:</span>
|
||||
<span className="text-amber-300">+{fmtDec(upgradeEffects.permanentRegenBonus, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.regenMultiplier > 1 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-400">Upgrade Regen Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(upgradeEffects.regenMultiplier, 2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Click Mana Value:</span>
|
||||
<span className="text-purple-300">+{clickMana}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Tap Bonus:</span>
|
||||
<span className="text-purple-300">+{store.skills.manaTap || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Surge Bonus:</span>
|
||||
<span className="text-purple-300">+{(store.skills.manaSurge || 0) * 3}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Mana Overflow:</span>
|
||||
<span className="text-purple-300">×{fmtDec(1 + (store.skills.manaOverflow || 0) * 0.25, 2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Meditation Multiplier:</span>
|
||||
<span className={`font-semibold ${meditationMultiplier > 1.5 ? 'text-purple-400' : 'text-gray-300'}`}>
|
||||
{fmtDec(meditationMultiplier, 2)}x
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Incursion Strength:</span>
|
||||
<span className="text-red-400">{Math.round(upgradeEffects.incursionStrength * 100)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-semibold border-t border-gray-700 pt-2">
|
||||
<span className="text-gray-300">Effective Regen:</span>
|
||||
<span className="text-green-400 font-semibold">{fmtDec(effectiveRegen, 2)}/hr</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Special Effects */}
|
||||
{(upgradeEffects.hasSteadyStream || upgradeEffects.hasManaTorrent ||
|
||||
upgradeEffects.hasDesperateWells || upgradeEffects.manaCascadeBonus > 0 ||
|
||||
upgradeEffects.manaWaterfallBonus > 0) && (
|
||||
<>
|
||||
<div className="mt-3 mb-2"><span className="text-xs text-amber-400 game-panel-title">Special Effects</span></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{upgradeEffects.hasSteadyStream && (
|
||||
<div className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
|
||||
<span className="text-gray-300">Steady Stream:</span>
|
||||
<span className="text-green-400">Immune to incursion</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.manaCascadeBonus > 0 && (
|
||||
<div className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
|
||||
<span className="text-gray-300">Mana Cascade:</span>
|
||||
<span className="text-cyan-400">+{fmtDec(upgradeEffects.manaCascadeBonus, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.manaWaterfallBonus > 0 && (
|
||||
<div className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
|
||||
<span className="text-gray-300">Mana Waterfall:</span>
|
||||
<span className="text-cyan-400">+{fmtDec(upgradeEffects.manaWaterfallBonus, 2)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.hasFlowSurge && (
|
||||
<div className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
|
||||
<span className="text-gray-300">Flow Surge:</span>
|
||||
<span className="text-cyan-400">Clicks +100% regen for 1hr</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.hasManaOverflow && (
|
||||
<div className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
|
||||
<span className="text-gray-300">Mana Overflow:</span>
|
||||
<span className="text-cyan-400">Raw can exceed max by 20%</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.hasEternalFlow && (
|
||||
<div className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
|
||||
<span className="text-gray-300">Eternal Flow:</span>
|
||||
<span className="text-green-400">Regen immune to ALL penalties</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.hasManaTorrent && upgradeEffects.rawMana > maxMana * 0.75 && (
|
||||
<div className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
|
||||
<span className="text-gray-300">Mana Torrent:</span>
|
||||
<span className="text-cyan-400">+50% regen (high mana)</span>
|
||||
</div>
|
||||
)}
|
||||
{upgradeEffects.hasDesperateWells && upgradeEffects.rawMana < maxMana * 0.25 && (
|
||||
<div className="flex justify-between text-xs bg-gray-800/50 rounded px-2 py-1">
|
||||
<span className="text-gray-300">Desperate Wells:</span>
|
||||
<span className="text-cyan-400">+50% regen (low mana)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Element Max */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-700">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Element Capacity:</span>
|
||||
<span className="text-green-300">{elemMax}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { fmtDec } from '@/lib/game/store';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
interface PactStatusSectionProps {
|
||||
store: any;
|
||||
pactMultiplier: number;
|
||||
pactInsightMultiplier: number;
|
||||
}
|
||||
|
||||
export function PactStatusSection({ store, pactMultiplier, pactInsightMultiplier }: PactStatusSectionProps) {
|
||||
const pactInterferenceMitigation = store.pactInterferenceMitigation || 0;
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4" />
|
||||
Pact Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Pact Slots:</span>
|
||||
<span className="text-amber-300">{store.signedPacts.length} / {1 + (store.prestigeUpgrades.pactCapacity || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Damage Multiplier:</span>
|
||||
<span className="text-amber-300">×{fmtDec(pactMultiplier, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Insight Multiplier:</span>
|
||||
<span className="text-purple-300">×{fmtDec(pactInsightMultiplier, 2)}</span>
|
||||
</div>
|
||||
{store.signedPacts.length > 1 && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Interference Mitigation:</span>
|
||||
<span className="text-green-300">{Math.min(pactInterferenceMitigation, 5) * 10}%</span>
|
||||
</div>
|
||||
{pactInterferenceMitigation >= 5 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-cyan-400">Synergy Bonus:</span>
|
||||
<span className="text-cyan-300">+{(pactInterferenceMitigation - 5) * 10}%</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-400 mb-2">Unlocked Mana Types:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.keys(store.elements).map((id) => {
|
||||
const state = store.elements[id];
|
||||
if (!state.unlocked) return null;
|
||||
const elem = ELEMENTS[id];
|
||||
return (
|
||||
<span key={id} className="px-2 py-1 text-xs rounded border" style={{ borderColor: elem?.color, color: elem?.color }}>
|
||||
{elem?.sym} {elem?.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import { fmtDec } from '@/lib/game/store';
|
||||
|
||||
interface StudyStatsSectionProps {
|
||||
studySpeedMult: number;
|
||||
studyCostMult: number;
|
||||
store: any;
|
||||
}
|
||||
|
||||
export function StudyStatsSection({ studySpeedMult, studyCostMult, store }: StudyStatsSectionProps) {
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-purple-400 game-panel-title text-xs flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
Study Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Study Speed:</span>
|
||||
<span className="text-purple-300">×{fmtDec(studySpeedMult, 2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Quick Learner Bonus:</span>
|
||||
<span className="text-purple-300">+{((store.skills.quickLearner || 0) * 10)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Study Cost:</span>
|
||||
<span className="text-purple-300">{Math.round(studyCostMult * 100)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Focused Mind Bonus:</span>
|
||||
<span className="text-purple-300">-{((store.skills.focusedMind || 0) * 5)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">Progress Retention:</span>
|
||||
<span className="text-purple-300">{Math.round((1 + (store.skills.knowledgeRetention || 0) * 0.2) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { StatRow } from '@/components/ui/stat-row';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { AlertCircle, Wand2, Scroll, Trash2, Plus, Minus, Check } from 'lucide-react';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from '@/lib/game/data/enchantment-effects';
|
||||
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, EquipmentCraftingProgress, EquipmentCategory } from '@/lib/game/types';
|
||||
import { fmt, type GameStore } from '@/lib/game/store';
|
||||
|
||||
export interface EnchantmentDesignerProps {
|
||||
store: GameStore;
|
||||
selectedEquipmentType: string | null;
|
||||
setSelectedEquipmentType: (type: string | null) => void;
|
||||
selectedEffects: DesignEffect[];
|
||||
setSelectedEffects: (effects: DesignEffect[]) => void;
|
||||
designName: string;
|
||||
setDesignName: (name: string) => void;
|
||||
selectedDesign: string | null;
|
||||
setSelectedDesign: (id: string | null) => void;
|
||||
}
|
||||
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, EquipmentCraftingProgress } from '@/lib/game/types';
|
||||
import { type GameStore } from '@/lib/game/store';
|
||||
import type { EnchantmentDesignerProps } from './EnchantmentDesigner/types';
|
||||
import { EquipmentTypeSelector } from './EnchantmentDesigner/EquipmentTypeSelector';
|
||||
import { EffectSelector } from './EnchantmentDesigner/EffectSelector';
|
||||
import { SavedDesigns } from './EnchantmentDesigner/SavedDesigns';
|
||||
import { DesignForm } from './EnchantmentDesigner/DesignForm';
|
||||
import {
|
||||
getAvailableEffects,
|
||||
getIncompatibleEffects,
|
||||
getOwnedEquipmentTypes,
|
||||
getIncompatibilityReason,
|
||||
calculateDesignCapacityCost,
|
||||
getEquipmentCapacity,
|
||||
calculateDesignTime,
|
||||
addEffectToDesign,
|
||||
removeEffectFromDesign,
|
||||
} from './EnchantmentDesigner/utils';
|
||||
|
||||
export function EnchantmentDesigner({
|
||||
store,
|
||||
@@ -52,55 +47,23 @@ export function EnchantmentDesigner({
|
||||
const efficiencyBonus = (skills.efficientEnchant || 0) * 0.05;
|
||||
|
||||
// Calculate total capacity cost for current design
|
||||
const designCapacityCost = selectedEffects.reduce(
|
||||
(total, eff) => total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus),
|
||||
0
|
||||
);
|
||||
const designCapacityCost = calculateDesignCapacityCost(selectedEffects, efficiencyBonus);
|
||||
|
||||
// Get capacity limit for selected equipment type
|
||||
const selectedEquipmentCapacity = selectedEquipmentType ? EQUIPMENT_TYPES[selectedEquipmentType]?.baseCapacity || 0 : 0;
|
||||
const selectedEquipmentCapacity = getEquipmentCapacity(selectedEquipmentType);
|
||||
const isOverCapacity = selectedEquipmentType ? designCapacityCost > selectedEquipmentCapacity : false;
|
||||
|
||||
// Calculate design time
|
||||
const designTime = selectedEffects.reduce((total, eff) => total + 0.5 * eff.stacks, 1);
|
||||
const designTime = calculateDesignTime(selectedEffects);
|
||||
|
||||
// Add effect to design
|
||||
const addEffect = (effectId: string) => {
|
||||
const existing = selectedEffects.find(e => e.effectId === effectId);
|
||||
const effectDef = ENCHANTMENT_EFFECTS[effectId];
|
||||
if (!effectDef) return;
|
||||
|
||||
if (existing) {
|
||||
if (existing.stacks < effectDef.maxStacks) {
|
||||
setSelectedEffects(selectedEffects.map(e =>
|
||||
e.effectId === effectId
|
||||
? { ...e, stacks: e.stacks + 1 }
|
||||
: e
|
||||
));
|
||||
}
|
||||
} else {
|
||||
setSelectedEffects([...selectedEffects, {
|
||||
effectId,
|
||||
stacks: 1,
|
||||
capacityCost: calculateEffectCapacityCost(effectId, 1, efficiencyBonus),
|
||||
}]);
|
||||
}
|
||||
addEffectToDesign(effectId, selectedEffects, efficiencyBonus, setSelectedEffects);
|
||||
};
|
||||
|
||||
// Remove effect from design
|
||||
const removeEffect = (effectId: string) => {
|
||||
const existing = selectedEffects.find(e => e.effectId === effectId);
|
||||
if (!existing) return;
|
||||
|
||||
if (existing.stacks > 1) {
|
||||
setSelectedEffects(selectedEffects.map(e =>
|
||||
e.effectId === effectId
|
||||
? { ...e, stacks: e.stacks - 1 }
|
||||
: e
|
||||
));
|
||||
} else {
|
||||
setSelectedEffects(selectedEffects.filter(e => e.effectId !== effectId));
|
||||
}
|
||||
removeEffectFromDesign(effectId, selectedEffects, setSelectedEffects);
|
||||
};
|
||||
|
||||
// Create design
|
||||
@@ -117,331 +80,73 @@ export function EnchantmentDesigner({
|
||||
};
|
||||
|
||||
// Get available effects for selected equipment type (only unlocked ones)
|
||||
const getAvailableEffects = () => {
|
||||
if (!selectedEquipmentType) return [];
|
||||
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
||||
if (!type) return [];
|
||||
|
||||
return Object.values(ENCHANTMENT_EFFECTS).filter(
|
||||
effect =>
|
||||
effect.allowedEquipmentCategories.includes(type.category) &&
|
||||
unlockedEffects.includes(effect.id)
|
||||
);
|
||||
};
|
||||
const availableEffects = getAvailableEffects(selectedEquipmentType, unlockedEffects);
|
||||
|
||||
// Get incompatible effects (unlocked but not for this equipment type)
|
||||
// Requirement (task3 bug #7): Show incompatible enchantments in greyed-out "Unavailable" section
|
||||
const getIncompatibleEffects = () => {
|
||||
if (!selectedEquipmentType) return [];
|
||||
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
||||
if (!type) return [];
|
||||
|
||||
return Object.values(ENCHANTMENT_EFFECTS).filter(
|
||||
effect =>
|
||||
!effect.allowedEquipmentCategories.includes(type.category) &&
|
||||
unlockedEffects.includes(effect.id)
|
||||
);
|
||||
};
|
||||
const incompatibleEffects = getIncompatibleEffects(selectedEquipmentType, unlockedEffects);
|
||||
|
||||
// Get equipment types that the player actually owns (has instances of)
|
||||
// This ensures enchantment compatibility is based on owned items, not just blueprints
|
||||
const getOwnedEquipmentTypes = () => {
|
||||
// Get all unique equipment type IDs from owned instances
|
||||
const ownedEquipmentTypeIds = new Set<string>();
|
||||
|
||||
// Check all equipment instances the player owns
|
||||
for (const instance of Object.values(store.equipmentInstances)) {
|
||||
ownedEquipmentTypeIds.add(instance.typeId);
|
||||
}
|
||||
|
||||
// Filter EQUIPMENT_TYPES to only include types the player owns
|
||||
return Object.values(EQUIPMENT_TYPES).filter(type => ownedEquipmentTypeIds.has(type.id));
|
||||
};
|
||||
|
||||
const ownedEquipmentTypes = getOwnedEquipmentTypes();
|
||||
const availableEffects = getAvailableEffects();
|
||||
const incompatibleEffects = getIncompatibleEffects();
|
||||
const ownedEquipmentTypes = getOwnedEquipmentTypes(store);
|
||||
|
||||
// Get the reason why an effect is incompatible
|
||||
const getIncompatibilityReason = (effect: typeof ENCHANTMENT_EFFECTS[string]): string => {
|
||||
if (!selectedEquipmentType) return 'No equipment selected';
|
||||
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
||||
if (!type) return 'Unknown equipment type';
|
||||
|
||||
// Check what categories this effect is allowed for
|
||||
const allowedCategories = effect.allowedEquipmentCategories;
|
||||
const equipmentCategory = type.category;
|
||||
|
||||
if (allowedCategories.includes(equipmentCategory)) {
|
||||
return 'Compatible';
|
||||
}
|
||||
|
||||
// Provide specific reasons
|
||||
if (allowedCategories.includes('weapon' as EquipmentCategory) && equipmentCategory !== 'sword' && equipmentCategory !== 'caster' && equipmentCategory !== 'catalyst') {
|
||||
return `Requires a weapon (${allowedCategories.filter(c => ['sword', 'caster', 'catalyst'].includes(c)).join(', ')})`;
|
||||
}
|
||||
|
||||
return `Requires ${allowedCategories.join(' or ')} equipment`;
|
||||
const getIncompatibilityReasonWrapper = (effect: { id: string; name: string; description: string; allowedEquipmentCategories: any[] }) => {
|
||||
return getIncompatibilityReason(effect, selectedEquipmentType);
|
||||
};
|
||||
|
||||
// Render stage
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Equipment Type Selection */}
|
||||
<GameCard variant="default">
|
||||
<SectionHeader title="1. Select Equipment Type" />
|
||||
{designProgress ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
Designing for: {EQUIPMENT_TYPES[designProgress.equipmentType]?.name}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-[var(--mana-light)]">{designProgress.name}</div>
|
||||
<Progress
|
||||
value={(designProgress.progress / designProgress.required) * 100}
|
||||
className="h-3 bg-[var(--bg-sunken)]"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-[var(--text-muted)]">
|
||||
<span>{designProgress.progress.toFixed(1)}h / {designProgress.required.toFixed(1)}h</span>
|
||||
<ActionButton size="sm" variant="outline" onClick={cancelDesign}>Cancel</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ownedEquipmentTypes.map(type => (
|
||||
<div
|
||||
key={type.id}
|
||||
className={`p-2 rounded border cursor-pointer transition-all
|
||||
${selectedEquipmentType === type.id
|
||||
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
onClick={() => setSelectedEquipmentType(type.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select ${type.name}`}
|
||||
>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">{type.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">Cap: {type.baseCapacity}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{ownedEquipmentTypes.length === 0 && (
|
||||
<div className="text-center text-[var(--text-muted)] py-4 text-sm">
|
||||
No equipment blueprints owned. Craft or find equipment blueprints first.
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</GameCard>
|
||||
<EquipmentTypeSelector
|
||||
ownedEquipmentTypes={ownedEquipmentTypes}
|
||||
selectedEquipmentType={selectedEquipmentType}
|
||||
setSelectedEquipmentType={setSelectedEquipmentType}
|
||||
designProgress={designProgress}
|
||||
cancelDesign={cancelDesign}
|
||||
/>
|
||||
|
||||
{/* Effect Selection */}
|
||||
<GameCard variant="default">
|
||||
<SectionHeader title="2. Select Effects" />
|
||||
{enchantingLevel < 1 ? (
|
||||
<div className="text-center text-[var(--text-muted)] py-8">
|
||||
<Wand2 className="w-12 h-12 mx-auto mb-2 opacity-50 text-[var(--text-disabled)]" />
|
||||
<p>Learn Enchanting skill to design enchantments</p>
|
||||
</div>
|
||||
) : designProgress ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-[var(--text-secondary)]">Design in progress...</div>
|
||||
{designProgress.effects.map(eff => {
|
||||
const def = ENCHANTMENT_EFFECTS[eff.effectId];
|
||||
return (
|
||||
<div key={eff.effectId} className="flex justify-between text-sm text-[var(--text-primary)]">
|
||||
<span>{def?.name} x{eff.stacks}</span>
|
||||
<span className="text-[var(--text-muted)]">{eff.capacityCost} cap</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : !selectedEquipmentType ? (
|
||||
<div className="text-center text-[var(--text-muted)] py-8">
|
||||
Select an equipment type first
|
||||
</div>
|
||||
) : (
|
||||
<EffectSelector
|
||||
selectedEquipmentType={selectedEquipmentType}
|
||||
selectedEffects={selectedEffects}
|
||||
setSelectedEffects={setSelectedEffects}
|
||||
availableEffects={availableEffects}
|
||||
incompatibleEffects={incompatibleEffects}
|
||||
enchantingLevel={enchantingLevel}
|
||||
efficiencyBonus={efficiencyBonus}
|
||||
designProgress={designProgress}
|
||||
addEffect={addEffect}
|
||||
removeEffect={removeEffect}
|
||||
getIncompatibilityReason={getIncompatibilityReasonWrapper}
|
||||
/>
|
||||
|
||||
{/* Selected effects summary - only show when not in design progress and equipment type is selected */}
|
||||
{!designProgress && selectedEquipmentType && (
|
||||
<>
|
||||
<ScrollArea className="h-48 mb-4">
|
||||
<div className="space-y-2">
|
||||
{/* Compatible Effects */}
|
||||
{availableEffects.map(effect => {
|
||||
const selected = selectedEffects.find(e => e.effectId === effect.id);
|
||||
const cost = calculateEffectCapacityCost(effect.id, (selected?.stacks || 0) + 1, efficiencyBonus);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={effect.id}
|
||||
className={`p-2 rounded border transition-all
|
||||
${selected
|
||||
? 'border-[var(--mana-stellar)] bg-[var(--mana-stellar)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">{effect.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">{effect.description}</div>
|
||||
<div className="text-xs text-[var(--text-disabled)] mt-1">
|
||||
Cost: {effect.baseCapacityCost} cap | Max: {effect.maxStacks}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{selected && (
|
||||
<ActionButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeEffect(effect.id)}
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => addEffect(effect.id)}
|
||||
disabled={!selected && selectedEffects.length >= 5}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{selected && (
|
||||
<Badge variant="outline" className="mt-1 text-xs border-[var(--mana-stellar)] text-[var(--mana-stellar)]">
|
||||
{selected.stacks}/{effect.maxStacks}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Incompatible Effects - Requirement: greyed-out "Unavailable" section with tooltips */}
|
||||
{incompatibleEffects.length > 0 && (
|
||||
<>
|
||||
<Separator className="bg-[var(--border-subtle)] my-2" />
|
||||
<div className="text-xs font-semibold text-[var(--text-disabled)] uppercase tracking-wider mb-2">
|
||||
Unavailable
|
||||
</div>
|
||||
{incompatibleEffects.map(effect => {
|
||||
const reason = getIncompatibilityReason(effect);
|
||||
|
||||
return (
|
||||
<TooltipProvider key={effect.id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="p-2 rounded border border-[var(--border-subtle)] bg-[var(--bg-sunken)]/30 opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-[var(--text-disabled)]">{effect.name}</div>
|
||||
<div className="text-xs text-[var(--text-disabled)]">{effect.description}</div>
|
||||
</div>
|
||||
<AlertCircle size={14} className="text-[var(--text-disabled)]" />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
|
||||
<p className="font-semibold">Incompatible Effect</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">{reason}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Selected effects summary */}
|
||||
<Separator className="bg-[var(--border-subtle)] my-2" />
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Design name..."
|
||||
value={designName}
|
||||
onChange={(e) => setDesignName(e.target.value)}
|
||||
className="w-full bg-[var(--bg-sunken)] border border-[var(--border-default)] rounded px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] focus:outline-none focus:border-[var(--border-focus)]"
|
||||
aria-label="Design name"
|
||||
/>
|
||||
<StatRow
|
||||
label="Total Capacity:"
|
||||
value={
|
||||
<span className={isOverCapacity ? 'text-[var(--color-danger)]' : 'text-[var(--color-success)]'}>
|
||||
{designCapacityCost.toFixed(0)} / {selectedEquipmentCapacity}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label="Design Time:"
|
||||
value={`${designTime.toFixed(1)}h`}
|
||||
highlight="default"
|
||||
/>
|
||||
<ActionButton
|
||||
className="w-full"
|
||||
disabled={!designName || selectedEffects.length === 0 || isOverCapacity}
|
||||
onClick={handleCreateDesign}
|
||||
>
|
||||
{isOverCapacity ? 'Over Capacity!' : `Start Design (${designTime.toFixed(1)}h)`}
|
||||
</ActionButton>
|
||||
</div>
|
||||
<DesignForm
|
||||
designName={designName}
|
||||
setDesignName={setDesignName}
|
||||
selectedEffects={selectedEffects}
|
||||
designCapacityCost={designCapacityCost}
|
||||
selectedEquipmentCapacity={selectedEquipmentCapacity}
|
||||
isOverCapacity={isOverCapacity}
|
||||
designTime={designTime}
|
||||
selectedEquipmentType={selectedEquipmentType}
|
||||
handleCreateDesign={handleCreateDesign}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</GameCard>
|
||||
|
||||
{/* Saved Designs */}
|
||||
<GameCard variant="default" className="lg:col-span-2">
|
||||
<SectionHeader title={`Saved Designs (${enchantmentDesigns.length})`} />
|
||||
{enchantmentDesigns.length === 0 ? (
|
||||
<div className="text-center text-[var(--text-muted)] py-4">
|
||||
No saved designs yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{enchantmentDesigns.map(design => (
|
||||
<div
|
||||
key={design.id}
|
||||
className={`p-3 rounded border cursor-pointer transition-all
|
||||
${selectedDesign === design.id
|
||||
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
onClick={() => setSelectedDesign(design.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select design: ${design.name}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-semibold text-[var(--text-primary)]">{design.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
{EQUIPMENT_TYPES[design.equipmentType]?.name}
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-[var(--text-muted)] hover:text-[var(--color-danger)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteDesign(design.id);
|
||||
}}
|
||||
aria-label={`Delete design: ${design.name}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-[var(--text-muted)]">
|
||||
{design.effects.length} effects | {design.totalCapacityUsed} cap
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GameCard>
|
||||
<SavedDesigns
|
||||
enchantmentDesigns={enchantmentDesigns}
|
||||
selectedDesign={selectedDesign}
|
||||
setSelectedDesign={setSelectedDesign}
|
||||
deleteDesign={deleteDesign}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { StatRow } from '@/components/ui/stat-row';
|
||||
import type { DesignFormProps } from './types';
|
||||
|
||||
export function DesignForm({
|
||||
designName,
|
||||
setDesignName,
|
||||
selectedEffects,
|
||||
designCapacityCost,
|
||||
selectedEquipmentCapacity,
|
||||
isOverCapacity,
|
||||
designTime,
|
||||
selectedEquipmentType,
|
||||
handleCreateDesign,
|
||||
}: DesignFormProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Design name..."
|
||||
value={designName}
|
||||
onChange={(e) => setDesignName(e.target.value)}
|
||||
className="w-full bg-[var(--bg-sunken)] border border-[var(--border-default)] rounded px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] focus:outline-none focus:border-[var(--border-focus)]"
|
||||
aria-label="Design name"
|
||||
/>
|
||||
<StatRow
|
||||
label="Total Capacity:"
|
||||
value={
|
||||
<span className={isOverCapacity ? 'text-[var(--color-danger)]' : 'text-[var(--color-success)]'}>
|
||||
{designCapacityCost.toFixed(0)} / {selectedEquipmentCapacity}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label="Design Time:"
|
||||
value={`${designTime.toFixed(1)}h`}
|
||||
highlight="default"
|
||||
/>
|
||||
<ActionButton
|
||||
className="w-full"
|
||||
disabled={!designName || selectedEffects.length === 0 || isOverCapacity}
|
||||
onClick={handleCreateDesign}
|
||||
>
|
||||
{isOverCapacity ? 'Over Capacity!' : `Start Design (${designTime.toFixed(1)}h)`}
|
||||
</ActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DesignForm.displayName = 'DesignForm';
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { AlertCircle, Wand2, Plus, Minus } from 'lucide-react';
|
||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from '@/lib/game/data/enchantment-effects';
|
||||
import type { EffectSelectorProps } from './types';
|
||||
|
||||
export function EffectSelector({
|
||||
selectedEquipmentType,
|
||||
selectedEffects,
|
||||
setSelectedEffects,
|
||||
availableEffects,
|
||||
incompatibleEffects,
|
||||
enchantingLevel,
|
||||
efficiencyBonus,
|
||||
designProgress,
|
||||
addEffect,
|
||||
removeEffect,
|
||||
getIncompatibilityReason,
|
||||
}: EffectSelectorProps) {
|
||||
return (
|
||||
<>
|
||||
{enchantingLevel < 1 ? (
|
||||
<div className="text-center text-[var(--text-muted)] py-8">
|
||||
<Wand2 className="w-12 h-12 mx-auto mb-2 opacity-50 text-[var(--text-disabled)]" />
|
||||
<p>Learn Enchanting skill to design enchantments</p>
|
||||
</div>
|
||||
) : designProgress ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-[var(--text-secondary)]">Design in progress...</div>
|
||||
{designProgress.effects.map(eff => {
|
||||
const def = ENCHANTMENT_EFFECTS[eff.effectId];
|
||||
return (
|
||||
<div key={eff.effectId} className="flex justify-between text-sm text-[var(--text-primary)]">
|
||||
<span>{def?.name} x{eff.stacks}</span>
|
||||
<span className="text-[var(--text-muted)]">{eff.capacityCost} cap</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : !selectedEquipmentType ? (
|
||||
<div className="text-center text-[var(--text-muted)] py-8">
|
||||
Select an equipment type first
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ScrollArea className="h-48 mb-4">
|
||||
<div className="space-y-2">
|
||||
{/* Compatible Effects */}
|
||||
{availableEffects.map(effect => {
|
||||
const selected = selectedEffects.find(e => e.effectId === effect.id);
|
||||
const cost = calculateEffectCapacityCost(effect.id, (selected?.stacks || 0) + 1, efficiencyBonus);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={effect.id}
|
||||
className={`p-2 rounded border transition-all
|
||||
${selected
|
||||
? 'border-[var(--mana-stellar)] bg-[var(--mana-stellar)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">{effect.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">{effect.description}</div>
|
||||
<div className="text-xs text-[var(--text-disabled)] mt-1">
|
||||
Cost: {effect.baseCapacityCost} cap | Max: {effect.maxStacks}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{selected && (
|
||||
<ActionButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeEffect(effect.id)}
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => addEffect(effect.id)}
|
||||
disabled={!selected && selectedEffects.length >= 5}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{selected && (
|
||||
<Badge variant="outline" className="mt-1 text-xs border-[var(--mana-stellar)] text-[var(--mana-stellar)]">
|
||||
{selected.stacks}/{effect.maxStacks}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Incompatible Effects - Requirement: greyed-out "Unavailable" section with tooltips */}
|
||||
{incompatibleEffects.length > 0 && (
|
||||
<>
|
||||
<Separator className="bg-[var(--border-subtle)] my-2" />
|
||||
<div className="text-xs font-semibold text-[var(--text-disabled)] uppercase tracking-wider mb-2">
|
||||
Unavailable
|
||||
</div>
|
||||
{incompatibleEffects.map(effect => {
|
||||
const reason = getIncompatibilityReason(effect);
|
||||
|
||||
return (
|
||||
<TooltipProvider key={effect.id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="p-2 rounded border border-[var(--border-subtle)] bg-[var(--bg-sunken)]/30 opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-[var(--text-disabled)]">{effect.name}</div>
|
||||
<div className="text-xs text-[var(--text-disabled)]">{effect.description}</div>
|
||||
</div>
|
||||
<AlertCircle size={14} className="text-[var(--text-disabled)]" />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
|
||||
<p className="font-semibold">Incompatible Effect</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">{reason}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
EffectSelector.displayName = 'EffectSelector';
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { EquipmentTypeSelectorProps } from './types';
|
||||
|
||||
export function EquipmentTypeSelector({
|
||||
ownedEquipmentTypes,
|
||||
selectedEquipmentType,
|
||||
setSelectedEquipmentType,
|
||||
designProgress,
|
||||
cancelDesign,
|
||||
}: EquipmentTypeSelectorProps) {
|
||||
return (
|
||||
<GameCard variant="default">
|
||||
<SectionHeader title="1. Select Equipment Type" />
|
||||
{designProgress ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
Designing for: {designProgress.equipmentType}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-[var(--mana-light)]">{designProgress.name}</div>
|
||||
<Progress
|
||||
value={(designProgress.progress / designProgress.required) * 100}
|
||||
className="h-3 bg-[var(--bg-sunken)]"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-[var(--text-muted)]">
|
||||
<span>{designProgress.progress.toFixed(1)}h / {designProgress.required.toFixed(1)}h</span>
|
||||
<ActionButton size="sm" variant="outline" onClick={cancelDesign}>Cancel</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ownedEquipmentTypes.map(type => (
|
||||
<div
|
||||
key={type.id}
|
||||
className={`p-2 rounded border cursor-pointer transition-all
|
||||
${selectedEquipmentType === type.id
|
||||
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
onClick={() => setSelectedEquipmentType(type.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select ${type.name}`}
|
||||
>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">{type.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">Cap: {type.baseCapacity}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{ownedEquipmentTypes.length === 0 && (
|
||||
<div className="text-center text-[var(--text-muted)] py-4 text-sm">
|
||||
No equipment blueprints owned. Craft or find equipment blueprints first.
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</GameCard>
|
||||
);
|
||||
}
|
||||
|
||||
EquipmentTypeSelector.displayName = 'EquipmentTypeSelector';
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import type { SavedDesignsProps } from './types';
|
||||
|
||||
export function SavedDesigns({
|
||||
enchantmentDesigns,
|
||||
selectedDesign,
|
||||
setSelectedDesign,
|
||||
deleteDesign,
|
||||
}: SavedDesignsProps) {
|
||||
return (
|
||||
<GameCard variant="default" className="lg:col-span-2">
|
||||
<SectionHeader title={`Saved Designs (${enchantmentDesigns.length})`} />
|
||||
{enchantmentDesigns.length === 0 ? (
|
||||
<div className="text-center text-[var(--text-muted)] py-4">
|
||||
No saved designs yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{enchantmentDesigns.map(design => (
|
||||
<div
|
||||
key={design.id}
|
||||
className={`p-3 rounded border cursor-pointer transition-all
|
||||
${selectedDesign === design.id
|
||||
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
onClick={() => setSelectedDesign(design.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select design: ${design.name}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-semibold text-[var(--text-primary)]">{design.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
{EQUIPMENT_TYPES[design.equipmentType]?.name}
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-[var(--text-muted)] hover:text-[var(--color-danger)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteDesign(design.id);
|
||||
}}
|
||||
aria-label={`Delete design: ${design.name}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-[var(--text-muted)]">
|
||||
{design.effects.length} effects | {design.totalCapacityUsed} cap
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GameCard>
|
||||
);
|
||||
}
|
||||
|
||||
SavedDesigns.displayName = 'SavedDesigns';
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, EquipmentCraftingProgress, EquipmentCategory } from '@/lib/game/types';
|
||||
import type { GameStore } from '@/lib/game/store';
|
||||
|
||||
export interface EnchantmentDesignerProps {
|
||||
store: GameStore;
|
||||
selectedEquipmentType: string | null;
|
||||
setSelectedEquipmentType: (type: string | null) => void;
|
||||
selectedEffects: DesignEffect[];
|
||||
setSelectedEffects: (effects: DesignEffect[]) => void;
|
||||
designName: string;
|
||||
setDesignName: (name: string) => void;
|
||||
selectedDesign: string | null;
|
||||
setSelectedDesign: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export interface EquipmentTypeSelectorProps {
|
||||
ownedEquipmentTypes: Array<{ id: string; name: string; baseCapacity: number }>;
|
||||
selectedEquipmentType: string | null;
|
||||
setSelectedEquipmentType: (type: string | null) => void;
|
||||
designProgress: EquipmentCraftingProgress | null;
|
||||
cancelDesign: () => void;
|
||||
}
|
||||
|
||||
export interface EffectSelectorProps {
|
||||
selectedEquipmentType: string | null;
|
||||
selectedEffects: DesignEffect[];
|
||||
setSelectedEffects: (effects: DesignEffect[]) => void;
|
||||
availableEffects: Array<{ id: string; name: string; description: string; baseCapacityCost: number; maxStacks: number }>;
|
||||
incompatibleEffects: Array<{ id: string; name: string; description: string }>;
|
||||
enchantingLevel: number;
|
||||
efficiencyBonus: number;
|
||||
designProgress: EquipmentCraftingProgress | null;
|
||||
addEffect: (effectId: string) => void;
|
||||
removeEffect: (effectId: string) => void;
|
||||
getIncompatibilityReason: (effect: { id: string; name: string; description: string; allowedEquipmentCategories: EquipmentCategory[] }) => string;
|
||||
}
|
||||
|
||||
export interface SavedDesignsProps {
|
||||
enchantmentDesigns: EnchantmentDesign[];
|
||||
selectedDesign: string | null;
|
||||
setSelectedDesign: (id: string | null) => void;
|
||||
deleteDesign: (id: string) => void;
|
||||
}
|
||||
|
||||
export interface DesignFormProps {
|
||||
designName: string;
|
||||
setDesignName: (name: string) => void;
|
||||
selectedEffects: DesignEffect[];
|
||||
designCapacityCost: number;
|
||||
selectedEquipmentCapacity: number;
|
||||
isOverCapacity: boolean;
|
||||
designTime: number;
|
||||
selectedEquipmentType: string | null;
|
||||
handleCreateDesign: () => void;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from '@/lib/game/data/enchantment-effects';
|
||||
import type { DesignEffect, EquipmentCategory } from '@/lib/game/types';
|
||||
import type { GameStore } from '@/lib/game/store';
|
||||
|
||||
/**
|
||||
* Get available effects for selected equipment type (only unlocked ones)
|
||||
* Requirement (task3 bug #7): Show incompatible enchantments in greyed-out "Unavailable" section
|
||||
*/
|
||||
export function getAvailableEffects(
|
||||
selectedEquipmentType: string | null,
|
||||
unlockedEffects: string[]
|
||||
) {
|
||||
if (!selectedEquipmentType) return [];
|
||||
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
||||
if (!type) return [];
|
||||
|
||||
return Object.values(ENCHANTMENT_EFFECTS).filter(
|
||||
effect =>
|
||||
effect.allowedEquipmentCategories.includes(type.category) &&
|
||||
unlockedEffects.includes(effect.id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incompatible effects (unlocked but not for this equipment type)
|
||||
*/
|
||||
export function getIncompatibleEffects(
|
||||
selectedEquipmentType: string | null,
|
||||
unlockedEffects: string[]
|
||||
) {
|
||||
if (!selectedEquipmentType) return [];
|
||||
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
||||
if (!type) return [];
|
||||
|
||||
return Object.values(ENCHANTMENT_EFFECTS).filter(
|
||||
effect =>
|
||||
!effect.allowedEquipmentCategories.includes(type.category) &&
|
||||
unlockedEffects.includes(effect.id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get equipment types that the player actually owns (has instances of)
|
||||
* This ensures enchantment compatibility is based on owned items, not just blueprints
|
||||
*/
|
||||
export function getOwnedEquipmentTypes(store: GameStore) {
|
||||
// Get all unique equipment type IDs from owned instances
|
||||
const ownedEquipmentTypeIds = new Set<string>();
|
||||
|
||||
// Check all equipment instances the player owns
|
||||
for (const instance of Object.values(store.equipmentInstances)) {
|
||||
ownedEquipmentTypeIds.add(instance.typeId);
|
||||
}
|
||||
|
||||
// Filter EQUIPMENT_TYPES to only include types the player owns
|
||||
return Object.values(EQUIPMENT_TYPES).filter(type => ownedEquipmentTypeIds.has(type.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reason why an effect is incompatible
|
||||
*/
|
||||
export function getIncompatibilityReason(
|
||||
effect: { id: string; name: string; description: string; allowedEquipmentCategories: EquipmentCategory[] },
|
||||
selectedEquipmentType: string | null
|
||||
): string {
|
||||
if (!selectedEquipmentType) return 'No equipment selected';
|
||||
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
||||
if (!type) return 'Unknown equipment type';
|
||||
|
||||
// Check what categories this effect is allowed for
|
||||
const allowedCategories = effect.allowedEquipmentCategories;
|
||||
const equipmentCategory = type.category;
|
||||
|
||||
if (allowedCategories.includes(equipmentCategory)) {
|
||||
return 'Compatible';
|
||||
}
|
||||
|
||||
// Provide specific reasons
|
||||
if (allowedCategories.includes('weapon' as EquipmentCategory) && equipmentCategory !== 'sword' && equipmentCategory !== 'caster' && equipmentCategory !== 'catalyst') {
|
||||
return `Requires a weapon (${allowedCategories.filter(c => ['sword', 'caster', 'catalyst'].includes(c)).join(', ')})`;
|
||||
}
|
||||
|
||||
return `Requires ${allowedCategories.join(' or ')} equipment`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total capacity cost for current design
|
||||
*/
|
||||
export function calculateDesignCapacityCost(
|
||||
selectedEffects: DesignEffect[],
|
||||
efficiencyBonus: number
|
||||
): number {
|
||||
return selectedEffects.reduce(
|
||||
(total, eff) => total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get capacity limit for selected equipment type
|
||||
*/
|
||||
export function getEquipmentCapacity(selectedEquipmentType: string | null): number {
|
||||
return selectedEquipmentType ? EQUIPMENT_TYPES[selectedEquipmentType]?.baseCapacity || 0 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate design time
|
||||
*/
|
||||
export function calculateDesignTime(selectedEffects: DesignEffect[]): number {
|
||||
return selectedEffects.reduce((total, eff) => total + 0.5 * eff.stacks, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add effect to design
|
||||
*/
|
||||
export function addEffectToDesign(
|
||||
effectId: string,
|
||||
selectedEffects: DesignEffect[],
|
||||
efficiencyBonus: number,
|
||||
setSelectedEffects: (effects: DesignEffect[]) => void
|
||||
) {
|
||||
const existing = selectedEffects.find(e => e.effectId === effectId);
|
||||
const effectDef = ENCHANTMENT_EFFECTS[effectId];
|
||||
if (!effectDef) return;
|
||||
|
||||
if (existing) {
|
||||
if (existing.stacks < effectDef.maxStacks) {
|
||||
setSelectedEffects(selectedEffects.map(e =>
|
||||
e.effectId === effectId
|
||||
? { ...e, stacks: e.stacks + 1 }
|
||||
: e
|
||||
));
|
||||
}
|
||||
} else {
|
||||
setSelectedEffects([...selectedEffects, {
|
||||
effectId,
|
||||
stacks: 1,
|
||||
capacityCost: calculateEffectCapacityCost(effectId, 1, efficiencyBonus),
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove effect from design
|
||||
*/
|
||||
export function removeEffectFromDesign(
|
||||
effectId: string,
|
||||
selectedEffects: DesignEffect[],
|
||||
setSelectedEffects: (effects: DesignEffect[]) => void
|
||||
) {
|
||||
const existing = selectedEffects.find(e => e.effectId === effectId);
|
||||
if (!existing) return;
|
||||
|
||||
if (existing.stacks > 1) {
|
||||
setSelectedEffects(selectedEffects.map(e =>
|
||||
e.effectId === effectId
|
||||
? { ...e, stacks: e.stacks - 1 }
|
||||
: e
|
||||
));
|
||||
} else {
|
||||
setSelectedEffects(selectedEffects.filter(e => e.effectId !== effectId));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useGameStore, useGameLoop } from '@/lib/game/store';
|
||||
import { useGameStore } from '@/lib/game/store';
|
||||
import { useGameLoop } from '@/lib/game/stores/gameHooks';
|
||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||
import {
|
||||
ELEMENTS,
|
||||
|
||||
Reference in New Issue
Block a user