Add floor navigation system with up/down direction and respawn mechanics

- Added climbDirection state to track player movement direction
- Added clearedFloors tracking for floor respawn system
- Players can now manually navigate between floors using ascend/descend buttons
- Floors respawn when player leaves and returns (for loot farming)
- Enhanced LootInventory component with:
  - Full inventory management (materials, essence, equipment)
  - Search and filter functionality
  - Sorting by name, rarity, or count
  - Delete functionality with confirmation dialog
- Added updateLootInventory function to store
- Blueprints are now shown as permanent unlocks in inventory
- Floor navigation UI shows direction toggle and respawn indicators
This commit is contained in:
2026-03-26 10:28:15 +00:00
parent ee0268d9f6
commit 4f4cbeb527
5 changed files with 679 additions and 94 deletions

View File

@@ -5,6 +5,7 @@ import { useGameStore, useGameLoop, fmt, fmtDec, getFloorElement, computeMaxMana
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, PRESTIGE_DEF, MAX_DAY, INCURSION_START_DAY, MANA_PER_ELEMENT, SKILL_CATEGORIES, getStudySpeedMultiplier, getStudyCostMultiplier, ELEMENT_OPPOSITES, HOURS_PER_TICK, TICK_MS } from '@/lib/game/constants';
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
import { LOOT_DROPS } from '@/lib/game/data/loot-drops';
import { SKILL_EVOLUTION_PATHS, getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '@/lib/game/skill-evolution';
import { getUnifiedEffects, hasSpecial, SPECIAL_EFFECTS } from '@/lib/game/effects';
import type { SkillUpgradeChoice } from '@/lib/game/types';
@@ -18,7 +19,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Sparkles, Swords, BookOpen, FlaskConical, Trophy, RotateCcw, Pause, Play, Zap, Clock, Target, Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull, Brain, Link, Wind as Force, Droplets, TreeDeciduous, Hourglass, Gem, Star, CircleDot, X, Circle, BarChart3, Award, Package, Heart } from 'lucide-react';
import { Sparkles, Swords, BookOpen, FlaskConical, Trophy, RotateCcw, Pause, Play, Zap, Clock, Target, Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull, Brain, Link, Wind as Force, Droplets, TreeDeciduous, Hourglass, Gem, Star, CircleDot, X, Circle, BarChart3, Award, Package, Heart, ChevronUp, ChevronDown } from 'lucide-react';
import type { GameAction } from '@/lib/game/types';
import { CraftingTab } from '@/components/game/tabs/CraftingTab';
import { FamiliarTab } from '@/components/game/tabs/FamiliarTab';
@@ -564,6 +565,65 @@ export default function ManaLoopGame() {
<Separator className="bg-gray-700" />
{/* Floor Navigation Controls */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">Auto-Direction</span>
<div className="flex gap-1">
<Button
variant={(store.climbDirection || 'up') === 'up' ? 'default' : 'outline'}
size="sm"
className={`h-7 px-2 ${(store.climbDirection || 'up') === 'up' ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-800/50'}`}
onClick={() => store.setClimbDirection?.('up')}
>
<ChevronUp className="w-4 h-4 mr-1" />
Up
</Button>
<Button
variant={store.climbDirection === 'down' ? 'default' : 'outline'}
size="sm"
className={`h-7 px-2 ${store.climbDirection === 'down' ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50'}`}
onClick={() => store.setClimbDirection?.('down')}
>
<ChevronDown className="w-4 h-4 mr-1" />
Down
</Button>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 h-8 bg-gray-800/50 hover:bg-gray-700/50 disabled:opacity-50"
disabled={store.currentFloor <= 1}
onClick={() => store.changeFloor?.('down')}
>
<ChevronDown className="w-4 h-4 mr-1" />
Descend
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 h-8 bg-gray-800/50 hover:bg-gray-700/50 disabled:opacity-50"
disabled={store.currentFloor >= 100}
onClick={() => store.changeFloor?.('up')}
>
<ChevronUp className="w-4 h-4 mr-1" />
Ascend
</Button>
</div>
{store.clearedFloors?.[store.currentFloor] && (
<div className="text-xs text-amber-400 text-center flex items-center justify-center gap-1">
<RotateCcw className="w-3 h-3" />
Floor will respawn when you leave and return
</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>
@@ -2310,7 +2370,25 @@ export default function ManaLoopGame() {
<TabsContent value="loot">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<LootInventoryDisplay inventory={store.lootInventory} />
<LootInventoryDisplay
inventory={store.lootInventory}
elements={store.elements}
equipmentInstances={store.equipmentInstances}
onDeleteMaterial={(id, amount) => {
// Remove material from inventory
const newMaterials = { ...store.lootInventory.materials };
delete newMaterials[id];
store.updateLootInventory?.({
...store.lootInventory,
materials: newMaterials,
});
store.addLog?.(`🗑️ Deleted ${LOOT_DROPS[id]?.name || id} (${amount})`);
}}
onDeleteEquipment={(instanceId) => {
store.deleteEquipmentInstance?.(instanceId);
store.addLog?.(`🗑️ Deleted equipment`);
}}
/>
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">

View File

@@ -1,117 +1,460 @@
'use client';
import { useState } from 'react';
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 { Gem, Sparkles, Scroll, Droplet } from 'lucide-react';
import type { LootInventory as LootInventoryType } from '@/lib/game/types';
import { Separator } from '@/components/ui/separator';
import { Input } from '@/components/ui/input';
import {
Gem, Sparkles, Scroll, Droplet, Trash2, Search,
Package, Sword, Shield, Shirt, Crown, ArrowUpDown,
Wrench, AlertTriangle
} from 'lucide-react';
import type { LootInventory as LootInventoryType, EquipmentInstance, ElementState } from '@/lib/game/types';
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
import { ELEMENTS } from '@/lib/game/constants';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface LootInventoryProps {
inventory: LootInventoryType;
elements?: Record<string, ElementState>;
equipmentInstances?: Record<string, EquipmentInstance>;
onDeleteMaterial?: (materialId: string, amount: number) => void;
onDeleteEquipment?: (instanceId: string) => void;
}
export function LootInventoryDisplay({ inventory }: LootInventoryProps) {
type SortMode = 'name' | 'rarity' | 'count';
type FilterMode = 'all' | 'materials' | 'essence' | 'blueprints' | 'equipment';
const RARITY_ORDER = {
common: 0,
uncommon: 1,
rare: 2,
epic: 3,
legendary: 4,
mythic: 5,
};
const CATEGORY_ICONS: Record<string, typeof Sword> = {
caster: Sword,
shield: Shield,
catalyst: Sparkles,
head: Crown,
body: Shirt,
hands: Wrench,
feet: Package,
accessory: Gem,
};
export function LootInventoryDisplay({
inventory,
elements,
equipmentInstances = {},
onDeleteMaterial,
onDeleteEquipment,
}: LootInventoryProps) {
const [searchTerm, setSearchTerm] = useState('');
const [sortMode, setSortMode] = useState<SortMode>('rarity');
const [filterMode, setFilterMode] = useState<FilterMode>('all');
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'material' | 'equipment'; id: string; name: string } | null>(null);
// Count items
const materialCount = Object.values(inventory.materials).reduce((a, b) => a + b, 0);
const essenceCount = elements ? Object.values(elements).reduce((a, e) => a + e.current, 0) : 0;
const blueprintCount = inventory.blueprints.length;
if (materialCount === 0 && blueprintCount === 0) {
const equipmentCount = Object.keys(equipmentInstances).length;
const totalItems = materialCount + blueprintCount + equipmentCount;
// Filter and sort materials
const filteredMaterials = Object.entries(inventory.materials)
.filter(([id, count]) => {
if (count <= 0) return false;
const drop = LOOT_DROPS[id];
if (!drop) return false;
if (searchTerm && !drop.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
return true;
})
.sort(([aId, aCount], [bId, bCount]) => {
const aDrop = LOOT_DROPS[aId];
const bDrop = LOOT_DROPS[bId];
if (!aDrop || !bDrop) return 0;
switch (sortMode) {
case 'name':
return aDrop.name.localeCompare(bDrop.name);
case 'rarity':
return RARITY_ORDER[bDrop.rarity] - RARITY_ORDER[aDrop.rarity];
case 'count':
return bCount - aCount;
default:
return 0;
}
});
// Filter and sort essence
const filteredEssence = elements
? Object.entries(elements)
.filter(([id, state]) => {
if (!state.unlocked || state.current <= 0) return false;
if (searchTerm && !ELEMENTS[id]?.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
return true;
})
.sort(([aId, aState], [bId, bState]) => {
switch (sortMode) {
case 'name':
return (ELEMENTS[aId]?.name || aId).localeCompare(ELEMENTS[bId]?.name || bId);
case 'count':
return bState.current - aState.current;
default:
return 0;
}
})
: [];
// Filter and sort equipment
const filteredEquipment = Object.entries(equipmentInstances)
.filter(([id, instance]) => {
if (searchTerm && !instance.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
return true;
})
.sort(([aId, aInst], [bId, bInst]) => {
switch (sortMode) {
case 'name':
return aInst.name.localeCompare(bInst.name);
case 'rarity':
return RARITY_ORDER[bInst.rarity] - RARITY_ORDER[aInst.rarity];
default:
return 0;
}
});
// Check if we have anything to show
const hasItems = totalItems > 0 || essenceCount > 0;
if (!hasItems) {
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
<Gem className="w-4 h-4" />
Loot Inventory
Inventory
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-gray-500 text-sm text-center py-4">
No loot collected yet. Defeat floors and guardians to find items!
No items collected yet. Defeat floors and guardians to find loot!
</div>
</CardContent>
</Card>
);
}
const handleDeleteMaterial = (materialId: string) => {
const drop = LOOT_DROPS[materialId];
if (drop) {
setDeleteConfirm({ type: 'material', id: materialId, name: drop.name });
}
};
const handleDeleteEquipment = (instanceId: string) => {
const instance = equipmentInstances[instanceId];
if (instance) {
setDeleteConfirm({ type: 'equipment', id: instanceId, name: instance.name });
}
};
const confirmDelete = () => {
if (!deleteConfirm) return;
if (deleteConfirm.type === 'material' && onDeleteMaterial) {
onDeleteMaterial(deleteConfirm.id, inventory.materials[deleteConfirm.id] || 0);
} else if (deleteConfirm.type === 'equipment' && onDeleteEquipment) {
onDeleteEquipment(deleteConfirm.id);
}
setDeleteConfirm(null);
};
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
<Gem className="w-4 h-4" />
Loot Inventory
<Badge className="ml-auto bg-gray-800 text-gray-300">
{materialCount + blueprintCount} items
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-48">
<div className="space-y-3">
{/* Materials */}
{Object.entries(inventory.materials).length > 0 && (
<div>
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
<Sparkles className="w-3 h-3" />
Materials
</div>
<div className="grid grid-cols-2 gap-2">
{Object.entries(inventory.materials).map(([id, count]) => {
const drop = LOOT_DROPS[id];
if (!drop || count <= 0) return null;
const rarityStyle = RARITY_COLORS[drop.rarity];
return (
<div
key={id}
className="p-2 rounded border bg-gray-800/50"
style={{
borderColor: rarityStyle?.color || '#9CA3AF',
}}
>
<div className="text-xs font-semibold" style={{ color: rarityStyle?.color }}>
{drop.name}
</div>
<div className="text-xs text-gray-400">
x{count}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Blueprints */}
{inventory.blueprints.length > 0 && (
<div>
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
<Scroll className="w-3 h-3" />
Blueprints Discovered
</div>
<div className="flex flex-wrap gap-1">
{inventory.blueprints.map((id) => {
const drop = LOOT_DROPS[id];
if (!drop) return null;
const rarityStyle = RARITY_COLORS[drop.rarity];
return (
<Badge
key={id}
className="text-xs"
style={{
backgroundColor: `${rarityStyle?.color}20`,
color: rarityStyle?.color,
borderColor: rarityStyle?.color,
}}
>
{drop.name}
</Badge>
);
})}
</div>
</div>
)}
<>
<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">
<Gem className="w-4 h-4" />
Inventory
<Badge className="ml-auto bg-gray-800 text-gray-300 text-xs">
{totalItems} items
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Search and Filter Controls */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-500" />
<Input
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-7 pl-7 bg-gray-800/50 border-gray-700 text-xs"
/>
</div>
<Button
variant="outline"
size="sm"
className="h-7 px-2 bg-gray-800/50"
onClick={() => setSortMode(sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity')}
>
<ArrowUpDown className="w-3 h-3" />
</Button>
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Filter Tabs */}
<div className="flex gap-1 flex-wrap">
{[
{ mode: 'all' as FilterMode, label: 'All' },
{ mode: 'materials' as FilterMode, label: `Materials (${materialCount})` },
{ mode: 'essence' as FilterMode, label: `Essence (${essenceCount})` },
{ mode: 'blueprints' as FilterMode, label: `Blueprints (${blueprintCount})` },
{ mode: 'equipment' as FilterMode, label: `Equipment (${equipmentCount})` },
].map(({ mode, label }) => (
<Button
key={mode}
variant={filterMode === mode ? 'default' : 'outline'}
size="sm"
className={`h-6 px-2 text-xs ${filterMode === mode ? 'bg-amber-600 hover:bg-amber-700' : 'bg-gray-800/50'}`}
onClick={() => setFilterMode(mode)}
>
{label}
</Button>
))}
</div>
<Separator className="bg-gray-700" />
<ScrollArea className="h-64">
<div className="space-y-3">
{/* Materials */}
{(filterMode === 'all' || filterMode === 'materials') && filteredMaterials.length > 0 && (
<div>
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
<Sparkles className="w-3 h-3" />
Materials
</div>
<div className="grid grid-cols-2 gap-2">
{filteredMaterials.map(([id, count]) => {
const drop = LOOT_DROPS[id];
if (!drop) return null;
const rarityStyle = RARITY_COLORS[drop.rarity];
return (
<div
key={id}
className="p-2 rounded border bg-gray-800/50 group relative"
style={{
borderColor: rarityStyle?.color || '#9CA3AF',
}}
>
<div className="flex items-start justify-between">
<div>
<div className="text-xs font-semibold" style={{ color: rarityStyle?.color }}>
{drop.name}
</div>
<div className="text-xs text-gray-400">
x{count}
</div>
<div className="text-xs text-gray-500 capitalize">
{drop.rarity}
</div>
</div>
{onDeleteMaterial && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => handleDeleteMaterial(id)}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Essence */}
{(filterMode === 'all' || filterMode === 'essence') && filteredEssence.length > 0 && (
<div>
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
<Droplet className="w-3 h-3" />
Elemental Essence
</div>
<div className="grid grid-cols-2 gap-2">
{filteredEssence.map(([id, state]) => {
const elem = ELEMENTS[id];
if (!elem) return null;
return (
<div
key={id}
className="p-2 rounded border bg-gray-800/50"
style={{
borderColor: elem.color,
}}
>
<div className="flex items-center gap-1">
<span style={{ color: elem.color }}>{elem.sym}</span>
<span className="text-xs font-semibold" style={{ color: elem.color }}>
{elem.name}
</span>
</div>
<div className="text-xs text-gray-400">
{state.current} / {state.max}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Blueprints */}
{(filterMode === 'all' || filterMode === 'blueprints') && inventory.blueprints.length > 0 && (
<div>
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
<Scroll className="w-3 h-3" />
Blueprints (permanent)
</div>
<div className="flex flex-wrap gap-1">
{inventory.blueprints.map((id) => {
const drop = LOOT_DROPS[id];
if (!drop) return null;
const rarityStyle = RARITY_COLORS[drop.rarity];
return (
<Badge
key={id}
className="text-xs"
style={{
backgroundColor: `${rarityStyle?.color}20`,
color: rarityStyle?.color,
borderColor: rarityStyle?.color,
}}
>
{drop.name}
</Badge>
);
})}
</div>
<div className="text-xs text-gray-500 mt-1 italic">
Blueprints are permanent unlocks - use them to craft equipment
</div>
</div>
)}
{/* Equipment */}
{(filterMode === 'all' || filterMode === 'equipment') && filteredEquipment.length > 0 && (
<div>
<div className="text-xs text-gray-500 mb-2 flex items-center gap-1">
<Package className="w-3 h-3" />
Equipment
</div>
<div className="space-y-2">
{filteredEquipment.map(([id, instance]) => {
const type = EQUIPMENT_TYPES[instance.typeId];
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
const rarityStyle = RARITY_COLORS[instance.rarity];
return (
<div
key={id}
className="p-2 rounded border bg-gray-800/50 group"
style={{
borderColor: rarityStyle?.color || '#9CA3AF',
}}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-2">
<Icon className="w-4 h-4 mt-0.5" style={{ color: rarityStyle?.color }} />
<div>
<div className="text-xs font-semibold" style={{ color: rarityStyle?.color }}>
{instance.name}
</div>
<div className="text-xs text-gray-400">
{type?.name} {instance.usedCapacity}/{instance.totalCapacity} cap
</div>
<div className="text-xs text-gray-500 capitalize">
{instance.rarity} {instance.enchantments.length} enchants
</div>
</div>
</div>
{onDeleteEquipment && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => handleDeleteEquipment(id)}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteConfirm} onOpenChange={() => setDeleteConfirm(null)}>
<AlertDialogContent className="bg-gray-900 border-gray-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-amber-400 flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Delete Item
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-300">
Are you sure you want to delete <strong>{deleteConfirm?.name}</strong>?
{deleteConfirm?.type === 'material' && (
<span className="block mt-2 text-red-400">
This will delete ALL {inventory.materials[deleteConfirm?.id || ''] || 0} of this material!
</span>
)}
{deleteConfirm?.type === 'equipment' && (
<span className="block mt-2 text-red-400">
This equipment and all its enchantments will be permanently lost!
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="bg-gray-800 border-gray-700">Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={confirmDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -7,7 +7,7 @@ import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Swords, Sparkles, BookOpen } from 'lucide-react';
import { Swords, Sparkles, BookOpen, ChevronUp, ChevronDown, ArrowUp, ArrowDown, RefreshCw } from 'lucide-react';
import type { GameState, GameAction } from '@/lib/game/types';
import { ELEMENTS, GUARDIANS, SPELLS_DEF, MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
import { fmt, fmtDec, getFloorElement, calcDamage, canAffordSpellCost } from '@/lib/game/store';
@@ -20,6 +20,8 @@ interface SpireTabProps {
setSpell: (spellId: string) => void;
cancelStudy: () => void;
cancelParallelStudy: () => void;
setClimbDirection: (direction: 'up' | 'down') => void;
changeFloor: (direction: 'up' | 'down') => void;
};
upgradeEffects: ComputedEffects;
maxMana: number;
@@ -45,6 +47,11 @@ export function SpireTab({
const isGuardianFloor = !!GUARDIANS[store.currentFloor];
const currentGuardian = GUARDIANS[store.currentFloor];
const activeSpellDef = SPELLS_DEF[store.activeSpell];
const climbDirection = store.climbDirection || 'up';
const clearedFloors = store.clearedFloors || {};
// Check if current floor is cleared (for respawn indicator)
const isFloorCleared = clearedFloors[store.currentFloor];
const canCastSpell = (spellId: string): boolean => {
const spell = SPELLS_DEF[spellId];
@@ -137,6 +144,64 @@ export function SpireTab({
<Separator className="bg-gray-700" />
{/* Floor Navigation */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">Direction</span>
<div className="flex gap-1">
<Button
variant={climbDirection === 'up' ? 'default' : 'outline'}
size="sm"
className={`h-7 px-2 ${climbDirection === 'up' ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-800/50'}`}
onClick={() => store.setClimbDirection('up')}
>
<ArrowUp className="w-4 h-4 mr-1" />
Up
</Button>
<Button
variant={climbDirection === 'down' ? 'default' : 'outline'}
size="sm"
className={`h-7 px-2 ${climbDirection === 'down' ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-800/50'}`}
onClick={() => store.setClimbDirection('down')}
>
<ArrowDown className="w-4 h-4 mr-1" />
Down
</Button>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 h-8 bg-gray-800/50 hover:bg-gray-700/50 disabled:opacity-50"
disabled={store.currentFloor <= 1}
onClick={() => store.changeFloor('down')}
>
<ChevronDown className="w-4 h-4 mr-1" />
Go Down
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 h-8 bg-gray-800/50 hover:bg-gray-700/50 disabled:opacity-50"
disabled={store.currentFloor >= 100}
onClick={() => store.changeFloor('up')}
>
<ChevronUp className="w-4 h-4 mr-1" />
Go Up
</Button>
</div>
{isFloorCleared && (
<div className="text-xs text-amber-400 text-center">
Floor will respawn when you return
</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>

View File

@@ -2,7 +2,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { GameState, GameAction, StudyTarget, SpellCost, SkillUpgradeChoice, EquipmentSlot, EquipmentInstance, EnchantmentDesign, DesignEffect } from './types';
import type { GameState, GameAction, StudyTarget, SpellCost, SkillUpgradeChoice, EquipmentSlot, EquipmentInstance, EnchantmentDesign, DesignEffect, LootInventory } from './types';
import {
ELEMENTS,
GUARDIANS,
@@ -490,6 +490,11 @@ function makeInitial(overrides: Partial<GameState> = {}): GameState {
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
// Floor Navigation
climbDirection: 'up',
clearedFloors: {},
lastClearedFloor: null,
spells: startSpells,
skills: overrides.skills || {},
@@ -602,6 +607,13 @@ interface GameStore extends GameState, CraftingActions, FamiliarActions {
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
tierUpSkill: (skillId: string) => void;
// Floor Navigation
setClimbDirection: (direction: 'up' | 'down') => void;
changeFloor: (direction: 'up' | 'down') => void;
// Inventory Management
updateLootInventory: (inventory: LootInventory) => void;
// Computed getters
getMaxMana: () => number;
getRegen: () => number;
@@ -967,6 +979,12 @@ export const useGameStore = create<GameStore>()(
if (floorHP <= 0) {
// Floor cleared
const wasGuardian = GUARDIANS[currentFloor];
const clearedFloors = state.clearedFloors;
const climbDirection = state.climbDirection;
// Mark this floor as cleared (needs respawn if we leave and return)
clearedFloors[currentFloor] = true;
const lastClearedFloor = currentFloor;
// ─── Loot Drop System ───
const lootDrops = rollLootDrops(currentFloor, !!wasGuardian, 0);
@@ -1003,11 +1021,22 @@ export const useGameStore = create<GameStore>()(
}
}
currentFloor = currentFloor + 1;
if (currentFloor > 100) {
currentFloor = 100;
}
// Move to next floor based on direction
const nextFloor = climbDirection === 'up'
? Math.min(currentFloor + 1, 100)
: Math.max(currentFloor - 1, 1);
currentFloor = nextFloor;
floorMaxHP = getFloorMaxHP(currentFloor);
// Check if this floor was previously cleared (has enemies respawned?)
// Floors respawn when you leave them and come back
const floorWasCleared = clearedFloors[currentFloor];
if (floorWasCleared) {
// Floor has respawned - reset it but mark as uncleared
delete clearedFloors[currentFloor];
}
floorHP = floorMaxHP;
maxFloorReached = Math.max(maxFloorReached, currentFloor);
@@ -1019,6 +1048,10 @@ export const useGameStore = create<GameStore>()(
// Reset ALL spell progress on floor change
equipmentSpellStates = equipmentSpellStates.map(s => ({ ...s, castProgress: 0 }));
spellState = { ...spellState, castProgress: 0 };
// Update clearedFloors in the state
set((s) => ({ ...s, clearedFloors, lastClearedFloor }));
break; // Exit the while loop - new floor
}
}
@@ -1637,6 +1670,10 @@ export const useGameStore = create<GameStore>()(
});
},
updateLootInventory: (inventory: LootInventory) => {
set({ lootInventory: inventory });
},
startDesigningEnchantment: (name: string, equipmentTypeId: string, effects: DesignEffect[]) => {
const state = get();
@@ -1874,6 +1911,47 @@ export const useGameStore = create<GameStore>()(
if (!instance) return 0;
return instance.totalCapacity - instance.usedCapacity;
},
// ─── Floor Navigation ────────────────────────────────────────────────────────
setClimbDirection: (direction: 'up' | 'down') => {
set({ climbDirection: direction });
},
changeFloor: (direction: 'up' | 'down') => {
const state = get();
const currentFloor = state.currentFloor;
// Calculate next floor
const nextFloor = direction === 'up'
? Math.min(currentFloor + 1, 100)
: Math.max(currentFloor - 1, 1);
// Can't stay on same floor
if (nextFloor === currentFloor) return;
// Mark current floor as cleared (it will respawn when we come back)
const clearedFloors = { ...state.clearedFloors };
clearedFloors[currentFloor] = true;
// Check if next floor was cleared (needs respawn)
const nextFloorCleared = clearedFloors[nextFloor];
if (nextFloorCleared) {
// Respawn the floor
delete clearedFloors[nextFloor];
}
set({
currentFloor: nextFloor,
floorMaxHP: getFloorMaxHP(nextFloor),
floorHP: getFloorMaxHP(nextFloor),
maxFloorReached: Math.max(state.maxFloorReached, nextFloor),
clearedFloors,
climbDirection: direction,
equipmentSpellStates: state.equipmentSpellStates.map(s => ({ ...s, castProgress: 0 })),
log: [`🚶 Moved to floor ${nextFloor}${nextFloorCleared ? ' (respawned)' : ''}.`, ...state.log.slice(0, 49)],
});
},
}),
{
name: 'mana-loop-storage',
@@ -1908,6 +1986,9 @@ export const useGameStore = create<GameStore>()(
activeSpell: state.activeSpell,
currentAction: state.currentAction,
castProgress: state.castProgress,
climbDirection: state.climbDirection,
clearedFloors: state.clearedFloors,
lastClearedFloor: state.lastClearedFloor,
spells: state.spells,
skills: state.skills,
skillProgress: state.skillProgress,
@@ -1928,6 +2009,19 @@ export const useGameStore = create<GameStore>()(
designProgress: state.designProgress,
preparationProgress: state.preparationProgress,
applicationProgress: state.applicationProgress,
// Loot system
lootInventory: state.lootInventory,
lootDropsToday: state.lootDropsToday,
// Achievements
achievements: state.achievements,
totalDamageDealt: state.totalDamageDealt,
totalSpellsCast: state.totalSpellsCast,
totalCraftsCompleted: state.totalCraftsCompleted,
// Familiars
familiars: state.familiars,
activeFamiliarSlots: state.activeFamiliarSlots,
familiarSummonProgress: state.familiarSummonProgress,
totalFamiliarXpEarned: state.totalFamiliarXpEarned,
}),
}
)

View File

@@ -412,6 +412,11 @@ export interface GameState {
activeSpell: string;
currentAction: GameAction;
castProgress: number; // Progress towards next spell cast (0-1)
// Floor Navigation
climbDirection: 'up' | 'down'; // Direction of floor traversal
clearedFloors: Record<number, boolean>; // Floors that have been cleared (need respawn)
lastClearedFloor: number | null; // Last floor that was cleared (for respawn tracking)
// Spells
spells: Record<string, SpellState>;