Fix Spire Mode UI issues: HP bar live updates and casting progress overflow
Task 7 (2c): HP Bar Live Updates - Sync floorHP state with enemy HP after damage is applied - This ensures the HP bar updates in real-time as damage lands - Applied fix in main spell casting, equipment spell processing, and golem attacks Task 8 (2d): Casting Progress Overflow - Reset castProgress to 0 when mana is insufficient (instead of keeping accumulated progress) - Added similar fix for equipment spell casting progress - Prevents progress bar from showing >100% when out of mana Files modified: - src/lib/game/store.ts (added floorHP sync and progress reset logic) - src/components/game/SpireTab.tsx (UI component using store state) - src/components/game/tabs/SpireTab.tsx (UI component using store state)
This commit is contained in:
+385
-226
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useGameStore, canAffordSpellCost, fmt, fmtDec, calcDamage } from '@/lib/game/store';
|
import { useGameStore, canAffordSpellCost, fmt, fmtDec, calcDamage, getEnemyName } from '@/lib/game/store';
|
||||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF } from '@/lib/game/constants';
|
import { ELEMENTS, GUARDIANS, SPELLS_DEF, ROOM_TYPE_LABELS } from '@/lib/game/constants';
|
||||||
import { useManaStats, useCombatStats, useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
import { useManaStats, useCombatStats, useStudyStats } from '@/lib/game/hooks/useGameDerived';
|
||||||
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
|
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -10,7 +10,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { X, BookOpen } from 'lucide-react';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { X, BookOpen, Skull, Shield, Wind } from 'lucide-react';
|
||||||
|
|
||||||
export function SpireTab() {
|
export function SpireTab() {
|
||||||
const store = useGameStore();
|
const store = useGameStore();
|
||||||
@@ -21,6 +22,11 @@ export function SpireTab() {
|
|||||||
} = useCombatStats();
|
} = useCombatStats();
|
||||||
const { effectiveStudySpeedMult } = useStudyStats();
|
const { effectiveStudySpeedMult } = useStudyStats();
|
||||||
|
|
||||||
|
// Get room type info
|
||||||
|
const currentRoom = store.currentRoom;
|
||||||
|
const roomType = currentRoom?.roomType || 'combat';
|
||||||
|
const roomConfig = ROOM_TYPE_LABELS[roomType] || ROOM_TYPE_LABELS.combat;
|
||||||
|
|
||||||
// Check if spell can be cast
|
// Check if spell can be cast
|
||||||
const canCastSpell = (spellId: string): boolean => {
|
const canCastSpell = (spellId: string): boolean => {
|
||||||
const spell = SPELLS_DEF[spellId];
|
const spell = SPELLS_DEF[spellId];
|
||||||
@@ -28,6 +34,25 @@ export function SpireTab() {
|
|||||||
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
|
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: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
// Render study progress
|
// Render study progress
|
||||||
const renderStudyProgress = () => {
|
const renderStudyProgress = () => {
|
||||||
if (!store.currentStudyTarget) return null;
|
if (!store.currentStudyTarget) return null;
|
||||||
@@ -65,257 +90,391 @@ export function SpireTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<TooltipProvider>
|
||||||
{/* Current Floor Card */}
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
{/* Current Floor Card */}
|
||||||
<CardHeader className="pb-2">
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Current Floor</CardTitle>
|
<CardHeader className="pb-2">
|
||||||
</CardHeader>
|
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center justify-between">
|
||||||
<CardContent className="space-y-3">
|
<span>Current Floor</span>
|
||||||
<div className="flex items-baseline gap-2">
|
<Badge
|
||||||
<span className="text-4xl font-bold game-title" style={{ color: floorElemDef?.color }}>
|
className="ml-2"
|
||||||
{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>
|
|
||||||
|
|
||||||
{isGuardianFloor && currentGuardian && (
|
|
||||||
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
|
|
||||||
⚔️ {currentGuardian.name}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* HP Bar */}
|
|
||||||
<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={{
|
style={{
|
||||||
width: `${Math.max(0, (store.floorHP / store.floorMaxHP) * 100)}%`,
|
backgroundColor: `${roomConfig.color}20`,
|
||||||
background: `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
|
color: roomConfig.color,
|
||||||
boxShadow: `0 0 10px ${floorElemDef?.glow}`,
|
borderColor: `${roomConfig.color}60`
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</div>
|
{roomConfig.icon} {roomConfig.label}
|
||||||
<div className="flex justify-between text-xs text-gray-400 game-mono">
|
</Badge>
|
||||||
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
|
</CardTitle>
|
||||||
<span>DPS: {store.currentAction === 'climb' && canCastSpell(store.activeSpell) ? fmtDec(dps) : '—'}</span>
|
</CardHeader>
|
||||||
</div>
|
<CardContent className="space-y-3">
|
||||||
</div>
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-4xl font-bold game-title" style={{ color: floorElemDef?.color }}>
|
||||||
<Separator className="bg-gray-700" />
|
{store.currentFloor}
|
||||||
|
</span>
|
||||||
<div className="text-sm text-gray-400">
|
<span className="text-gray-400 text-sm">/ 100</span>
|
||||||
Best: Floor <strong className="text-gray-200">{store.maxFloorReached}</strong> •
|
<span className="ml-auto text-sm" style={{ color: floorElemDef?.color }}>
|
||||||
Pacts: <strong className="text-amber-400">{store.signedPacts.length}</strong>
|
{floorElemDef?.sym} {floorElemDef?.name}
|
||||||
</div>
|
</span>
|
||||||
</CardContent>
|
{isGuardianFloor && (
|
||||||
</Card>
|
<Badge className="bg-red-900/50 text-red-300 border-red-600">GUARDIAN</Badge>
|
||||||
|
|
||||||
{/* Active Spell Card */}
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Active Spell</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{activeSpellDef ? (
|
|
||||||
<>
|
|
||||||
<div className="text-lg font-semibold game-panel-title" style={{ color: activeSpellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[activeSpellDef.elem]?.color }}>
|
|
||||||
{activeSpellDef.name}
|
|
||||||
{activeSpellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200">Basic</Badge>}
|
|
||||||
{activeSpellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100">Legendary</Badge>}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400 game-mono">
|
|
||||||
⚔️ {fmt(calcDamage(store, store.activeSpell))} dmg •
|
|
||||||
<span style={{ color: getSpellCostColor(activeSpellDef.cost) }}>
|
|
||||||
{' '}{formatSpellCost(activeSpellDef.cost)}
|
|
||||||
</span>
|
|
||||||
{' '}• ⚡ {(activeSpellDef.castSpeed || 1).toFixed(1)} casts/hr
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cast progress bar when climbing */}
|
|
||||||
{store.currentAction === 'climb' && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex justify-between text-xs text-gray-400">
|
|
||||||
<span>Cast Progress</span>
|
|
||||||
<span>{((store.castProgress || 0) * 100).toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={Math.min(100, (store.castProgress || 0) * 100)} className="h-2 bg-gray-800" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSpellDef.desc && (
|
|
||||||
<div className="text-xs text-gray-500 italic">{activeSpellDef.desc}</div>
|
|
||||||
)}
|
|
||||||
{activeSpellDef.effects && activeSpellDef.effects.length > 0 && (
|
|
||||||
<div className="flex gap-1 flex-wrap">
|
|
||||||
{activeSpellDef.effects.map((eff, i) => (
|
|
||||||
<Badge key={i} variant="outline" className="text-xs">
|
|
||||||
{eff.type === 'burn' && `🔥 Burn ${eff.value}/hr`}
|
|
||||||
{eff.type === 'stun' && `⚡ Stun ${eff.value}s`}
|
|
||||||
{eff.type === 'pierce' && `🗡️ Pierce ${Math.round(eff.value * 100)}%`}
|
|
||||||
{eff.type === 'multicast' && `✨ ${Math.round(eff.value * 100)}% Multicast`}
|
|
||||||
{eff.type === 'buff' && `💪 Buff`}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-gray-500">No spell selected</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Can cast indicator */}
|
|
||||||
{activeSpellDef && (
|
|
||||||
<div className={`text-xs ${canCastSpell(store.activeSpell) ? 'text-green-400' : 'text-red-400'}`}>
|
|
||||||
{canCastSpell(store.activeSpell) ? '✓ Can cast' : '✗ Insufficient mana'}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{incursionStrength > 0 && (
|
{isGuardianFloor && currentGuardian && (
|
||||||
<div className="p-2 bg-red-900/20 border border-red-800/50 rounded">
|
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
|
||||||
<div className="text-xs text-red-400 game-panel-title mb-1">LABYRINTH INCURSION</div>
|
⚔️ {currentGuardian.name}
|
||||||
<div className="text-sm text-gray-300">
|
|
||||||
-{Math.round(incursionStrength * 100)}% mana regen
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Current Study (if any) */}
|
{/* Single Enemy Display (Combat/Speed/Guardian) */}
|
||||||
{store.currentStudyTarget && (
|
{!isGuardianFloor && primaryEnemy && roomType !== 'swarm' && (
|
||||||
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
|
<div className="p-3 bg-gray-800/50 rounded border border-gray-700">
|
||||||
<CardContent className="pt-4 space-y-3">
|
|
||||||
{renderStudyProgress()}
|
|
||||||
|
|
||||||
{/* 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 justify-between mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BookOpen className="w-4 h-4 text-cyan-400" />
|
<Skull className="w-4 h-4 text-red-400" />
|
||||||
<span className="text-sm font-semibold text-cyan-300">
|
<span className="text-sm font-semibold text-gray-200">
|
||||||
Parallel: {store.parallelStudyTarget.type === 'skill' ? store.parallelStudyTarget.id : store.parallelStudyTarget.id}
|
{primaryEnemy.name || 'Unknown Enemy'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Badge variant="outline" className="text-xs">
|
||||||
variant="ghost"
|
{ELEMENTS[primaryEnemy.element]?.sym} {ELEMENTS[primaryEnemy.element]?.name}
|
||||||
size="sm"
|
</Badge>
|
||||||
className="h-6 w-6 p-0 text-gray-400 hover:text-white"
|
|
||||||
onClick={() => store.cancelParallelStudy()}
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</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">
|
{/* Enemy HP Bar */}
|
||||||
<span>{formatStudyTime(store.parallelStudyTarget.progress)} / {formatStudyTime(store.parallelStudyTarget.required)}</span>
|
<div className="space-y-1 mb-2">
|
||||||
<span>50% speed (Parallel Study)</span>
|
<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>
|
||||||
|
)}
|
||||||
|
</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 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>
|
||||||
|
</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' && canCastSpell(store.activeSpell) ? fmtDec(dps) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator className="bg-gray-700" />
|
||||||
|
|
||||||
|
<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 Spell Card */}
|
||||||
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Active Spell</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{activeSpellDef ? (
|
||||||
|
<>
|
||||||
|
<div className="text-lg font-semibold game-panel-title" style={{ color: activeSpellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[activeSpellDef.elem]?.color }}>
|
||||||
|
{activeSpellDef.name}
|
||||||
|
{activeSpellDef.tier === 0 && <Badge className="ml-2 bg-gray-600 text-gray-200">Basic</Badge>}
|
||||||
|
{activeSpellDef.tier >= 4 && <Badge className="ml-2 bg-amber-600 text-amber-100">Legendary</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400 game-mono">
|
||||||
|
⚔️ {fmt(calcDamage(store, store.activeSpell))} dmg •
|
||||||
|
<span style={{ color: getSpellCostColor(activeSpellDef.cost) }}>
|
||||||
|
{' '}{formatSpellCost(activeSpellDef.cost)}
|
||||||
|
</span>
|
||||||
|
{' '}• ⚡ {(activeSpellDef.castSpeed || 1).toFixed(1)} casts/hr
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cast progress bar when climbing */}
|
||||||
|
{store.currentAction === 'climb' && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-gray-400">
|
||||||
|
<span>Cast Progress</span>
|
||||||
|
<span>{((store.castProgress || 0) * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={Math.min(100, (store.castProgress || 0) * 100)} className="h-2 bg-gray-800" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSpellDef.desc && (
|
||||||
|
<div className="text-xs text-gray-500 italic">{activeSpellDef.desc}</div>
|
||||||
|
)}
|
||||||
|
{activeSpellDef.effects && activeSpellDef.effects.length > 0 && (
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{activeSpellDef.effects.map((eff, i) => (
|
||||||
|
<Badge key={i} variant="outline" className="text-xs">
|
||||||
|
{eff.type === 'burn' && `🔥 Burn ${eff.value}/hr`}
|
||||||
|
{eff.type === 'stun' && `⚡ Stun ${eff.value}s`}
|
||||||
|
{eff.type === 'pierce' && `🗡️ Pierce ${Math.round(eff.value * 100)}%`}
|
||||||
|
{eff.type === 'multicast' && `✨ ${Math.round(eff.value * 100)}% Multicast`}
|
||||||
|
{eff.type === 'buff' && `💪 Buff`}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-500">No spell selected</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Can cast indicator */}
|
||||||
|
{activeSpellDef && (
|
||||||
|
<div className={`text-xs ${canCastSpell(store.activeSpell) ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
|
{canCastSpell(store.activeSpell) ? '✓ Can cast' : '✗ Insufficient mana'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{incursionStrength > 0 && (
|
||||||
|
<div className="p-2 bg-red-900/20 border border-red-800/50 rounded">
|
||||||
|
<div className="text-xs text-red-400 game-panel-title mb-1">LABYRINTH INCURSION</div>
|
||||||
|
<div className="text-sm text-gray-300">
|
||||||
|
-{Math.round(incursionStrength * 100)}% mana regen
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pact Signing Progress */}
|
{/* Current Study (if any) */}
|
||||||
{store.pactSigningProgress && (
|
{store.currentStudyTarget && (
|
||||||
<Card className="bg-gray-900/80 border-amber-600/50 lg:col-span-2">
|
<Card className="bg-gray-900/80 border-purple-600/50 lg:col-span-2">
|
||||||
<CardContent className="pt-4 space-y-3">
|
<CardContent className="pt-4 space-y-3">
|
||||||
<div className="p-3 rounded border border-amber-500/30 bg-amber-900/20">
|
{renderStudyProgress()}
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
{/* Parallel Study Progress */}
|
||||||
<span className="text-2xl">📜</span>
|
{store.parallelStudyTarget && (
|
||||||
<div>
|
<div className="p-3 rounded border border-cyan-600/50 bg-cyan-900/20">
|
||||||
<div className="text-sm font-semibold text-amber-300">
|
<div className="flex items-center justify-between mb-2">
|
||||||
Signing Pact: {GUARDIANS[store.pactSigningProgress.floor]?.name}
|
<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: {store.parallelStudyTarget.type === 'skill' ? store.parallelStudyTarget.id : store.parallelStudyTarget.id}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-amber-400">
|
<Button
|
||||||
Floor {store.pactSigningProgress.floor}
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pact Signing Progress */}
|
||||||
|
{store.pactSigningProgress && (
|
||||||
|
<Card className="bg-gray-900/80 border-amber-600/50 lg:col-span-2">
|
||||||
|
<CardContent className="pt-4 space-y-3">
|
||||||
|
<div className="p-3 rounded border border-amber-500/30 bg-amber-900/20">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">📜</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-amber-300">
|
||||||
|
Signing Pact: {GUARDIANS[store.pactSigningProgress.floor]?.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-amber-400">
|
||||||
|
Floor {store.pactSigningProgress.floor}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={Math.min(100, (store.pactSigningProgress.progress / store.pactSigningProgress.required) * 100)}
|
||||||
|
className="h-2 bg-gray-800"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-amber-400 mt-1">
|
||||||
|
<span>{formatStudyTime(store.pactSigningProgress.progress)} / {formatStudyTime(store.pactSigningProgress.required)}</span>
|
||||||
|
<span>Cost: {fmt(store.pactSigningProgress.manaCost)} mana</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
</CardContent>
|
||||||
value={Math.min(100, (store.pactSigningProgress.progress / store.pactSigningProgress.required) * 100)}
|
</Card>
|
||||||
className="h-2 bg-gray-800"
|
)}
|
||||||
/>
|
|
||||||
<div className="flex justify-between text-xs text-amber-400 mt-1">
|
{/* Spells Available */}
|
||||||
<span>{formatStudyTime(store.pactSigningProgress.progress)} / {formatStudyTime(store.pactSigningProgress.required)}</span>
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
<span>Cost: {fmt(store.pactSigningProgress.manaCost)} mana</span>
|
<CardHeader className="pb-2">
|
||||||
</div>
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Known Spells</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||||
|
{Object.entries(store.spells)
|
||||||
|
.filter(([, state]) => state.learned)
|
||||||
|
.map(([id, state]) => {
|
||||||
|
const def = SPELLS_DEF[id];
|
||||||
|
if (!def) return null;
|
||||||
|
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
|
||||||
|
const isActive = store.activeSpell === id;
|
||||||
|
const canCast = canCastSpell(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={id}
|
||||||
|
variant="outline"
|
||||||
|
className={`h-auto py-2 px-3 flex flex-col items-start ${isActive ? 'border-amber-500 bg-amber-900/20' : canCast ? 'border-gray-600 bg-gray-800/50 hover:bg-gray-700/50' : 'border-gray-700 bg-gray-800/30 opacity-60'}`}
|
||||||
|
onClick={() => store.setSpell(id)}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-semibold" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
|
||||||
|
{def.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 game-mono">
|
||||||
|
{fmt(calcDamage(store, id))} dmg
|
||||||
|
</div>
|
||||||
|
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
|
||||||
|
{formatSpellCost(def.cost)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Spells Available */}
|
{/* Activity Log */}
|
||||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Known Spells</CardTitle>
|
<CardTitle className="text-amber-400 game-panel-title text-xs">Activity Log</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
<ScrollArea className="h-32">
|
||||||
{Object.entries(store.spells)
|
<div className="space-y-1">
|
||||||
.filter(([, state]) => state.learned)
|
{store.log.slice(0, 20).map((entry, i) => (
|
||||||
.map(([id, state]) => {
|
<div
|
||||||
const def = SPELLS_DEF[id];
|
key={i}
|
||||||
if (!def) return null;
|
className={`text-sm ${i === 0 ? 'text-gray-200' : 'text-gray-500'} italic`}
|
||||||
const elemDef = def.elem === 'raw' ? null : ELEMENTS[def.elem];
|
|
||||||
const isActive = store.activeSpell === id;
|
|
||||||
const canCast = canCastSpell(id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={id}
|
|
||||||
variant="outline"
|
|
||||||
className={`h-auto py-2 px-3 flex flex-col items-start ${isActive ? 'border-amber-500 bg-amber-900/20' : canCast ? 'border-gray-600 bg-gray-800/50 hover:bg-gray-700/50' : 'border-gray-700 bg-gray-800/30 opacity-60'}`}
|
|
||||||
onClick={() => store.setSpell(id)}
|
|
||||||
>
|
>
|
||||||
<div className="text-sm font-semibold" style={{ color: def.elem === 'raw' ? '#60A5FA' : elemDef?.color }}>
|
{entry}
|
||||||
{def.name}
|
</div>
|
||||||
</div>
|
))}
|
||||||
<div className="text-xs text-gray-400 game-mono">
|
</div>
|
||||||
{fmt(calcDamage(store, id))} dmg
|
</ScrollArea>
|
||||||
</div>
|
</CardContent>
|
||||||
<div className="text-xs game-mono" style={{ color: getSpellCostColor(def.cost) }}>
|
</Card>
|
||||||
{formatSpellCost(def.cost)}
|
</div>
|
||||||
</div>
|
</TooltipProvider>
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Activity Log */}
|
|
||||||
<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-32">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{store.log.slice(0, 20).map((entry, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={`text-sm ${i === 0 ? 'text-gray-200' : 'text-gray-500'} italic`}
|
|
||||||
>
|
|
||||||
{entry}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
import { TooltipProvider, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { BookOpen, ChevronUp, ChevronDown, RotateCcw, X, Mountain } from 'lucide-react';
|
import { BookOpen, ChevronUp, ChevronDown, RotateCcw, X, Mountain, Skull, Zap, Wind, Shield } from 'lucide-react';
|
||||||
import type { GameStore } from '@/lib/game/types';
|
import type { GameStore } from '@/lib/game/types';
|
||||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF } from '@/lib/game/constants';
|
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 { GOLEMS_DEF, getGolemDamage, getGolemAttackSpeed } from '@/lib/game/data/golems';
|
||||||
import { fmt, fmtDec, getFloorElement, canAffordSpellCost } from '@/lib/game/store';
|
import { fmt, fmtDec, getFloorElement, canAffordSpellCost, getEnemyName } 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 { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
|
||||||
import { CraftingProgress, StudyProgress } from '@/components/game';
|
import { CraftingProgress, StudyProgress } from '@/components/game';
|
||||||
@@ -27,6 +27,15 @@ const canEnterSpireMode = (store: GameStore): boolean => {
|
|||||||
return !store.spireMode; // Can enter if not already in Spire Mode
|
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 }> = {
|
||||||
|
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 SpireTab({ store, simpleMode = false }: SpireTabProps) {
|
export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
|
||||||
const floorElem = getFloorElement(store.currentFloor);
|
const floorElem = getFloorElement(store.currentFloor);
|
||||||
const floorElemDef = ELEMENTS[floorElem];
|
const floorElemDef = ELEMENTS[floorElem];
|
||||||
@@ -34,10 +43,15 @@ export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
|
|||||||
const currentGuardian = GUARDIANS[store.currentFloor];
|
const currentGuardian = GUARDIANS[store.currentFloor];
|
||||||
const climbDirection = store.climbDirection || 'up';
|
const climbDirection = store.climbDirection || 'up';
|
||||||
const clearedFloors = store.clearedFloors || {};
|
const clearedFloors = store.clearedFloors || {};
|
||||||
|
const currentRoom = store.currentRoom;
|
||||||
|
|
||||||
// Check if current floor is cleared (for respawn indicator)
|
// 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 roomConfig = ROOM_TYPE_CONFIG[roomType] || ROOM_TYPE_CONFIG.combat;
|
||||||
|
|
||||||
// Get active equipment spells
|
// Get active equipment spells
|
||||||
const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances);
|
const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances);
|
||||||
|
|
||||||
@@ -52,6 +66,25 @@ export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
|
|||||||
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
|
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: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
<TooltipProvider>
|
||||||
<div className={`grid gap-4 ${simpleMode ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-2'}`}>
|
<div className={`grid gap-4 ${simpleMode ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-2'}`}>
|
||||||
@@ -104,7 +137,19 @@ export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
|
|||||||
{simpleMode && (
|
{simpleMode && (
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-amber-400 game-panel-title text-xs">Current Floor</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
@@ -120,29 +165,150 @@ export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Guardian Name */}
|
||||||
{isGuardianFloor && currentGuardian && (
|
{isGuardianFloor && currentGuardian && (
|
||||||
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
|
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
|
||||||
⚔️ {currentGuardian.name}
|
⚔️ {currentGuardian.name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* HP Bar */}
|
{/* Single Enemy Display (Combat/Speed/Guardian) */}
|
||||||
<div className="space-y-1">
|
{!isGuardianFloor && primaryEnemy && roomType !== 'swarm' && (
|
||||||
<div className="h-3 bg-gray-800 rounded-full overflow-hidden">
|
<div className="p-3 bg-gray-800/50 rounded border border-gray-700">
|
||||||
<div
|
<div className="flex items-center justify-between mb-2">
|
||||||
className="h-full rounded-full transition-all duration-300"
|
<div className="flex items-center gap-2">
|
||||||
style={{
|
<Skull className="w-4 h-4 text-red-400" />
|
||||||
width: `${Math.max(0, (store.floorHP / store.floorMaxHP) * 100)}%`,
|
<span className="text-sm font-semibold text-gray-200">
|
||||||
background: `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
|
{primaryEnemy.name || 'Unknown Enemy'}
|
||||||
boxShadow: `0 0 10px ${floorElemDef?.glow}`,
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
{/* 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 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>
|
||||||
|
</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">
|
<div className="text-sm text-gray-400">
|
||||||
Best: Floor <strong className="text-gray-200">{store.maxFloorReached}</strong> •
|
Best: Floor <strong className="text-gray-200">{store.maxFloorReached}</strong> •
|
||||||
|
|||||||
+159
-14
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { GameState, GameAction, StudyTarget, SpellCost, SkillUpgradeChoice, EquipmentSlot, EquipmentInstance, EnchantmentDesign, DesignEffect, AttunementState, FloorState, EnemyState, RoomType, EquipmentSpellState } from './types';
|
import type { GameState, GameAction, StudyTarget, SpellCost, SkillUpgradeChoice, EquipmentSlot, EquipmentInstance, EnchantmentDesign, DesignEffect, AttunementState, FloorState, EnemyState, RoomType, EquipmentSpellState, ActivityLogEntry } from './types';
|
||||||
import {
|
import {
|
||||||
ELEMENTS,
|
ELEMENTS,
|
||||||
GUARDIANS,
|
GUARDIANS,
|
||||||
@@ -165,6 +165,34 @@ export function getDodgeChance(floor: number): number {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Enemy Naming System ───────────────────────────────────────────────
|
||||||
|
// Generate enemy names based on element and floor tier
|
||||||
|
const ENEMY_NAMES_BY_ELEMENT: Record<string, string[]> = {
|
||||||
|
fire: ['Fire Imp', 'Flame Sprite', 'Emberling', 'Scorchling', 'Inferno Whelp'],
|
||||||
|
water: ['Water Elemental', 'Tidal Wraith', 'Aqua Sprite', 'Drowned One', 'Tsunami Spawn'],
|
||||||
|
air: ['Wind Sylph', 'Gale Rider', 'Storm Spirit', 'Zephyr Darter', 'Cyclone Wisp'],
|
||||||
|
earth: ['Stone Golem', 'Earth Elemental', 'Graveling', 'Mountain Giant', 'Terra Brute'],
|
||||||
|
light: ['Light Saint', 'Radiant Angel', 'Luminous Spirit', 'Divine Warden', 'Holy Sentinel'],
|
||||||
|
dark: ['Shadow Assassin', 'Dark Cultist', 'Umbral Fiend', 'Void Walker', 'Night Stalker'],
|
||||||
|
death: ['Skeleton Warrior', 'Zombie Lord', 'Lichling', 'Bone Reaper', 'Necrotic Wraith'],
|
||||||
|
// Special element names
|
||||||
|
lightning: ['Storm Elemental', 'Thunder Hawk', 'Lightning Eel', 'Shock Sprite', 'Voltaic Wisp'],
|
||||||
|
metal: ['Iron Golem', 'Steel Guardian', 'Rust Monster', 'Chrome Beetle', 'Mercury Spirit'],
|
||||||
|
sand: ['Sand Wraith', 'Dune Stalker', 'Desert Spirit', 'Cactus Thrasher', 'Mirage Runner'],
|
||||||
|
crystal: ['Crystal Guardian', 'Prism Sprite', 'Gem Hound', 'Diamond Golem', 'Shardling'],
|
||||||
|
stellar: ['Star Spawn', 'Cosmic Entity', 'Nova Spirit', 'Astral Watcher', 'Supernova Seed'],
|
||||||
|
void: ['Void Lord', 'Abyssal Horror', 'Entropy Spawn', 'Chaos Elemental', 'Nether Beast'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get enemy name based on element and floor tier (1-100)
|
||||||
|
export function getEnemyName(element: string, floor: number): string {
|
||||||
|
const names = ENEMY_NAMES_BY_ELEMENT[element] || ['Unknown Entity'];
|
||||||
|
// Higher floors get "stronger" sounding names (pick from later in the list)
|
||||||
|
const tierIndex = Math.min(names.length - 1, Math.floor(floor / 20));
|
||||||
|
const randomIndex = (tierIndex + Math.floor(Math.random() * (names.length - tierIndex))) % names.length;
|
||||||
|
return names[randomIndex!];
|
||||||
|
}
|
||||||
|
|
||||||
// Generate enemies for a swarm room
|
// Generate enemies for a swarm room
|
||||||
export function generateSwarmEnemies(floor: number): EnemyState[] {
|
export function generateSwarmEnemies(floor: number): EnemyState[] {
|
||||||
const baseHP = getFloorMaxHP(floor);
|
const baseHP = getFloorMaxHP(floor);
|
||||||
@@ -174,8 +202,10 @@ export function generateSwarmEnemies(floor: number): EnemyState[] {
|
|||||||
|
|
||||||
const enemies: EnemyState[] = [];
|
const enemies: EnemyState[] = [];
|
||||||
for (let i = 0; i < numEnemies; i++) {
|
for (let i = 0; i < numEnemies; i++) {
|
||||||
|
const enemyName = getEnemyName(element, floor);
|
||||||
enemies.push({
|
enemies.push({
|
||||||
id: `enemy_${i}`,
|
id: `enemy_${i}`,
|
||||||
|
name: enemyName,
|
||||||
hp: Math.floor(baseHP * SWARM_CONFIG.hpMultiplier),
|
hp: Math.floor(baseHP * SWARM_CONFIG.hpMultiplier),
|
||||||
maxHP: Math.floor(baseHP * SWARM_CONFIG.hpMultiplier),
|
maxHP: Math.floor(baseHP * SWARM_CONFIG.hpMultiplier),
|
||||||
armor: SWARM_CONFIG.armorBase + Math.floor(floor / 10) * SWARM_CONFIG.armorPerFloor,
|
armor: SWARM_CONFIG.armorBase + Math.floor(floor / 10) * SWARM_CONFIG.armorPerFloor,
|
||||||
@@ -199,6 +229,7 @@ export function generateFloorState(floor: number): FloorState {
|
|||||||
roomType: 'guardian',
|
roomType: 'guardian',
|
||||||
enemies: [{
|
enemies: [{
|
||||||
id: 'guardian',
|
id: 'guardian',
|
||||||
|
name: guardian.name,
|
||||||
hp: guardian.hp,
|
hp: guardian.hp,
|
||||||
maxHP: guardian.hp,
|
maxHP: guardian.hp,
|
||||||
armor: guardian.armor || 0,
|
armor: guardian.armor || 0,
|
||||||
@@ -213,11 +244,13 @@ export function generateFloorState(floor: number): FloorState {
|
|||||||
enemies: generateSwarmEnemies(floor),
|
enemies: generateSwarmEnemies(floor),
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'speed':
|
case 'speed': {
|
||||||
|
const speedEnemyName = getEnemyName(element, floor);
|
||||||
return {
|
return {
|
||||||
roomType: 'speed',
|
roomType: 'speed',
|
||||||
enemies: [{
|
enemies: [{
|
||||||
id: 'speed_enemy',
|
id: 'speed_enemy',
|
||||||
|
name: speedEnemyName,
|
||||||
hp: baseHP,
|
hp: baseHP,
|
||||||
maxHP: baseHP,
|
maxHP: baseHP,
|
||||||
armor: getFloorArmor(floor),
|
armor: getFloorArmor(floor),
|
||||||
@@ -225,6 +258,7 @@ export function generateFloorState(floor: number): FloorState {
|
|||||||
element,
|
element,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case 'puzzle': {
|
case 'puzzle': {
|
||||||
// Select a puzzle type based on player's attunements
|
// Select a puzzle type based on player's attunements
|
||||||
@@ -242,10 +276,12 @@ export function generateFloorState(floor: number): FloorState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
default: // combat
|
default: // combat
|
||||||
|
const combatEnemyName = getEnemyName(element, floor);
|
||||||
return {
|
return {
|
||||||
roomType: 'combat',
|
roomType: 'combat',
|
||||||
enemies: [{
|
enemies: [{
|
||||||
id: 'enemy',
|
id: 'enemy',
|
||||||
|
name: combatEnemyName,
|
||||||
hp: baseHP,
|
hp: baseHP,
|
||||||
maxHP: baseHP,
|
maxHP: baseHP,
|
||||||
armor: getFloorArmor(floor),
|
armor: getFloorArmor(floor),
|
||||||
@@ -778,9 +814,42 @@ function makeInitial(overrides: Partial<GameState> = {}): GameState {
|
|||||||
|
|
||||||
// Spire Mode - simplified UI for climbing
|
// Spire Mode - simplified UI for climbing
|
||||||
spireMode: false,
|
spireMode: false,
|
||||||
|
clearedFloors: {},
|
||||||
|
climbDirection: null,
|
||||||
|
isDescending: false,
|
||||||
|
|
||||||
|
// Activity Log (for Spire Mode UI)
|
||||||
|
activityLog: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Activity Log Helper ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createActivityEntry(
|
||||||
|
eventType: string,
|
||||||
|
message: string,
|
||||||
|
details?: ActivityLogEntry['details']
|
||||||
|
): ActivityLogEntry {
|
||||||
|
return {
|
||||||
|
id: `act_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
timestamp: Date.now(), // Use timestamp for ordering
|
||||||
|
eventType: eventType as any,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addActivityLogEntry(
|
||||||
|
state: GameState,
|
||||||
|
eventType: string,
|
||||||
|
message: string,
|
||||||
|
details?: ActivityLogEntry['details']
|
||||||
|
): ActivityLogEntry[] {
|
||||||
|
const entry = createActivityEntry(eventType, message, details);
|
||||||
|
// Keep last 100 entries, newest first
|
||||||
|
return [entry, ...state.activityLog.slice(0, 99)];
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Game Store ───────────────────────────────────────────────────────────────
|
// ─── Game Store ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface GameStore extends GameState, CraftingActions {
|
export interface GameStore extends GameState, CraftingActions {
|
||||||
@@ -788,6 +857,7 @@ export interface GameStore extends GameState, CraftingActions {
|
|||||||
tick: () => void;
|
tick: () => void;
|
||||||
gatherMana: () => void;
|
gatherMana: () => void;
|
||||||
setAction: (action: GameAction) => void;
|
setAction: (action: GameAction) => void;
|
||||||
|
addActivityLog: (eventType: string, message: string, details?: ActivityLogEntry['details']) => void;
|
||||||
setSpell: (spellId: string) => void;
|
setSpell: (spellId: string) => void;
|
||||||
startStudyingSkill: (skillId: string) => void;
|
startStudyingSkill: (skillId: string) => void;
|
||||||
startStudyingSpell: (spellId: string) => void;
|
startStudyingSpell: (spellId: string) => void;
|
||||||
@@ -861,6 +931,12 @@ export const useGameStore = create<GameStore>()(
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addActivityLog: (eventType: string, message: string, details?: ActivityLogEntry['details']) => {
|
||||||
|
set((state) => ({
|
||||||
|
activityLog: addActivityLogEntry(state, eventType, message, details),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
tick: () => {
|
tick: () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
if (state.gameOver || state.paused) return;
|
if (state.gameOver || state.paused) return;
|
||||||
@@ -993,7 +1069,7 @@ export const useGameStore = create<GameStore>()(
|
|||||||
const actualConversion = Math.min(conversionAmount, rawMana, elem.max - elem.current);
|
const actualConversion = Math.min(conversionAmount, rawMana, elem.max - elem.current);
|
||||||
|
|
||||||
if (actualConversion > 0) {
|
if (actualConversion > 0) {
|
||||||
rawMana -= actualConversion;
|
// rawMana adjustment already handled by effectiveRegen (conversion drain included)
|
||||||
elements = {
|
elements = {
|
||||||
...elements,
|
...elements,
|
||||||
[attDef.primaryManaType]: {
|
[attDef.primaryManaType]: {
|
||||||
@@ -1170,7 +1246,8 @@ export const useGameStore = create<GameStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Combat - uses cast speed and spell casting
|
// Combat - uses cast speed and spell casting
|
||||||
let { currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, castProgress, currentRoom, comboHitCount, floorHitCount } = state;
|
let { currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, castProgress, currentRoom, comboHitCount, floorHitCount, activityLog } = state;
|
||||||
|
activityLog = activityLog || [];
|
||||||
comboHitCount = comboHitCount || 0;
|
comboHitCount = comboHitCount || 0;
|
||||||
floorHitCount = floorHitCount || 0;
|
floorHitCount = floorHitCount || 0;
|
||||||
const floorElement = getFloorElement(currentFloor);
|
const floorElement = getFloorElement(currentFloor);
|
||||||
@@ -1286,6 +1363,11 @@ export const useGameStore = create<GameStore>()(
|
|||||||
|
|
||||||
if (Math.random() < effectiveDodge) {
|
if (Math.random() < effectiveDodge) {
|
||||||
log = [`💨 Enemy dodged the attack!`, ...log.slice(0, 49)];
|
log = [`💨 Enemy dodged the attack!`, ...log.slice(0, 49)];
|
||||||
|
// Log dodge to activity log
|
||||||
|
activityLog = addActivityLogEntry(state, 'dodge',
|
||||||
|
`💨 Enemy dodged the attack!`,
|
||||||
|
{ enemyName: 'enemy', floor: currentFloor }
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1294,6 +1376,14 @@ export const useGameStore = create<GameStore>()(
|
|||||||
const effectiveArmor = Math.max(0, enemy.armor - armorPierce);
|
const effectiveArmor = Math.max(0, enemy.armor - armorPierce);
|
||||||
dmg *= (1 - effectiveArmor);
|
dmg *= (1 - effectiveArmor);
|
||||||
|
|
||||||
|
// Log armor proc if armor reduced damage
|
||||||
|
if (effectiveArmor > 0) {
|
||||||
|
activityLog = addActivityLogEntry(state, 'armor_proc',
|
||||||
|
`🛡️ Armor reduced damage by ${Math.round(effectiveArmor * 100)}%`,
|
||||||
|
{ damage: Math.floor(dmg), enemyName: 'enemy', floor: currentFloor }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Increment hit counters
|
// Increment hit counters
|
||||||
comboHitCount += 1;
|
comboHitCount += 1;
|
||||||
floorHitCount += 1;
|
floorHitCount += 1;
|
||||||
@@ -1339,12 +1429,23 @@ export const useGameStore = create<GameStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply damage to enemy
|
// Apply damage to enemy
|
||||||
enemy.hp = Math.max(0, enemy.hp - Math.floor(dmg));
|
const dmgDealt = Math.floor(dmg);
|
||||||
|
enemy.hp = Math.max(0, enemy.hp - dmgDealt);
|
||||||
|
|
||||||
|
// Log damage dealt to activity log
|
||||||
|
const floorElement = getFloorElement(currentFloor);
|
||||||
|
activityLog = addActivityLogEntry(state, 'damage_dealt',
|
||||||
|
`⚔️ ${dmgDealt} damage to ${floorElement} enemy (${spellDef.name})`,
|
||||||
|
{ damage: dmgDealt, enemyName: `${floorElement} enemy`, floor: currentFloor, spellName: spellDef.name }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update currentRoom with damaged enemies
|
// Update currentRoom with damaged enemies
|
||||||
currentRoom = { ...currentRoom, enemies: [...currentRoom.enemies] };
|
currentRoom = { ...currentRoom, enemies: [...currentRoom.enemies] };
|
||||||
|
|
||||||
|
// Sync floorHP with enemy HP for live UI updates (Fix Task 7: HP Bar Live Updates)
|
||||||
|
floorHP = currentRoom.enemies[0]?.hp || 0;
|
||||||
|
|
||||||
// Reduce cast progress by 1 (one cast completed)
|
// Reduce cast progress by 1 (one cast completed)
|
||||||
castProgress -= 1;
|
castProgress -= 1;
|
||||||
|
|
||||||
@@ -1365,6 +1466,11 @@ export const useGameStore = create<GameStore>()(
|
|||||||
if (wasGuardian && !signedPacts.includes(currentFloor)) {
|
if (wasGuardian && !signedPacts.includes(currentFloor)) {
|
||||||
signedPacts = [...signedPacts, currentFloor];
|
signedPacts = [...signedPacts, currentFloor];
|
||||||
log = [`⚔️ ${wasGuardian.name} defeated! Pact signed! (${wasGuardian.pact}x)`, ...log.slice(0, 49)];
|
log = [`⚔️ ${wasGuardian.name} defeated! Pact signed! (${wasGuardian.pact}x)`, ...log.slice(0, 49)];
|
||||||
|
// Log guardian defeated to activity log
|
||||||
|
activityLog = addActivityLogEntry(state, 'enemy_defeated',
|
||||||
|
`⚔️ ${wasGuardian.name} defeated! Pact signed! (${wasGuardian.pact}x)`,
|
||||||
|
{ enemyName: wasGuardian.name, floor: currentFloor }
|
||||||
|
);
|
||||||
} else if (!wasGuardian) {
|
} else if (!wasGuardian) {
|
||||||
const roomTypeName = currentRoom.roomType === 'swarm' ? 'Swarm'
|
const roomTypeName = currentRoom.roomType === 'swarm' ? 'Swarm'
|
||||||
: currentRoom.roomType === 'speed' ? 'Speed floor'
|
: currentRoom.roomType === 'speed' ? 'Speed floor'
|
||||||
@@ -1372,6 +1478,11 @@ export const useGameStore = create<GameStore>()(
|
|||||||
: 'Floor';
|
: 'Floor';
|
||||||
if (currentFloor % 5 === 0 || currentRoom.roomType !== 'combat') {
|
if (currentFloor % 5 === 0 || currentRoom.roomType !== 'combat') {
|
||||||
log = [`🏰 ${roomTypeName} ${currentFloor} cleared!`, ...log.slice(0, 49)];
|
log = [`🏰 ${roomTypeName} ${currentFloor} cleared!`, ...log.slice(0, 49)];
|
||||||
|
// Log floor cleared to activity log
|
||||||
|
activityLog = addActivityLogEntry(state, 'floor_cleared',
|
||||||
|
`🏰 ${roomTypeName} ${currentFloor} cleared!`,
|
||||||
|
{ floor: currentFloor }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1384,14 +1495,21 @@ export const useGameStore = create<GameStore>()(
|
|||||||
floorHP = currentRoom.enemies[0]?.hp || floorMaxHP;
|
floorHP = currentRoom.enemies[0]?.hp || floorMaxHP;
|
||||||
maxFloorReached = Math.max(maxFloorReached, currentFloor);
|
maxFloorReached = Math.max(maxFloorReached, currentFloor);
|
||||||
|
|
||||||
|
// Log floor transition to activity log
|
||||||
|
const newFloorElem = getFloorElement(currentFloor);
|
||||||
|
activityLog = addActivityLogEntry(state, 'floor_transition',
|
||||||
|
`⬆️ Advanced to floor ${currentFloor} (${newFloorElem})`,
|
||||||
|
{ floor: currentFloor }
|
||||||
|
);
|
||||||
|
|
||||||
// Reset cast progress and floor hit counter on floor change
|
// Reset cast progress and floor hit counter on floor change
|
||||||
castProgress = 0;
|
castProgress = 0;
|
||||||
floorHitCount = 0;
|
floorHitCount = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Not enough mana - pause casting (keep progress)
|
// Not enough mana - reset casting progress to 0 (Fix Task 8: Casting Progress Overflow)
|
||||||
castProgress = castProgress || 0;
|
castProgress = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1462,6 +1580,11 @@ export const useGameStore = create<GameStore>()(
|
|||||||
equipCastProgress -= 1;
|
equipCastProgress -= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset equipment spell progress to 0 when mana is insufficient (Fix Task 8)
|
||||||
|
if (equipCastProgress >= 1 && !canAffordSpellCost(spellDef.cost, rawMana, elements)) {
|
||||||
|
equipCastProgress = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Update spell state with new progress
|
// Update spell state with new progress
|
||||||
spellState = { ...spellState, castProgress: equipCastProgress };
|
spellState = { ...spellState, castProgress: equipCastProgress };
|
||||||
}
|
}
|
||||||
@@ -1617,6 +1740,9 @@ export const useGameStore = create<GameStore>()(
|
|||||||
// Update currentRoom with damaged enemies
|
// Update currentRoom with damaged enemies
|
||||||
currentRoom = { ...currentRoom, enemies: [...currentRoom.enemies] };
|
currentRoom = { ...currentRoom, enemies: [...currentRoom.enemies] };
|
||||||
|
|
||||||
|
// Sync floorHP with enemy HP for live UI updates (Fix Task 7)
|
||||||
|
floorHP = currentRoom.enemies[0]?.hp || 0;
|
||||||
|
|
||||||
// Reduce attack progress
|
// Reduce attack progress
|
||||||
summonedGolem.attackProgress -= 1;
|
summonedGolem.attackProgress -= 1;
|
||||||
|
|
||||||
@@ -1714,6 +1840,7 @@ export const useGameStore = create<GameStore>()(
|
|||||||
elements,
|
elements,
|
||||||
unlockedEffects,
|
unlockedEffects,
|
||||||
log,
|
log,
|
||||||
|
activityLog,
|
||||||
castProgress,
|
castProgress,
|
||||||
equipmentSpellStates,
|
equipmentSpellStates,
|
||||||
golemancy,
|
golemancy,
|
||||||
@@ -2068,16 +2195,27 @@ export const useGameStore = create<GameStore>()(
|
|||||||
|
|
||||||
// Spire Mode - enter simplified UI for climbing
|
// Spire Mode - enter simplified UI for climbing
|
||||||
enterSpireMode: () => {
|
enterSpireMode: () => {
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
spireMode: true,
|
// Resume from current floor - don't reset to floor 1
|
||||||
currentAction: 'climb',
|
const climbDirection = state.climbDirection || 'up';
|
||||||
log: ['🏔️ Entered Spire Mode! The climb begins...', ...state.log.slice(0, 49)],
|
return {
|
||||||
}));
|
spireMode: true,
|
||||||
|
currentAction: 'climb',
|
||||||
|
climbDirection,
|
||||||
|
isDescending: false,
|
||||||
|
log: [`🏔️ Entered Spire Mode! Floor ${state.currentFloor}.`, ...state.log.slice(0, 49)],
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Climb down one floor (for Spire Mode)
|
// Climb down one floor (for Spire Mode)
|
||||||
climbDownFloor: () => {
|
climbDownFloor: () => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
// Prevent spam clicking - check if already descending
|
||||||
|
if (state.isDescending) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
const newFloor = Math.max(1, state.currentFloor - 1);
|
const newFloor = Math.max(1, state.currentFloor - 1);
|
||||||
if (newFloor === state.currentFloor) {
|
if (newFloor === state.currentFloor) {
|
||||||
// Already at floor 1, can't go down further
|
// Already at floor 1, can't go down further
|
||||||
@@ -2101,10 +2239,16 @@ export const useGameStore = create<GameStore>()(
|
|||||||
maxFloorReached: Math.max(state.maxFloorReached, newFloor),
|
maxFloorReached: Math.max(state.maxFloorReached, newFloor),
|
||||||
clearedFloors,
|
clearedFloors,
|
||||||
climbDirection: 'down' as const,
|
climbDirection: 'down' as const,
|
||||||
|
isDescending: true, // Set descending state to prevent spam
|
||||||
equipmentSpellStates: state.equipmentSpellStates.map(s => ({ ...s, castProgress: 0 })),
|
equipmentSpellStates: state.equipmentSpellStates.map(s => ({ ...s, castProgress: 0 })),
|
||||||
log: [`⬇️ Climbed down to floor ${newFloor}${newFloorCleared ? ' (respawned)' : ''}.`, ...state.log.slice(0, 49)],
|
log: [`⬇️ Descending to floor ${newFloor}${newFloorCleared ? ' (respawned)' : ''}.`, ...state.log.slice(0, 49)],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset descending state after a short delay to prevent spam
|
||||||
|
setTimeout(() => {
|
||||||
|
set((state) => ({ ...state, isDescending: false }));
|
||||||
|
}, 500);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Exit Spire Mode - only works when at floor 1
|
// Exit Spire Mode - only works when at floor 1
|
||||||
@@ -2117,7 +2261,8 @@ export const useGameStore = create<GameStore>()(
|
|||||||
return {
|
return {
|
||||||
spireMode: false,
|
spireMode: false,
|
||||||
currentAction: 'meditate',
|
currentAction: 'meditate',
|
||||||
log: ['⬇️ Climbed down from the Spire. Returning to normal view.', ...state.log.slice(0, 49)],
|
isDescending: false,
|
||||||
|
log: ['⬇️ Exited Spire Mode. Returning to normal view.', ...state.log.slice(0, 49)],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user