Files
Mana-Loop/src/components/game/tabs/SpireTab.tsx
Z User 17c6d5652d
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 3m17s
Add golemancy system, combination skills, and UI redesigns
- Remove scroll crafting skill (no consumables in idle game)
- Add golem types (Earth, Metal, Crystal) with variants (Lava, Mud, Forge, Storm)
- Implement golemancy state in store with summoning/drain mechanics
- Add combination skills requiring level 5+ in two attunements:
  - Enchanter+Fabricator: Enchanted Golems, Capacity Overflow
  - Invoker+Fabricator: Pact-Bonded Golems, Guardian Infusion
  - Invoker+Enchanter: Pact Enchantments, Elemental Resonance
- Redesign CraftingTab with sub-tabs for Enchanter/Fabricator
- Redesign SpireTab to show summoned golems and DPS contribution
- Redesign StatsTab with attunement-specific stats sections
- Update documentation (README.md, AGENTS.md)
2026-03-28 07:56:52 +00:00

425 lines
20 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 } 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 { 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 clearedFloors = store.clearedFloors || {};
// 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>
)}
{/* 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: `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>
{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>
{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" />
<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 === 'lifesteal' && `🩸 ${Math.round(eff.value * 100)}%`}
{eff.type === 'burn' && `🔥 Burn`}
{eff.type === 'freeze' && `❄️ Freeze`}
</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>
);
}