Fix Sub-Task 1: Spire UI Fixes (Bugs 1, 2, 3)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 5m12s

- Bug 1: Floor health display now reactive (uses Zustand store properly)
- Bug 2: Climb Down button now climbs floor-by-floor, exit only at floor 1
- Bug 3: Redesigned SpireTab as Spire Stats view, moved Enter Spire Mode button to SpireTab, moved activity log to SpireModeUI

Changes:
- Added climbDownFloor() action to store.ts
- Modified exitSpireMode() to only work at floor 1
- Updated SpireTab.tsx: removed Current Floor stat, added Enter Spire Mode button
- Updated page.tsx: Climb Down climbs one floor, added Exit Spire button at floor 1, moved activity log to SpireModeUI
This commit is contained in:
Refactoring Agent
2026-04-27 11:15:54 +02:00
parent 900c0e8fe9
commit 35c69809a1
4 changed files with 301 additions and 208 deletions
+184 -187
View File
@@ -22,6 +22,11 @@ interface SpireTabProps {
simpleMode?: boolean; // When true, only show essential Spire info (for Spire Mode)
}
// Helper to check if player can enter spire mode
const canEnterSpireMode = (store: GameStore): boolean => {
return !store.spireMode; // Can enter if not already in Spire Mode
};
export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
const floorElem = getFloorElement(store.currentFloor);
const floorElemDef = ELEMENTS[floorElem];
@@ -40,7 +45,7 @@ export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
const upgradeEffects = getUnifiedEffects(store);
const totalDPS = getTotalDPS(store, upgradeEffects, floorElem);
const studySpeedMult = 1; // Base study speed
const canCastSpell = (spellId: string): boolean => {
const spell = SPELLS_DEF[spellId];
if (!spell) return false;
@@ -50,168 +55,183 @@ export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
return (
<TooltipProvider>
<div className={`grid gap-4 ${simpleMode ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-2'}`}>
{/* 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' && activeEquipmentSpells.length > 0 ? fmtDec(totalDPS) : '—'}</span>
</div>
</div>
{!simpleMode && (
<>
<Separator className="bg-gray-700" />
{/* Floor Navigation - Direction indicator only */}
<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">
<Badge variant={climbDirection === 'up' ? 'default' : 'outline'}
className={climbDirection === 'up' ? 'bg-green-600' : ''}>
<ChevronUp className="w-3 h-3 mr-1" />
Up
</Badge>
<Badge variant={climbDirection === 'down' ? 'default' : 'outline'}
className={climbDirection === 'down' ? 'bg-blue-600' : ''}>
<ChevronDown className="w-3 h-3 mr-1" />
Down
</Badge>
</div>
</div>
{isFloorCleared && (
<div className="text-xs text-amber-400 text-center flex items-center justify-center gap-1">
<RotateCcw className="w-3 h-3" />
Floor cleared! Advancing...
</div>
)}
{/* Spire Stats View - Only show in normal mode (not simpleMode) */}
{!simpleMode && (
<>
{/* Enter Spire Mode Button */}
<Card className="bg-gray-900/80 border-amber-600/50">
<CardContent className="pt-4">
<Button
className="w-full bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-700 hover:to-orange-700"
size="lg"
onClick={() => store.enterSpireMode()}
disabled={!canEnterSpireMode(store)}
>
<Mountain className="w-5 h-5 mr-2" />
Enter Spire Mode
</Button>
<div className="text-xs text-gray-400 text-center mt-2">
Climb the Spire to face guardians and earn pacts
</div>
<Separator className="bg-gray-700" />
</>
)}
</CardContent>
</Card>
<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 Spells Card - Shows all spells from equipped weapons */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Active Spells ({activeEquipmentSpells.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{activeEquipmentSpells.length > 0 ? (
<div className="space-y-3">
{activeEquipmentSpells.map(({ spellId, equipmentId }) => {
const spellDef = SPELLS_DEF[spellId];
if (!spellDef) return null;
const spellState = store.equipmentSpellStates?.find(
s => s.spellId === spellId && s.sourceEquipment === equipmentId
);
const progress = spellState?.castProgress || 0;
const canCast = canAffordSpellCost(spellDef.cost, store.rawMana, store.elements);
return (
<div key={`${spellId}-${equipmentId}`} className="p-2 rounded border border-gray-700 bg-gray-800/30">
<div className="flex items-center justify-between mb-1">
<div className="text-sm font-semibold game-panel-title" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
{spellDef.name}
{spellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200 text-xs">Basic</Badge>}
{spellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100 text-xs">Legendary</Badge>}
</div>
<span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>
{canCast ? '✓' : '✗'}
</span>
</div>
<div className="text-xs text-gray-400 game-mono mb-1">
{fmt(totalDPS)} DPS
<span style={{ color: getSpellCostColor(spellDef.cost) }}>
{' '}{formatSpellCost(spellDef.cost)}
</span>
{' '} {(spellDef.castSpeed || 1).toFixed(1)}/hr
</div>
{/* Cast progress bar when climbing */}
{store.currentAction === 'climb' && (
<div className="space-y-0.5">
<div className="flex justify-between text-xs text-gray-500">
<span>Cast</span>
<span>{(progress * 100).toFixed(0)}%</span>
</div>
<Progress value={Math.min(100, progress * 100)} className="h-1.5 bg-gray-700" />
</div>
)}
{spellDef.effects && spellDef.effects.length > 0 && (
<div className="flex gap-1 flex-wrap mt-1">
{spellDef.effects.map((eff, i) => (
<Badge key={i} variant="outline" className="text-xs py-0">
{eff.type === 'burn' && `🔥 Burn`}
{eff.type === 'freeze' && `❄️ Freeze`}
{eff.type === 'stun' && `⚡ Stun`}
{eff.type === 'armor_pierce' && `🗡️ Pierce`}
{eff.type === 'buff' && `⬆ Buff`}
{eff.type === 'chain' && `⛓️ Chain`}
{eff.type === 'aoe' && `💥 AOE`}
</Badge>
))}
</div>
)}
</div>
);
})}
{/* Spire Stats Card - Replaces Current Floor stat */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Spire Stats</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.maxFloorReached}</div>
<div className="text-xs text-gray-400">Best Floor</div>
</div>
<div className="p-3 bg-gray-800/50 rounded">
<div className="text-2xl font-bold text-purple-400 game-mono">{store.signedPacts.length}</div>
<div className="text-xs text-gray-400">Pacts Signed</div>
</div>
</div>
<div className="text-sm text-gray-400">
Current Floor: <strong className="text-gray-200">{store.currentFloor}</strong>
</div>
</CardContent>
</Card>
</>
)}
{/* Current Floor Card - Only show in Spire Mode (simpleMode) */}
{simpleMode && (
<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>
) : (
<div className="text-gray-500 text-sm">No spells on equipped weapons. Enchant a staff with spell effects in the Crafting tab.</div>
)}
</CardContent>
</Card>
{/* Summoned Golems Card - Always show in simple mode, conditional in normal mode */}
{(simpleMode || store.golemancy.summonedGolems.length > 0) && (
{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' && activeEquipmentSpells.length > 0 ? fmtDec(totalDPS) : '—'}</span>
</div>
</div>
<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 Spells Card - Only show in Spire Mode (simpleMode) */}
{simpleMode && (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Active Spells ({activeEquipmentSpells.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{activeEquipmentSpells.length > 0 ? (
<div className="space-y-3">
{activeEquipmentSpells.map(({ spellId, equipmentId }) => {
const spellDef = SPELLS_DEF[spellId];
if (!spellDef) return null;
const spellState = store.equipmentSpellStates?.find(
s => s.spellId === spellId && s.sourceEquipment === equipmentId
);
const progress = spellState?.castProgress || 0;
const canCast = canAffordSpellCost(spellDef.cost, store.rawMana, store.elements);
return (
<div key={`${spellId}-${equipmentId}`} className="p-2 rounded border border-gray-700 bg-gray-800/30">
<div className="flex items-center justify-between mb-1">
<div className="text-sm font-semibold game-panel-title" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
{spellDef.name}
{spellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200 text-xs">Basic</Badge>}
{spellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100 text-xs">Legendary</Badge>}
</div>
<span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>
{canCast ? '✓' : '✗'}
</span>
</div>
<div className="text-xs text-gray-400 game-mono mb-1">
{fmt(totalDPS)} DPS
<span style={{ color: getSpellCostColor(spellDef.cost) }}>
{' '}{formatSpellCost(spellDef.cost)}
</span>
{' '} {(spellDef.castSpeed || 1).toFixed(1)}/hr
</div>
{/* Cast progress bar when climbing */}
{store.currentAction === 'climb' && (
<div className="space-y-0.5">
<div className="flex justify-between text-xs text-gray-500">
<span>Cast</span>
<span>{(progress * 100).toFixed(0)}%</span>
</div>
<Progress value={Math.min(100, progress * 100)} className="h-1.5 bg-gray-700" />
</div>
)}
{spellDef.effects && spellDef.effects.length > 0 && (
<div className="flex gap-1 flex-wrap mt-1">
{spellDef.effects.map((eff, i) => (
<Badge key={i} variant="outline" className="text-xs py-0">
{eff.type === 'burn' && `🔥 Burn`}
{eff.type === 'freeze' && `❄️ Freeze`}
{eff.type === 'stun' && `⚡ Stun`}
{eff.type === 'armor_pierce' && `🗡️ Pierce`}
{eff.type === 'buff' && `⬆ Buff`}
{eff.type === 'chain' && `⛓️ Chain`}
{eff.type === 'aoe' && `💥 AOE`}
</Badge>
))}
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-gray-500 text-sm">No spells on equipped weapons. Enchant a staff with spell effects in the Crafting tab.</div>
)}
</CardContent>
</Card>
)}
{/* Summoned Golems Card - Show in Spire Mode (simpleMode) or if have golems */}
{simpleMode && store.golemancy.summonedGolems.length > 0 && (
<Card className="bg-gray-900/80 border-amber-600/50">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
@@ -243,7 +263,7 @@ export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
</div>
<div className="text-xs text-gray-400 game-mono">
{damage} DMG {attackSpeed.toFixed(1)}/hr
🛡 {Math.floor(golemDef.armorPierce * 100)}% Pierce
🗡 {Math.floor(golemDef.armorPierce * 100)}% Pierce
</div>
{/* Attack progress bar when climbing */}
{store.currentAction === 'climb' && summoned.attackProgress > 0 && (
@@ -261,7 +281,7 @@ export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
</CardContent>
</Card>
)}
{/* Current Study (if any) - Only show in normal mode */}
{!simpleMode && store.currentStudyTarget && (
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
@@ -272,7 +292,7 @@ export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
studySpeedMult={studySpeedMult}
cancelStudy={store.cancelStudy}
/>
{/* Parallel Study Progress */}
{store.parallelStudyTarget && (
<div className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
@@ -303,7 +323,7 @@ export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
</CardContent>
</Card>
)}
{/* Crafting Progress (if any) - Only show in normal mode */}
{!simpleMode && (store.designProgress || store.preparationProgress || store.applicationProgress) && (
<Card className="bg-gray-900/80 border-cyan-600/50 lg:col-span-2">
@@ -323,29 +343,6 @@ export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
</CardContent>
</Card>
)}
{/* Activity Log - Only show in normal mode */}
{!simpleMode && (
<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>
</TooltipProvider>
);