feat: Recreate Spire Combat Page — full spire climbing experience
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
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:
@@ -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';
|
||||
Reference in New Issue
Block a user