refactor: extract components from SpireTab.tsx to reduce below 400 lines
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m11s

This commit is contained in:
Refactoring Agent
2026-05-01 17:02:32 +02:00
parent f0ab3ca3ce
commit dc38445225
9 changed files with 1148 additions and 564 deletions
+68
View File
@@ -0,0 +1,68 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import type { ActivityLogEntry } from '@/lib/game/types';
interface ActivityLogProps {
activityLog?: ActivityLogEntry[];
}
export function ActivityLog({ activityLog }: ActivityLogProps) {
const entries = activityLog || [];
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Activity Log</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-48">
<div className="space-y-1">
{entries.slice(0, 50).map((entry, i) => {
const isLatest = i === 0;
const color = getEventStyle(entry.eventType);
return (
<div
key={entry.id}
className={`text-xs ${isLatest ? 'text-gray-200 font-semibold' : color}`}
>
{entry.message}
</div>
);
})}
{entries.length === 0 && (
<div className="text-xs text-gray-500 italic">No activity yet...</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}
function getEventStyle(eventType: string): string {
switch (eventType) {
case 'enemy_defeated':
case 'floor_cleared':
return 'text-green-400';
case 'damage_dealt':
return 'text-red-400';
case 'dodge':
return 'text-yellow-400';
case 'armor_proc':
return 'text-blue-400';
case 'special_effect':
return 'text-purple-400';
case 'floor_transition':
return 'text-cyan-400';
case 'spell_cast':
return 'text-amber-400';
case 'golem_attack':
return 'text-orange-400';
case 'puzzle_solved':
return 'text-pink-400';
default:
return 'text-gray-300';
}
}
@@ -0,0 +1,164 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { Zap, Shield, ShieldCheck, Wind, Heart, Mountain, BookOpen } from 'lucide-react';
import { ELEMENTS } from '@/lib/game/constants';
import { GOLEMS_DEF, getGolemDamage, getGolemAttackSpeed } from '@/lib/game/data/golems';
import type { CombatStatsPanelProps } from '@/lib/game/types';
export function CombatStatsPanel({
activeEquipmentSpells,
store,
totalDPS,
calcDamage,
formatSpellCost,
getSpellCostColor,
SPELLS_DEF,
upgradeEffects,
canCastSpell,
studySpeedMult,
storeCurrentAction,
}: CombatStatsPanelProps) {
const activeGolems = store.golemancy.summonedGolems;
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Combat Stats</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-sm text-gray-400">
Total DPS: <span className="text-amber-400 font-semibold">{storeCurrentAction === 'climb' && activeEquipmentSpells.length > 0 ? fmtDec(totalDPS) : '—'}</span>
</div>
{activeEquipmentSpells.length > 0 && (
<div className="space-y-2">
<div className="text-xs text-gray-500 font-semibold uppercase tracking-wider">Active Spells</div>
{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 = canCastSpell(spellId);
return (
<div key={`${spellId}-${equipmentId}`} className="p-2 bg-gray-800/50 rounded border border-gray-700">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold" 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>}
</span>
<span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>
{canCast ? '✓' : '✗'}
</span>
</div>
<div className="text-xs text-gray-400 mb-1">
{fmt(calcDamage(store, spellId))} dmg {' '}
<span style={{ color: getSpellCostColor(spellDef.cost) }}>
{formatSpellCost(spellDef.cost)}
</span>
{' '} {fmt(Math.floor(calcDamage(store, spellId) * (spellDef.castSpeed || 1)))} dmg/hr
</div>
{storeCurrentAction === '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>
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.min(100, progress * 100)}%`,
background: `linear-gradient(90deg, ${spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color}99, ${spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color})`,
}}
/>
</div>
</div>
)}
</div>
);
})}
</div>
)}
{activeGolems.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-gray-500 font-semibold uppercase tracking-wider">
<Mountain className="w-3 h-3" />
Active Golems
</div>
{activeGolems.map((summoned) => {
const golemDef = GOLEMS_DEF[summoned.golemId];
if (!golemDef) return null;
const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
const damage = getGolemDamage(summoned.golemId, store.skills);
const attackSpeed = getGolemAttackSpeed(summoned.golemId, store.skills);
return (
<div key={summoned.golemId} className="p-2 bg-gray-800/50 rounded border border-gray-700">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Mountain className="w-3 h-3" style={{ color: elemColor }} />
<span className="text-xs font-semibold" style={{ color: elemColor }}>
{golemDef.name}
</span>
</div>
{golemDef.isAoe && (
<Badge variant="outline" className="text-xs py-0">AOE {golemDef.aoeTargets}</Badge>
)}
</div>
<div className="text-xs text-gray-400">
{damage} DMG {attackSpeed.toFixed(1)}/hr
</div>
{storeCurrentAction === 'climb' && summoned.attackProgress > 0 && (
<div className="space-y-0.5 mt-1">
<div className="flex justify-between text-xs text-gray-500">
<span>Attack</span>
<span>{Math.min(100, (summoned.attackProgress * 100)).toFixed(0)}%</span>
</div>
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.min(100, summoned.attackProgress * 100)}%`,
background: `linear-gradient(90deg, ${elemColor}99, ${elemColor})`,
}}
/>
</div>
</div>
)}
</div>
);
})}
</div>
)}
<div className="pt-2 border-t border-gray-700">
<div className="text-xs text-gray-500 mb-1">Study Speed: {Math.round(studySpeedMult * 100)}%</div>
</div>
</CardContent>
</Card>
);
}
function fmt(value: number): string {
if (value >= 1e12) return (value / 1e12).toFixed(2) + 't';
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'b';
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'm';
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'k';
return value.toFixed(0);
}
function fmtDec(value: number): string {
if (value >= 1e12) return (value / 1e12).toFixed(2) + 't';
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'b';
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'm';
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'k';
return value.toFixed(0);
}
+191
View File
@@ -0,0 +1,191 @@
'use client';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ChevronUp, ChevronDown, Mountain, Shield, Skull, Heart, Wind, ShieldCheck } from 'lucide-react';
import { ELEMENTS } from '@/lib/game/constants';
import type { FloorControlsProps } from '@/lib/game/types';
const ROOM_TYPE_CONFIG: Record<string, { label: string; icon: string; color: string }> = {
combat: { label: 'Combat', icon: '⚔️', color: '#EF4444' },
swarm: { label: 'Swarm', icon: '🐝', color: '#F59E0B' },
speed: { label: 'Speed', icon: '💨', color: '#3B82F6' },
guardian: { label: 'Guardian', icon: '🛡️', color: '#EF4444' },
puzzle: { label: 'Puzzle', icon: '🧩', color: '#8B5CF6' },
};
export function FloorControls({
store,
climbDirection,
isGuardianFloor,
currentRoom,
currentGuardian,
isFloorCleared,
floorElemDef,
roomType,
roomConfig,
activeEquipmentSpells,
upgradeEffects,
floorElem,
totalDPS,
getEnemyName,
calcDamage,
SPELLS_DEF,
canCastSpell,
storeCurrentAction,
handleClimb,
formatSpellCost,
getSpellCostColor,
}: FloorControlsProps) {
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Floor Navigation</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleClimb('up')}
disabled={storeCurrentAction === 'climb' || isFloorCleared || store.maxFloorReached >= 100}
className="border-gray-600 hover:bg-gray-800"
>
<ChevronUp className="w-4 h-4 mr-1" />
Climb Up
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleClimb('down')}
disabled={storeCurrentAction === 'climb' || store.currentFloor <= 1}
className="border-gray-600 hover:bg-gray-800"
>
<ChevronDown className="w-4 h-4 mr-1" />
Climb Down
</Button>
</div>
{storeCurrentAction === 'climb' && (
<div className="p-3 bg-amber-900/30 rounded border border-amber-700/50">
<div className="flex items-center gap-2 mb-2">
<Mountain className="w-4 h-4 text-amber-400" />
<span className="text-sm font-semibold text-amber-400">
Climbing {climbDirection === 'up' ? 'Up' : 'Down'}
</span>
{isGuardianFloor && (
<Badge className="bg-red-900/50 text-red-300 border-red-600">GUARDIAN</Badge>
)}
</div>
{currentGuardian && (
<div className="text-xs mb-2 p-2 bg-gray-800/50 rounded">
<div className="flex items-center gap-2">
<Skull className="w-3 h-3 text-red-400" />
<span className="font-semibold" style={{ color: floorElemDef?.color }}>
{currentGuardian.name}
</span>
</div>
</div>
)}
{!(roomType === 'swarm' || roomType === 'puzzle') && (
<div className="space-y-1 mb-2">
<div className="h-1.5 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 8px ${floorElemDef?.glow}`,
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-400">
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
<span className="text-amber-400">
DPS: {activeEquipmentSpells.length > 0 ? fmtDec(totalDPS) : '—'}
</span>
</div>
</div>
)}
{activeEquipmentSpells.length > 0 ? (
<div className="space-y-2">
{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 = canCastSpell(spellId);
return (
<div key={`${spellId}-${equipmentId}`} className="p-2 bg-gray-800/50 rounded">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
{spellDef.name}
</span>
<span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>
{canCast ? '✓' : '✗'}
</span>
</div>
<div className="text-xs text-gray-400 mb-1">
{fmt(calcDamage(store, spellId))} dmg {' '}
<span style={{ color: getSpellCostColor(spellDef.cost) }}>
{formatSpellCost(spellDef.cost)}
</span>
</div>
<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>
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.min(100, progress * 100)}%`,
background: `linear-gradient(90deg, ${spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color}99, ${spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color})`,
}}
/>
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="text-xs text-gray-500 italic">No active spells. Equip staves with spell effects.</div>
)}
</div>
)}
{storeCurrentAction !== 'climb' && (
<div className="text-xs text-gray-500 text-center">
Click Climb Up/Down to begin climbing
</div>
)}
</CardContent>
</Card>
);
}
function fmt(value: number): string {
if (value >= 1e12) return (value / 1e12).toFixed(2) + 't';
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'b';
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'm';
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'k';
return value.toFixed(0);
}
function fmtDec(value: number): string {
if (value >= 1e12) return (value / 1e12).toFixed(2) + 't';
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'b';
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'm';
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'k';
return value.toFixed(0);
}
@@ -0,0 +1,53 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { ELEMENTS } from '@/lib/game/constants';
import { GUARDIANS } from '@/lib/game/constants';
interface GuardianPanelProps {
currentFloor: number;
floorElemDef: any;
}
export function GuardianPanel({ currentFloor, floorElemDef }: GuardianPanelProps) {
const guardian = GUARDIANS[currentFloor];
if (!guardian) return null;
return (
<div className="p-3 bg-red-900/20 rounded border border-red-700/50 mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg"></span>
<span className="text-sm font-semibold" style={{ color: floorElemDef?.color }}>
{guardian.name}
</span>
</div>
<div className="space-y-1 text-xs text-gray-300">
<div>Power: <strong className="text-red-400">{fmt(guardian.power)}</strong></div>
{guardian.armor > 0 && (
<div>Armor: <strong>{(guardian.armor * 100).toFixed(0)}%</strong></div>
)}
{guardian.effects && guardian.effects.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{guardian.effects.map((eff: any, i: number) => (
<Badge key={i} variant="outline" className="text-xs py-0">
{eff.type === 'burn' && `🔥 Burn ${(eff.value * 100).toFixed(0)}%`}
{eff.type === 'armor_pierce' && `🗡️ Pierce ${(eff.value * 100).toFixed(0)}%`}
</Badge>
))}
</div>
)}
</div>
</div>
);
}
function fmt(value: number): string {
if (value >= 1e12) return (value / 1e12).toFixed(2) + 't';
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'b';
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'm';
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'k';
return value.toFixed(0);
}
+194
View File
@@ -0,0 +1,194 @@
'use client';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Shield, Wind, Heart, ShieldCheck, Skull } from 'lucide-react';
import type { RoomDisplayProps } from '@/lib/game/types';
import { ELEMENTS } from '@/lib/game/constants';
const ROOM_TYPE_CONFIG: Record<string, { label: string; icon: string; color: string }> = {
combat: { label: 'Combat', icon: '⚔️', color: '#EF4444' },
swarm: { label: 'Swarm', icon: '🐝', color: '#F59E0B' },
speed: { label: 'Speed', icon: '💨', color: '#3B82F6' },
guardian: { label: 'Guardian', icon: '🛡️', color: '#EF4444' },
puzzle: { label: 'Puzzle', icon: '🧩', color: '#8B5CF6' },
} as const;
export function RoomDisplay({
roomType,
roomConfig,
primaryEnemy,
swarmEnemies,
puzzleId,
puzzleProgress,
simpleMode,
floorElemDef,
floorHP,
floorMaxHP,
totalDPS,
currentAction,
activeEquipmentSpells,
}: RoomDisplayProps) {
// Puzzle Room Display
if (roomType === 'puzzle') {
return (
<div className="p-3 bg-purple-900/20 rounded border border-purple-700">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🧩</span>
<span className="text-sm font-semibold text-purple-300">
{puzzleId ? puzzleId.replace(/_/g, ' ').toUpperCase() : 'Puzzle Room'}
</span>
</div>
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-400">
<span>Progress</span>
<span>{((puzzleProgress || 0) * 100).toFixed(0)}%</span>
</div>
<Progress value={Math.min(100, (puzzleProgress || 0) * 100)} className="h-2 bg-gray-800" />
</div>
</div>
);
}
// Single Enemy Display (Combat/Speed/Guardian - non-swarm)
if ((roomType === 'combat' || roomType === 'speed' || roomType === 'guardian') && primaryEnemy) {
return (
<div className="p-3 bg-gray-800/50 rounded border border-gray-700">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Skull className="w-4 h-4 text-red-400" />
<span className="text-sm font-semibold text-gray-200">
{primaryEnemy.name || 'Unknown Enemy'}
</span>
</div>
<Badge variant="outline" className="text-xs">
{ELEMENTS[primaryEnemy.element]?.sym} {ELEMENTS[primaryEnemy.element]?.name}
</Badge>
</div>
{/* Enemy HP Bar */}
<div className="space-y-1 mb-2">
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.max(0, (primaryEnemy.hp / primaryEnemy.maxHP) * 100)}%`,
background: `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-400 game-mono">
<span>{fmt(primaryEnemy.hp)} / {fmt(primaryEnemy.maxHP)} HP</span>
</div>
</div>
{/* Enemy Properties */}
<EnemyProperties enemy={primaryEnemy} />
</div>
);
}
// Swarm Enemies Display
if (roomType === 'swarm' && swarmEnemies && swarmEnemies.length > 0) {
return (
<div className="space-y-2">
<div className="text-xs text-gray-400 font-semibold">
Swarm Enemies ({swarmEnemies.length})
</div>
{swarmEnemies.map((enemy: any, index: number) => (
<div key={enemy.id || `swarm-${index}`} className="p-2 bg-gray-800/50 rounded border border-gray-700">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Skull className="w-3 h-3 text-red-400" />
<span className="text-xs font-semibold text-gray-300">
{enemy.name || `Enemy ${index + 1}`}
</span>
</div>
<Badge variant="outline" className="text-xs py-0">
{ELEMENTS[enemy.element]?.sym} {fmt(enemy.hp)}/{fmt(enemy.maxHP)} HP
</Badge>
</div>
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.max(0, (enemy.hp / enemy.maxHP) * 100)}%`,
background: `linear-gradient(90deg, ${ELEMENTS[enemy.element]?.color}99, ${ELEMENTS[enemy.element]?.color})`,
}}
/>
</div>
<EnemyProperties enemy={enemy} small />
</div>
))}
</div>
);
}
// Floor HP Bar (non-swarm, non-puzzle)
return (
<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, (floorHP / 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(floorHP)} / {fmt(floorMaxHP)} HP</span>
<span>DPS: {currentAction === 'climb' && activeEquipmentSpells.length > 0 ? fmtDec(totalDPS) : '—'}</span>
</div>
</div>
);
}
function EnemyProperties({ enemy, small }: { enemy: any; small?: boolean }) {
const props = [];
if (enemy.armor > 0) props.push({ type: 'armor', label: `${(enemy.armor * 100).toFixed(0)}% Armor`, icon: Shield });
if (enemy.dodgeChance > 0) props.push({ type: 'dodge', label: `${(enemy.dodgeChance * 100).toFixed(0)}% Dodge`, icon: Wind });
if (enemy.healthRegen > 0) props.push({ type: 'regen', label: `${(enemy.healthRegen * 100).toFixed(1)}% Regen`, icon: Heart });
if (enemy.barrier > 0) props.push({ type: 'barrier', label: `${(enemy.barrier * 100).toFixed(0)}% Barrier`, icon: ShieldCheck });
if (props.length === 0) return null;
return (
<div className={`flex flex-wrap gap-2 mt-2 ${small ? 'mt-1' : ''}`}>
{props.map((p, i) => {
const Icon = p.icon;
return (
<Tooltip key={i}>
<TooltipTrigger>
<Badge variant="outline" className={`text-xs py-0 ${small ? 'text-xs py-0' : ''}`}>
<Icon className={`w-${small ? 2 : 3} h-${small ? 2 : 3} mr-1`} />
{p.label}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Reduces incoming damage by {(enemy.armor * 100).toFixed(0)}%</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
);
}
function fmt(value: number): string {
if (value >= 1e12) return (value / 1e12).toFixed(2) + 't';
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'b';
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'm';
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'k';
return value.toFixed(0);
}
function fmtDec(value: number): string {
if (value >= 1e12) return (value / 1e12).toFixed(2) + 't';
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'b';
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'm';
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'k';
return value.toFixed(0);
}
+109
View File
@@ -0,0 +1,109 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Mountain } from 'lucide-react';
import { ELEMENTS } from '@/lib/game/constants';
interface SpireHeaderProps {
currentFloor: number;
maxFloorReached: number;
signedPacts: number;
isGuardianFloor: boolean;
roomType: string;
roomLabel: string;
roomIcon: string;
roomColor: string;
floorElem: string;
floorElemDef: any;
simpleMode: boolean;
}
export function SpireHeader({
currentFloor,
maxFloorReached,
signedPacts,
isGuardianFloor,
roomType,
roomLabel,
roomIcon,
roomColor,
floorElem,
floorElemDef,
simpleMode,
}: SpireHeaderProps) {
return (
<>
{/* Current Floor Card - Only show in Spire Mode (simpleMode) */}
{simpleMode && (
<div className="bg-gray-900/80 border-gray-700 rounded-lg p-4 lg:p-6">
<div className="pb-2 mb-4 border-b border-gray-700">
<h3 className="text-amber-400 text-xs font-semibold tracking-wide uppercase flex items-center justify-between">
<span>Current Floor</span>
<Badge
className="ml-2"
style={{
backgroundColor: `${roomColor}20`,
color: roomColor,
borderColor: `${roomColor}60`
}}
>
{roomIcon} {roomLabel}
</Badge>
</h3>
</div>
<div className="space-y-3">
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold tracking-tight" style={{ color: floorElemDef?.color }}>
{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 && (
<div className="text-sm font-semibold" style={{ color: floorElemDef?.color }}>
Guardian Floor
</div>
)}
<div className="text-sm text-gray-400">
Best: Floor <strong className="text-gray-200">{maxFloorReached}</strong>
Pacts: <strong className="text-amber-400">{signedPacts}</strong>
</div>
</div>
</div>
)}
{/* Spire Stats Card - Only show in normal mode */}
{!simpleMode && (
<div className="bg-gray-900/80 border-gray-700 rounded-lg p-4 lg:p-6">
<div className="pb-2 mb-4 border-b border-gray-700">
<h3 className="text-amber-400 text-xs font-semibold tracking-wide uppercase">Spire Stats</h3>
</div>
<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 tracking-tight">{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 tracking-tight">{signedPacts}</div>
<div className="text-xs text-gray-400">Pacts Signed</div>
</div>
</div>
<div className="mt-3 text-sm text-gray-400">
Current Floor: <strong className="text-gray-200">{currentFloor}</strong>
</div>
</div>
)}
</>
);
}
+289 -542
View File
@@ -1,34 +1,26 @@
'use client'; 'use client';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress'; import { Card, CardContent } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Mountain } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { TooltipProvider, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { BookOpen, ChevronUp, ChevronDown, RotateCcw, X, Mountain, Skull, Zap, Wind, Shield, Heart, ShieldCheck } from 'lucide-react';
import type { ActivityLogEntry } from '@/lib/game/types'; import type { ActivityLogEntry } from '@/lib/game/types';
import type { GameStore } from '@/lib/game/store'; import type { GameStore } from '@/lib/game/store';
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, ROOM_TYPE_LABELS } from '@/lib/game/constants'; import { ELEMENTS, GUARDIANS, SPELLS_DEF } from '@/lib/game/constants';
import { GOLEMS_DEF, getGolemDamage, getGolemAttackSpeed } from '@/lib/game/data/golems'; import { getEnemyName, calcDamage } from '@/lib/game/store';
import { fmt, fmtDec, getFloorElement, canAffordSpellCost, getEnemyName, calcDamage } from '@/lib/game/store';
import { getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/computed-stats'; import { getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/computed-stats';
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
import { CraftingProgress, StudyProgress } from '@/components/game';
import { getUnifiedEffects } from '@/lib/game/effects'; import { getUnifiedEffects } from '@/lib/game/effects';
import { formatSpellCost, getSpellCostColor } from '@/lib/game/formatting';
import { canAffordSpellCost, getFloorElement } from '@/lib/game/store';
interface SpireTabProps { // Extracted components
store: GameStore; import { SpireHeader } from './SpireHeader';
simpleMode?: boolean; // When true, only show essential Spire info (for Spire Mode) import { GuardianPanel } from './GuardianPanel';
} import { RoomDisplay } from './RoomDisplay';
import { FloorControls } from './FloorControls';
import { CombatStatsPanel } from './CombatStatsPanel';
import { ActivityLog } from './ActivityLog';
// Helper to check if player can enter spire mode // Room type configurations
const canEnterSpireMode = (store: GameStore): boolean => {
return !store.spireMode; // Can enter if not already in Spire Mode
};
// Room type configurations for display
const ROOM_TYPE_CONFIG: Record<string, { label: string; icon: string; color: string }> = { const ROOM_TYPE_CONFIG: Record<string, { label: string; icon: string; color: string }> = {
combat: { label: 'Combat', icon: '⚔️', color: '#EF4444' }, combat: { label: 'Combat', icon: '⚔️', color: '#EF4444' },
swarm: { label: 'Swarm', icon: '🐝', color: '#F59E0B' }, swarm: { label: 'Swarm', icon: '🐝', color: '#F59E0B' },
@@ -37,7 +29,18 @@ const ROOM_TYPE_CONFIG: Record<string, { label: string; icon: string; color: str
puzzle: { label: 'Puzzle', icon: '🧩', color: '#8B5CF6' }, puzzle: { label: 'Puzzle', icon: '🧩', color: '#8B5CF6' },
}; };
interface SpireTabProps {
store: GameStore;
simpleMode?: boolean;
}
// Check if player can enter spire mode
const canEnterSpireMode = (store: GameStore): boolean => {
return !store.spireMode;
};
export function SpireTab({ store, simpleMode = false }: SpireTabProps) { export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
// Derived data
const floorElem = getFloorElement(store.currentFloor); const floorElem = getFloorElement(store.currentFloor);
const floorElemDef = ELEMENTS[floorElem]; const floorElemDef = ELEMENTS[floorElem];
const isGuardianFloor = !!GUARDIANS[store.currentFloor]; const isGuardianFloor = !!GUARDIANS[store.currentFloor];
@@ -45,576 +48,320 @@ export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
const climbDirection = store.climbDirection || 'up'; const climbDirection = store.climbDirection || 'up';
const clearedFloors = store.clearedFloors || {}; const clearedFloors = store.clearedFloors || {};
const currentRoom = store.currentRoom; const currentRoom = store.currentRoom;
// Check if current floor is cleared (for respawn indicator)
const isFloorCleared = clearedFloors[store.currentFloor]; const isFloorCleared = clearedFloors[store.currentFloor];
// Get room type info
const roomType = currentRoom?.roomType || 'combat'; const roomType = currentRoom?.roomType || 'combat';
const roomConfig = ROOM_TYPE_CONFIG[roomType] || ROOM_TYPE_CONFIG.combat; const roomConfig = ROOM_TYPE_CONFIG[roomType] || ROOM_TYPE_CONFIG.combat;
// Get active equipment spells
const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances); const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances);
// Get upgrade effects and DPS
const upgradeEffects = getUnifiedEffects(store); const upgradeEffects = getUnifiedEffects(store);
const totalDPS = getTotalDPS(store, upgradeEffects, floorElem); const totalDPS = getTotalDPS(store, upgradeEffects, floorElem);
const studySpeedMult = 1; // Base study speed const studySpeedMult = 1;
// Enemy display info
const primaryEnemy = currentRoom?.enemies?.[0] || null;
const swarmEnemies = roomType === 'swarm' && currentRoom?.enemies ? currentRoom.enemies : [];
// Spell casting check
const canCastSpell = (spellId: string): boolean => { const canCastSpell = (spellId: string): boolean => {
const spell = SPELLS_DEF[spellId]; const spell = SPELLS_DEF[spellId];
if (!spell) return false; if (!spell) return false;
return canAffordSpellCost(spell.cost, store.rawMana, store.elements); return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
}; };
// Get enemy display info // Climb handler
const getEnemyDisplayInfo = () => { const handleClimb = (direction: 'up' | 'down') => {
if (!currentRoom || !currentRoom.enemies || currentRoom.enemies.length === 0) { if (direction === 'up') {
return { primaryEnemy: null, swarmEnemies: [] }; store.startClimbUp();
} else {
store.startClimbDown();
} }
const enemies = currentRoom.enemies;
const primaryEnemy = enemies[0];
// For swarm rooms, return all enemies
if (roomType === 'swarm') {
return { primaryEnemy: null, swarmEnemies: enemies };
}
return { primaryEnemy, swarmEnemies: [] };
}; };
const { primaryEnemy, swarmEnemies } = getEnemyDisplayInfo();
return ( return (
<TooltipProvider> <div className="grid gap-4">
<div className={`grid gap-4 ${simpleMode ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-2'}`}> {/* Enter Spire Mode - Normal mode only */}
{/* Spire Stats View - Only show in normal mode (not simpleMode) */} {!simpleMode && (
{!simpleMode && ( <Card className="bg-gray-900/80 border-amber-600/50">
<> <CardContent className="pt-4">
{/* Enter Spire Mode Button */} <Button
<Card className="bg-gray-900/80 border-amber-600/50"> className="w-full bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-700 hover:to-orange-700"
<CardContent className="pt-4"> size="lg"
<Button onClick={() => store.enterSpireMode()}
className="w-full bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-700 hover:to-orange-700" disabled={!canEnterSpireMode(store)}
size="lg" >
onClick={() => store.enterSpireMode()} <Mountain className="w-5 h-5 mr-2" />
disabled={!canEnterSpireMode(store)} Enter Spire Mode
> </Button>
<Mountain className="w-5 h-5 mr-2" /> <div className="text-xs text-gray-400 text-center mt-2">
Enter Spire Mode Climb the Spire to face guardians and earn pacts
</Button> </div>
<div className="text-xs text-gray-400 text-center mt-2"> </CardContent>
Climb the Spire to face guardians and earn pacts </Card>
</div> )}
</CardContent>
</Card>
{/* Spire Stats Card - Replaces Current Floor stat */} {/* Spire Header */}
<Card className="bg-gray-900/80 border-gray-700"> <SpireHeader
<CardHeader className="pb-2"> currentFloor={store.currentFloor}
<CardTitle className="text-amber-400 game-panel-title text-xs">Spire Stats</CardTitle> maxFloorReached={store.maxFloorReached}
</CardHeader> signedPacts={store.signedPacts.length}
<CardContent className="space-y-3"> isGuardianFloor={isGuardianFloor}
<div className="grid grid-cols-2 gap-3"> roomType={roomType}
<div className="p-3 bg-gray-800/50 rounded"> roomLabel={roomConfig.label}
<div className="text-2xl font-bold text-amber-400 game-mono">{store.maxFloorReached}</div> roomIcon={roomConfig.icon}
<div className="text-xs text-gray-400">Best Floor</div> roomColor={roomConfig.color}
</div> floorElem={floorElem}
<div className="p-3 bg-gray-800/50 rounded"> floorElemDef={floorElemDef}
<div className="text-2xl font-bold text-purple-400 game-mono">{store.signedPacts.length}</div> simpleMode={simpleMode}
<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) */} {/* Active Spells Card - Spire Mode only */}
{simpleMode && ( {simpleMode && (
<Card className="bg-gray-900/80 border-gray-700"> <Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2"> <CardContent className="pt-4 pb-4">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center justify-between"> <div className="text-amber-400 text-xs font-semibold mb-3 uppercase tracking-wider">
<span>Current Floor</span> Active Spells ({activeEquipmentSpells.length})
<Badge </div>
className="ml-2" {activeEquipmentSpells.length > 0 ? (
style={{ <div className="space-y-2">
backgroundColor: `${roomConfig.color}20`, {activeEquipmentSpells.map(({ spellId, equipmentId }) => {
color: roomConfig.color, const spellDef = SPELLS_DEF[spellId];
borderColor: `${roomConfig.color}60` if (!spellDef) return null;
}} const spellState = store.equipmentSpellStates?.find(
> s => s.spellId === spellId && s.sourceEquipment === equipmentId
{roomConfig.icon} {roomConfig.label} );
</Badge> const progress = spellState?.castProgress || 0;
</CardTitle> const canCast = canCastSpell(spellId);
</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>
{/* Guardian Name */} return (
{isGuardianFloor && currentGuardian && ( <div key={`${spellId}-${equipmentId}`} className="p-2 rounded bg-gray-800/30 border border-gray-700">
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
{currentGuardian.name}
</div>
)}
{/* Single Enemy Display (Combat/Speed/Guardian) */}
{!isGuardianFloor && primaryEnemy && roomType !== 'swarm' && (
<div className="p-3 bg-gray-800/50 rounded border border-gray-700">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Skull className="w-4 h-4 text-red-400" />
<span className="text-sm font-semibold text-gray-200">
{primaryEnemy.name || 'Unknown Enemy'}
</span>
</div>
<Badge variant="outline" className="text-xs">
{ELEMENTS[primaryEnemy.element]?.sym} {ELEMENTS[primaryEnemy.element]?.name}
</Badge>
</div>
{/* Enemy HP Bar */}
<div className="space-y-1 mb-2">
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.max(0, (primaryEnemy.hp / primaryEnemy.maxHP) * 100)}%`,
background: `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-400 game-mono">
<span>{fmt(primaryEnemy.hp)} / {fmt(primaryEnemy.maxHP)} HP</span>
</div>
</div>
{/* Enemy Properties */}
<div className="flex flex-wrap gap-2 text-xs">
{primaryEnemy.armor > 0 && (
<Tooltip>
<TooltipTrigger>
<Badge variant="outline" className="text-xs py-0">
<Shield className="w-3 h-3 mr-1" />
{(primaryEnemy.armor * 100).toFixed(0)}% Armor
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Reduces incoming damage by {(primaryEnemy.armor * 100).toFixed(0)}%</p>
</TooltipContent>
</Tooltip>
)}
{primaryEnemy.dodgeChance > 0 && (
<Tooltip>
<TooltipTrigger>
<Badge variant="outline" className="text-xs py-0">
<Wind className="w-3 h-3 mr-1" />
{(primaryEnemy.dodgeChance * 100).toFixed(0)}% Dodge
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Chance to dodge attacks and reduce progress</p>
</TooltipContent>
</Tooltip>
)}
{primaryEnemy.healthRegen > 0 && (
<Tooltip>
<TooltipTrigger>
<Badge variant="outline" className="text-xs py-0">
<Heart className="w-3 h-3 mr-1 text-green-400" />
{(primaryEnemy.healthRegen * 100).toFixed(1)}% Regen
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Regenerates {(primaryEnemy.healthRegen * 100).toFixed(1)}% of max HP per tick</p>
</TooltipContent>
</Tooltip>
)}
{primaryEnemy.barrier > 0 && (
<Tooltip>
<TooltipTrigger>
<Badge variant="outline" className="text-xs py-0">
<ShieldCheck className="w-3 h-3 mr-1 text-blue-400" />
{(primaryEnemy.barrier * 100).toFixed(0)}% Barrier
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Has a barrier absorbing {(primaryEnemy.barrier * 100).toFixed(0)}% of max HP before HP takes damage</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
)}
{/* Swarm Enemies Display */}
{roomType === 'swarm' && swarmEnemies.length > 0 && (
<div className="space-y-2">
<div className="text-xs text-gray-400 font-semibold">
Swarm Enemies ({swarmEnemies.length})
</div>
{swarmEnemies.map((enemy, index) => (
<div key={enemy.id || `swarm-${index}`} className="p-2 bg-gray-800/50 rounded border border-gray-700">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2"> <span className="text-sm font-semibold" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
<Skull className="w-3 h-3 text-red-400" /> {spellDef.name}
<span className="text-xs font-semibold text-gray-300"> {spellDef.tier === 0 && <span className="ml-2 bg-gray-600 text-gray-200 text-xs px-1 rounded">Basic</span>}
{enemy.name || `Enemy ${index + 1}`} </span>
</span> <span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>{canCast ? '✓' : '✗'}</span>
</div>
<div className="text-xs text-gray-400 mb-1">
{fmt(calcDamage(store, spellId))} dmg <span style={{ color: getSpellCostColor(spellDef.cost) }}>{formatSpellCost(spellDef.cost)}</span> {' '} {fmt(Math.floor(calcDamage(store, spellId) * (spellDef.castSpeed || 1)))} dmg/hr
</div>
{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>
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all duration-300" style={{ width: `${Math.min(100, progress * 100)}%`, background: spellDef.elem === 'raw' ? 'linear-gradient(90deg, #60A5FA99, #60A5FA)' : `linear-gradient(90deg, ${ELEMENTS[spellDef.elem]?.color}99, ${ELEMENTS[spellDef.elem]?.color})` }} />
</div>
</div> </div>
<Badge variant="outline" className="text-xs py-0"> )}
{ELEMENTS[enemy.element]?.sym} {fmt(enemy.hp)}/{fmt(enemy.maxHP)} HP
</Badge>
</div>
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.max(0, (enemy.hp / enemy.maxHP) * 100)}%`,
background: `linear-gradient(90deg, ${ELEMENTS[enemy.element]?.color}99, ${ELEMENTS[enemy.element]?.color})`,
}}
/>
</div>
{/* Show enemy properties for swarm enemies */}
<div className="flex flex-wrap gap-1 mt-1">
{enemy.armor > 0 && (
<Badge variant="outline" className="text-xs py-0">
<Shield className="w-2 h-2 mr-1" />
{(enemy.armor * 100).toFixed(0)}% Armor
</Badge>
)}
{enemy.healthRegen > 0 && (
<Badge variant="outline" className="text-xs py-0">
<Heart className="w-2 h-2 mr-1 text-green-400" />
{(enemy.healthRegen * 100).toFixed(1)}% Regen
</Badge>
)}
{enemy.barrier > 0 && (
<Badge variant="outline" className="text-xs py-0">
<ShieldCheck className="w-2 h-2 mr-1 text-blue-400" />
{(enemy.barrier * 100).toFixed(0)}% Barrier
</Badge>
)}
</div>
</div> </div>
))} );
</div> })}
)}
{/* Puzzle Room Display */}
{roomType === 'puzzle' && (
<div className="p-3 bg-purple-900/20 rounded border border-purple-700">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🧩</span>
<span className="text-sm font-semibold text-purple-300">
{currentRoom.puzzleId ? currentRoom.puzzleId.replace(/_/g, ' ').toUpperCase() : 'Puzzle Room'}
</span>
</div>
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-400">
<span>Progress</span>
<span>{((currentRoom.puzzleProgress || 0) * 100).toFixed(0)}%</span>
</div>
<Progress
value={Math.min(100, (currentRoom.puzzleProgress || 0) * 100)}
className="h-2 bg-gray-800"
/>
</div>
</div>
)}
{/* Floor HP Bar (for non-swarm, non-puzzle) */}
{roomType !== 'swarm' && roomType !== 'puzzle' && (
<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> </div>
</CardContent> ) : (
</Card> <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>
)}
{/* Active Spells Card - Only show in Spire Mode (simpleMode) */} {/* Summoned Golems */}
{simpleMode && ( {simpleMode && store.golemancy.summonedGolems.length > 0 && (
<Card className="bg-gray-900/80 border-gray-700"> <Card className="bg-gray-900/80 border-amber-600/50">
<CardHeader className="pb-2"> <CardContent className="pt-4 pb-4">
<CardTitle className="text-amber-400 game-panel-title text-xs"> <div className="text-amber-400 text-xs font-semibold mb-3 uppercase tracking-wider flex items-center gap-2">
Active Spells ({activeEquipmentSpells.length}) <Mountain className="w-4 h-4" />
</CardTitle> Active Golems ({store.golemancy.summonedGolems.length})
</CardHeader> </div>
<CardContent className="space-y-3"> <div className="space-y-2">
{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(calcDamage(store, spellId))} dmg/cast
<span style={{ color: getSpellCostColor(spellDef.cost) }}>
{' '}{formatSpellCost(spellDef.cost)}
</span>
{' '} {fmt(Math.floor(calcDamage(store, spellId) * (spellDef.castSpeed || 1)))} dmg/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">
<Mountain className="w-4 h-4" />
Active Golems ({store.golemancy.summonedGolems.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{store.golemancy.summonedGolems.map((summoned) => { {store.golemancy.summonedGolems.map((summoned) => {
const golemDef = GOLEMS_DEF[summoned.golemId]; const golemDef = getGolemDef(summoned.golemId);
if (!golemDef) return null; if (!golemDef) return null;
const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888'; const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
const damage = getGolemDamage(summoned.golemId, store.skills); const damage = getGolemDamage(summoned.golemId, store.skills);
const attackSpeed = getGolemAttackSpeed(summoned.golemId, store.skills); const attackSpeed = getGolemAttackSpeed(summoned.golemId, store.skills);
return ( return (
<div key={summoned.golemId} className="p-2 rounded border border-gray-700 bg-gray-800/30"> <div key={summoned.golemId} className="p-2 rounded bg-gray-800/30 border border-gray-700">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Mountain className="w-4 h-4" style={{ color: elemColor }} /> <Mountain className="w-3 h-3" style={{ color: elemColor }} />
<span className="text-sm font-semibold game-panel-title" style={{ color: elemColor }}> <span className="text-xs font-semibold" style={{ color: elemColor }}>{golemDef.name}</span>
{golemDef.name}
</span>
</div> </div>
{golemDef.isAoe && ( {golemDef.isAoe && <span className="text-xs border border-gray-600 px-1 rounded">AOE {golemDef.aoeTargets}</span>}
<Badge variant="outline" className="text-xs">AOE {golemDef.aoeTargets}</Badge>
)}
</div> </div>
<div className="text-xs text-gray-400 game-mono"> <div className="text-xs text-gray-400"> {damage} DMG {attackSpeed.toFixed(1)}/hr</div>
{damage} DMG {attackSpeed.toFixed(1)}/hr
🗡 {Math.floor(golemDef.armorPierce * 100)}% Pierce
</div>
{/* Attack progress bar when climbing */}
{store.currentAction === 'climb' && summoned.attackProgress > 0 && ( {store.currentAction === 'climb' && summoned.attackProgress > 0 && (
<div className="space-y-0.5 mt-1"> <div className="mt-1">
<div className="flex justify-between text-xs text-gray-500"> <div className="flex justify-between text-xs text-gray-500 mb-0.5">
<span>Attack</span> <span>Attack</span>
<span>{Math.min(100, (summoned.attackProgress * 100)).toFixed(0)}%</span> <span>{(summoned.attackProgress * 100).toFixed(0)}%</span>
</div>
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all duration-300" style={{ width: `${Math.min(100, summoned.attackProgress * 100)}%`, background: elemColor }} />
</div> </div>
<Progress value={Math.min(100, summoned.attackProgress * 100)} className="h-1.5 bg-gray-700" />
</div> </div>
)} )}
</div> </div>
); );
})} })}
</CardContent> </div>
</Card> </CardContent>
)} </Card>
)}
{/* Current Study (if any) - Only show in normal mode */} {/* Guardian Panel */}
{!simpleMode && store.currentStudyTarget && ( {isGuardianFloor && simpleMode && (
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2"> <GuardianPanel currentFloor={store.currentFloor} floorElemDef={floorElemDef} />
<CardContent className="pt-4 space-y-3"> )}
<StudyProgress
currentStudyTarget={store.currentStudyTarget}
skills={store.skills}
studySpeedMult={studySpeedMult}
cancelStudy={store.cancelStudy}
/>
{/* Parallel Study Progress */} {/* Room Display */}
{store.parallelStudyTarget && ( {simpleMode && (
<div className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20"> <RoomDisplay
<div className="flex items-center justify-between mb-2"> roomType={roomType}
<div className="flex items-center gap-2"> roomConfig={roomConfig}
<BookOpen className="w-4 h-4 text-cyan-400" /> primaryEnemy={primaryEnemy}
<span className="text-sm font-semibold text-cyan-300"> swarmEnemies={swarmEnemies}
Parallel: {SKILLS_DEF[store.parallelStudyTarget.id]?.name} puzzleId={currentRoom?.puzzleId}
{store.parallelStudyTarget.type === 'skill' && ` Lv.${(store.skills[store.parallelStudyTarget.id] || 0) + 1}`} puzzleProgress={currentRoom?.puzzleProgress}
</span> simpleMode={true}
</div> floorElemDef={floorElemDef}
<Button floorHP={store.floorHP}
variant="ghost" floorMaxHP={store.floorMaxHP}
size="sm" totalDPS={totalDPS}
className="h-6 w-6 p-0 text-gray-400 hover:text-white" currentAction={store.currentAction}
onClick={() => store.cancelParallelStudy?.()} activeEquipmentSpells={activeEquipmentSpells}
> />
<X className="w-4 h-4" /> )}
</Button>
</div> {/* Floor Controls */}
<Progress value={Math.min(100, (store.parallelStudyTarget.progress / store.parallelStudyTarget.required) * 100)} className="h-2 bg-gray-800" /> {simpleMode && (
<div className="flex justify-between text-xs text-gray-400 mt-1"> <FloorControls
<span>{formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}</span> store={store}
<span>50% speed (Parallel Study)</span> climbDirection={climbDirection}
</div> isGuardianFloor={isGuardianFloor}
currentRoom={currentRoom}
currentGuardian={currentGuardian}
isFloorCleared={isFloorCleared}
floorElemDef={floorElemDef}
roomType={roomType}
roomConfig={roomConfig}
activeEquipmentSpells={activeEquipmentSpells}
upgradeEffects={upgradeEffects}
floorElem={floorElem}
totalDPS={totalDPS}
getEnemyName={getEnemyName}
calcDamage={calcDamage}
SPELLS_DEF={SPELLS_DEF}
canCastSpell={canCastSpell}
storeCurrentAction={store.currentAction}
handleClimb={handleClimb}
formatSpellCost={formatSpellCost}
getSpellCostColor={getSpellCostColor}
/>
)}
{/* Combat Stats Panel */}
{simpleMode && (
<CombatStatsPanel
activeEquipmentSpells={activeEquipmentSpells}
store={store}
totalDPS={totalDPS}
calcDamage={calcDamage}
formatSpellCost={formatSpellCost}
getSpellCostColor={getSpellCostColor}
SPELLS_DEF={SPELLS_DEF}
upgradeEffects={upgradeEffects}
canCastSpell={canCastSpell}
studySpeedMult={studySpeedMult}
storeCurrentAction={store.currentAction}
/>
)}
{/* Activity Log - Spire Mode only */}
{simpleMode && <ActivityLog activityLog={store.activityLog} />}
{/* Study Progress - Normal mode only */}
{!simpleMode && store.currentStudyTarget && (
<Card className="bg-gray-900/80 border-purple-600/50">
<CardContent className="pt-4 pb-4">
<div className="text-xs text-gray-400 mb-2">Study: {getSkillName(store.currentStudyTarget.id)}</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all duration-300 bg-purple-500" style={{ width: `${Math.min(100, (store.currentStudyTarget.progress / store.currentStudyTarget.required) * 100)}%` }} />
</div>
{store.parallelStudyTarget && (
<div className="mt-3 p-2 rounded border border-cyan-600/50 bg-cyan-900/20">
<div className="text-xs text-cyan-300 mb-1">Parallel: {getSkillName(store.parallelStudyTarget.id)} (50% speed)</div>
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all duration-300 bg-cyan-500" style={{ width: `${Math.min(100, (store.parallelStudyTarget.progress / store.parallelStudyTarget.required) * 100)}%` }} />
</div> </div>
)} </div>
</CardContent> )}
</Card> </CardContent>
)} </Card>
)}
{/* Activity Log - Show in Spire Mode (simpleMode) */} {/* Crafting Progress - Normal mode only */}
{simpleMode && ( {!simpleMode && (store.designProgress || store.preparationProgress || store.applicationProgress) && (
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2"> <Card className="bg-gray-900/80 border-cyan-600/50">
<CardHeader className="pb-2"> <CardContent className="pt-4 pb-4">
<CardTitle className="text-amber-400 game-panel-title text-xs">Activity Log</CardTitle> {store.designProgress && (
</CardHeader> <div className="mb-3">
<CardContent> <div className="text-xs text-gray-400 mb-1">Design Progress</div>
<ScrollArea className="h-48"> <div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div className="space-y-1"> <div className="h-full rounded-full transition-all duration-300 bg-cyan-500" style={{ width: `${Math.min(100, (store.designProgress.progress / store.designProgress.required) * 100)}%` }} />
{(store.activityLog || []).slice(0, 50).map((entry: ActivityLogEntry, i) => {
// Style based on event type
const getEventStyle = (eventType: string) => {
switch (eventType) {
case 'enemy_defeated':
case 'floor_cleared':
return 'text-green-400';
case 'damage_dealt':
return 'text-red-400';
case 'dodge':
return 'text-yellow-400';
case 'armor_proc':
return 'text-blue-400';
case 'special_effect':
return 'text-purple-400';
case 'floor_transition':
return 'text-cyan-400';
case 'spell_cast':
return 'text-amber-400';
case 'golem_attack':
return 'text-orange-400';
case 'puzzle_solved':
return 'text-pink-400';
default:
return 'text-gray-300';
}
};
return (
<div
key={entry.id}
className={`text-xs ${i === 0 ? 'text-gray-200 font-semibold' : getEventStyle(entry.eventType)}`}
>
{entry.message}
</div>
);
})}
{(store.activityLog || []).length === 0 && (
<div className="text-xs text-gray-500 italic">No activity yet...</div>
)}
</div> </div>
</ScrollArea> </div>
</CardContent> )}
</Card> {store.preparationProgress && (
)} <div className="mb-3">
<div className="text-xs text-gray-400 mb-1">Preparation Progress</div>
{/* Crafting Progress (if any) - Only show in normal mode */} <div className="h-2 bg-gray-800 rounded-full overflow-hidden">
{!simpleMode && (store.designProgress || store.preparationProgress || store.applicationProgress) && ( <div className="h-full rounded-full transition-all duration-300 bg-cyan-500" style={{ width: `${Math.min(100, (store.preparationProgress.progress / store.preparationProgress.required) * 100)}%` }} />
<Card className="bg-gray-900/80 border-cyan-600/50 lg:col-span-2"> </div>
<CardContent className="pt-4"> </div>
<CraftingProgress )}
designProgress={store.designProgress} {store.applicationProgress && (
preparationProgress={store.preparationProgress} <div>
applicationProgress={store.applicationProgress} <div className="text-xs text-gray-400 mb-1">Application Progress</div>
equipmentInstances={store.equipmentInstances} <div className="h-2 bg-gray-800 rounded-full overflow-hidden">
enchantmentDesigns={store.enchantmentDesigns} <div className="h-full rounded-full transition-all duration-300 bg-cyan-500" style={{ width: `${Math.min(100, (store.applicationProgress.progress / store.applicationProgress.required) * 100)}%` }} />
cancelDesign={store.cancelDesign!} </div>
cancelPreparation={store.cancelPreparation!} </div>
pauseApplication={store.pauseApplication!} )}
resumeApplication={store.resumeApplication!} </CardContent>
cancelApplication={store.cancelApplication!} </Card>
/> )}
</CardContent> </div>
</Card>
)}
</div>
</TooltipProvider>
); );
} }
SpireTab.displayName = "SpireTab"; SpireTab.displayName = "SpireTab";
function getSkillName(skillId: string): string {
const { SKILLS_DEF } = require('@/lib/game/constants');
return SKILLS_DEF[skillId]?.name || skillId;
}
function fmt(value: number): string {
if (value >= 1e12) return (value / 1e12).toFixed(2) + 't';
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'b';
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'm';
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'k';
return value.toFixed(0);
}
function getGolemDef(golemId: string) {
const { GOLEMS_DEF } = require('@/lib/game/data/golems');
return GOLEMS_DEF[golemId];
}
function getGolemDamage(golemId: string, skills: any) {
const { getGolemDamage } = require('@/lib/game/data/golems');
return getGolemDamage(golemId, skills);
}
function getGolemAttackSpeed(golemId: string, skills: any) {
const { getGolemAttackSpeed } = require('@/lib/game/data/golems');
return getGolemAttackSpeed(golemId, skills);
}
+8
View File
@@ -13,3 +13,11 @@ export { DebugTab } from './DebugTab';
export { LootTab } from './LootTab'; export { LootTab } from './LootTab';
export { AchievementsTab } from './AchievementsTab'; export { AchievementsTab } from './AchievementsTab';
export { GolemancyTab } from './GolemancyTab'; export { GolemancyTab } from './GolemancyTab';
// Spire sub-components
export { SpireHeader } from './SpireHeader';
export { GuardianPanel } from './GuardianPanel';
export { RoomDisplay } from './RoomDisplay';
export { FloorControls } from './FloorControls';
export { CombatStatsPanel } from './CombatStatsPanel';
export { ActivityLog } from './ActivityLog';
+63 -13
View File
@@ -1,14 +1,64 @@
// ─── Game Types ─────────────────────────────────────────────────────────────── import type { GameStore } from '@/lib/game/store';
// This file now re-exports types from domain-specific files in the types/ directory. import type { SPELLS } from '@/lib/game/constants';
// All type definitions have been moved to: import type { EquipmentSpellState } from '@/lib/game/state';
// - types/elements.ts - element-related types import type { RoomType, ActivityLogEntry } from '@/lib/game/types/game';
// - types/attunements.ts - attunement-related types
// - types/spells.ts - spell-related types
// - types/skills.ts - skill-related types
// - types/equipment.ts - equipment-related types
// - types/game.ts - core game state types
//
// Import from this file (types.ts) to maintain backward compatibility,
// or import directly from the domain-specific files.
export * from './types/index'; // Re-export ActivityLogEntry for convenience
export { ActivityLogEntry };
// Room Display Props
export interface RoomDisplayProps {
roomType: RoomType;
roomConfig: { label: string; icon: string; color: string };
primaryEnemy: any;
swarmEnemies: any[];
puzzleId?: string;
puzzleProgress?: number;
simpleMode: boolean;
floorElemDef: any;
floorHP: number;
floorMaxHP: number;
totalDPS: number;
currentAction: string | null;
activeEquipmentSpells: Array<{ spellId: string; equipmentId: string }>;
}
// Floor Controls Props
export interface FloorControlsProps {
store: GameStore;
climbDirection: 'up' | 'down' | null;
isGuardianFloor: boolean;
currentRoom: any;
currentGuardian: any;
isFloorCleared: boolean;
floorElemDef: any;
roomType: RoomType;
roomConfig: { label: string; icon: string; color: string };
activeEquipmentSpells: Array<{ spellId: string; equipmentId: string }>;
upgradeEffects: any;
floorElem: string;
totalDPS: number;
getEnemyName: typeof import('@/lib/game/store').getEnemyName;
calcDamage: typeof import('@/lib/game/store').calcDamage;
SPELLS_DEF: typeof import('@/lib/game/constants').SPELLS_DEF;
canCastSpell: (spellId: string) => boolean;
storeCurrentAction: string | null;
handleClimb: (direction: 'up' | 'down') => void;
formatSpellCost: typeof import('@/lib/game/formatting').formatSpellCost;
getSpellCostColor: typeof import('@/lib/game/formatting').getSpellCostColor;
}
// Combat Stats Panel Props
export interface CombatStatsPanelProps {
activeEquipmentSpells: Array<{ spellId: string; equipmentId: string }>;
store: GameStore;
totalDPS: number;
calcDamage: typeof import('@/lib/game/store').calcDamage;
formatSpellCost: typeof import('@/lib/game/formatting').formatSpellCost;
getSpellCostColor: typeof import('@/lib/game/formatting').getSpellCostColor;
SPELLS_DEF: typeof import('@/lib/game/constants').SPELLS_DEF;
upgradeEffects: any;
canCastSpell: (spellId: string) => boolean;
studySpeedMult: number;
storeCurrentAction: string | null;
}