Files
Mana-Loop/src/components/game/tabs/SpireSummaryTab.tsx
T
n8n-gitea ef850e98e2
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m23s
refactor: simplify ManaStatsSection props from 17 fields to single stats object
2026-05-26 18:28:24 +02:00

383 lines
14 KiB
TypeScript
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 { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores';
import { ELEMENT_OPPOSITES, FLOOR_ELEM_CYCLE } from '@/lib/game/constants';
import { getGuardianForFloor, getAllGuardianFloors } from '@/lib/game/data/guardian-encounters';
import type { GuardianDef } from '@/lib/game/types';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { SectionHeader } from '@/components/ui/section-header';
import { DebugName } from '@/components/game/debug/debug-context';
import { Mountain } from 'lucide-react';
// ─── Guardian Data ────────────────────────────────────────────────────────────
const GUARDIAN_FLOORS = getAllGuardianFloors();
// ─── Helper: Get Counter Element ─────────────────────────────────────────────
function getCounterElement(element: string): string | null {
return ELEMENT_OPPOSITES[element] || null;
}
function getElementColor(element: string): string {
const colors: Record<string, string> = {
fire: '#FF6B35',
water: '#4ECDC4',
air: '#00D4FF',
earth: '#F4A261',
light: '#FFD700',
dark: '#9B59B6',
death: '#778CA3',
void: '#4A235A',
stellar: '#F0E68C',
};
return colors[element] || '#9CA3AF';
}
// ─── Sub-component: Floor Progress Bar ────────────────────────────────────────
function FloorProgressBar({ maxFloor, clearedFloors }: { maxFloor: number; clearedFloors: Record<number, boolean> }) {
const totalFloors = Math.min(maxFloor, 100);
const clearedSet = new Set(Object.entries(clearedFloors).filter(([, v]) => v).map(([k]) => Number(k)));
const rows: number[][] = [];
for (let i = 0; i < totalFloors; i += 10) {
rows.push(Array.from({ length: 10 }, (_, j) => i + j + 1).filter((f) => f <= totalFloors));
}
return (
<div className="space-y-1">
{rows.reverse().map((row) => (
<div key={row[0]} className="flex gap-1">
{row.map((floor) => {
const isCleared = clearedSet.has(floor);
const isGuardian = !!getGuardianForFloor(floor);
const isCurrent = floor === maxFloor;
let bgClass = 'bg-gray-800';
if (isCleared) bgClass = 'bg-emerald-600/60';
else if (isCurrent) bgClass = 'bg-amber-600/60';
const borderClass = isGuardian
? 'border-amber-500'
: isCurrent
? 'border-amber-400'
: 'border-gray-700';
return (
<div
key={floor}
className={`w-7 h-7 flex items-center justify-center text-[9px] rounded border ${bgClass} ${borderClass} ${
isGuardian ? 'font-bold' : ''
}`}
title={
getGuardianForFloor(floor)
? `Floor ${floor}${getGuardianForFloor(floor)!.name} (${getGuardianForFloor(floor)!.element})`
: `Floor ${floor}${isCleared ? ' (cleared)' : ''}`
}
>
{floor}
</div>
);
})}
</div>
))}
<FloorLegend />
</div>
);
}
function FloorLegend() {
return (
<div className="flex items-center gap-3 mt-2 text-[10px] text-gray-500">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-emerald-600/60 border border-gray-700" />
<span>Cleared</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-gray-700" />
<span>Uncleared</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-amber-500" />
<span>Guardian</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-amber-600/60 border border-amber-400" />
<span>Current</span>
</div>
</div>
);
}
// ─── Top Stats Row ───────────────────────────────────────────────────────────
function TopStatsRow({ maxFloorReached, totalFloorsCleared, defeatedCount, insight }: {
maxFloorReached: number;
totalFloorsCleared: number;
defeatedCount: number;
insight: number;
}) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<CardContent className="py-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCell value={maxFloorReached} label="Max Floor Reached" color="text-amber-400" />
<StatCell value={totalFloorsCleared} label="Floors Cleared" color="text-gray-200" />
<StatCell value={defeatedCount} label="Guardians Defeated" color="text-emerald-400" />
<StatCell value={fmt(insight)} label="Insight Earned" color="text-purple-400" />
</div>
</CardContent>
</Card>
);
}
function StatCell({ value, label, color }: { value: number | string; label: string; color: string }) {
return (
<div className="text-center">
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-xs text-gray-400 mt-0.5">{label}</div>
</div>
);
}
// ─── Next Guardian Card ──────────────────────────────────────────────────────
function NextGuardianCard({ nextGuardian, nextGuardianData }: { nextGuardian: number; nextGuardianData: GuardianDef }) {
const counterElement = getCounterElement(nextGuardianData.element);
const nextFloorElement = FLOOR_ELEM_CYCLE[(nextGuardian - 1) % FLOOR_ELEM_CYCLE.length];
return (
<Card className="bg-gray-900/60 border-amber-800/40">
<SectionHeader
title={`🛡️ Next Guardian — Floor ${nextGuardian}`}
className="text-amber-400"
/>
<CardContent className="pt-0 space-y-3">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-lg font-bold"
style={{
backgroundColor: `${nextGuardianData.color}20`,
border: `2px solid ${nextGuardianData.color}`,
color: nextGuardianData.color,
}}
>
{nextGuardian}
</div>
<div>
<div className="font-semibold text-gray-100">{nextGuardianData.name}</div>
<div className="flex items-center gap-2 mt-0.5">
<Badge
variant="outline"
className="text-xs"
style={{ borderColor: getElementColor(nextGuardianData.element), color: getElementColor(nextGuardianData.element) }}
>
{nextGuardianData.element}
</Badge>
<span className="text-xs text-gray-500">HP: {fmt(nextGuardianData.hp)}</span>
{nextGuardianData.armor && (
<span className="text-xs text-gray-500">
Armor: {Math.round(nextGuardianData.armor * 100)}%
</span>
)}
</div>
</div>
</div>
<PreparationTips
counterElement={counterElement}
nextFloorElement={nextFloorElement}
hasHighArmor={!!(nextGuardianData.armor && nextGuardianData.armor > 0.15)}
/>
</CardContent>
</Card>
);
}
function PreparationTips({ counterElement, nextFloorElement, hasHighArmor }: { counterElement: string | null; nextFloorElement: string | null; hasHighArmor: boolean }) {
return (
<div className="bg-gray-800/50 rounded-lg p-3 space-y-2">
<div className="text-xs font-medium text-gray-300">Recommended Preparation:</div>
<div className="text-xs text-gray-400 space-y-1">
{counterElement && (
<div className="flex items-center gap-2">
<span className="text-emerald-400"></span>
<span>
Use <span style={{ color: getElementColor(counterElement) }} className="font-medium">{counterElement}</span> spells for super effective damage (+50%)
</span>
</div>
)}
{nextFloorElement && (
<div className="flex items-center gap-2">
<span className="text-blue-400">🔄</span>
<span>
Floor element: <span style={{ color: getElementColor(nextFloorElement) }} className="font-medium">{nextFloorElement}</span>
</span>
</div>
)}
{hasHighArmor && (
<div className="flex items-center gap-2">
<span className="text-red-400">🛡</span>
<span>High armor consider armor-piercing or raw damage spells</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-amber-400">💡</span>
<span>Ensure mana pools are full before attempting</span>
</div>
</div>
</div>
);
}
// ─── Guardian Roster ─────────────────────────────────────────────────────────
function GuardianRoster({ clearedFloors }: { clearedFloors: Record<number, boolean> }) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🏛️ Guardian Roster" />
<CardContent className="pt-0">
<div className="space-y-2">
{GUARDIAN_FLOORS.map((floor) => {
const guardian = getGuardianForFloor(floor);
return guardian ? (
<GuardianRosterItem key={floor} floor={floor} guardian={guardian} isDefeated={!!clearedFloors[floor]} />
) : null;
})}
</div>
</CardContent>
</Card>
);
}
function GuardianRosterItem({ floor, guardian, isDefeated }: { floor: number; guardian: GuardianDef; isDefeated: boolean }) {
return (
<div
className={`flex items-center justify-between p-2 rounded border ${
isDefeated
? 'bg-emerald-900/20 border-emerald-800/40'
: 'bg-gray-800/40 border-gray-700/50'
}`}
>
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded flex items-center justify-center text-xs font-bold"
style={{
backgroundColor: isDefeated ? `${guardian.color}30` : '#374151',
color: isDefeated ? guardian.color : '#6B7280',
}}
>
{floor}
</div>
<div>
<div className={`text-sm font-medium ${isDefeated ? 'text-gray-100' : 'text-gray-400'}`}>
{guardian.name}
</div>
<div className="flex items-center gap-1.5">
<span
className="text-[10px] px-1.5 py-0.5 rounded"
style={{
backgroundColor: `${guardian.color}15`,
color: guardian.color,
}}
>
{guardian.element}
</span>
<span className="text-[10px] text-gray-500">HP: {fmt(guardian.hp)}</span>
</div>
</div>
</div>
<div>
{isDefeated ? (
<Badge variant="outline" className="border-emerald-600 text-emerald-400 text-xs">
Defeated
</Badge>
) : (
<Badge variant="outline" className="border-gray-600 text-gray-500 text-xs">
Undefeated
</Badge>
)}
</div>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function SpireSummaryTab() {
const {
maxFloorReached,
clearedFloors,
enterSpireMode,
} = useCombatStore(useShallow((s) => ({
maxFloorReached: s.maxFloorReached,
clearedFloors: s.clearedFloors,
enterSpireMode: s.enterSpireMode,
})));
const { insight } = usePrestigeStore(useShallow((s) => ({
insight: s.insight,
})));
const defeatedGuardians = useMemo(() => {
return GUARDIAN_FLOORS.filter((floor) => clearedFloors[floor]);
}, [clearedFloors]);
const nextGuardian = useMemo(() => {
return GUARDIAN_FLOORS.find((floor) => !clearedFloors[floor]) || null;
}, [clearedFloors]);
const nextGuardianData = nextGuardian ? getGuardianForFloor(nextGuardian) : null;
const totalFloorsCleared = useMemo(() => {
return Object.values(clearedFloors).filter(Boolean).length;
}, [clearedFloors]);
return (
<DebugName name="SpireSummaryTab">
<div className="space-y-4">
<TopStatsRow
maxFloorReached={maxFloorReached}
totalFloorsCleared={totalFloorsCleared}
defeatedCount={defeatedGuardians.length}
insight={insight}
/>
<DebugName name="ClimbSpireButton">
<Button
className="w-full bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-700 hover:to-orange-600 text-white"
size="lg"
onClick={enterSpireMode}
>
<Mountain className="w-5 h-5 mr-2" />
Climb the Spire
</Button>
</DebugName>
{nextGuardianData && nextGuardian && (
<NextGuardianCard nextGuardian={nextGuardian} nextGuardianData={nextGuardianData} />
)}
<GuardianRoster clearedFloors={clearedFloors} />
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🗺️ Floor Progress" />
<CardContent className="pt-0">
<ScrollArea className="h-[300px] pr-2">
<FloorProgressBar maxFloor={maxFloorReached} clearedFloors={clearedFloors} />
</ScrollArea>
</CardContent>
</Card>
</div>
</DebugName>
);
}
SpireSummaryTab.displayName = 'SpireSummaryTab';