Files
Mana-Loop/src/components/game/tabs/SpireTab.tsx
Z User a1f19e705b
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m46s
Remove impossible mechanics and fix game balance
- Remove lifesteal from spells (player has no health to heal)
- Remove execute effects (too powerful instant-kill mechanic)
- Remove freeze status effect (doesn't fit game design)
- Remove knowledgeRetention skill (study progress is now always saved)
- Fix soulBinding skill (now binds guardian essence to equipment)
- Buff ancientEcho skill (+1 capacity per level instead of per 2 levels)
- Rename lifesteal_5 to mana_siphon_5 in enchantment effects
- Update guardian perks:
  - Water: 10% double cast chance instead of lifesteal
  - Dark: 25% crit chance instead of lifesteal
  - Life: 30% damage restored as mana instead of healing
  - Death: +50% damage to enemies below 50% HP instead of execute
- Add floor armor system (flat damage reduction)
- Update spell effects display in UI
- Fix study cancellation - progress is always saved when pausing
2026-03-28 13:41:10 +00:00

487 lines
23 KiB
TypeScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 } from '@/components/ui/tooltip';
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, 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';
import { getUnifiedEffects } from '@/lib/game/effects';
interface SpireTabProps {
store: GameStore;
}
export function SpireTab({ store }: SpireTabProps) {
const floorElem = getFloorElement(store.currentFloor);
const floorElemDef = ELEMENTS[floorElem];
const isGuardianFloor = !!GUARDIANS[store.currentFloor];
const currentGuardian = GUARDIANS[store.currentFloor];
const climbDirection = store.climbDirection || 'up';
const isDescending = store.isDescending || false;
const clearedFloors = store.clearedFloors || {};
// Barrier state
const floorBarrier = store.floorBarrier || 0;
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];
// Get active equipment spells
const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances);
// Get upgrade effects and DPS
const upgradeEffects = getUnifiedEffects(store);
const spellDPS = getTotalDPS(store, upgradeEffects, floorElem);
const golemDPS = store.getActiveGolemDPS();
const totalDPS = spellDPS + golemDPS;
const studySpeedMult = 1; // Base study speed
const canCastSpell = (spellId: string): boolean => {
const spell = SPELLS_DEF[spellId];
if (!spell) return false;
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
};
// Helper to get golem element color
const getGolemElementColor = (element: string): string => {
return ELEMENTS[element]?.color || '#F4A261'; // Default to earth color
};
// Helper to get golem element symbol
const getGolemElementSymbol = (element: string): string => {
return ELEMENTS[element]?.sym || '⛰️';
};
// Get active golems on current floor
const activeGolemsOnFloor = store.activeGolems.filter(g => g.currentFloor === store.currentFloor);
return (
<TooltipProvider>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Current Floor Card */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">Current Floor</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>
{isGuardianFloor && currentGuardian && (
<div className="text-sm font-semibold game-panel-title" style={{ color: floorElemDef?.color }}>
{currentGuardian.name}
</div>
)}
{/* Barrier Bar (Guardians only) */}
{isGuardianFloor && floorMaxBarrier > 0 && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<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"
style={{
width: `${Math.max(0, (floorBarrier / floorMaxBarrier) * 100)}%`,
background: hasBarrier ? 'linear-gradient(90deg, #6B7280, #9CA3AF)' : 'linear-gradient(90deg, #374151, #4B5563)',
}}
/>
</div>
</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={{
width: `${Math.max(0, (store.floorHP / store.floorMaxHP) * 100)}%`,
background: hasBarrier ? `linear-gradient(90deg, #6B728099, #6B7280)` : `linear-gradient(90deg, ${floorElemDef?.color}99, ${floorElemDef?.color})`,
boxShadow: hasBarrier ? 'none' : `0 0 10px ${floorElemDef?.glow}`,
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-400 game-mono">
<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>
DPS: {fmtDec(totalDPS)}
{activeGolemsOnFloor.length > 0 && (
<span className="text-gray-500 ml-1">
(Spell: {fmtDec(spellDPS)} | Golem: {fmtDec(golemDPS)})
</span>
)}
</span>
) : '—'}
</span>
</div>
</div>
<Separator className="bg-gray-700" />
{/* Floor Navigation - Direction indicator only */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">Direction</span>
<div className="flex gap-1">
<Badge variant={climbDirection === 'up' ? 'default' : 'outline'}
className={climbDirection === 'up' ? 'bg-green-600' : ''}>
<ChevronUp className="w-3 h-3 mr-1" />
Up
</Badge>
<Badge variant={climbDirection === 'down' ? 'default' : 'outline'}
className={climbDirection === 'down' ? 'bg-blue-600' : ''}>
<ChevronDown className="w-3 h-3 mr-1" />
Down
</Badge>
</div>
</div>
{isDescending && (
<div className="text-xs text-blue-400 text-center flex items-center justify-center gap-1">
<ChevronDown className="w-3 h-3 animate-bounce" />
Descending... Fight through each floor to exit!
</div>
)}
{isFloorCleared && (
<div className="text-xs text-amber-400 text-center flex items-center justify-center gap-1">
<RotateCcw className="w-3 h-3" />
Floor cleared! Advancing...
</div>
)}
</div>
<Separator className="bg-gray-700" />
{/* Exit Spire Button */}
{store.currentAction === 'climb' && store.currentFloor > 1 && !isDescending && (
<Button
variant="outline"
className="w-full border-blue-600 text-blue-400 hover:bg-blue-900/20"
onClick={() => store.exitSpire?.()}
>
<X className="w-4 h-4 mr-2" />
Exit Spire (Descend from Floor {store.currentFloor})
</Button>
)}
<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 Golems Card */}
<Card className="bg-gray-900/80 border-gray-700" style={{ borderColor: activeGolemsOnFloor.length > 0 ? '#F4A26150' : undefined }}>
<CardHeader className="pb-2">
<CardTitle className="game-panel-title text-xs flex items-center gap-2" style={{ color: '#F4A261' }}>
<span>🗿 Active Golems</span>
{activeGolemsOnFloor.length > 0 && (
<Badge className="bg-amber-900/50 text-amber-300 border-amber-600">
{activeGolemsOnFloor.length}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{activeGolemsOnFloor.length > 0 ? (
<>
<ScrollArea className="max-h-48">
<div className="space-y-2 pr-2">
{activeGolemsOnFloor.map((golem, index) => {
const golemDef = GOLEM_DEFS[golem.golemId];
const variantDef = golem.variant ? GOLEM_VARIANTS[golem.variant] : null;
const elementColor = getGolemElementColor(golemDef?.element || 'earth');
const elementSymbol = getGolemElementSymbol(golemDef?.element || 'earth');
// Calculate golem DPS
let golemSingleDPS = golemDef?.baseDamage || 0;
if (variantDef) {
golemSingleDPS *= variantDef.damageMultiplier;
}
if (store.skills.golemancyMaster === 1) {
golemSingleDPS *= 1.5;
}
if (store.skills.pactBondedGolems === 1) {
golemSingleDPS *= 1 + (store.signedPacts.length * 0.1);
}
if (store.skills.guardianInfusion === 1 && GUARDIANS[store.currentFloor]) {
golemSingleDPS *= 1.25;
}
golemSingleDPS *= golemDef?.attackSpeed || 1;
return (
<div
key={`${golem.golemId}-${index}`}
className="p-2 rounded border bg-gray-800/30"
style={{ borderColor: `${elementColor}50` }}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span style={{ color: elementColor }}>{elementSymbol}</span>
<span className="text-sm font-semibold game-panel-title" style={{ color: elementColor }}>
{variantDef?.name || golemDef?.name || 'Unknown Golem'}
</span>
{golem.variant && !variantDef && (
<span className="text-xs text-gray-500">({golem.variant})</span>
)}
</div>
<span className="text-xs text-gray-400">
{fmtDec(golemSingleDPS)} DPS
</span>
</div>
{/* HP Bar */}
<div className="space-y-0.5 mb-1">
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.max(0, (golem.currentHP / golem.maxHP) * 100)}%`,
background: `linear-gradient(90deg, ${elementColor}99, ${elementColor})`,
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-500 game-mono">
<span>{fmt(golem.currentHP)} / {fmt(golem.maxHP)} HP</span>
</div>
</div>
{/* Remaining Floors */}
<div className="flex items-center justify-between text-xs text-gray-400">
<span className="flex items-center gap-1">
<RotateCcw className="w-3 h-3" />
{golem.remainingFloors} floor{golem.remainingFloors !== 1 ? 's' : ''} remaining
</span>
<span className="text-gray-500">
{fmt(golem.damageDealt)} total dmg
</span>
</div>
</div>
);
})}
</div>
</ScrollArea>
{/* Total Golem DPS Summary */}
<Separator className="bg-gray-700" />
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Total Golem DPS</span>
<span className="font-semibold game-mono" style={{ color: '#F4A261' }}>
{fmtDec(golemDPS)}
</span>
</div>
</>
) : (
<div className="text-gray-500 text-sm py-4 text-center">
<p className="mb-2">No golems summoned.</p>
<p className="text-xs text-gray-600">Visit the Crafting tab to summon golems.</p>
</div>
)}
</CardContent>
</Card>
{/* Active Spells Card - Shows all spells from equipped weapons */}
<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(spellDPS)} DPS
<span style={{ color: getSpellCostColor(spellDef.cost) }}>
{' '}{formatSpellCost(spellDef.cost)}
</span>
{' '} {(spellDef.castSpeed || 1).toFixed(1)}/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 === 'stun' && `⚡ Stun`}
{eff.type === 'pierce' && `🎯 Pierce`}
{eff.type === 'multicast' && `✨ Multicast`}
</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>
{/* Current Study (if any) */}
{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>
)}
{/* Crafting Progress (if any) */}
{(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>
)}
{/* 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>
</TooltipProvider>
);
}