368 lines
16 KiB
TypeScript
Executable File
368 lines
16 KiB
TypeScript
Executable File
'use client';
|
||
|
||
import { Button } from '@/components/ui/button';
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { Mountain } from 'lucide-react';
|
||
import type { ActivityLogEntry } from '@/lib/game/types';
|
||
import type { GameStore } from '@/lib/game/store';
|
||
import { ELEMENTS, GUARDIANS, SPELLS_DEF } from '@/lib/game/constants';
|
||
import { getEnemyName, calcDamage } from '@/lib/game/store';
|
||
import { getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/computed-stats';
|
||
import { getUnifiedEffects } from '@/lib/game/effects';
|
||
import { formatSpellCost, getSpellCostColor } from '@/lib/game/formatting';
|
||
import { canAffordSpellCost, getFloorElement } from '@/lib/game/store';
|
||
|
||
// Extracted components
|
||
import { SpireHeader } from './SpireHeader';
|
||
import { GuardianPanel } from './GuardianPanel';
|
||
import { RoomDisplay } from './RoomDisplay';
|
||
import { FloorControls } from './FloorControls';
|
||
import { CombatStatsPanel } from './CombatStatsPanel';
|
||
import { ActivityLog } from './ActivityLog';
|
||
|
||
// Room type configurations
|
||
const ROOM_TYPE_CONFIG: Record<string, { label: string; icon: string; color: string }> = {
|
||
combat: { label: 'Combat', icon: '⚔️', color: '#EF4444' },
|
||
swarm: { label: 'Swarm', icon: '🐝', color: '#F59E0B' },
|
||
speed: { label: 'Speed', icon: '💨', color: '#3B82F6' },
|
||
guardian: { label: 'Guardian', icon: '🛡️', color: '#EF4444' },
|
||
puzzle: { label: 'Puzzle', icon: '🧩', color: '#8B5CF6' },
|
||
};
|
||
|
||
interface SpireTabProps {
|
||
store: GameStore;
|
||
simpleMode?: boolean;
|
||
}
|
||
|
||
// Check if player can enter spire mode
|
||
const canEnterSpireMode = (store: GameStore): boolean => {
|
||
return !store.spireMode;
|
||
};
|
||
|
||
export function SpireTab({ store, simpleMode = false }: SpireTabProps) {
|
||
// Derived data
|
||
const floorElem = getFloorElement(store.currentFloor);
|
||
const floorElemDef = ELEMENTS[floorElem];
|
||
const isGuardianFloor = !!GUARDIANS[store.currentFloor];
|
||
const currentGuardian = GUARDIANS[store.currentFloor];
|
||
const climbDirection = store.climbDirection || 'up';
|
||
const clearedFloors = store.clearedFloors || {};
|
||
const currentRoom = store.currentRoom;
|
||
const isFloorCleared = clearedFloors[store.currentFloor];
|
||
const roomType = currentRoom?.roomType || 'combat';
|
||
const roomConfig = ROOM_TYPE_CONFIG[roomType] || ROOM_TYPE_CONFIG.combat;
|
||
const activeEquipmentSpells = getActiveEquipmentSpells(store.equippedInstances, store.equipmentInstances);
|
||
const upgradeEffects = getUnifiedEffects(store);
|
||
const totalDPS = getTotalDPS(store, upgradeEffects, floorElem);
|
||
const studySpeedMult = 1;
|
||
|
||
// Enemy display info
|
||
const primaryEnemy = currentRoom?.enemies?.[0] || null;
|
||
const swarmEnemies = roomType === 'swarm' && currentRoom?.enemies ? currentRoom.enemies : [];
|
||
|
||
// Spell casting check
|
||
const canCastSpell = (spellId: string): boolean => {
|
||
const spell = SPELLS_DEF[spellId];
|
||
if (!spell) return false;
|
||
return canAffordSpellCost(spell.cost, store.rawMana, store.elements);
|
||
};
|
||
|
||
// Climb handler
|
||
const handleClimb = (direction: 'up' | 'down') => {
|
||
if (direction === 'up') {
|
||
store.startClimbUp();
|
||
} else {
|
||
store.startClimbDown();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="grid gap-4">
|
||
{/* Enter Spire Mode - Normal mode only */}
|
||
{!simpleMode && (
|
||
<Card className="bg-gray-900/80 border-amber-600/50">
|
||
<CardContent className="pt-4">
|
||
<Button
|
||
className="w-full bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-700 hover:to-orange-700"
|
||
size="lg"
|
||
onClick={() => store.enterSpireMode()}
|
||
disabled={!canEnterSpireMode(store)}
|
||
>
|
||
<Mountain className="w-5 h-5 mr-2" />
|
||
Enter Spire Mode
|
||
</Button>
|
||
<div className="text-xs text-gray-400 text-center mt-2">
|
||
Climb the Spire to face guardians and earn pacts
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Spire Header */}
|
||
<SpireHeader
|
||
currentFloor={store.currentFloor}
|
||
maxFloorReached={store.maxFloorReached}
|
||
signedPacts={store.signedPacts.length}
|
||
isGuardianFloor={isGuardianFloor}
|
||
roomType={roomType}
|
||
roomLabel={roomConfig.label}
|
||
roomIcon={roomConfig.icon}
|
||
roomColor={roomConfig.color}
|
||
floorElem={floorElem}
|
||
floorElemDef={floorElemDef}
|
||
simpleMode={simpleMode}
|
||
/>
|
||
|
||
{/* Active Spells Card - Spire Mode only */}
|
||
{simpleMode && (
|
||
<Card className="bg-gray-900/80 border-gray-700">
|
||
<CardContent className="pt-4 pb-4">
|
||
<div className="text-amber-400 text-xs font-semibold mb-3 uppercase tracking-wider">
|
||
Active Spells ({activeEquipmentSpells.length})
|
||
</div>
|
||
{activeEquipmentSpells.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{activeEquipmentSpells.map(({ spellId, equipmentId }) => {
|
||
const spellDef = SPELLS_DEF[spellId];
|
||
if (!spellDef) return null;
|
||
const spellState = store.equipmentSpellStates?.find(
|
||
s => s.spellId === spellId && s.sourceEquipment === equipmentId
|
||
);
|
||
const progress = spellState?.castProgress || 0;
|
||
const canCast = canCastSpell(spellId);
|
||
|
||
return (
|
||
<div key={`${spellId}-${equipmentId}`} className="p-2 rounded bg-gray-800/30 border border-gray-700">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="text-sm font-semibold" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
|
||
{spellDef.name}
|
||
{spellDef.tier === 0 && <span className="ml-2 bg-gray-600 text-gray-200 text-xs px-1 rounded">Basic</span>}
|
||
</span>
|
||
<span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>{canCast ? '✓' : '✗'}</span>
|
||
</div>
|
||
<div className="text-xs text-gray-400 mb-1">
|
||
⚔️ {fmt(calcDamage(store, spellId))} dmg • <span style={{ color: getSpellCostColor(spellDef.cost) }}>{formatSpellCost(spellDef.cost)}</span> {' '}• ⚡ {fmt(Math.floor(calcDamage(store, spellId) * (spellDef.castSpeed || 1)))} dmg/hr
|
||
</div>
|
||
{store.currentAction === 'climb' && (
|
||
<div className="space-y-0.5">
|
||
<div className="flex justify-between text-xs text-gray-500">
|
||
<span>Cast</span>
|
||
<span>{(progress * 100).toFixed(0)}%</span>
|
||
</div>
|
||
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
|
||
<div className="h-full rounded-full transition-all duration-300" style={{ width: `${Math.min(100, progress * 100)}%`, background: spellDef.elem === 'raw' ? 'linear-gradient(90deg, #60A5FA99, #60A5FA)' : `linear-gradient(90deg, ${ELEMENTS[spellDef.elem]?.color}99, ${ELEMENTS[spellDef.elem]?.color})` }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="text-gray-500 text-sm">No spells on equipped weapons. Enchant a staff with spell effects in the Crafting tab.</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Summoned Golems */}
|
||
{simpleMode && store.golemancy.summonedGolems.length > 0 && (
|
||
<Card className="bg-gray-900/80 border-amber-600/50">
|
||
<CardContent className="pt-4 pb-4">
|
||
<div className="text-amber-400 text-xs font-semibold mb-3 uppercase tracking-wider flex items-center gap-2">
|
||
<Mountain className="w-4 h-4" />
|
||
Active Golems ({store.golemancy.summonedGolems.length})
|
||
</div>
|
||
<div className="space-y-2">
|
||
{store.golemancy.summonedGolems.map((summoned) => {
|
||
const golemDef = getGolemDef(summoned.golemId);
|
||
if (!golemDef) return null;
|
||
const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
|
||
const damage = getGolemDamage(summoned.golemId, store.skills);
|
||
const attackSpeed = getGolemAttackSpeed(summoned.golemId, store.skills);
|
||
|
||
return (
|
||
<div key={summoned.golemId} className="p-2 rounded bg-gray-800/30 border border-gray-700">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<div className="flex items-center gap-2">
|
||
<Mountain className="w-3 h-3" style={{ color: elemColor }} />
|
||
<span className="text-xs font-semibold" style={{ color: elemColor }}>{golemDef.name}</span>
|
||
</div>
|
||
{golemDef.isAoe && <span className="text-xs border border-gray-600 px-1 rounded">AOE {golemDef.aoeTargets}</span>}
|
||
</div>
|
||
<div className="text-xs text-gray-400">⚔️ {damage} DMG • ⚡ {attackSpeed.toFixed(1)}/hr</div>
|
||
{store.currentAction === 'climb' && summoned.attackProgress > 0 && (
|
||
<div className="mt-1">
|
||
<div className="flex justify-between text-xs text-gray-500 mb-0.5">
|
||
<span>Attack</span>
|
||
<span>{(summoned.attackProgress * 100).toFixed(0)}%</span>
|
||
</div>
|
||
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
|
||
<div className="h-full rounded-full transition-all duration-300" style={{ width: `${Math.min(100, summoned.attackProgress * 100)}%`, background: elemColor }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Guardian Panel */}
|
||
{isGuardianFloor && simpleMode && (
|
||
<GuardianPanel currentFloor={store.currentFloor} floorElemDef={floorElemDef} />
|
||
)}
|
||
|
||
{/* Room Display */}
|
||
{simpleMode && (
|
||
<RoomDisplay
|
||
roomType={roomType}
|
||
roomConfig={roomConfig}
|
||
primaryEnemy={primaryEnemy}
|
||
swarmEnemies={swarmEnemies}
|
||
puzzleId={currentRoom?.puzzleId}
|
||
puzzleProgress={currentRoom?.puzzleProgress}
|
||
simpleMode={true}
|
||
floorElemDef={floorElemDef}
|
||
floorHP={store.floorHP}
|
||
floorMaxHP={store.floorMaxHP}
|
||
totalDPS={totalDPS}
|
||
currentAction={store.currentAction}
|
||
activeEquipmentSpells={activeEquipmentSpells}
|
||
/>
|
||
)}
|
||
|
||
{/* Floor Controls */}
|
||
{simpleMode && (
|
||
<FloorControls
|
||
store={store}
|
||
climbDirection={climbDirection}
|
||
isGuardianFloor={isGuardianFloor}
|
||
currentRoom={currentRoom}
|
||
currentGuardian={currentGuardian}
|
||
isFloorCleared={isFloorCleared}
|
||
floorElemDef={floorElemDef}
|
||
roomType={roomType}
|
||
roomConfig={roomConfig}
|
||
activeEquipmentSpells={activeEquipmentSpells}
|
||
upgradeEffects={upgradeEffects}
|
||
floorElem={floorElem}
|
||
totalDPS={totalDPS}
|
||
getEnemyName={getEnemyName}
|
||
calcDamage={calcDamage}
|
||
SPELLS_DEF={SPELLS_DEF}
|
||
canCastSpell={canCastSpell}
|
||
storeCurrentAction={store.currentAction}
|
||
handleClimb={handleClimb}
|
||
formatSpellCost={formatSpellCost}
|
||
getSpellCostColor={getSpellCostColor}
|
||
/>
|
||
)}
|
||
|
||
{/* Combat Stats Panel */}
|
||
{simpleMode && (
|
||
<CombatStatsPanel
|
||
activeEquipmentSpells={activeEquipmentSpells}
|
||
store={store}
|
||
totalDPS={totalDPS}
|
||
calcDamage={calcDamage}
|
||
formatSpellCost={formatSpellCost}
|
||
getSpellCostColor={getSpellCostColor}
|
||
SPELLS_DEF={SPELLS_DEF}
|
||
upgradeEffects={upgradeEffects}
|
||
canCastSpell={canCastSpell}
|
||
studySpeedMult={studySpeedMult}
|
||
storeCurrentAction={store.currentAction}
|
||
/>
|
||
)}
|
||
|
||
{/* Activity Log - Spire Mode only */}
|
||
{simpleMode && <ActivityLog activityLog={store.activityLog} />}
|
||
|
||
{/* Study Progress - Normal mode only */}
|
||
{!simpleMode && store.currentStudyTarget && (
|
||
<Card className="bg-gray-900/80 border-purple-600/50">
|
||
<CardContent className="pt-4 pb-4">
|
||
<div className="text-xs text-gray-400 mb-2">Study: {getSkillName(store.currentStudyTarget.id)}</div>
|
||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||
<div className="h-full rounded-full transition-all duration-300 bg-purple-500" style={{ width: `${Math.min(100, (store.currentStudyTarget.progress / store.currentStudyTarget.required) * 100)}%` }} />
|
||
</div>
|
||
{store.parallelStudyTarget && (
|
||
<div className="mt-3 p-2 rounded border border-cyan-600/50 bg-cyan-900/20">
|
||
<div className="text-xs text-cyan-300 mb-1">Parallel: {getSkillName(store.parallelStudyTarget.id)} (50% speed)</div>
|
||
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||
<div className="h-full rounded-full transition-all duration-300 bg-cyan-500" style={{ width: `${Math.min(100, (store.parallelStudyTarget.progress / store.parallelStudyTarget.required) * 100)}%` }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Crafting Progress - Normal mode only */}
|
||
{!simpleMode && (store.designProgress || store.preparationProgress || store.applicationProgress) && (
|
||
<Card className="bg-gray-900/80 border-cyan-600/50">
|
||
<CardContent className="pt-4 pb-4">
|
||
{store.designProgress && (
|
||
<div className="mb-3">
|
||
<div className="text-xs text-gray-400 mb-1">Design Progress</div>
|
||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||
<div className="h-full rounded-full transition-all duration-300 bg-cyan-500" style={{ width: `${Math.min(100, (store.designProgress.progress / store.designProgress.required) * 100)}%` }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{store.preparationProgress && (
|
||
<div className="mb-3">
|
||
<div className="text-xs text-gray-400 mb-1">Preparation Progress</div>
|
||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||
<div className="h-full rounded-full transition-all duration-300 bg-cyan-500" style={{ width: `${Math.min(100, (store.preparationProgress.progress / store.preparationProgress.required) * 100)}%` }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{store.applicationProgress && (
|
||
<div>
|
||
<div className="text-xs text-gray-400 mb-1">Application Progress</div>
|
||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||
<div className="h-full rounded-full transition-all duration-300 bg-cyan-500" style={{ width: `${Math.min(100, (store.applicationProgress.progress / store.applicationProgress.required) * 100)}%` }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
SpireTab.displayName = "SpireTab";
|
||
|
||
function getSkillName(skillId: string): string {
|
||
const { SKILLS_DEF } = require('@/lib/game/constants');
|
||
return SKILLS_DEF[skillId]?.name || skillId;
|
||
}
|
||
|
||
function fmt(value: number): string {
|
||
if (value >= 1e12) return (value / 1e12).toFixed(2) + 't';
|
||
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'b';
|
||
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'm';
|
||
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'k';
|
||
return value.toFixed(0);
|
||
}
|
||
|
||
function getGolemDef(golemId: string) {
|
||
const { GOLEMS_DEF } = require('@/lib/game/data/golems');
|
||
return GOLEMS_DEF[golemId];
|
||
}
|
||
|
||
function getGolemDamage(golemId: string, skills: any) {
|
||
const { getGolemDamage } = require('@/lib/game/data/golems');
|
||
return getGolemDamage(golemId, skills);
|
||
}
|
||
|
||
function getGolemAttackSpeed(golemId: string, skills: any) {
|
||
const { getGolemAttackSpeed } = require('@/lib/game/data/golems');
|
||
return getGolemAttackSpeed(golemId, skills);
|
||
}
|