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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user