feat: Recreate Spire Combat Page — full spire climbing experience
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s

- Add guardian-encounters.ts: Extended guardian definitions for all mana types (compound, exotic, combo) with dynamic name generation
- Add spire-utils.ts: Spire-specific utilities (room generation, enemy stat scaling, insight calculation)
- Add enemy-generator.ts: Enemy generation with combinable modifiers (mage, shield, armored, swarm, agile)
- Add SpireCombatPage/ directory with modular sub-components:
  - SpireHeader.tsx: Floor info, climb controls, exit button, HP/room progress bars
  - RoomDisplay.tsx: Current room info with enemies, barriers, armor, dodge stats
  - SpireCombatControls.tsx: Spell selection panel, golem status panel
  - SpireActivityLog.tsx: Combat activity log
  - SpireManaDisplay.tsx: Compact mana display with elemental pools
- Modify page.tsx: Conditionally render SpireCombatPage when spireMode is true
- Add comprehensive tests (49 tests) for spire utilities, guardian encounters, and enemy generation
This commit is contained in:
2026-05-20 09:28:05 +02:00
parent 1c7fc8c551
commit 7d56fc368f
17 changed files with 2004 additions and 5 deletions
@@ -0,0 +1,190 @@
'use client';
import type { FloorState, EnemyState } from '@/lib/game/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { getSpireRoomTypeDisplay } from '@/lib/game/utils/spire-utils';
import { getModifierDisplay, getModifierDescription } from '@/lib/game/utils/enemy-generator';
import { ELEMENTS } from '@/lib/game/constants';
import { fmt } from '@/lib/game/stores';
interface RoomDisplayProps {
floorState: FloorState;
floor: number;
}
function EnemyRow({ enemy, floor }: { enemy: EnemyState; floor: number }) {
const elemDef = ELEMENTS[enemy.element];
const hpPercent = enemy.maxHP > 0 ? (enemy.hp / enemy.maxHP) * 100 : 0;
const barrierVal = enemy.barrier ?? 0;
const hasBarrier = barrierVal > 0;
const barrierPercent = hasBarrier ? barrierVal * 100 : 0;
return (
<div className="space-y-1 p-2 bg-gray-800/50 rounded border border-gray-700/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{elemDef && (
<span style={{ color: elemDef.color }}>{elemDef.sym}</span>
)}
<span className="text-sm font-medium text-gray-200">{enemy.name}</span>
</div>
<span className="text-xs text-gray-500">
{fmt(enemy.hp)} / {fmt(enemy.maxHP)}
</span>
</div>
{/* HP bar */}
<div className="relative">
<Progress
value={hpPercent}
className="h-2 bg-gray-700"
style={{ '--progress-bg': elemDef?.color || '#EF4444' } as React.CSSProperties}
/>
{hasBarrier && (
<div
className="absolute top-0 left-0 h-2 rounded-full bg-blue-400/40"
style={{ width: `${barrierPercent}%` }}
/>
)}
</div>
{/* Enemy stats */}
<div className="flex items-center gap-2 flex-wrap">
{enemy.armor > 0 && (
<Badge variant="outline" className="text-[10px] border-amber-600 text-amber-400">
{Math.round(enemy.armor * 100)}% armor
</Badge>
)}
{enemy.dodgeChance > 0 && (
<Badge variant="outline" className="text-[10px] border-green-600 text-green-400">
💨 {Math.round(enemy.dodgeChance * 100)}% dodge
</Badge>
)}
{hasBarrier && (
<Badge variant="outline" className="text-[10px] border-blue-600 text-blue-400">
🛡 Barrier
</Badge>
)}
</div>
</div>
);
}
export function RoomDisplay({ floorState, floor }: RoomDisplayProps) {
const roomDisplay = getSpireRoomTypeDisplay(floorState.roomType as any);
// Handle special room types (cast to string for extended types)
const rt = floorState.roomType as string;
if (rt === 'recovery') {
const progress = (floorState as any).puzzleProgress || 0;
const required = (floorState as any).puzzleRequired || 1;
return (
<Card className="bg-gray-900/80 border-green-800/40">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#10B981' }}>
💚 Recovery Room
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-gray-400 mb-2">
Rest and recover. Spend 1 hour to gain 5x mana regen & conversion rates.
</p>
<Progress
value={(progress / required) * 100}
className="h-2 bg-gray-800"
style={{ '--progress-bg': '#10B981' } as React.CSSProperties}
/>
</CardContent>
</Card>
);
}
if (rt === 'library') {
return (
<Card className="bg-gray-900/80 border-indigo-800/40">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#6366F1' }}>
📚 Ancient Library
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-gray-400">
Study a random discipline at 10x XP speed (no mana cost). Spend 1 hour to gain knowledge.
</p>
</CardContent>
</Card>
);
}
if (rt === 'treasure') {
return (
<Card className="bg-gray-900/80 border-amber-800/40">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#F59E0B' }}>
💎 Treasure Room
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-gray-400">
A hidden cache of resources awaits. Claim your reward!
</p>
</CardContent>
</Card>
);
}
if (floorState.roomType === 'puzzle') {
const puzzleId = floorState.puzzleId || 'unknown';
const progress = floorState.puzzleProgress || 0;
const required = floorState.puzzleRequired || 1;
return (
<Card className="bg-gray-900/80 border-purple-800/40">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#8B5CF6' }}>
🧩 Puzzle Room {puzzleId.replace(/_/g, ' ')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-gray-400 mb-2">
Solve the puzzle. Higher attunement levels speed up progress.
</p>
<Progress
value={(progress / required) * 100}
className="h-2 bg-gray-800"
style={{ '--progress-bg': '#8B5CF6' } as React.CSSProperties}
/>
<div className="text-xs text-gray-500 mt-1">
Progress: {Math.round(progress * 100)} / {Math.round(required * 100)}
</div>
</CardContent>
</Card>
);
}
// Combat rooms (combat, swarm, speed, guardian)
const enemies = floorState.enemies || [];
const isGuardian = floorState.roomType === 'guardian';
return (
<Card className={`bg-gray-900/80 ${isGuardian ? 'border-red-800/40' : 'border-gray-700'}`}>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: roomDisplay.color }}>
{roomDisplay.icon} {roomDisplay.label}
{isGuardian && <Badge className="bg-red-900/50 text-red-300 text-xs">BOSS</Badge>}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{enemies.length === 0 ? (
<div className="text-xs text-gray-500 italic">Room cleared!</div>
) : (
enemies.map((enemy) => (
<EnemyRow key={enemy.id} enemy={enemy} floor={floor} />
))
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,43 @@
'use client';
import type { ActivityLogEntry } from '@/lib/game/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
interface SpireActivityLogProps {
activityLog: ActivityLogEntry[];
maxEntries?: number;
}
export function SpireActivityLog({ activityLog, maxEntries = 30 }: SpireActivityLogProps) {
const entries = activityLog.slice(0, maxEntries);
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-gray-400">📜 Activity Log</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-48">
{entries.length === 0 ? (
<div className="text-xs text-gray-500 italic">No activity yet.</div>
) : (
<div className="space-y-1">
{entries.map((entry) => (
<div
key={entry.id}
className="text-xs text-gray-300 border-b border-gray-800 pb-1 last:border-0"
>
<span className="text-gray-600 mr-1">
[{entry.eventType}]
</span>
{entry.message}
</div>
))}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
);
}
@@ -0,0 +1,133 @@
'use client';
import { useCombatStore, useManaStore, canAffordSpellCost, fmt } from '@/lib/game/stores';
import { SPELLS_DEF, ELEMENTS } from '@/lib/game/constants';
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 { GOLEMS_DEF } from '@/lib/game/data/golems';
interface SpireCombatControlsProps {
castProgress: number;
}
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 || '?'}`;
}
export function SpireCombatControls({ castProgress }: SpireCombatControlsProps) {
const spells = useCombatStore((s) => s.spells);
const activeSpell = useCombatStore((s) => s.activeSpell);
const setSpell = useCombatStore((s) => s.setSpell);
const golemancy = useCombatStore((s) => s.golemancy);
const rawMana = useManaStore((s) => s.rawMana);
const elements = useManaStore((s) => s.elements);
const learnedSpells = Object.entries(spells)
.filter(([, state]) => state?.learned)
.map(([id]) => id);
const summonedGolems = golemancy.summonedGolems || [];
return (
<div className="space-y-4">
{/* Active Spell Panel */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-amber-400">🔮 Active Spells</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{/* Cast progress */}
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-400">Cast Progress</span>
<span className="text-gray-500">{Math.round(castProgress * 100)}%</span>
</div>
<Progress
value={castProgress * 100}
className="h-2 bg-gray-800"
style={{ '--progress-bg': '#F59E0B' } as React.CSSProperties}
/>
</div>
{/* Spell selection */}
<div className="grid grid-cols-1 gap-1.5 max-h-48 overflow-y-auto">
{learnedSpells.map((spellId) => {
const def = SPELLS_DEF[spellId];
if (!def) return null;
const isActive = activeSpell === spellId;
const canCast = canAffordSpellCost(def.cost, rawMana, elements);
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
return (
<button
key={spellId}
onClick={() => setSpell(spellId)}
className={`flex items-center justify-between p-2 rounded text-xs transition-colors ${
isActive
? 'bg-amber-900/40 border border-amber-600'
: canCast
? 'bg-gray-800/50 border border-gray-700 hover:border-gray-500'
: 'bg-gray-800/30 border border-gray-800 opacity-50'
}`}
>
<div className="flex items-center gap-2">
{elemDef && <span>{elemDef.sym}</span>}
<span className={isActive ? 'text-amber-300 font-medium' : 'text-gray-300'}>
{def.name}
</span>
</div>
<span className="text-gray-500">
{formatSpellCost(def.cost)}
</span>
</button>
);
})}
</div>
{learnedSpells.length === 0 && (
<div className="text-xs text-gray-500 italic">No spells learned yet.</div>
)}
</CardContent>
</Card>
{/* Golem Status Panel */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-blue-400">🗿 Golems</CardTitle>
</CardHeader>
<CardContent>
{summonedGolems.length === 0 ? (
<div className="text-xs text-gray-500 italic">No golems summoned.</div>
) : (
<div className="space-y-2">
{summonedGolems.map((sg) => {
const golemDef = GOLEMS_DEF[sg.golemId];
if (!golemDef) return null;
const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
return (
<div
key={sg.golemId}
className="flex items-center justify-between p-2 bg-gray-800/50 rounded border border-gray-700/50"
>
<div className="flex items-center gap-2">
<span style={{ color: elemColor }}></span>
<span className="text-xs text-gray-200">{golemDef.name}</span>
</div>
<div className="text-xs text-gray-500">
{golemDef.damage} dmg · {golemDef.attackSpeed}/h
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,237 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useCombatStore, useManaStore, usePrestigeStore, fmt, computeMaxMana, computeRegen } from '@/lib/game/stores';
import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects';
import { getUnifiedEffects } from '@/lib/game/effects';
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
import { useCraftingStore } from '@/lib/game/stores/craftingStore';
import { GUARDIANS } from '@/lib/game/constants';
import { getExtendedGuardian, isGuardianFloor } from '@/lib/game/data/guardian-encounters';
import { getRoomsForFloor, generateSpireFloorState, calcInsight } from '@/lib/game/utils/spire-utils';
import { SpireHeader } from './SpireHeader';
import { RoomDisplay } from './RoomDisplay';
import { SpireCombatControls } from './SpireCombatControls';
import { SpireActivityLog } from './SpireActivityLog';
import { SpireManaDisplay } from './SpireManaDisplay';
export function SpireCombatPage() {
const [mounted, setMounted] = useState(false);
const [roomsCleared, setRoomsCleared] = useState(0);
// Combat store
const {
currentFloor,
floorHP,
floorMaxHP,
castProgress,
clearedFloors,
isDescending,
currentRoom,
activityLog,
setCurrentRoom,
setFloorHP,
setClearedFloor,
climbDownFloor,
exitSpireMode,
startClimbUp,
startClimbDown,
addActivityLog,
processCombatTick,
setAction,
} = useCombatStore(useShallow((s) => ({
currentFloor: s.currentFloor,
floorHP: s.floorHP,
floorMaxHP: s.floorMaxHP,
castProgress: s.castProgress,
clearedFloors: s.clearedFloors,
isDescending: s.isDescending,
currentRoom: s.currentRoom,
activityLog: s.activityLog,
setCurrentRoom: s.setCurrentRoom,
setFloorHP: s.setFloorHP,
setClearedFloor: s.setClearedFloor,
climbDownFloor: s.climbDownFloor,
exitSpireMode: s.exitSpireMode,
startClimbUp: s.startClimbUp,
startClimbDown: s.startClimbDown,
addActivityLog: s.addActivityLog,
processCombatTick: s.processCombatTick,
setAction: s.setAction,
})));
// Mana store
const { rawMana, elements } = useManaStore(useShallow((s) => ({
rawMana: s.rawMana,
elements: s.elements,
})));
// Prestige store
const { prestigeUpgrades, insight } = usePrestigeStore(useShallow((s) => ({
prestigeUpgrades: s.prestigeUpgrades,
insight: s.insight,
})));
// Crafting store for equipment effects
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
// Discipline effects
const disciplineStoreState = useDisciplineStore();
const disciplineEffects = computeDisciplineEffects(disciplineStoreState as any);
// Compute derived stats
const upgradeEffects = getUnifiedEffects({
skillUpgrades: {},
skillTiers: {},
equippedInstances,
equipmentInstances,
});
const maxMana = computeMaxMana({
skills: {},
prestigeUpgrades,
skillUpgrades: {},
skillTiers: {},
}, upgradeEffects as any, disciplineEffects);
const baseRegen = computeRegen({
skills: {},
prestigeUpgrades,
skillUpgrades: {},
skillTiers: {},
attunements: {},
}, upgradeEffects as any, disciplineEffects);
// Total rooms for current floor
const totalRooms = useMemo(() => getRoomsForFloor(currentFloor), [currentFloor]);
// Initialize room on floor change
useEffect(() => {
setMounted(true);
setRoomsCleared(0);
const newRoom = generateSpireFloorState(currentFloor, 0, totalRooms);
setCurrentRoom(newRoom);
setAction('climb');
}, [currentFloor, totalRooms, setCurrentRoom, setAction]);
// Handle room/floor transitions
const handleRoomCleared = () => {
const nextRoomIndex = roomsCleared + 1;
if (nextRoomIndex >= totalRooms) {
// Floor cleared
const wasGuardian = isGuardianFloor(currentFloor);
setClearedFloor(currentFloor, true);
if (wasGuardian) {
const guardian = GUARDIANS[currentFloor] || getExtendedGuardian(currentFloor);
if (guardian) {
addActivityLog('enemy_defeated', `⚔️ ${guardian.name} defeated!`, {
enemyName: guardian.name,
floor: currentFloor,
});
}
}
addActivityLog('floor_cleared', `🏰 Floor ${currentFloor} cleared!`, {
floor: currentFloor,
});
// Auto-advance to next floor
const newFloor = currentFloor + 1;
const newTotalRooms = getRoomsForFloor(newFloor);
const newRoom = generateSpireFloorState(newFloor, 0, newTotalRooms);
setCurrentRoom(newRoom);
setFloorHP(floorMaxHP); // Reset HP for new floor
setClearedFloor(currentFloor, true);
setRoomsCleared(0);
} else {
// Next room on same floor
const newRoom = generateSpireFloorState(currentFloor, nextRoomIndex, totalRooms);
setCurrentRoom(newRoom);
setRoomsCleared(nextRoomIndex);
}
};
// Handle climb up
const handleClimbUp = () => {
startClimbUp();
addActivityLog('floor_transition', `⬆️ Climbing to floor ${currentFloor + 1}...`);
};
// Handle climb down
const handleClimbDown = () => {
if (currentFloor <= 1) return;
startClimbDown();
climbDownFloor();
setRoomsCleared(0);
addActivityLog('floor_transition', `⬇️ Descending to floor ${currentFloor - 1}...`);
};
// Handle exit spire
const handleExitSpire = () => {
exitSpireMode();
addActivityLog('floor_transition', '🚪 Exited the Spire.');
};
if (!mounted) {
return (
<div className="flex items-center justify-center min-h-screen text-gray-500">
Loading spire...
</div>
);
}
return (
<div className="min-h-screen bg-gray-950 flex flex-col">
{/* Compact header */}
<header className="sticky top-0 z-50 bg-gray-900/95 border-b border-gray-800 px-4 py-2">
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold text-amber-400 tracking-wider">🏔 SPIRE</h1>
<div className="text-xs text-gray-500">
Floor {currentFloor} · Insight: {fmt(insight)}
</div>
</div>
</header>
{/* Main content */}
<main className="flex-1 p-4 space-y-4 max-w-7xl mx-auto w-full">
{/* Top section: Header + Mana */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2">
<SpireHeader
currentFloor={currentFloor}
floorHP={floorHP}
floorMaxHP={floorMaxHP}
roomsCleared={roomsCleared}
totalRooms={totalRooms}
onClimbUp={handleClimbUp}
onClimbDown={handleClimbDown}
onExitSpire={handleExitSpire}
isDescending={isDescending}
/>
</div>
<div>
<SpireManaDisplay maxMana={maxMana} effectiveRegen={baseRegen} />
</div>
</div>
{/* Middle section: Room + Controls */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2">
<RoomDisplay floorState={currentRoom} floor={currentFloor} />
</div>
<div>
<SpireCombatControls castProgress={castProgress} />
</div>
</div>
{/* Bottom: Activity Log */}
<SpireActivityLog activityLog={activityLog} maxEntries={20} />
</main>
</div>
);
}
@@ -0,0 +1,128 @@
'use client';
import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Mountain, ArrowUp, ArrowDown, LogOut } from 'lucide-react';
import { GUARDIANS } from '@/lib/game/constants';
import { isGuardianFloor, getExtendedGuardian } from '@/lib/game/data/guardian-encounters';
interface SpireHeaderProps {
currentFloor: number;
floorHP: number;
floorMaxHP: number;
roomsCleared: number;
totalRooms: number;
onClimbUp: () => void;
onClimbDown: () => void;
onExitSpire: () => void;
isDescending: boolean;
}
export function SpireHeader({
currentFloor,
floorHP,
floorMaxHP,
roomsCleared,
totalRooms,
onClimbUp,
onClimbDown,
onExitSpire,
isDescending,
}: SpireHeaderProps) {
const maxFloorReached = useCombatStore((s) => s.maxFloorReached);
const { insight } = usePrestigeStore((s) => ({ insight: s.insight }));
const guardian = GUARDIANS[currentFloor] || getExtendedGuardian(currentFloor);
const isGuardian = isGuardianFloor(currentFloor);
const hpPercent = floorMaxHP > 0 ? (floorHP / floorMaxHP) * 100 : 100;
const roomProgress = totalRooms > 0 ? ((roomsCleared) / totalRooms) * 100 : 0;
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardContent className="py-3 space-y-3">
{/* Top row: Floor info + controls */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Mountain className="w-6 h-6 text-amber-400" />
<div>
<div className="text-xl font-bold text-amber-400">
Floor {currentFloor}
</div>
<div className="text-xs text-gray-400">
Max: {maxFloorReached} · Insight: {fmt(insight)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={onClimbUp}
disabled={isDescending}
className="border-gray-600 hover:border-amber-500"
>
<ArrowUp className="w-4 h-4 mr-1" />
Climb Up
</Button>
<Button
size="sm"
variant="outline"
onClick={onClimbDown}
disabled={currentFloor <= 1 || isDescending}
className="border-gray-600 hover:border-amber-500"
>
<ArrowDown className="w-4 h-4 mr-1" />
Climb Down
</Button>
{currentFloor === 1 && (
<Button
size="sm"
variant="outline"
onClick={onExitSpire}
className="border-green-600 text-green-400 hover:bg-green-900/30"
>
<LogOut className="w-4 h-4 mr-1" />
Exit Spire
</Button>
)}
</div>
</div>
{/* Floor HP bar */}
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className={isGuardian ? 'text-red-400 font-semibold' : 'text-gray-400'}>
{isGuardian && guardian ? `🛡️ ${guardian.name}` : 'Floor HP'}
</span>
<span className="text-gray-500">
{fmt(floorHP)} / {fmt(floorMaxHP)}
</span>
</div>
<Progress
value={hpPercent}
className="h-3 bg-gray-800"
style={{
'--progress-bg': isGuardian ? '#EF4444' : '#F59E0B',
} as React.CSSProperties}
/>
</div>
{/* Room progress */}
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-400">Rooms Cleared</span>
<span className="text-gray-500">{roomsCleared} / {totalRooms}</span>
</div>
<Progress
value={roomProgress}
className="h-2 bg-gray-800"
style={{ '--progress-bg': '#6366F1' } as React.CSSProperties}
/>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,72 @@
'use client';
import { useManaStore, fmt, fmtDec } from '@/lib/game/stores';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { ELEMENTS } from '@/lib/game/constants';
interface SpireManaDisplayProps {
maxMana: number;
effectiveRegen: number;
}
export function SpireManaDisplay({ maxMana, effectiveRegen }: SpireManaDisplayProps) {
const rawMana = useManaStore((s) => s.rawMana);
const elements = useManaStore((s) => s.elements);
const unlockedElements = Object.entries(elements)
.filter(([, state]) => state.unlocked && state.current > 0)
.sort((a, b) => b[1].current - a[1].current)
.slice(0, 6); // Show max 6 in compact view
const manaPercent = maxMana > 0 ? (rawMana / maxMana) * 100 : 0;
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardContent className="py-3 space-y-2">
{/* Raw Mana */}
<div>
<div className="flex items-baseline gap-1">
<span className="text-xl font-bold game-mono" style={{ color: '#60A5FA' }}>
{fmt(rawMana)}
</span>
<span className="text-xs text-gray-500">/ {fmt(maxMana)}</span>
</div>
<div className="text-[10px] text-gray-500">
+{fmtDec(effectiveRegen)}/hr
</div>
</div>
<Progress
value={manaPercent}
className="h-1.5 bg-gray-800"
style={{ '--progress-bg': '#60A5FA' } as React.CSSProperties}
/>
{/* Elemental pools (compact) */}
{unlockedElements.length > 0 && (
<div className="grid grid-cols-3 gap-1">
{unlockedElements.map(([id, state]) => {
const elem = ELEMENTS[id];
if (!elem) return null;
const pct = state.max > 0 ? (state.current / state.max) * 100 : 0;
return (
<div key={id} className="text-center">
<div className="text-[10px]" style={{ color: elem.color }}>
{elem.sym} {fmt(state.current)}
</div>
<div className="h-1 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${pct}%`, backgroundColor: elem.color }}
/>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,8 @@
// ─── SpireCombatPage Barrel ────────────────────────────────────────────────────
export { SpireCombatPage } from './SpireCombatPage';
export { SpireHeader } from './SpireHeader';
export { RoomDisplay } from './RoomDisplay';
export { SpireCombatControls } from './SpireCombatControls';
export { SpireActivityLog } from './SpireActivityLog';
export { SpireManaDisplay } from './SpireManaDisplay';