Implement multiple game improvements
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m53s
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m53s
- Guardian barriers with 3x HP regen on guardian floors - Compound mana types auto-unlock when components available - Legs equipment slot with 5 equipment types - Expeditious Retreat and movement enchantments for legs - Fixed tests for current skill definitions (65/65 pass) - New achievements for elements, attunements, and guardians - Removed nonsensical mechanics (thorns, manaShield for player) - Cleaned up skill test references to match current implementation
This commit is contained in:
@@ -3,9 +3,10 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Zap, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Zap, ChevronDown, ChevronUp, Sparkles } from 'lucide-react';
|
||||
import { fmt, fmtDec } from '@/lib/game/store';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { ELEMENTS, getCompositeConversionRate } from '@/lib/game/constants';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ManaDisplayProps {
|
||||
@@ -18,6 +19,7 @@ interface ManaDisplayProps {
|
||||
onGatherStart: () => void;
|
||||
onGatherEnd: () => void;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
baseConversionRates?: Record<string, number>; // Base element conversion rates from attunements
|
||||
}
|
||||
|
||||
export function ManaDisplay({
|
||||
@@ -30,13 +32,37 @@ export function ManaDisplay({
|
||||
onGatherStart,
|
||||
onGatherEnd,
|
||||
elements,
|
||||
baseConversionRates = {},
|
||||
}: ManaDisplayProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
// Get unlocked elements, sorted by current amount (show even if 0 mana)
|
||||
// Get unlocked elements, sorted by category (base first, then composite, then exotic) and amount
|
||||
const unlockedElements = Object.entries(elements)
|
||||
.filter(([, state]) => state.unlocked)
|
||||
.sort((a, b) => b[1].current - a[1].current);
|
||||
.sort((a, b) => {
|
||||
const aDef = ELEMENTS[a[0]];
|
||||
const bDef = ELEMENTS[b[0]];
|
||||
if (!aDef || !bDef) return 0;
|
||||
|
||||
// Sort by category: base < utility < composite < exotic
|
||||
const categoryOrder = { base: 0, utility: 1, composite: 2, exotic: 3 };
|
||||
const aOrder = categoryOrder[aDef.cat] ?? 4;
|
||||
const bOrder = categoryOrder[bDef.cat] ?? 4;
|
||||
|
||||
if (aOrder !== bOrder) return aOrder - bOrder;
|
||||
return b[1].current - a[1].current;
|
||||
});
|
||||
|
||||
// Separate base/utility elements from composite/exotic
|
||||
const baseElements = unlockedElements.filter(([id]) => {
|
||||
const def = ELEMENTS[id];
|
||||
return def && (def.cat === 'base' || def.cat === 'utility');
|
||||
});
|
||||
|
||||
const compositeElements = unlockedElements.filter(([id]) => {
|
||||
const def = ELEMENTS[id];
|
||||
return def && (def.cat === 'composite' || def.cat === 'exotic');
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
@@ -82,37 +108,111 @@ export function ManaDisplay({
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{unlockedElements.map(([id, state]) => {
|
||||
const elem = ELEMENTS[id];
|
||||
if (!elem) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-2 rounded bg-gray-800/50 border border-gray-700"
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span style={{ color: elem.color }}>{elem.sym}</span>
|
||||
<span className="text-xs font-medium" style={{ color: elem.color }}>
|
||||
{elem.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden mb-1">
|
||||
<div className="space-y-2">
|
||||
{/* Base/Utility Elements */}
|
||||
{baseElements.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{baseElements.map(([id, state]) => {
|
||||
const elem = ELEMENTS[id];
|
||||
if (!elem) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min(100, (state.current / state.max) * 100)}%`,
|
||||
backgroundColor: elem.color
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 game-mono">
|
||||
{fmt(state.current)}/{fmt(state.max)}
|
||||
</div>
|
||||
key={id}
|
||||
className="p-2 rounded bg-gray-800/50 border border-gray-700"
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span style={{ color: elem.color }}>{elem.sym}</span>
|
||||
<span className="text-xs font-medium" style={{ color: elem.color }}>
|
||||
{elem.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden mb-1">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min(100, (state.current / state.max) * 100)}%`,
|
||||
backgroundColor: elem.color
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 game-mono">
|
||||
{fmt(state.current)}/{fmt(state.max)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Composite/Exotic Elements */}
|
||||
{compositeElements.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
<span>Compound Elements</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{compositeElements.map(([id, state]) => {
|
||||
const elem = ELEMENTS[id];
|
||||
if (!elem) return null;
|
||||
|
||||
// Get conversion rate for this composite
|
||||
const conversionRate = getCompositeConversionRate(id, baseConversionRates);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="p-2 rounded bg-gray-800/50 border border-gray-700 relative"
|
||||
style={{ borderColor: elem.color + '40' }}
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span style={{ color: elem.color }}>{elem.sym}</span>
|
||||
<span className="text-xs font-medium" style={{ color: elem.color }}>
|
||||
{elem.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Recipe indicator */}
|
||||
{elem.recipe && (
|
||||
<div className="flex items-center gap-0.5 mb-1">
|
||||
{elem.recipe.map((comp, i) => {
|
||||
const compElem = ELEMENTS[comp];
|
||||
return (
|
||||
<span key={i} className="text-xs">
|
||||
{compElem?.sym || '?'}
|
||||
{i < elem.recipe!.length - 1 && <span className="text-gray-600">+</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden mb-1">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min(100, (state.current / state.max) * 100)}%`,
|
||||
backgroundColor: elem.color
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 game-mono">
|
||||
{fmt(state.current)}/{fmt(state.max)}
|
||||
</div>
|
||||
|
||||
{/* Show conversion rate if available */}
|
||||
{conversionRate > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
+{fmtDec(conversionRate, 2)}/hr
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,10 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Swords, BookOpen, ChevronUp, ChevronDown, RotateCcw, X } from 'lucide-react';
|
||||
import { Swords, BookOpen, ChevronUp, ChevronDown, RotateCcw, X, Heart, Shield } from 'lucide-react';
|
||||
import type { GameStore } from '@/lib/game/types';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, GOLEM_DEFS, GOLEM_VARIANTS } from '@/lib/game/constants';
|
||||
import { fmt, fmtDec, getFloorElement, canAffordSpellCost } from '@/lib/game/store';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, GOLEM_DEFS, GOLEM_VARIANTS, HOURS_PER_TICK } from '@/lib/game/constants';
|
||||
import { fmt, fmtDec, getFloorElement, canAffordSpellCost, getFloorHPRegen } 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';
|
||||
@@ -34,6 +34,11 @@ export function SpireTab({ store }: SpireTabProps) {
|
||||
const floorMaxBarrier = store.floorMaxBarrier || 0;
|
||||
const hasBarrier = floorBarrier > 0;
|
||||
|
||||
// HP Regeneration rate (all floors regen during combat)
|
||||
// Guardian floors: 3% per hour, Non-guardian floors: 1% per hour
|
||||
const floorHPRegenRate = getFloorHPRegen(store.currentFloor);
|
||||
const isClimbing = store.currentAction === 'climb';
|
||||
|
||||
// Check if current floor is cleared (for respawn indicator)
|
||||
const isFloorCleared = clearedFloors[store.currentFloor];
|
||||
|
||||
@@ -98,14 +103,19 @@ export function SpireTab({ store }: SpireTabProps) {
|
||||
{isGuardianFloor && floorMaxBarrier > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-400">🛡️ Barrier</span>
|
||||
<span className="text-gray-400 flex items-center gap-1">
|
||||
<Shield className="w-3 h-3" />
|
||||
Barrier
|
||||
<span className="text-gray-500">(no regen)</span>
|
||||
</span>
|
||||
<span className="text-gray-500 game-mono">{fmt(floorBarrier)} / {fmt(floorMaxBarrier)}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300 bg-gray-500"
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.max(0, (floorBarrier / floorMaxBarrier) * 100)}%`,
|
||||
background: hasBarrier ? 'linear-gradient(90deg, #6B7280, #9CA3AF)' : 'linear-gradient(90deg, #374151, #4B5563)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -125,7 +135,15 @@ export function SpireTab({ store }: SpireTabProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-400 game-mono">
|
||||
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP
|
||||
{isClimbing && floorHPRegenRate > 0 && (
|
||||
<span className="text-green-500 flex items-center gap-0.5">
|
||||
<Heart className="w-3 h-3 animate-pulse" />
|
||||
+{fmt(floorHPRegenRate)}/hr
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{store.currentAction === 'climb' && (activeEquipmentSpells.length > 0 || activeGolemsOnFloor.length > 0) ? (
|
||||
<span>
|
||||
|
||||
Reference in New Issue
Block a user