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
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m11s
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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 { Card, CardContent } from '@/components/ui/card';
|
||||
import { Mountain } from 'lucide-react';
|
||||
import type { ActivityLogEntry } from '@/lib/game/types';
|
||||
import type { GameStore } from '@/lib/game/store';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, ROOM_TYPE_LABELS } from '@/lib/game/constants';
|
||||
import { GOLEMS_DEF, getGolemDamage, getGolemAttackSpeed } from '@/lib/game/data/golems';
|
||||
import { fmt, fmtDec, getFloorElement, canAffordSpellCost, getEnemyName, calcDamage } from '@/lib/game/store';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF } from '@/lib/game/constants';
|
||||
import { getEnemyName, calcDamage } from '@/lib/game/store';
|
||||
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 { formatSpellCost, getSpellCostColor } from '@/lib/game/formatting';
|
||||
import { canAffordSpellCost, getFloorElement } from '@/lib/game/store';
|
||||
|
||||
interface SpireTabProps {
|
||||
store: GameStore;
|
||||
simpleMode?: boolean; // When true, only show essential Spire info (for Spire Mode)
|
||||
}
|
||||
// Extracted components
|
||||
import { SpireHeader } from './SpireHeader';
|
||||
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
|
||||
const canEnterSpireMode = (store: GameStore): boolean => {
|
||||
return !store.spireMode; // Can enter if not already in Spire Mode
|
||||
};
|
||||
|
||||
// Room type configurations for display
|
||||
// Room type configurations
|
||||
const ROOM_TYPE_CONFIG: Record<string, { label: string; icon: string; color: string }> = {
|
||||
combat: { label: 'Combat', icon: '⚔️', color: '#EF4444' },
|
||||
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' },
|
||||
};
|
||||
|
||||
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) {
|
||||
// Derived data
|
||||
const floorElem = getFloorElement(store.currentFloor);
|
||||
const floorElemDef = ELEMENTS[floorElem];
|
||||
const isGuardianFloor = !!GUARDIANS[store.currentFloor];
|
||||
@@ -45,576 +48,320 @@ export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
|
||||
const climbDirection = store.climbDirection || 'up';
|
||||
const clearedFloors = store.clearedFloors || {};
|
||||
const currentRoom = store.currentRoom;
|
||||
|
||||
// Check if current floor is cleared (for respawn indicator)
|
||||
const isFloorCleared = clearedFloors[store.currentFloor];
|
||||
|
||||
// Get room type info
|
||||
const roomType = currentRoom?.roomType || 'combat';
|
||||
const roomConfig = ROOM_TYPE_CONFIG[roomType] || ROOM_TYPE_CONFIG.combat;
|
||||
|
||||
// Get active equipment spells
|
||||
const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances);
|
||||
|
||||
// Get upgrade effects and DPS
|
||||
const upgradeEffects = getUnifiedEffects(store);
|
||||
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 spell = SPELLS_DEF[spellId];
|
||||
if (!spell) return false;
|
||||
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
|
||||
};
|
||||
|
||||
// Get enemy display info
|
||||
const getEnemyDisplayInfo = () => {
|
||||
if (!currentRoom || !currentRoom.enemies || currentRoom.enemies.length === 0) {
|
||||
return { primaryEnemy: null, swarmEnemies: [] };
|
||||
// Climb handler
|
||||
const handleClimb = (direction: 'up' | 'down') => {
|
||||
if (direction === 'up') {
|
||||
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 (
|
||||
<TooltipProvider>
|
||||
<div className={`grid gap-4 ${simpleMode ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-2'}`}>
|
||||
{/* Spire Stats View - Only show in normal mode (not simpleMode) */}
|
||||
{!simpleMode && (
|
||||
<>
|
||||
{/* Enter Spire Mode Button */}
|
||||
<Card className="bg-gray-900/80 border-amber-600/50">
|
||||
<CardContent className="pt-4">
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-700 hover:to-orange-700"
|
||||
size="lg"
|
||||
onClick={() => store.enterSpireMode()}
|
||||
disabled={!canEnterSpireMode(store)}
|
||||
>
|
||||
<Mountain className="w-5 h-5 mr-2" />
|
||||
Enter Spire Mode
|
||||
</Button>
|
||||
<div className="text-xs text-gray-400 text-center mt-2">
|
||||
Climb the Spire to face guardians and earn pacts
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Spire Stats Card - Replaces Current Floor stat */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Spire Stats</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<div className="text-2xl font-bold text-amber-400 game-mono">{store.maxFloorReached}</div>
|
||||
<div className="text-xs text-gray-400">Best Floor</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-800/50 rounded">
|
||||
<div className="text-2xl font-bold text-purple-400 game-mono">{store.signedPacts.length}</div>
|
||||
<div className="text-xs text-gray-400">Pacts Signed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Current Floor: <strong className="text-gray-200">{store.currentFloor}</strong>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Current Floor Card - Only show in Spire Mode (simpleMode) */}
|
||||
{simpleMode && (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center justify-between">
|
||||
<span>Current Floor</span>
|
||||
<Badge
|
||||
className="ml-2"
|
||||
style={{
|
||||
backgroundColor: `${roomConfig.color}20`,
|
||||
color: roomConfig.color,
|
||||
borderColor: `${roomConfig.color}60`
|
||||
}}
|
||||
>
|
||||
{roomConfig.icon} {roomConfig.label}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold game-title" style={{ color: floorElemDef?.color }}>
|
||||
{store.currentFloor}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm">/ 100</span>
|
||||
<span className="ml-auto text-sm" style={{ color: floorElemDef?.color }}>
|
||||
{floorElemDef?.sym} {floorElemDef?.name}
|
||||
</span>
|
||||
{isGuardianFloor && (
|
||||
<Badge className="bg-red-900/50 text-red-300 border-red-600">GUARDIAN</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Guardian Name */}
|
||||
{isGuardianFloor && currentGuardian && (
|
||||
<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="grid gap-4">
|
||||
{/* Enter Spire Mode - Normal mode only */}
|
||||
{!simpleMode && (
|
||||
<Card className="bg-gray-900/80 border-amber-600/50">
|
||||
<CardContent className="pt-4">
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-700 hover:to-orange-700"
|
||||
size="lg"
|
||||
onClick={() => store.enterSpireMode()}
|
||||
disabled={!canEnterSpireMode(store)}
|
||||
>
|
||||
<Mountain className="w-5 h-5 mr-2" />
|
||||
Enter Spire Mode
|
||||
</Button>
|
||||
<div className="text-xs text-gray-400 text-center mt-2">
|
||||
Climb the Spire to face guardians and earn pacts
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Spire Header */}
|
||||
<SpireHeader
|
||||
currentFloor={store.currentFloor}
|
||||
maxFloorReached={store.maxFloorReached}
|
||||
signedPacts={store.signedPacts.length}
|
||||
isGuardianFloor={isGuardianFloor}
|
||||
roomType={roomType}
|
||||
roomLabel={roomConfig.label}
|
||||
roomIcon={roomConfig.icon}
|
||||
roomColor={roomConfig.color}
|
||||
floorElem={floorElem}
|
||||
floorElemDef={floorElemDef}
|
||||
simpleMode={simpleMode}
|
||||
/>
|
||||
|
||||
{/* Active Spells Card - Spire Mode only */}
|
||||
{simpleMode && (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="text-amber-400 text-xs font-semibold mb-3 uppercase tracking-wider">
|
||||
Active Spells ({activeEquipmentSpells.length})
|
||||
</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 rounded bg-gray-800/30 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>
|
||||
<span className="text-sm font-semibold" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
|
||||
{spellDef.name}
|
||||
{spellDef.tier === 0 && <span className="ml-2 bg-gray-600 text-gray-200 text-xs px-1 rounded">Basic</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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Active Spells Card - Only show in Spire Mode (simpleMode) */}
|
||||
{simpleMode && (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">
|
||||
Active Spells ({activeEquipmentSpells.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{activeEquipmentSpells.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{activeEquipmentSpells.map(({ spellId, equipmentId }) => {
|
||||
const spellDef = SPELLS_DEF[spellId];
|
||||
if (!spellDef) return null;
|
||||
|
||||
const spellState = store.equipmentSpellStates?.find(
|
||||
s => s.spellId === spellId && s.sourceEquipment === equipmentId
|
||||
);
|
||||
const progress = spellState?.castProgress || 0;
|
||||
const canCast = canAffordSpellCost(spellDef.cost, store.rawMana, store.elements);
|
||||
|
||||
return (
|
||||
<div key={`${spellId}-${equipmentId}`} className="p-2 rounded border border-gray-700 bg-gray-800/30">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-sm font-semibold game-panel-title" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
|
||||
{spellDef.name}
|
||||
{spellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200 text-xs">Basic</Badge>}
|
||||
{spellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100 text-xs">Legendary</Badge>}
|
||||
</div>
|
||||
<span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{canCast ? '✓' : '✗'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 game-mono mb-1">
|
||||
⚔️ {fmt(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">
|
||||
) : (
|
||||
<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 */}
|
||||
{simpleMode && store.golemancy.summonedGolems.length > 0 && (
|
||||
<Card className="bg-gray-900/80 border-amber-600/50">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="text-amber-400 text-xs font-semibold mb-3 uppercase tracking-wider flex items-center gap-2">
|
||||
<Mountain className="w-4 h-4" />
|
||||
Active Golems ({store.golemancy.summonedGolems.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{store.golemancy.summonedGolems.map((summoned) => {
|
||||
const golemDef = GOLEMS_DEF[summoned.golemId];
|
||||
const golemDef = getGolemDef(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 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 gap-2">
|
||||
<Mountain className="w-4 h-4" style={{ color: elemColor }} />
|
||||
<span className="text-sm font-semibold game-panel-title" style={{ color: elemColor }}>
|
||||
{golemDef.name}
|
||||
</span>
|
||||
<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">AOE {golemDef.aoeTargets}</Badge>
|
||||
)}
|
||||
{golemDef.isAoe && <span className="text-xs border border-gray-600 px-1 rounded">AOE {golemDef.aoeTargets}</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 game-mono">
|
||||
⚔️ {damage} DMG • ⚡ {attackSpeed.toFixed(1)}/hr •
|
||||
🗡️ {Math.floor(golemDef.armorPierce * 100)}% Pierce
|
||||
</div>
|
||||
{/* Attack progress bar when climbing */}
|
||||
<div className="text-xs text-gray-400">⚔️ {damage} DMG • ⚡ {attackSpeed.toFixed(1)}/hr</div>
|
||||
{store.currentAction === 'climb' && summoned.attackProgress > 0 && (
|
||||
<div className="space-y-0.5 mt-1">
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<div className="mt-1">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-0.5">
|
||||
<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>
|
||||
<Progress value={Math.min(100, summoned.attackProgress * 100)} className="h-1.5 bg-gray-700" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current Study (if any) - Only show in normal mode */}
|
||||
{!simpleMode && store.currentStudyTarget && (
|
||||
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<StudyProgress
|
||||
currentStudyTarget={store.currentStudyTarget}
|
||||
skills={store.skills}
|
||||
studySpeedMult={studySpeedMult}
|
||||
cancelStudy={store.cancelStudy}
|
||||
/>
|
||||
|
||||
{/* Parallel Study Progress */}
|
||||
{store.parallelStudyTarget && (
|
||||
<div className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-cyan-400" />
|
||||
<span className="text-sm font-semibold text-cyan-300">
|
||||
Parallel: {SKILLS_DEF[store.parallelStudyTarget.id]?.name}
|
||||
{store.parallelStudyTarget.type === 'skill' && ` Lv.${(store.skills[store.parallelStudyTarget.id] || 0) + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
||||
onClick={() => store.cancelParallelStudy?.()}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Progress value={Math.min(100, (store.parallelStudyTarget.progress / store.parallelStudyTarget.required) * 100)} className="h-2 bg-gray-800" />
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>{formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}</span>
|
||||
<span>50% speed (Parallel Study)</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Guardian Panel */}
|
||||
{isGuardianFloor && simpleMode && (
|
||||
<GuardianPanel currentFloor={store.currentFloor} floorElemDef={floorElemDef} />
|
||||
)}
|
||||
|
||||
{/* Room Display */}
|
||||
{simpleMode && (
|
||||
<RoomDisplay
|
||||
roomType={roomType}
|
||||
roomConfig={roomConfig}
|
||||
primaryEnemy={primaryEnemy}
|
||||
swarmEnemies={swarmEnemies}
|
||||
puzzleId={currentRoom?.puzzleId}
|
||||
puzzleProgress={currentRoom?.puzzleProgress}
|
||||
simpleMode={true}
|
||||
floorElemDef={floorElemDef}
|
||||
floorHP={store.floorHP}
|
||||
floorMaxHP={store.floorMaxHP}
|
||||
totalDPS={totalDPS}
|
||||
currentAction={store.currentAction}
|
||||
activeEquipmentSpells={activeEquipmentSpells}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floor Controls */}
|
||||
{simpleMode && (
|
||||
<FloorControls
|
||||
store={store}
|
||||
climbDirection={climbDirection}
|
||||
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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Activity Log - Show in Spire Mode (simpleMode) */}
|
||||
{simpleMode && (
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Activity Log</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-48">
|
||||
<div className="space-y-1">
|
||||
{(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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Crafting Progress - Normal mode only */}
|
||||
{!simpleMode && (store.designProgress || store.preparationProgress || store.applicationProgress) && (
|
||||
<Card className="bg-gray-900/80 border-cyan-600/50">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
{store.designProgress && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-gray-400 mb-1">Design Progress</div>
|
||||
<div className="h-2 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.designProgress.progress / store.designProgress.required) * 100)}%` }} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Crafting Progress (if any) - Only show in normal mode */}
|
||||
{!simpleMode && (store.designProgress || store.preparationProgress || store.applicationProgress) && (
|
||||
<Card className="bg-gray-900/80 border-cyan-600/50 lg:col-span-2">
|
||||
<CardContent className="pt-4">
|
||||
<CraftingProgress
|
||||
designProgress={store.designProgress}
|
||||
preparationProgress={store.preparationProgress}
|
||||
applicationProgress={store.applicationProgress}
|
||||
equipmentInstances={store.equipmentInstances}
|
||||
enchantmentDesigns={store.enchantmentDesigns}
|
||||
cancelDesign={store.cancelDesign!}
|
||||
cancelPreparation={store.cancelPreparation!}
|
||||
pauseApplication={store.pauseApplication!}
|
||||
resumeApplication={store.resumeApplication!}
|
||||
cancelApplication={store.cancelApplication!}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
{store.preparationProgress && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-gray-400 mb-1">Preparation Progress</div>
|
||||
<div className="h-2 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.preparationProgress.progress / store.preparationProgress.required) * 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{store.applicationProgress && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 mb-1">Application Progress</div>
|
||||
<div className="h-2 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.applicationProgress.progress / store.applicationProgress.required) * 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -13,3 +13,11 @@ export { DebugTab } from './DebugTab';
|
||||
export { LootTab } from './LootTab';
|
||||
export { AchievementsTab } from './AchievementsTab';
|
||||
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
@@ -1,14 +1,64 @@
|
||||
// ─── Game Types ───────────────────────────────────────────────────────────────
|
||||
// This file now re-exports types from domain-specific files in the types/ directory.
|
||||
// All type definitions have been moved to:
|
||||
// - types/elements.ts - element-related types
|
||||
// - 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.
|
||||
import type { GameStore } from '@/lib/game/store';
|
||||
import type { SPELLS } from '@/lib/game/constants';
|
||||
import type { EquipmentSpellState } from '@/lib/game/state';
|
||||
import type { RoomType, ActivityLogEntry } from '@/lib/game/types/game';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user