386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
'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 { 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';
|
||
|
||
|
||
// ─── 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.join(' + ')})`
|
||
: `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[0]);
|
||
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[0]), color: getElementColor(nextGuardianData.element[0]) }}
|
||
>
|
||
{nextGuardianData.element.join(' + ')}
|
||
</Badge>
|
||
<span className="text-xs text-gray-500">Health: {fmt(nextGuardianData.hp)}</span>
|
||
{nextGuardianData.armor && (
|
||
<span className="text-xs text-gray-500">
|
||
Armor: {Math.round(nextGuardianData.armor * 100)}%
|
||
</span>
|
||
)}
|
||
{nextGuardianData.shield && nextGuardianData.shield > 0 && (
|
||
<span className="text-xs text-cyan-400">
|
||
Shield: {fmt(nextGuardianData.shield)}
|
||
</span>
|
||
)}
|
||
{nextGuardianData.barrier && nextGuardianData.barrier > 0 && (
|
||
<span className="text-xs text-blue-400">
|
||
Barrier: {Math.round(nextGuardianData.barrier * 100)}%
|
||
</span>
|
||
)}
|
||
{nextGuardianData.healthRegen && nextGuardianData.healthRegen > 0 && (
|
||
<span className="text-xs text-green-400">
|
||
Regen: {nextGuardianData.healthRegenIsPercent ? nextGuardianData.healthRegen + '%/tick' : nextGuardianData.healthRegen + '/tick'}
|
||
</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.join(' + ')}
|
||
</span>
|
||
<span className="text-[10px] text-gray-500">Health: {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}
|
||
/>
|
||
|
||
{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';
|