Fix mana conversion visibility and UI improvements
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 3m9s
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 3m9s
- Increase attunement mana conversion rates (0.2 -> 2 for Enchanter) - Hide mana types with current < 1 in ManaDisplay and LabTab - Only show owned equipment types when designing enchantments
This commit is contained in:
405
src/components/game/GameContext.tsx
Executable file
405
src/components/game/GameContext.tsx
Executable file
@@ -0,0 +1,405 @@
|
||||
'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 { getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||
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;
|
||||
effectiveRegen: number;
|
||||
|
||||
// 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 effectiveRegen = (effectiveRegenWithSpecials + manaCascadeBonus) * meditationMultiplier;
|
||||
|
||||
// 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,
|
||||
effectiveRegen,
|
||||
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;
|
||||
}
|
||||
|
||||
// Re-export useGameLoop for convenience
|
||||
export { useGameLoop };
|
||||
193
src/components/game/GrimoireTab.tsx
Executable file
193
src/components/game/GrimoireTab.tsx
Executable file
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useGameStore, fmt, fmtDec, computePactMultiplier } from '@/lib/game/store';
|
||||
import { ELEMENTS, GUARDIANS, PRESTIGE_DEF } from '@/lib/game/constants';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
|
||||
export function GrimoireTab() {
|
||||
const store = useGameStore();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Current Status */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Loop Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<div className="text-2xl font-bold text-green-400 game-mono">{store.memorySlots}</div>
|
||||
<div className="text-xs text-gray-400">Memory Slots</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Signed Pacts */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Signed Pacts ({store.signedPacts.length}/{1 + (store.prestigeUpgrades.pactCapacity || 0)})</CardTitle>
|
||||
{store.signedPacts.length > 1 && (
|
||||
<div className="text-xs text-gray-400">
|
||||
Combined: ×{fmtDec(computePactMultiplier(store), 2)} damage
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{store.signedPacts.length === 0 ? (
|
||||
<div className="text-gray-500 text-sm">No pacts signed yet. Defeat guardians to earn pacts.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{store.signedPacts.map((floor) => {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) return null;
|
||||
return (
|
||||
<div
|
||||
key={floor}
|
||||
className="p-3 rounded border"
|
||||
style={{ borderColor: guardian.color, backgroundColor: `${guardian.color}10` }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
|
||||
{guardian.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">{guardian.theme} • Floor {floor}</div>
|
||||
</div>
|
||||
<Badge style={{ backgroundColor: `${guardian.color}30`, color: guardian.color }}>
|
||||
{guardian.damageMultiplier}x dmg / {guardian.insightMultiplier}x insight
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Unique Boon */}
|
||||
{guardian.uniqueBoon && (
|
||||
<div className="mb-2 p-2 bg-cyan-900/20 rounded border border-cyan-800/30">
|
||||
<div className="text-xs font-semibold text-cyan-300">✨ {guardian.uniqueBoon.name}</div>
|
||||
<div className="text-xs text-cyan-200/70">{guardian.uniqueBoon.desc}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Perks & Costs */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{guardian.perks.length > 0 && (
|
||||
<div>
|
||||
<div className="text-green-400 font-semibold mb-1">Perks</div>
|
||||
{guardian.perks.map(perk => (
|
||||
<div key={perk.id} className="text-green-300/70">• {perk.desc}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{guardian.costs.length > 0 && (
|
||||
<div>
|
||||
<div className="text-red-400 font-semibold mb-1">Costs</div>
|
||||
{guardian.costs.map(cost => (
|
||||
<div key={cost.id} className="text-red-300/70">• {cost.desc}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unlocked Mana */}
|
||||
{guardian.unlocksMana.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{guardian.unlocksMana.map(elemId => {
|
||||
const elem = ELEMENTS[elemId];
|
||||
return (
|
||||
<Badge key={elemId} variant="outline" className="text-xs" style={{ borderColor: elem?.color, color: elem?.color }}>
|
||||
{elem?.sym}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Prestige Upgrades */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Insight Upgrades (Permanent)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Object.entries(PRESTIGE_DEF).map(([id, def]) => {
|
||||
const level = store.prestigeUpgrades[id] || 0;
|
||||
const maxed = level >= def.max;
|
||||
const canBuy = !maxed && store.insight >= def.cost;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-3 rounded border border-gray-700 bg-gray-800/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-semibold text-amber-400 text-sm">{def.name}</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{level}/{def.max}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 italic mb-2">{def.desc}</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canBuy ? 'default' : 'outline'}
|
||||
className="w-full"
|
||||
disabled={!canBuy}
|
||||
onClick={() => store.doPrestige(id)}
|
||||
>
|
||||
{maxed ? 'Maxed' : `Upgrade (${fmt(def.cost)} insight)`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reset Game Button */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">Reset All Progress</div>
|
||||
<div className="text-xs text-gray-500">Clear all data and start fresh</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-600/50 text-red-400 hover:bg-red-900/20"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to reset ALL progress? This cannot be undone!')) {
|
||||
store.resetGame();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
src/components/game/LabTab.tsx
Executable file
171
src/components/game/LabTab.tsx
Executable file
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useGameStore } from '@/lib/game/store';
|
||||
import { ELEMENTS, MANA_PER_ELEMENT } from '@/lib/game/constants';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function LabTab() {
|
||||
const store = useGameStore();
|
||||
const [convertTarget, setConvertTarget] = useState('fire');
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Elemental Mana Display */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Elemental Mana</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
|
||||
{Object.entries(store.elements)
|
||||
.filter(([, state]) => state.unlocked && state.current >= 1)
|
||||
.map(([id, state]) => {
|
||||
const def = ELEMENTS[id];
|
||||
const isSelected = convertTarget === id;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`p-2 rounded border cursor-pointer transition-all ${isSelected ? 'border-blue-500 bg-blue-900/20' : 'border-gray-700 bg-gray-800/50 hover:border-gray-600'}`}
|
||||
style={{ borderColor: isSelected ? def?.color : undefined }}
|
||||
onClick={() => setConvertTarget(id)}
|
||||
>
|
||||
<div className="text-lg text-center">{def?.sym}</div>
|
||||
<div className="text-xs font-semibold text-center" style={{ color: def?.color }}>{def?.name}</div>
|
||||
<div className="text-xs text-gray-400 game-mono text-center">{state.current}/{state.max}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Element Conversion */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Element Conversion</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
Convert raw mana to elemental mana (100:1 ratio)
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => store.convertMana(convertTarget, 1)}
|
||||
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT}
|
||||
>
|
||||
+1 ({MANA_PER_ELEMENT})
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => store.convertMana(convertTarget, 10)}
|
||||
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 10}
|
||||
>
|
||||
+10 ({MANA_PER_ELEMENT * 10})
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => store.convertMana(convertTarget, 100)}
|
||||
disabled={!store.elements[convertTarget]?.unlocked || store.rawMana < MANA_PER_ELEMENT * 100}
|
||||
>
|
||||
+100 ({MANA_PER_ELEMENT * 100})
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Unlock Elements */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Unlock Elements</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
Unlock new elemental affinities (500 mana each)
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{Object.entries(store.elements)
|
||||
.filter(([id, state]) => !state.unlocked && ELEMENTS[id]?.cat !== 'exotic')
|
||||
.map(([id]) => {
|
||||
const def = ELEMENTS[id];
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-2 rounded border border-gray-700 bg-gray-800/50"
|
||||
>
|
||||
<div className="text-lg opacity-50">{def?.sym}</div>
|
||||
<div className="text-xs font-semibold text-gray-500">{def?.name}</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-1 w-full"
|
||||
disabled={store.rawMana < 500}
|
||||
onClick={() => store.unlockElement(id)}
|
||||
>
|
||||
Unlock
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Composite Crafting */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Composite & Exotic Crafting</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{Object.entries(ELEMENTS)
|
||||
.filter(([, def]) => def.recipe)
|
||||
.map(([id, def]) => {
|
||||
const state = store.elements[id];
|
||||
const recipe = def.recipe!;
|
||||
const canCraft = recipe.every(
|
||||
(r) => (store.elements[r]?.current || 0) >= recipe.filter((x) => x === r).length
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`p-3 rounded border ${canCraft ? 'border-gray-600 bg-gray-800/50' : 'border-gray-700 bg-gray-800/30 opacity-50'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">{def.sym}</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: def.color }}>
|
||||
{def.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{def.cat}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mb-2">
|
||||
{recipe.map((r) => ELEMENTS[r]?.sym).join(' + ')}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canCraft ? 'default' : 'outline'}
|
||||
className="w-full"
|
||||
disabled={!canCraft}
|
||||
onClick={() => store.craftComposite(id)}
|
||||
>
|
||||
Craft
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,9 +33,9 @@ export function ManaDisplay({
|
||||
}: ManaDisplayProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
// Get unlocked elements sorted by current amount
|
||||
// Get unlocked elements with mana, sorted by current amount
|
||||
const unlockedElements = Object.entries(elements)
|
||||
.filter(([, state]) => state.unlocked)
|
||||
.filter(([, state]) => state.unlocked && state.current >= 1)
|
||||
.sort((a, b) => b[1].current - a[1].current);
|
||||
|
||||
return (
|
||||
|
||||
418
src/components/game/SkillsTab.tsx
Executable file
418
src/components/game/SkillsTab.tsx
Executable file
@@ -0,0 +1,418 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useGameStore, fmt, fmtDec } from '@/lib/game/store';
|
||||
import { SKILLS_DEF, SKILL_CATEGORIES, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||||
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
|
||||
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } 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`;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Upgrade Selection Dialog */}
|
||||
{renderUpgradeDialog()}
|
||||
|
||||
{/* Current Study Progress */}
|
||||
{store.currentStudyTarget && store.currentStudyTarget.type === 'skill' && (
|
||||
<Card className="bg-gray-900/80 border-purple-600/50">
|
||||
<CardContent className="pt-4">
|
||||
{renderStudyProgress()}
|
||||
</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);
|
||||
|
||||
const canStudy = !maxed && prereqMet && store.rawMana >= cost && !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">
|
||||
<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>
|
||||
{/* 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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
src/components/game/SpellsTab.tsx
Executable file
163
src/components/game/SpellsTab.tsx
Executable file
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useGameStore, canAffordSpellCost, fmt, fmtDec, calcDamage } from '@/lib/game/store';
|
||||
import { ELEMENTS, SPELLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier } from '@/lib/game/constants';
|
||||
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';
|
||||
|
||||
// Format spell cost for display
|
||||
function formatSpellCost(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
|
||||
if (cost.type === 'raw') {
|
||||
return `${cost.amount} raw`;
|
||||
}
|
||||
const elemDef = ELEMENTS[cost.element || ''];
|
||||
return `${cost.amount} ${elemDef?.sym || '?'}`;
|
||||
}
|
||||
|
||||
// Get cost color
|
||||
function getSpellCostColor(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
|
||||
if (cost.type === 'raw') {
|
||||
return '#60A5FA';
|
||||
}
|
||||
return ELEMENTS[cost.element || '']?.color || '#9CA3AF';
|
||||
}
|
||||
|
||||
// Format study time
|
||||
function formatStudyTime(hours: number): string {
|
||||
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||
return `${hours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
export function SpellsTab() {
|
||||
const store = useGameStore();
|
||||
const { studySpeedMult, studyCostMult } = useStudyStats();
|
||||
|
||||
const spellTiers = [0, 1, 2, 3, 4];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{spellTiers.map(tier => {
|
||||
const spellsInTier = Object.entries(SPELLS_DEF).filter(([, def]) => def.tier === tier);
|
||||
if (spellsInTier.length === 0) return null;
|
||||
|
||||
const tierNames = ['Basic Spells (Raw Mana)', 'Tier 1 - Elemental', 'Tier 2 - Advanced', 'Tier 3 - Master', 'Tier 4 - Legendary'];
|
||||
const tierColors = ['text-gray-400', 'text-green-400', 'text-blue-400', 'text-purple-400', 'text-amber-400'];
|
||||
|
||||
return (
|
||||
<div key={tier}>
|
||||
<h3 className={`text-lg font-semibold mb-3 ${tierColors[tier]}`}>{tierNames[tier]}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{spellsInTier.map(([id, def]) => {
|
||||
const state = store.spells[id];
|
||||
const learned = state?.learned;
|
||||
const isStudying = store.currentStudyTarget?.id === id;
|
||||
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
|
||||
const baseStudyTime = def.studyTime || (def.tier * 4);
|
||||
const isActive = store.activeSpell === id;
|
||||
const canCast = learned && canAffordSpellCost(def.cost, store.rawMana, store.elements);
|
||||
|
||||
// Apply skill modifiers
|
||||
const studyTime = baseStudyTime / studySpeedMult;
|
||||
const unlockCost = Math.floor(def.unlock * studyCostMult);
|
||||
|
||||
// Can start studying?
|
||||
const canStudy = !learned && !isStudying && store.rawMana >= unlockCost;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={id}
|
||||
className={`bg-gray-900/80 border-gray-700 ${learned ? '' : 'opacity-75'} ${isStudying ? 'border-purple-500' : ''} ${canCast ? 'ring-1 ring-green-500/30' : ''}`}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm game-panel-title" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
|
||||
{def.name}
|
||||
</CardTitle>
|
||||
{def.tier > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
T{def.tier}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-xs text-gray-400">
|
||||
{def.elem !== 'raw' && <span className="mr-2">{elemDef?.sym} {elemDef?.name}</span>}
|
||||
<span className="mr-2">⚔️ {def.dmg} dmg</span>
|
||||
</div>
|
||||
|
||||
{/* Cost display */}
|
||||
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
|
||||
Cost: {formatSpellCost(def.cost)}
|
||||
</div>
|
||||
|
||||
{def.desc && (
|
||||
<div className="text-xs text-gray-500 italic">{def.desc}</div>
|
||||
)}
|
||||
|
||||
{def.effects && def.effects.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{def.effects.map((eff, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">
|
||||
{eff.type === 'lifesteal' && `🩸 ${Math.round(eff.value * 100)}% lifesteal`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{learned ? (
|
||||
<div className="flex gap-2">
|
||||
<Badge className="bg-green-900/50 text-green-300">Learned</Badge>
|
||||
{isActive && <Badge className="bg-amber-900/50 text-amber-300">Active</Badge>}
|
||||
{!isActive && (
|
||||
<Button size="sm" variant="outline" onClick={() => store.setSpell(id)}>
|
||||
Set Active
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : isStudying ? (
|
||||
<div className="space-y-1">
|
||||
<Progress
|
||||
value={Math.min(100, ((state?.studyProgress || 0) / studyTime) * 100)}
|
||||
className="h-2 bg-gray-800"
|
||||
/>
|
||||
<div className="text-xs text-purple-400">
|
||||
Studying... {formatStudyTime(state?.studyProgress || 0)}/{formatStudyTime(studyTime)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className={studySpeedMult > 1 ? 'text-green-400' : ''}>
|
||||
Study: {formatStudyTime(studyTime)}{studySpeedMult > 1 && <span className="text-xs ml-1">({Math.round(studySpeedMult * 100)}% speed)</span>}
|
||||
</span>
|
||||
{' • '}
|
||||
<span className={studyCostMult < 1 ? 'text-green-400' : ''}>
|
||||
Cost: {fmt(unlockCost)} mana{studyCostMult < 1 && <span className="text-xs ml-1">({Math.round(studyCostMult * 100)}% cost)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canStudy ? 'default' : 'outline'}
|
||||
disabled={!canStudy}
|
||||
className={canStudy ? 'bg-purple-600 hover:bg-purple-700' : 'opacity-50'}
|
||||
onClick={() => store.startStudyingSpell(id)}
|
||||
>
|
||||
Start Study ({fmt(unlockCost)} mana)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
318
src/components/game/SpireTab.tsx
Executable file
318
src/components/game/SpireTab.tsx
Executable file
@@ -0,0 +1,318 @@
|
||||
'use client';
|
||||
|
||||
import { useGameStore, canAffordSpellCost, fmt, fmtDec, calcDamage, computePactMultiplier } from '@/lib/game/store';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, MANA_PER_ELEMENT } from '@/lib/game/constants';
|
||||
import { useManaStats, useCombatStats, useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
|
||||
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 { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { X, BookOpen } from 'lucide-react';
|
||||
|
||||
export function SpireTab() {
|
||||
const store = useGameStore();
|
||||
const { effectiveRegen, meditationMultiplier, incursionStrength } = useManaStats();
|
||||
const {
|
||||
floorElem, floorElemDef, isGuardianFloor, currentGuardian,
|
||||
activeSpellDef, dps, damageBreakdown
|
||||
} = useCombatStats();
|
||||
const { effectiveStudySpeedMult } = useStudyStats();
|
||||
|
||||
// Check if spell can be cast
|
||||
const canCastSpell = (spellId: string): boolean => {
|
||||
const spell = SPELLS_DEF[spellId];
|
||||
if (!spell) return false;
|
||||
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
|
||||
};
|
||||
|
||||
// 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 isSkill = target.type === 'skill';
|
||||
const def = isSkill ? SPELLS_DEF[target.id] : SPELLS_DEF[target.id];
|
||||
|
||||
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>{effectiveStudySpeedMult.toFixed(1)}x speed</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Current Floor Card */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Current Floor</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold game-title" style={{ color: floorElemDef?.color }}>
|
||||
{store.currentFloor}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm">/ 100</span>
|
||||
<span className="ml-auto text-sm" style={{ color: floorElemDef?.color }}>
|
||||
{floorElemDef?.sym} {floorElemDef?.name}
|
||||
</span>
|
||||
{isGuardianFloor && (
|
||||
<Badge className="bg-red-900/50 text-red-300 border-red-600">GUARDIAN</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isGuardianFloor && currentGuardian && (
|
||||
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
|
||||
⚔️ {currentGuardian.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HP Bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="h-3 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.max(0, (store.floorHP / store.floorMaxHP) * 100)}%`,
|
||||
background: `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
|
||||
boxShadow: `0 0 10px ${floorElemDef?.glow}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-400 game-mono">
|
||||
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
|
||||
<span>DPS: {store.currentAction === 'climb' && canCastSpell(store.activeSpell) ? fmtDec(dps) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-gray-700" />
|
||||
|
||||
<div className="text-sm text-gray-400">
|
||||
Best: Floor <strong className="text-gray-200">{store.maxFloorReached}</strong> •
|
||||
Pacts: <strong className="text-amber-400">{store.signedPacts.length}</strong>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Spell Card */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Active Spell</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{activeSpellDef ? (
|
||||
<>
|
||||
<div className="text-lg font-semibold game-panel-title" style={{ color: activeSpellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[activeSpellDef.elem]?.color }}>
|
||||
{activeSpellDef.name}
|
||||
{activeSpellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200">Basic</Badge>}
|
||||
{activeSpellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100">Legendary</Badge>}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 game-mono">
|
||||
⚔️ {fmt(calcDamage(store, store.activeSpell))} dmg •
|
||||
<span style={{ color: getSpellCostColor(activeSpellDef.cost) }}>
|
||||
{' '}{formatSpellCost(activeSpellDef.cost)}
|
||||
</span>
|
||||
{' '}• ⚡ {(activeSpellDef.castSpeed || 1).toFixed(1)} casts/hr
|
||||
</div>
|
||||
|
||||
{/* Cast progress bar when climbing */}
|
||||
{store.currentAction === 'climb' && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>Cast Progress</span>
|
||||
<span>{((store.castProgress || 0) * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={Math.min(100, (store.castProgress || 0) * 100)} className="h-2 bg-gray-800" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSpellDef.desc && (
|
||||
<div className="text-xs text-gray-500 italic">{activeSpellDef.desc}</div>
|
||||
)}
|
||||
{activeSpellDef.effects && activeSpellDef.effects.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{activeSpellDef.effects.map((eff, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">
|
||||
{eff.type === 'lifesteal' && `🩸 ${Math.round(eff.value * 100)}% lifesteal`}
|
||||
{eff.type === 'burn' && `🔥 Burn`}
|
||||
{eff.type === 'freeze' && `❄️ Freeze`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-gray-500">No spell selected</div>
|
||||
)}
|
||||
|
||||
{/* Can cast indicator */}
|
||||
{activeSpellDef && (
|
||||
<div className={`text-xs ${canCastSpell(store.activeSpell) ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{canCastSpell(store.activeSpell) ? '✓ Can cast' : '✗ Insufficient mana'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{incursionStrength > 0 && (
|
||||
<div className="p-2 bg-red-900/20 border border-red-800/50 rounded">
|
||||
<div className="text-xs text-red-400 game-panel-title mb-1">LABYRINTH INCURSION</div>
|
||||
<div className="text-sm text-gray-300">
|
||||
-{Math.round(incursionStrength * 100)}% mana regen
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Current Study (if any) */}
|
||||
{store.currentStudyTarget && (
|
||||
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
{renderStudyProgress()}
|
||||
|
||||
{/* Parallel Study Progress */}
|
||||
{store.parallelStudyTarget && (
|
||||
<div className="p-3 rounded border border-cyan-600/50 bg-cyan-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-cyan-400" />
|
||||
<span className="text-sm font-semibold text-cyan-300">
|
||||
Parallel: {store.parallelStudyTarget.type === 'skill' ? store.parallelStudyTarget.id : store.parallelStudyTarget.id}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={() => store.cancelParallelStudy()}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Progress value={Math.min(100, (store.parallelStudyTarget.progress / store.parallelStudyTarget.required) * 100)} className="h-2 bg-gray-800" />
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>{formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}</span>
|
||||
<span>50% speed (Parallel Study)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pact Signing Progress */}
|
||||
{store.pactSigningProgress && (
|
||||
<Card className="bg-gray-900/80 border-amber-600/50 lg:col-span-2">
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div className="p-3 rounded border border-amber-500/30 bg-amber-900/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">📜</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-amber-300">
|
||||
Signing Pact: {GUARDIANS[store.pactSigningProgress.floor]?.name}
|
||||
</div>
|
||||
<div className="text-xs text-amber-400">
|
||||
Floor {store.pactSigningProgress.floor}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min(100, (store.pactSigningProgress.progress / store.pactSigningProgress.required) * 100)}
|
||||
className="h-2 bg-gray-800"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-amber-400 mt-1">
|
||||
<span>{formatStudyTime(store.pactSigningProgress.progress)} / {formatStudyTime(store.pactSigningProgress.required)}</span>
|
||||
<span>Cost: {fmt(store.pactSigningProgress.manaCost)} mana</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Spells Available */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Known Spells</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{Object.entries(store.spells)
|
||||
.filter(([, state]) => state.learned)
|
||||
.map(([id, state]) => {
|
||||
const def = SPELLS_DEF[id];
|
||||
if (!def) return null;
|
||||
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
|
||||
const isActive = store.activeSpell === id;
|
||||
const canCast = canCastSpell(id);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={id}
|
||||
variant="outline"
|
||||
className={`h-auto py-2 px-3 flex flex-col items-start ${isActive ? 'border-amber-500 bg-amber-900/20' : canCast ? 'border-gray-600 bg-gray-800/50 hover:bg-gray-700/50' : 'border-gray-700 bg-gray-800/30 opacity-60'}`}
|
||||
onClick={() => store.setSpell(id)}
|
||||
>
|
||||
<div className="text-sm font-semibold" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
|
||||
{def.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 game-mono">
|
||||
{fmt(calcDamage(store, id))} dmg
|
||||
</div>
|
||||
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
|
||||
{formatSpellCost(def.cost)}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Activity Log */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Activity Log</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-32">
|
||||
<div className="space-y-1">
|
||||
{store.log.slice(0, 20).map((entry, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`text-sm ${i === 0 ? 'text-gray-200' : 'text-gray-500'} italic`}
|
||||
>
|
||||
{entry}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
551
src/components/game/StatsTab.tsx
Executable file
551
src/components/game/StatsTab.tsx
Executable file
@@ -0,0 +1,551 @@
|
||||
'use client';
|
||||
|
||||
import { useGameStore, fmt, fmtDec, calcDamage, computePactMultiplier, computePactInsightMultiplier } from '@/lib/game/store';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF } 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 type { SkillUpgradeChoice } from '@/lib/game/types';
|
||||
|
||||
export function StatsTab() {
|
||||
const store = useGameStore();
|
||||
const {
|
||||
upgradeEffects, maxMana, baseRegen, clickMana,
|
||||
meditationMultiplier, incursionStrength, manaCascadeBonus, effectiveRegen,
|
||||
hasSteadyStream, hasManaTorrent, hasDesperateWells
|
||||
} = useManaStats();
|
||||
const { activeSpellDef, pactMultiplier, pactInsightMultiplier } = useCombatStats();
|
||||
const { studySpeedMult, studyCostMult } = useStudyStats();
|
||||
|
||||
// Compute element max
|
||||
const elemMax = (() => {
|
||||
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 10 + level * 50 * tierMult + (store.prestigeUpgrades.elementalAttune || 0) * 25;
|
||||
})();
|
||||
|
||||
// Get all selected skill upgrades
|
||||
const getAllSelectedUpgrades = () => {
|
||||
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;
|
||||
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
||||
if (!path) continue;
|
||||
for (const tier of path.tiers) {
|
||||
if (tier.skillId === skillId) {
|
||||
for (const upgradeId of selectedIds) {
|
||||
const upgrade = tier.upgrades.find(u => u.id === upgradeId);
|
||||
if (upgrade) {
|
||||
upgrades.push({ skillId, upgrade });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/components/game/layout/GameFooter.tsx
Executable file
19
src/components/game/layout/GameFooter.tsx
Executable file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useGameContext } from '../GameContext';
|
||||
|
||||
export function GameFooter() {
|
||||
const { store } = useGameContext();
|
||||
|
||||
return (
|
||||
<footer className="sticky bottom-0 bg-gray-900/80 border-t border-gray-700 px-4 py-2 text-center text-xs text-gray-500">
|
||||
<span className="text-gray-400">Loop {store.loopCount + 1}</span>
|
||||
{' • '}
|
||||
<span>Pacts: {store.signedPacts.length}/{store.pactSlots}</span>
|
||||
{' • '}
|
||||
<span>Spells: {Object.values(store.spells).filter((s) => s.learned).length}</span>
|
||||
{' • '}
|
||||
<span>Skills: {Object.values(store.skills).reduce((a, b) => a + b, 0)}</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
79
src/components/game/layout/GameHeader.tsx
Executable file
79
src/components/game/layout/GameHeader.tsx
Executable file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Pause, Play } from 'lucide-react';
|
||||
import { useGameContext } from '../GameContext';
|
||||
import { formatTime } from '../types';
|
||||
import { MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
|
||||
export function GameHeader() {
|
||||
const { store } = useGameContext();
|
||||
|
||||
// Calendar rendering
|
||||
const renderCalendar = () => {
|
||||
const days: React.ReactElement[] = [];
|
||||
for (let d = 1; d <= MAX_DAY; d++) {
|
||||
let dayClass = 'w-7 h-7 rounded text-xs flex items-center justify-center font-mono border transition-all ';
|
||||
|
||||
if (d < store.day) {
|
||||
dayClass += 'bg-blue-900/30 border-blue-800/50 text-blue-400';
|
||||
} else if (d === store.day) {
|
||||
dayClass += 'bg-blue-600/40 border-blue-500 text-blue-300 shadow-lg shadow-blue-500/30';
|
||||
} else {
|
||||
dayClass += 'bg-gray-800/30 border-gray-700/50 text-gray-500';
|
||||
}
|
||||
|
||||
if (d >= INCURSION_START_DAY) {
|
||||
dayClass += ' border-red-600/50';
|
||||
}
|
||||
|
||||
days.push(
|
||||
<Tooltip key={d}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={dayClass}>{d}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Day {d}</p>
|
||||
{d >= INCURSION_START_DAY && <p className="text-red-400">Incursion Active</p>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-gradient-to-b from-gray-900 to-gray-900/80 border-b border-gray-700 px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold game-title tracking-wider">MANA LOOP</h1>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold game-mono text-amber-400">Day {store.day}</div>
|
||||
<div className="text-xs text-gray-400">{formatTime(store.hour)}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold game-mono text-purple-400">{fmt(store.insight)}</div>
|
||||
<div className="text-xs text-gray-400">Insight</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => store.togglePause()}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
{store.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
<div className="mt-2 flex gap-1 overflow-x-auto pb-1">{renderCalendar()}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
141
src/components/game/layout/GameSidebar.tsx
Executable file
141
src/components/game/layout/GameSidebar.tsx
Executable file
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Zap, Sparkles, Swords, BookOpen, FlaskConical, type LucideIcon } from 'lucide-react';
|
||||
import { useGameContext } from '../GameContext';
|
||||
import { fmt, fmtDec } from '@/lib/game/stores';
|
||||
import type { GameAction } from '@/lib/game/types';
|
||||
|
||||
export function GameSidebar() {
|
||||
const {
|
||||
store,
|
||||
maxMana,
|
||||
clickMana,
|
||||
effectiveRegen,
|
||||
meditationMultiplier,
|
||||
floorElemDef,
|
||||
} = useGameContext();
|
||||
|
||||
const [isGathering, setIsGathering] = useState(false);
|
||||
|
||||
// Auto-gather while holding
|
||||
useEffect(() => {
|
||||
if (!isGathering) return;
|
||||
|
||||
let lastGatherTime = 0;
|
||||
const minGatherInterval = 100;
|
||||
let animationFrameId: number;
|
||||
|
||||
const gatherLoop = (timestamp: number) => {
|
||||
if (timestamp - lastGatherTime >= minGatherInterval) {
|
||||
store.gatherMana();
|
||||
lastGatherTime = timestamp;
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(gatherLoop);
|
||||
};
|
||||
|
||||
animationFrameId = requestAnimationFrame(gatherLoop);
|
||||
return () => cancelAnimationFrame(animationFrameId);
|
||||
}, [isGathering, store]);
|
||||
|
||||
const handleGatherStart = useCallback(() => {
|
||||
setIsGathering(true);
|
||||
store.gatherMana();
|
||||
}, [store]);
|
||||
|
||||
const handleGatherEnd = useCallback(() => {
|
||||
setIsGathering(false);
|
||||
}, []);
|
||||
|
||||
// Action buttons
|
||||
const actions: { id: GameAction; label: string; icon: LucideIcon }[] = [
|
||||
{ id: 'meditate', label: 'Meditate', icon: Sparkles },
|
||||
{ id: 'climb', label: 'Climb', icon: Swords },
|
||||
{ id: 'study', label: 'Study', icon: BookOpen },
|
||||
{ id: 'convert', label: 'Convert', icon: FlaskConical },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="w-full md:w-64 bg-gray-900/50 border-b md:border-b-0 md:border-r border-gray-700 p-4 space-y-4">
|
||||
{/* Mana Panel */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-bold game-mono text-blue-400">{fmt(store.rawMana)}</span>
|
||||
<span className="text-sm text-gray-400">/ {fmt(maxMana)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
+{fmtDec(effectiveRegen)} mana/hr{' '}
|
||||
{meditationMultiplier > 1.01 && (
|
||||
<span className="text-purple-400">({fmtDec(meditationMultiplier, 1)}x med)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress value={(store.rawMana / maxMana) * 100} className="h-2 bg-gray-800" />
|
||||
|
||||
<Button
|
||||
className={`w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 ${
|
||||
isGathering ? 'animate-pulse' : ''
|
||||
}`}
|
||||
onMouseDown={handleGatherStart}
|
||||
onMouseUp={handleGatherEnd}
|
||||
onMouseLeave={handleGatherEnd}
|
||||
onTouchStart={handleGatherStart}
|
||||
onTouchEnd={handleGatherEnd}
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Gather +{clickMana} Mana
|
||||
{isGathering && <span className="ml-2 text-xs">(Holding...)</span>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardContent className="pt-4">
|
||||
<div className="text-xs text-amber-400 game-panel-title mb-2">Current Action</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{actions.map(({ id, label, icon: Icon }) => (
|
||||
<Button
|
||||
key={id}
|
||||
variant={store.currentAction === id ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={`h-9 ${
|
||||
store.currentAction === id
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-gray-800/50 hover:bg-gray-700/50 border-gray-600'
|
||||
}`}
|
||||
onClick={() => store.setAction(id)}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-1" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Floor Status */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
<div className="text-xs text-amber-400 game-panel-title mb-2">Floor Status</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Current</span>
|
||||
<span className="text-lg font-bold" style={{ color: floorElemDef?.color }}>
|
||||
{store.currentFloor}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={(store.floorHP / store.floorMaxHP) * 100} className="h-2 bg-gray-800" />
|
||||
<div className="text-xs text-gray-400 game-mono">
|
||||
{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
76
src/components/game/shared/GameOverScreen.tsx
Executable file
76
src/components/game/shared/GameOverScreen.tsx
Executable file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useGameContext } from '../GameContext';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
import { MemorySlotPicker } from './MemorySlotPicker';
|
||||
|
||||
export function GameOverScreen() {
|
||||
const { store } = useGameContext();
|
||||
const [memoriesConfirmed, setMemoriesConfirmed] = useState(false);
|
||||
|
||||
if (!store.gameOver) return null;
|
||||
|
||||
const handleStartNewLoop = () => {
|
||||
store.startNewLoop();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 game-overlay flex items-center justify-center z-50 overflow-auto py-4">
|
||||
<div className="max-w-lg w-full mx-4 space-y-4">
|
||||
<Card className="bg-gray-900 border-gray-600 shadow-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle
|
||||
className={`text-3xl text-center game-title ${store.victory ? 'text-amber-400' : 'text-red-400'}`}
|
||||
>
|
||||
{store.victory ? '🏆 VICTORY!' : '⏰ LOOP ENDS'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-center text-gray-400">
|
||||
{store.victory
|
||||
? 'The Awakened One falls! Your power echoes through eternity.'
|
||||
: 'The time loop resets... but you remember.'}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-amber-400 game-mono">{fmt(store.loopInsight)}</div>
|
||||
<div className="text-xs text-gray-400">Insight Gained</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-blue-400 game-mono">{store.maxFloorReached}</div>
|
||||
<div className="text-xs text-gray-400">Best Floor</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-purple-400 game-mono">{store.signedPacts.length}</div>
|
||||
<div className="text-xs text-gray-400">Pacts Signed</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800 rounded">
|
||||
<div className="text-xl font-bold text-green-400 game-mono">{store.loopCount + 1}</div>
|
||||
<div className="text-xs text-gray-400">Total Loops</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Memory Slot Picker */}
|
||||
{store.memorySlots > 0 && !memoriesConfirmed && (
|
||||
<MemorySlotPicker onConfirm={() => setMemoriesConfirmed(true)} />
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
|
||||
size="lg"
|
||||
onClick={handleStartNewLoop}
|
||||
disabled={store.memorySlots > 0 && !memoriesConfirmed}
|
||||
>
|
||||
Begin New Loop
|
||||
{store.memorySlots > 0 && !memoriesConfirmed && ' (Confirm Memories First)'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
src/components/game/shared/MemorySlotPicker.tsx
Executable file
206
src/components/game/shared/MemorySlotPicker.tsx
Executable file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Save, Trash2, Star, ChevronUp } from 'lucide-react';
|
||||
import { useGameContext } from '../GameContext';
|
||||
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||
import { getTierMultiplier, getBaseSkillId } from '@/lib/game/skill-evolution';
|
||||
import type { Memory } from '@/lib/game/types';
|
||||
|
||||
interface MemorySlotPickerProps {
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
export function MemorySlotPicker({ onConfirm }: MemorySlotPickerProps) {
|
||||
const { store } = useGameContext();
|
||||
const [selectedSkills, setSelectedSkills] = useState<Memory[]>(store.memories || []);
|
||||
|
||||
// Get all skills that have progress and can be saved
|
||||
const saveableSkills = useMemo(() => {
|
||||
const skills: { skillId: string; level: number; tier: number; upgrades: string[]; name: string }[] = [];
|
||||
|
||||
for (const [skillId, level] of Object.entries(store.skills)) {
|
||||
if (level && level > 0) {
|
||||
const baseSkillId = getBaseSkillId(skillId);
|
||||
const tier = store.skillTiers?.[baseSkillId] || 1;
|
||||
const tieredSkillId = tier > 1 ? `${baseSkillId}_t${tier}` : baseSkillId;
|
||||
const upgrades = store.skillUpgrades?.[tieredSkillId] || [];
|
||||
const skillDef = SKILLS_DEF[baseSkillId];
|
||||
|
||||
// Only include if it's a base skill (not a tiered variant in the skills object)
|
||||
if (skillId === baseSkillId || skillId.includes('_t')) {
|
||||
// Get the actual skill ID and level
|
||||
const actualLevel = store.skills[tieredSkillId] || store.skills[baseSkillId] || 0;
|
||||
if (actualLevel > 0) {
|
||||
skills.push({
|
||||
skillId: baseSkillId,
|
||||
level: actualLevel,
|
||||
tier,
|
||||
upgrades,
|
||||
name: skillDef?.name || baseSkillId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and keep highest tier/level
|
||||
const uniqueSkills = new Map<string, typeof skills[0]>();
|
||||
for (const skill of skills) {
|
||||
const existing = uniqueSkills.get(skill.skillId);
|
||||
if (!existing || skill.tier > existing.tier || (skill.tier === existing.tier && skill.level > existing.level)) {
|
||||
uniqueSkills.set(skill.skillId, skill);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniqueSkills.values()).sort((a, b) => {
|
||||
// Sort by tier then level then name
|
||||
if (a.tier !== b.tier) return b.tier - a.tier;
|
||||
if (a.level !== b.level) return b.level - a.level;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [store.skills, store.skillTiers, store.skillUpgrades]);
|
||||
|
||||
const isSkillSelected = (skillId: string) => selectedSkills.some(m => m.skillId === skillId);
|
||||
|
||||
const canAddMore = selectedSkills.length < store.memorySlots;
|
||||
|
||||
const toggleSkill = (skillId: string) => {
|
||||
const existingIndex = selectedSkills.findIndex(m => m.skillId === skillId);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Remove it
|
||||
setSelectedSkills(selectedSkills.filter((_, i) => i !== existingIndex));
|
||||
} else if (canAddMore) {
|
||||
// Add it
|
||||
const skill = saveableSkills.find(s => s.skillId === skillId);
|
||||
if (skill) {
|
||||
setSelectedSkills([...selectedSkills, {
|
||||
skillId: skill.skillId,
|
||||
level: skill.level,
|
||||
tier: skill.tier,
|
||||
upgrades: skill.upgrades,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
// Clear and re-add selected memories
|
||||
store.clearMemories();
|
||||
for (const memory of selectedSkills) {
|
||||
store.addMemory(memory);
|
||||
}
|
||||
onConfirm?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-sm flex items-center gap-2">
|
||||
<Save className="w-4 h-4" />
|
||||
Memory Slots ({selectedSkills.length}/{store.memorySlots})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-gray-400">
|
||||
Select skills to preserve in your memory. Saved skills will retain their level, tier, and upgrades in the next loop.
|
||||
</p>
|
||||
|
||||
{/* Selected Skills */}
|
||||
{selectedSkills.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-green-400 game-panel-title">Saved to Memory:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedSkills.map((memory) => {
|
||||
const skillDef = SKILLS_DEF[memory.skillId];
|
||||
return (
|
||||
<Badge
|
||||
key={memory.skillId}
|
||||
className="bg-amber-900/50 text-amber-200 cursor-pointer hover:bg-red-900/50"
|
||||
onClick={() => toggleSkill(memory.skillId)}
|
||||
>
|
||||
{skillDef?.name || memory.skillId}
|
||||
{' '}Lv.{memory.level}
|
||||
{memory.tier > 1 && ` T${memory.tier}`}
|
||||
{memory.upgrades.length > 0 && ` (${memory.upgrades.length}⭐)`}
|
||||
<Trash2 className="w-3 h-3 ml-1" />
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available Skills */}
|
||||
<div className="text-xs text-gray-400 game-panel-title">Skills to Save:</div>
|
||||
<ScrollArea className="h-48">
|
||||
<div className="space-y-1 pr-2">
|
||||
{saveableSkills.length === 0 ? (
|
||||
<div className="text-gray-500 text-xs text-center py-4">
|
||||
No skills with progress to save
|
||||
</div>
|
||||
) : (
|
||||
saveableSkills.map((skill) => {
|
||||
const isSelected = isSkillSelected(skill.skillId);
|
||||
const tierMult = getTierMultiplier(skill.tier > 1 ? `${skill.skillId}_t${skill.tier}` : skill.skillId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={skill.skillId}
|
||||
className={`p-2 rounded border cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'border-amber-500 bg-amber-900/30'
|
||||
: canAddMore
|
||||
? 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
|
||||
: 'border-gray-800 bg-gray-900/30 opacity-50'
|
||||
}`}
|
||||
onClick={() => toggleSkill(skill.skillId)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{skill.name}</span>
|
||||
{skill.tier > 1 && (
|
||||
<Badge className="bg-purple-600/50 text-purple-200 text-xs">
|
||||
Tier {skill.tier} ({tierMult}x)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400 text-sm">Lv.{skill.level}</span>
|
||||
{skill.upgrades.length > 0 && (
|
||||
<Badge className="bg-amber-700/50 text-amber-200 text-xs flex items-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
{skill.upgrades.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{skill.upgrades.length > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Upgrades: {skill.upgrades.length} selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Confirm Button */}
|
||||
<Button
|
||||
className="w-full bg-amber-600 hover:bg-amber-700"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Confirm Memories
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
60
src/components/game/shared/StudyProgress.tsx
Executable file
60
src/components/game/shared/StudyProgress.tsx
Executable file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { BookOpen, X } from 'lucide-react';
|
||||
import { useGameContext } from '../GameContext';
|
||||
import { formatStudyTime } from '../types';
|
||||
import { SKILLS_DEF, SPELLS_DEF } from '@/lib/game/constants';
|
||||
|
||||
interface StudyProgressProps {
|
||||
target: NonNullable<ReturnType<typeof useGameContext>['store']['currentStudyTarget']>;
|
||||
showCancel?: boolean;
|
||||
speedLabel?: string;
|
||||
}
|
||||
|
||||
export function StudyProgress({ target, showCancel = true, speedLabel }: StudyProgressProps) {
|
||||
const { store, studySpeedMult } = useGameContext();
|
||||
|
||||
const progressPct = Math.min(100, (target.progress / target.required) * 100);
|
||||
const isSkill = target.type === 'skill';
|
||||
const def = isSkill ? SKILLS_DEF[target.id] : SPELLS_DEF[target.id];
|
||||
const currentLevel = isSkill ? store.skills[target.id] || 0 : 0;
|
||||
|
||||
const handleCancel = () => {
|
||||
// Calculate retention bonus from knowledge retention skill
|
||||
const retentionBonus = 0.2 * (store.skills.knowledgeRetention || 0);
|
||||
store.cancelStudy(retentionBonus);
|
||||
};
|
||||
|
||||
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}
|
||||
{isSkill && ` Lv.${currentLevel + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
{showCancel && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<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>{speedLabel ?? `${studySpeedMult.toFixed(1)}x speed`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/components/game/shared/UpgradeDialog.tsx
Executable file
126
src/components/game/shared/UpgradeDialog.tsx
Executable file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useGameContext } from '../GameContext';
|
||||
import { SKILLS_DEF } from '@/lib/game/constants';
|
||||
|
||||
interface UpgradeDialogProps {
|
||||
skillId: string | null;
|
||||
milestone: 5 | 10;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function UpgradeDialog({ skillId, milestone, onClose }: UpgradeDialogProps) {
|
||||
const { store } = useGameContext();
|
||||
|
||||
const skillDef = skillId ? SKILLS_DEF[skillId] : null;
|
||||
const { available, selected: alreadySelected } = skillId
|
||||
? store.getSkillUpgradeChoices(skillId, milestone)
|
||||
: { available: [], selected: [] };
|
||||
|
||||
// Use local state for selections within this dialog session
|
||||
const [pendingSelections, setPendingSelections] = useState<string[]>(() => [...alreadySelected]);
|
||||
|
||||
const toggleUpgrade = (upgradeId: string) => {
|
||||
setPendingSelections((prev) => {
|
||||
if (prev.includes(upgradeId)) {
|
||||
return prev.filter((id) => id !== upgradeId);
|
||||
} else if (prev.length < 2) {
|
||||
return [...prev, upgradeId];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
if (pendingSelections.length === 2 && skillId) {
|
||||
store.commitSkillUpgrades(skillId, pendingSelections);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setPendingSelections([...alreadySelected]);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render if no skill selected
|
||||
if (!skillId) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={!!skillId} onOpenChange={handleOpenChange}>
|
||||
<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 ({pendingSelections.length}/2 chosen)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
{available.map((upgrade) => {
|
||||
const isSelected = pendingSelections.includes(upgrade.id);
|
||||
const canToggle = pendingSelections.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.desc || 'Special effect'}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setPendingSelections([...alreadySelected]);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={handleDone} disabled={pendingSelections.length !== 2}>
|
||||
{pendingSelections.length < 2 ? `Select ${2 - pendingSelections.length} more` : 'Confirm'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -83,6 +83,11 @@ export function CraftingTab({ store }: CraftingTabProps) {
|
||||
instance: equipmentInstances[instanceId!],
|
||||
}));
|
||||
|
||||
// Get equipment types the player has available
|
||||
const ownedEquipmentTypeIds = new Set(
|
||||
Object.values(equipmentInstances).map(inst => inst.typeId)
|
||||
);
|
||||
|
||||
// Calculate total capacity cost for current design
|
||||
const designCapacityCost = selectedEffects.reduce(
|
||||
(total, eff) => total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus),
|
||||
@@ -201,7 +206,9 @@ export function CraftingTab({ store }: CraftingTabProps) {
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.values(EQUIPMENT_TYPES).map(type => (
|
||||
{Object.values(EQUIPMENT_TYPES)
|
||||
.filter(type => ownedEquipmentTypeIds.has(type.id))
|
||||
.map(type => (
|
||||
<div
|
||||
key={type.id}
|
||||
className={`p-2 rounded border cursor-pointer transition-all ${
|
||||
|
||||
582
src/components/game/tabs/FamiliarTab.tsx
Executable file
582
src/components/game/tabs/FamiliarTab.tsx
Executable file
@@ -0,0 +1,582 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Sparkles, Heart, Zap, Star, Shield, Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull,
|
||||
Brain, Link, Wind as Force, Droplets, TreeDeciduous, Hourglass, Gem, CircleDot, Circle,
|
||||
Sword, Wand2, ShieldCheck, TrendingUp, Clock, Crown
|
||||
} from 'lucide-react';
|
||||
import type { GameState, FamiliarInstance, FamiliarDef, FamiliarAbilityType } from '@/lib/game/types';
|
||||
import { FAMILIARS_DEF, getFamiliarXpRequired, getFamiliarAbilityValue } from '@/lib/game/data/familiars';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
// Element icon mapping
|
||||
const ELEMENT_ICONS: Record<string, typeof Flame> = {
|
||||
fire: Flame,
|
||||
water: Droplet,
|
||||
air: Wind,
|
||||
earth: Mountain,
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
life: Leaf,
|
||||
death: Skull,
|
||||
mental: Brain,
|
||||
transference: Link,
|
||||
force: Force,
|
||||
blood: Droplets,
|
||||
metal: Shield,
|
||||
wood: TreeDeciduous,
|
||||
sand: Hourglass,
|
||||
crystal: Gem,
|
||||
stellar: Star,
|
||||
void: CircleDot,
|
||||
raw: Circle,
|
||||
};
|
||||
|
||||
// Rarity colors
|
||||
const RARITY_COLORS: Record<string, string> = {
|
||||
common: 'text-gray-400 border-gray-600',
|
||||
uncommon: 'text-green-400 border-green-600',
|
||||
rare: 'text-blue-400 border-blue-600',
|
||||
epic: 'text-purple-400 border-purple-600',
|
||||
legendary: 'text-amber-400 border-amber-600',
|
||||
};
|
||||
|
||||
const RARITY_BG: Record<string, string> = {
|
||||
common: 'bg-gray-900/50',
|
||||
uncommon: 'bg-green-900/20',
|
||||
rare: 'bg-blue-900/20',
|
||||
epic: 'bg-purple-900/20',
|
||||
legendary: 'bg-amber-900/20',
|
||||
};
|
||||
|
||||
// Role icons
|
||||
const ROLE_ICONS: Record<string, typeof Sword> = {
|
||||
combat: Sword,
|
||||
mana: Sparkles,
|
||||
support: Heart,
|
||||
guardian: ShieldCheck,
|
||||
};
|
||||
|
||||
// Ability type icons
|
||||
const ABILITY_ICONS: Record<FamiliarAbilityType, typeof Zap> = {
|
||||
damageBonus: Sword,
|
||||
manaRegen: Sparkles,
|
||||
autoGather: Zap,
|
||||
critChance: Star,
|
||||
castSpeed: Clock,
|
||||
manaShield: Shield,
|
||||
elementalBonus: Flame,
|
||||
lifeSteal: Heart,
|
||||
bonusGold: TrendingUp,
|
||||
autoConvert: Wand2,
|
||||
thorns: ShieldCheck,
|
||||
};
|
||||
|
||||
interface FamiliarTabProps {
|
||||
store: GameState & {
|
||||
setActiveFamiliar: (index: number, active: boolean) => void;
|
||||
setFamiliarNickname: (index: number, nickname: string) => void;
|
||||
summonFamiliar: (familiarId: string) => void;
|
||||
upgradeFamiliarAbility: (index: number, abilityType: FamiliarAbilityType) => void;
|
||||
getActiveFamiliarBonuses: () => ReturnType<typeof import('@/lib/game/familiar-slice').createFamiliarSlice>['getActiveFamiliarBonuses'] extends () => infer R ? R : never;
|
||||
getAvailableFamiliars: () => string[];
|
||||
};
|
||||
}
|
||||
|
||||
export function FamiliarTab({ store }: FamiliarTabProps) {
|
||||
const [selectedFamiliar, setSelectedFamiliar] = useState<number | null>(null);
|
||||
const [nicknameInput, setNicknameInput] = useState('');
|
||||
|
||||
const familiars = store.familiars;
|
||||
const activeFamiliarSlots = store.activeFamiliarSlots;
|
||||
const activeCount = familiars.filter(f => f.active).length;
|
||||
const availableFamiliars = store.getAvailableFamiliars();
|
||||
const familiarBonuses = store.getActiveFamiliarBonuses();
|
||||
|
||||
// Format XP display
|
||||
const formatXp = (current: number, level: number) => {
|
||||
const required = getFamiliarXpRequired(level);
|
||||
return `${current}/${required}`;
|
||||
};
|
||||
|
||||
// Get familiar definition
|
||||
const getFamiliarDef = (instance: FamiliarInstance): FamiliarDef | undefined => {
|
||||
return FAMILIARS_DEF[instance.familiarId];
|
||||
};
|
||||
|
||||
// Render a single familiar card
|
||||
const renderFamiliarCard = (instance: FamiliarInstance, index: number) => {
|
||||
const def = getFamiliarDef(instance);
|
||||
if (!def) return null;
|
||||
|
||||
const ElementIcon = ELEMENT_ICONS[def.element] || Circle;
|
||||
const RoleIcon = ROLE_ICONS[def.role] || Sparkles;
|
||||
const xpRequired = getFamiliarXpRequired(instance.level);
|
||||
const xpPercent = Math.min(100, (instance.experience / xpRequired) * 100);
|
||||
const bondPercent = instance.bond;
|
||||
const isSelected = selectedFamiliar === index;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`${instance.familiarId}-${index}`}
|
||||
className={`cursor-pointer transition-all ${RARITY_BG[def.rarity]} ${
|
||||
isSelected ? 'ring-2 ring-amber-500' : ''
|
||||
} ${instance.active ? 'ring-1 ring-green-500/50' : ''} border ${RARITY_COLORS[def.rarity]}`}
|
||||
onClick={() => setSelectedFamiliar(isSelected ? null : index)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 rounded-full bg-black/30 flex items-center justify-center">
|
||||
<ElementIcon className="w-6 h-6" style={{ color: ELEMENTS[def.element]?.color || '#fff' }} />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className={`text-sm ${RARITY_COLORS[def.rarity]}`}>
|
||||
{instance.nickname || def.name}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-400">
|
||||
<RoleIcon className="w-3 h-3" />
|
||||
<span>Lv.{instance.level}</span>
|
||||
{instance.active && (
|
||||
<Badge className="ml-1 bg-green-900/50 text-green-300 text-xs py-0">Active</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className={`${RARITY_COLORS[def.rarity]} text-xs`}>
|
||||
{def.rarity}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{/* XP Bar */}
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>XP</span>
|
||||
<span>{formatXp(instance.experience, instance.level)}</span>
|
||||
</div>
|
||||
<Progress value={xpPercent} className="h-1.5 bg-gray-800" />
|
||||
</div>
|
||||
|
||||
{/* Bond Bar */}
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="w-3 h-3" /> Bond
|
||||
</span>
|
||||
<span>{bondPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={bondPercent} className="h-1.5 bg-gray-800" />
|
||||
</div>
|
||||
|
||||
{/* Abilities Preview */}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{instance.abilities.slice(0, 3).map(ability => {
|
||||
const abilityDef = def.abilities.find(a => a.type === ability.type);
|
||||
if (!abilityDef) return null;
|
||||
const AbilityIcon = ABILITY_ICONS[ability.type] || Zap;
|
||||
const value = getFamiliarAbilityValue(abilityDef, instance.level, ability.level);
|
||||
|
||||
return (
|
||||
<Tooltip key={ability.type}>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="text-xs py-0 flex items-center gap-1">
|
||||
<AbilityIcon className="w-3 h-3" />
|
||||
{ability.type === 'damageBonus' || ability.type === 'elementalBonus' ||
|
||||
ability.type === 'castSpeed' || ability.type === 'critChance' ||
|
||||
ability.type === 'lifeSteal' || ability.type === 'thorns' ||
|
||||
ability.type === 'bonusGold'
|
||||
? `+${value.toFixed(1)}%`
|
||||
: `+${value.toFixed(1)}`}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-sm">{abilityDef.desc}</p>
|
||||
<p className="text-xs text-gray-400">Level {ability.level}/10</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Render selected familiar details
|
||||
const renderFamiliarDetails = () => {
|
||||
if (selectedFamiliar === null || selectedFamiliar >= familiars.length) return null;
|
||||
|
||||
const instance = familiars[selectedFamiliar];
|
||||
const def = getFamiliarDef(instance);
|
||||
if (!def) return null;
|
||||
|
||||
const ElementIcon = ELEMENT_ICONS[def.element] || Circle;
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-amber-400 text-sm">Familiar Details</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => setSelectedFamiliar(null)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Name and nickname */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-12 h-12 rounded-full bg-black/30 flex items-center justify-center">
|
||||
<ElementIcon className="w-8 h-8" style={{ color: ELEMENTS[def.element]?.color || '#fff' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`text-lg font-bold ${RARITY_COLORS[def.rarity]}`}>
|
||||
{def.name}
|
||||
</h3>
|
||||
{instance.nickname && (
|
||||
<p className="text-sm text-gray-400">"{instance.nickname}"</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nickname input */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={nicknameInput}
|
||||
onChange={(e) => setNicknameInput(e.target.value)}
|
||||
placeholder="Set nickname..."
|
||||
className="h-8 text-sm bg-gray-800 border-gray-600"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
store.setFamiliarNickname(selectedFamiliar, nicknameInput);
|
||||
setNicknameInput('');
|
||||
}}
|
||||
disabled={!nicknameInput.trim()}
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="text-sm text-gray-300 italic">
|
||||
{def.desc}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Level:</span>
|
||||
<span className="text-white">{instance.level}/100</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Bond:</span>
|
||||
<span className="text-white">{instance.bond.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Role:</span>
|
||||
<span className="text-white capitalize">{def.role}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Element:</span>
|
||||
<span style={{ color: ELEMENTS[def.element]?.color }}>{def.element}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-gray-700" />
|
||||
|
||||
{/* Abilities */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-gray-300">Abilities</h4>
|
||||
{instance.abilities.map(ability => {
|
||||
const abilityDef = def.abilities.find(a => a.type === ability.type);
|
||||
if (!abilityDef) return null;
|
||||
const AbilityIcon = ABILITY_ICONS[ability.type] || Zap;
|
||||
const value = getFamiliarAbilityValue(abilityDef, instance.level, ability.level);
|
||||
const upgradeCost = ability.level * 100;
|
||||
const canUpgrade = instance.experience >= upgradeCost && ability.level < 10;
|
||||
|
||||
return (
|
||||
<div key={ability.type} className="p-2 rounded bg-gray-800/50 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<AbilityIcon className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{ability.type.replace(/([A-Z])/g, ' $1').trim()}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">Lv.{ability.level}/10</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-2">{abilityDef.desc}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-green-400">
|
||||
Current: +{value.toFixed(2)}
|
||||
{ability.type === 'damageBonus' || ability.type === 'elementalBonus' ||
|
||||
ability.type === 'castSpeed' || ability.type === 'critChance' ||
|
||||
ability.type === 'lifeSteal' || ability.type === 'thorns' ||
|
||||
ability.type === 'bonusGold' ? '%' : ''}
|
||||
</span>
|
||||
{ability.level < 10 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-xs"
|
||||
disabled={!canUpgrade}
|
||||
onClick={() => store.upgradeFamiliarAbility(selectedFamiliar, ability.type)}
|
||||
>
|
||||
Upgrade ({upgradeCost} XP)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Activate/Deactivate */}
|
||||
<Button
|
||||
className={`w-full ${instance.active ? 'bg-red-900/50 hover:bg-red-800/50' : 'bg-green-900/50 hover:bg-green-800/50'}`}
|
||||
onClick={() => store.setActiveFamiliar(selectedFamiliar, !instance.active)}
|
||||
disabled={!instance.active && activeCount >= activeFamiliarSlots}
|
||||
>
|
||||
{instance.active ? 'Deactivate' : 'Activate'}
|
||||
</Button>
|
||||
|
||||
{/* Flavor text */}
|
||||
{def.flavorText && (
|
||||
<p className="text-xs text-gray-500 italic text-center">
|
||||
"{def.flavorText}"
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Render summonable familiars
|
||||
const renderSummonableFamiliars = () => {
|
||||
if (availableFamiliars.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Available to Summon ({availableFamiliars.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-48">
|
||||
<div className="space-y-2">
|
||||
{availableFamiliars.map(familiarId => {
|
||||
const def = FAMILIARS_DEF[familiarId];
|
||||
if (!def) return null;
|
||||
|
||||
const ElementIcon = ELEMENT_ICONS[def.element] || Circle;
|
||||
const RoleIcon = ROLE_ICONS[def.role] || Sparkles;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={familiarId}
|
||||
className={`p-2 rounded border ${RARITY_COLORS[def.rarity]} ${RARITY_BG[def.rarity]} flex items-center justify-between`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ElementIcon className="w-5 h-5" style={{ color: ELEMENTS[def.element]?.color || '#fff' }} />
|
||||
<div>
|
||||
<div className={`text-sm font-medium ${RARITY_COLORS[def.rarity]}`}>
|
||||
{def.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 flex items-center gap-1">
|
||||
<RoleIcon className="w-3 h-3" />
|
||||
{def.role}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7"
|
||||
onClick={() => store.summonFamiliar(familiarId)}
|
||||
>
|
||||
Summon
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Render active bonuses
|
||||
const renderActiveBonuses = () => {
|
||||
const hasBonuses = Object.entries(familiarBonuses).some(([key, value]) => {
|
||||
if (key === 'damageMultiplier' || key === 'castSpeedMultiplier' ||
|
||||
key === 'elementalDamageMultiplier' || key === 'insightMultiplier') {
|
||||
return value > 1;
|
||||
}
|
||||
return value > 0;
|
||||
});
|
||||
|
||||
if (!hasBonuses) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-green-400 text-sm flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Active Familiar Bonuses
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 text-sm">
|
||||
{familiarBonuses.damageMultiplier > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Sword className="w-4 h-4 text-red-400" />
|
||||
<span>+{((familiarBonuses.damageMultiplier - 1) * 100).toFixed(0)}% DMG</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.manaRegenBonus > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-blue-400" />
|
||||
<span>+{familiarBonuses.manaRegenBonus.toFixed(1)} regen</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.autoGatherRate > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-yellow-400" />
|
||||
<span>+{familiarBonuses.autoGatherRate.toFixed(1)}/hr gather</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.critChanceBonus > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-4 h-4 text-amber-400" />
|
||||
<span>+{familiarBonuses.critChanceBonus.toFixed(1)}% crit</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.castSpeedMultiplier > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-cyan-400" />
|
||||
<span>+{((familiarBonuses.castSpeedMultiplier - 1) * 100).toFixed(0)}% speed</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.elementalDamageMultiplier > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Flame className="w-4 h-4 text-orange-400" />
|
||||
<span>+{((familiarBonuses.elementalDamageMultiplier - 1) * 100).toFixed(0)}% elem</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.lifeStealPercent > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart className="w-4 h-4 text-red-400" />
|
||||
<span>+{familiarBonuses.lifeStealPercent.toFixed(0)}% lifesteal</span>
|
||||
</div>
|
||||
)}
|
||||
{familiarBonuses.insightMultiplier > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-purple-400" />
|
||||
<span>+{((familiarBonuses.insightMultiplier - 1) * 100).toFixed(0)}% insight</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Owned Familiars */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
||||
<Heart className="w-4 h-4" />
|
||||
Your Familiars ({familiars.length})
|
||||
</CardTitle>
|
||||
<div className="text-xs text-gray-400">
|
||||
Active Slots: {activeCount}/{activeFamiliarSlots}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{familiars.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{familiars.map((instance, index) => renderFamiliarCard(instance, index))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-8 text-gray-500">
|
||||
No familiars yet. Progress through the game to summon companions!
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Bonuses */}
|
||||
{renderActiveBonuses()}
|
||||
|
||||
{/* Selected Familiar Details */}
|
||||
{renderFamiliarDetails()}
|
||||
|
||||
{/* Summonable Familiars */}
|
||||
{renderSummonableFamiliars()}
|
||||
|
||||
{/* Familiar Guide */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-purple-400 text-sm flex items-center gap-2">
|
||||
<Crown className="w-4 h-4" />
|
||||
Familiar Guide
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm text-gray-300">
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-400 mb-1">Acquiring Familiars</h4>
|
||||
<p>Familiars become available to summon as you progress through floors, gather mana, and sign pacts with guardians. Higher rarity familiars are unlocked later.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-400 mb-1">Leveling & Bond</h4>
|
||||
<p>Active familiars gain XP from combat, gathering, and time. Higher bond increases their power and XP gain. Upgrade abilities using XP to boost their effects.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-400 mb-1">Roles</h4>
|
||||
<p>
|
||||
<span className="text-red-400">Combat</span> - Damage and crit bonuses<br/>
|
||||
<span className="text-blue-400">Mana</span> - Regeneration and auto-gathering<br/>
|
||||
<span className="text-green-400">Support</span> - Speed and utility<br/>
|
||||
<span className="text-amber-400">Guardian</span> - Defense and shields
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-400 mb-1">Active Slots</h4>
|
||||
<p>You can have 1 familiar active by default. Upgrade through prestige to unlock more active slots for stacking bonuses.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
567
src/components/game/tabs/GrimoireTab.tsx
Executable file
567
src/components/game/tabs/GrimoireTab.tsx
Executable file
@@ -0,0 +1,567 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { RotateCcw, Save, Trash2, Star, Flame, Clock, AlertCircle } from 'lucide-react';
|
||||
import { useGameContext } from '../GameContext';
|
||||
import { GUARDIANS, PRESTIGE_DEF, SKILLS_DEF, ELEMENTS } from '@/lib/game/constants';
|
||||
import { getTierMultiplier, getBaseSkillId } from '@/lib/game/skill-evolution';
|
||||
import { fmt, fmtDec, getBoonBonuses } from '@/lib/game/stores';
|
||||
import type { Memory } from '@/lib/game/types';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
export function GrimoireTab() {
|
||||
const { store } = useGameContext();
|
||||
const [showMemoryPicker, setShowMemoryPicker] = useState(false);
|
||||
|
||||
// Get all skills that can be saved to memory
|
||||
const saveableSkills = useMemo(() => {
|
||||
const skills: { skillId: string; level: number; tier: number; upgrades: string[]; name: string }[] = [];
|
||||
|
||||
for (const [skillId, level] of Object.entries(store.skills)) {
|
||||
if (level && level > 0) {
|
||||
const baseSkillId = getBaseSkillId(skillId);
|
||||
const tier = store.skillTiers?.[baseSkillId] || 1;
|
||||
const tieredSkillId = tier > 1 ? `${baseSkillId}_t${tier}` : baseSkillId;
|
||||
const upgrades = store.skillUpgrades?.[tieredSkillId] || [];
|
||||
const skillDef = SKILLS_DEF[baseSkillId];
|
||||
|
||||
if (skillId === baseSkillId || skillId.includes('_t')) {
|
||||
const actualLevel = store.skills[tieredSkillId] || store.skills[baseSkillId] || 0;
|
||||
if (actualLevel > 0) {
|
||||
skills.push({
|
||||
skillId: baseSkillId,
|
||||
level: actualLevel,
|
||||
tier,
|
||||
upgrades,
|
||||
name: skillDef?.name || baseSkillId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueSkills = new Map<string, typeof skills[0]>();
|
||||
for (const skill of skills) {
|
||||
const existing = uniqueSkills.get(skill.skillId);
|
||||
if (!existing || skill.tier > existing.tier || (skill.tier === existing.tier && skill.level > existing.level)) {
|
||||
uniqueSkills.set(skill.skillId, skill);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniqueSkills.values()).sort((a, b) => {
|
||||
if (a.tier !== b.tier) return b.tier - a.tier;
|
||||
if (a.level !== b.level) return b.level - a.level;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [store.skills, store.skillTiers, store.skillUpgrades]);
|
||||
|
||||
const isSkillInMemory = (skillId: string) => store.memories.some(m => m.skillId === skillId);
|
||||
const canAddMore = store.memories.length < store.memorySlots;
|
||||
|
||||
const addToMemory = (skill: typeof saveableSkills[0]) => {
|
||||
const memory: Memory = {
|
||||
skillId: skill.skillId,
|
||||
level: skill.level,
|
||||
tier: skill.tier,
|
||||
upgrades: skill.upgrades,
|
||||
};
|
||||
store.addMemory(memory);
|
||||
};
|
||||
|
||||
// Calculate total boons from active pacts
|
||||
const activeBoons = useMemo(() => {
|
||||
return getBoonBonuses(store.signedPacts);
|
||||
}, [store.signedPacts]);
|
||||
|
||||
// Check if player can sign a pact
|
||||
const canSignPact = (floor: number) => {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) return false;
|
||||
if (!store.defeatedGuardians.includes(floor)) return false;
|
||||
if (store.signedPacts.includes(floor)) return false;
|
||||
if (store.signedPacts.length >= store.pactSlots) return false;
|
||||
if (store.rawMana < guardian.pactCost) return false;
|
||||
if (store.pactRitualFloor !== null) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Get pact affinity bonus for display
|
||||
const pactAffinityBonus = (store.prestigeUpgrades.pactAffinity || 0) * 10;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Current Status */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Loop Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<div className="text-2xl font-bold text-green-400 game-mono">{store.memorySlots}</div>
|
||||
<div className="text-xs text-gray-400">Memory Slots</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pact Slots & Active Ritual */}
|
||||
<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">
|
||||
<Flame className="w-4 h-4" />
|
||||
Pact Slots ({store.signedPacts.length}/{store.pactSlots})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Active Ritual Progress */}
|
||||
{store.pactRitualFloor !== null && (
|
||||
<div className="mb-4 p-3 rounded border-2 border-amber-500/50 bg-amber-900/20">
|
||||
{(() => {
|
||||
const guardian = GUARDIANS[store.pactRitualFloor];
|
||||
if (!guardian) return null;
|
||||
const requiredTime = guardian.pactTime * (1 - (store.prestigeUpgrades.pactAffinity || 0) * 0.1);
|
||||
const progress = Math.min(100, (store.pactRitualProgress / requiredTime) * 100);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-amber-300 font-semibold text-sm">Signing Pact with {guardian.name}</span>
|
||||
<span className="text-xs text-gray-400">{fmtDec(progress, 1)}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-700 rounded overflow-hidden mb-2">
|
||||
<div
|
||||
className="h-full bg-amber-500 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-gray-400">
|
||||
<Clock className="w-3 h-3 inline mr-1" />
|
||||
{fmtDec(store.pactRitualProgress, 1)}h / {fmtDec(requiredTime, 1)}h
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-xs border-red-500/50 text-red-400 hover:bg-red-900/20"
|
||||
onClick={() => store.cancelPactRitual()}
|
||||
>
|
||||
Cancel Ritual
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Pacts */}
|
||||
{store.signedPacts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{store.signedPacts.map((floor) => {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) return null;
|
||||
return (
|
||||
<div
|
||||
key={floor}
|
||||
className="p-3 rounded border"
|
||||
style={{ borderColor: guardian.color, backgroundColor: `${guardian.color}15` }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
|
||||
{guardian.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">Floor {floor} Guardian</div>
|
||||
<div className="text-xs text-amber-300 mt-1 italic">"{guardian.uniquePerk}"</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
|
||||
onClick={() => store.removePact(floor)}
|
||||
title="Break Pact"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{guardian.boons.map((boon, idx) => (
|
||||
<Badge key={idx} className="bg-gray-700/50 text-gray-200 text-xs">
|
||||
{boon.desc}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 text-sm text-center py-4">
|
||||
No active pacts. Defeat guardians and sign pacts to gain boons.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Available Guardians for Pacts */}
|
||||
<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">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Available Guardians ({store.defeatedGuardians.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{store.defeatedGuardians.length === 0 ? (
|
||||
<div className="text-gray-500 text-sm text-center py-4">
|
||||
Defeat guardians in the Spire to make them available for pacts.
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-2 pr-2">
|
||||
{store.defeatedGuardians
|
||||
.sort((a, b) => a - b)
|
||||
.map((floor) => {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) return null;
|
||||
const canSign = canSignPact(floor);
|
||||
const notEnoughMana = store.rawMana < guardian.pactCost;
|
||||
const atCapacity = store.signedPacts.length >= store.pactSlots;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={floor}
|
||||
className="p-3 rounded border border-gray-700 bg-gray-800/50"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div className="font-semibold text-sm" style={{ color: guardian.color }}>
|
||||
{guardian.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
Floor {floor} • {ELEMENTS[guardian.element]?.name || guardian.element}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-amber-300">{fmt(guardian.pactCost)} mana</div>
|
||||
<div className="text-xs text-gray-400">{guardian.pactTime}h ritual</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-purple-300 italic mb-2">"{guardian.uniquePerk}"</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{guardian.boons.map((boon, idx) => (
|
||||
<Badge key={idx} className="bg-gray-700/50 text-gray-200 text-xs">
|
||||
{boon.desc}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canSign ? 'default' : 'outline'}
|
||||
className="w-full"
|
||||
disabled={!canSign}
|
||||
onClick={() => store.startPactRitual(floor, store.rawMana)}
|
||||
>
|
||||
{atCapacity
|
||||
? 'Pact Slots Full'
|
||||
: notEnoughMana
|
||||
? 'Not Enough Mana'
|
||||
: store.pactRitualFloor !== null
|
||||
? 'Ritual in Progress'
|
||||
: 'Start Pact Ritual'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Memory Slots */}
|
||||
<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">
|
||||
<Save className="w-4 h-4" />
|
||||
Memory Slots ({store.memories.length}/{store.memorySlots})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
Skills saved to memory will retain their level, tier, and upgrades when you start a new loop.
|
||||
</p>
|
||||
|
||||
{/* Saved Memories */}
|
||||
{store.memories.length > 0 ? (
|
||||
<div className="space-y-1 mb-3">
|
||||
{store.memories.map((memory) => {
|
||||
const skillDef = SKILLS_DEF[memory.skillId];
|
||||
const tierMult = getTierMultiplier(memory.tier > 1 ? `${memory.skillId}_t${memory.tier}` : memory.skillId);
|
||||
return (
|
||||
<div
|
||||
key={memory.skillId}
|
||||
className="flex items-center justify-between p-2 rounded border border-amber-600/30 bg-amber-900/10"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-amber-300">{skillDef?.name || memory.skillId}</span>
|
||||
{memory.tier > 1 && (
|
||||
<Badge className="bg-purple-600/50 text-purple-200 text-xs">
|
||||
T{memory.tier} ({tierMult}x)
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-purple-400 text-xs">Lv.{memory.level}</span>
|
||||
{memory.upgrades.length > 0 && (
|
||||
<Badge className="bg-amber-700/50 text-amber-200 text-xs flex items-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
{memory.upgrades.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
|
||||
onClick={() => store.removeMemory(memory.skillId)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 text-sm mb-3 text-center py-2">
|
||||
No memories saved. Add skills below.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Memory Button */}
|
||||
{canAddMore && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowMemoryPicker(!showMemoryPicker)}
|
||||
>
|
||||
{showMemoryPicker ? 'Hide Skills' : 'Add Skill to Memory'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Skill Picker */}
|
||||
{showMemoryPicker && canAddMore && (
|
||||
<ScrollArea className="h-48 mt-2">
|
||||
<div className="space-y-1 pr-2">
|
||||
{saveableSkills.length === 0 ? (
|
||||
<div className="text-gray-500 text-xs text-center py-4">
|
||||
No skills with progress to save
|
||||
</div>
|
||||
) : (
|
||||
saveableSkills.map((skill) => {
|
||||
const isInMemory = isSkillInMemory(skill.skillId);
|
||||
const tierMult = getTierMultiplier(skill.tier > 1 ? `${skill.skillId}_t${skill.tier}` : skill.skillId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={skill.skillId}
|
||||
className={`p-2 rounded border cursor-pointer transition-all ${
|
||||
isInMemory
|
||||
? 'border-amber-500 bg-amber-900/30 opacity-50'
|
||||
: 'border-gray-700 bg-gray-800/50 hover:border-amber-500/50'
|
||||
}`}
|
||||
onClick={() => !isInMemory && addToMemory(skill)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{skill.name}</span>
|
||||
{skill.tier > 1 && (
|
||||
<Badge className="bg-purple-600/50 text-purple-200 text-xs">
|
||||
T{skill.tier} ({tierMult}x)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400 text-sm">Lv.{skill.level}</span>
|
||||
{skill.upgrades.length > 0 && (
|
||||
<Badge className="bg-amber-700/50 text-amber-200 text-xs flex items-center gap-1">
|
||||
<Star className="w-3 h-3" />
|
||||
{skill.upgrades.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Boons Summary */}
|
||||
{store.signedPacts.length > 0 && (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Active Boons Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{activeBoons.maxMana > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Max Mana:</span>
|
||||
<span className="text-blue-300">+{activeBoons.maxMana}</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.manaRegen > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Mana Regen:</span>
|
||||
<span className="text-blue-300">+{activeBoons.manaRegen}/h</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.castingSpeed > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Cast Speed:</span>
|
||||
<span className="text-amber-300">+{activeBoons.castingSpeed}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.elementalDamage > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Elem. Damage:</span>
|
||||
<span className="text-red-300">+{activeBoons.elementalDamage}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.rawDamage > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Raw Damage:</span>
|
||||
<span className="text-red-300">+{activeBoons.rawDamage}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.critChance > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Crit Chance:</span>
|
||||
<span className="text-yellow-300">+{activeBoons.critChance}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.critDamage > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Crit Damage:</span>
|
||||
<span className="text-yellow-300">+{activeBoons.critDamage}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.spellEfficiency > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Spell Cost:</span>
|
||||
<span className="text-green-300">-{activeBoons.spellEfficiency}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.manaGain > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Mana Gain:</span>
|
||||
<span className="text-blue-300">+{activeBoons.manaGain}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.insightGain > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Insight Gain:</span>
|
||||
<span className="text-purple-300">+{activeBoons.insightGain}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.studySpeed > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Study Speed:</span>
|
||||
<span className="text-cyan-300">+{activeBoons.studySpeed}%</span>
|
||||
</div>
|
||||
)}
|
||||
{activeBoons.prestigeInsight > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Prestige Insight:</span>
|
||||
<span className="text-purple-300">+{activeBoons.prestigeInsight}/loop</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Prestige Upgrades */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Insight Upgrades (Permanent)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Object.entries(PRESTIGE_DEF).map(([id, def]) => {
|
||||
const level = store.prestigeUpgrades[id] || 0;
|
||||
const maxed = level >= def.max;
|
||||
const canBuy = !maxed && store.insight >= def.cost;
|
||||
|
||||
return (
|
||||
<div key={id} className="p-3 rounded border border-gray-700 bg-gray-800/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-semibold text-amber-400 text-sm">{def.name}</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{level}/{def.max}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 italic mb-2">{def.desc}</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={canBuy ? 'default' : 'outline'}
|
||||
className="w-full"
|
||||
disabled={!canBuy}
|
||||
onClick={() => store.doPrestige(id)}
|
||||
>
|
||||
{maxed ? 'Maxed' : `Upgrade (${fmt(def.cost)} insight)`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reset Game Button */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">Reset All Progress</div>
|
||||
<div className="text-xs text-gray-500">Clear all data and start fresh</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-600/50 text-red-400 hover:bg-red-900/20"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to reset ALL progress? This cannot be undone!')) {
|
||||
store.resetGame();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/components/game/types.ts
Executable file
47
src/components/game/types.ts
Executable file
@@ -0,0 +1,47 @@
|
||||
import type { SpellCost } from '@/lib/game/types';
|
||||
import { Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull } from 'lucide-react';
|
||||
|
||||
// Format spell cost for display
|
||||
export function formatSpellCost(cost: SpellCost): string {
|
||||
if (cost.type === 'raw') {
|
||||
return `${cost.amount} raw`;
|
||||
}
|
||||
const elemDef = ELEMENTS[cost.element || ''];
|
||||
return `${cost.amount} ${elemDef?.sym || '?'}`;
|
||||
}
|
||||
|
||||
// Format time (hour to HH:MM)
|
||||
export function formatTime(hour: number): string {
|
||||
const h = Math.floor(hour);
|
||||
const m = Math.floor((hour % 1) * 60);
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Format study time
|
||||
export function formatStudyTime(hours: number): string {
|
||||
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||
return `${hours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
// Element icons mapping
|
||||
export const ELEMENT_ICONS: Record<string, typeof Flame> = {
|
||||
fire: Flame,
|
||||
water: Droplet,
|
||||
wind: Wind,
|
||||
earth: Mountain,
|
||||
light: Sun,
|
||||
shadow: Moon,
|
||||
nature: Leaf,
|
||||
death: Skull,
|
||||
};
|
||||
|
||||
// Import ELEMENTS at the bottom to avoid circular deps
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
// Get cost color
|
||||
export function getSpellCostColor(cost: SpellCost): string {
|
||||
if (cost.type === 'raw') {
|
||||
return '#60A5FA'; // Blue for raw mana
|
||||
}
|
||||
return ELEMENTS[cost.element || '']?.color || '#9CA3AF';
|
||||
}
|
||||
Reference in New Issue
Block a user